diff --git a/deployment/compose_cpu.yaml b/deployment/compose_cpu.yaml index e59f350..5dca4d2 100644 --- a/deployment/compose_cpu.yaml +++ b/deployment/compose_cpu.yaml @@ -19,8 +19,8 @@ services: - USE_HTTPS=${USE_HTTPS} - CORS_ORIGIN=${CORS_ORIGIN} - SERVER_ADDRESS=${SERVER_ADDRESS} - - WHISPER_SERVICE_ENDPOINT=ws://whisper-service:80/whisper?api_key=${API_KEY}&model_key=${MODEL_KEY} - - WHISPER_RECONNECT_INTERVAL_SEC=${WHISPER_RECONNECT_INTERVAL_SEC} + - WHISPER_SERVICE_ENDPOINT=ws://whisper-service:80/whisper + - API_KEY=${API_KEY} - REQUIRE_AUTH=${REQUIRE_AUTH} - SOURCE_TOKEN=${SOURCE_TOKEN} - ACCESS_TOKEN_BYTES=${ACCESS_TOKEN_BYTES} @@ -30,9 +30,6 @@ services: - SESSION_LENGTH_SEC=${SESSION_LENGTH_SEC} ports: - ${NODE_PORT}:80 - volumes: - - ${KEY_FILEPATH:-/dev/null}:/app/cert/key.pem - - ${CERTIFICATE_FILEPATH:-/dev/null}:/app/cert/key.pem restart: unless-stopped frontend: diff --git a/deployment/compose_cuda.yaml b/deployment/compose_cuda.yaml index f057bc7..831892c 100644 --- a/deployment/compose_cuda.yaml +++ b/deployment/compose_cuda.yaml @@ -22,8 +22,8 @@ services: - USE_HTTPS=${USE_HTTPS} - CORS_ORIGIN=${CORS_ORIGIN} - SERVER_ADDRESS=${SERVER_ADDRESS} - - WHISPER_SERVICE_ENDPOINT=ws://whisper-service:80/whisper?api_key=${API_KEY}&model_key=${MODEL_KEY} - - WHISPER_RECONNECT_INTERVAL_SEC=${WHISPER_RECONNECT_INTERVAL_SEC} + - WHISPER_SERVICE_ENDPOINT=ws://whisper-service:80/whisper + - API_KEY=${API_KEY} - REQUIRE_AUTH=${REQUIRE_AUTH} - SOURCE_TOKEN=${SOURCE_TOKEN} - ACCESS_TOKEN_BYTES=${ACCESS_TOKEN_BYTES} @@ -33,9 +33,6 @@ services: - SESSION_LENGTH_SEC=${SESSION_LENGTH_SEC} ports: - ${NODE_PORT}:80 - volumes: - - ${KEY_FILEPATH:-/dev/null}:/app/cert/key.pem - - ${CERTIFICATE_FILEPATH:-/dev/null}:/app/cert/key.pem restart: unless-stopped frontend: diff --git a/node-server/package-lock.json b/node-server/package-lock.json index 7cd1f23..88af726 100644 --- a/node-server/package-lock.json +++ b/node-server/package-lock.json @@ -14,9 +14,9 @@ "@fastify/websocket": "11.0.2", "@sinclair/typebox": "0.34.15", "ajv": "8.17.1", - "axios": "1.7.9", + "axios": "1.9.0", "dotenv": "16.4.7", - "fastify": "5.2.1", + "fastify": "5.3.2", "http": "0.0.1-security", "pino": "9.6.0", "tiny-typed-emitter": "2.1.0", @@ -59,14 +59,15 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", - "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", + "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" @@ -192,19 +193,21 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", - "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -219,25 +222,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.0.tgz", - "integrity": "sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.1.tgz", + "integrity": "sha512-FCvFTm0sWV8Fxhpp2McP5/W53GPllQ9QeQ7SiqGWjMf/LVG07lFa5+pgK05IRhVwtvafT22KF+ZSnM9I545CvQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/template": "^7.25.9", - "@babel/types": "^7.26.0" + "@babel/template": "^7.27.1", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.3.tgz", - "integrity": "sha512-WJ/CvmY8Mea8iDXo6a7RK2wbmJITT5fN3BEkRuFlxVyNx8jOKIIhmC4fSkTcPcf8JyavbBwIe6OpiCOBXt/IcA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.1.tgz", + "integrity": "sha512-I0dZ3ZpCrJ1c04OqlNsQcKiZlsrXf/kkE4FXzID9rIOYICsAbA8mMDzhW/luRNAHdCNt7os/u8wenklZDlUVUQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/types": "^7.26.3" + "@babel/types": "^7.27.1" }, "bin": { "parser": "bin/babel-parser.js" @@ -247,14 +252,15 @@ } }, "node_modules/@babel/template": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", - "integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.1.tgz", + "integrity": "sha512-Fyo3ghWMqkHHpHQCoBs2VnYjR4iWFFjguTDEqA5WgZDOrFesVjMhMM2FSqTKSoUSDO1VQtavj8NFpdRBEvJTtg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.25.9", - "@babel/parser": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.1", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -288,13 +294,14 @@ } }, "node_modules/@babel/types": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.3.tgz", - "integrity": "sha512-vN5p+1kl59GVKMvTHt55NzzmYVxprfJD+ql7U9NFIfKCBkYE55LYtS+WtPlaYOyzydrKI8Nezd+aZextrd+FMA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.1.tgz", + "integrity": "sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9" + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1280,247 +1287,280 @@ "dev": true }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.34.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.34.1.tgz", - "integrity": "sha512-kwctwVlswSEsr4ljpmxKrRKp1eG1v2NAhlzFzDf1x1OdYaMjBYjDCbHkzWm57ZXzTwqn8stMXgROrnMw8dJK3w==", + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.1.tgz", + "integrity": "sha512-kxz0YeeCrRUHz3zyqvd7n+TVRlNyTifBsmnmNPtk3hQURUyG9eAB+usz6DAwagMusjx/zb3AjvDUvhFGDAexGw==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.34.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.34.1.tgz", - "integrity": "sha512-4H5ZtZitBPlbPsTv6HBB8zh1g5d0T8TzCmpndQdqq20Ugle/nroOyDMf9p7f88Gsu8vBLU78/cuh8FYHZqdXxw==", + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.40.1.tgz", + "integrity": "sha512-PPkxTOisoNC6TpnDKatjKkjRMsdaWIhyuMkA4UsBXT9WEZY4uHezBTjs6Vl4PbqQQeu6oION1w2voYZv9yquCw==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "android" ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.34.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.34.1.tgz", - "integrity": "sha512-f2AJ7Qwx9z25hikXvg+asco8Sfuc5NCLg8rmqQBIOUoWys5sb/ZX9RkMZDPdnnDevXAMJA5AWLnRBmgdXGEUiA==", + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.40.1.tgz", + "integrity": "sha512-VWXGISWFY18v/0JyNUy4A46KCFCb9NVsH+1100XP31lud+TzlezBbz24CYzbnA4x6w4hx+NYCXDfnvDVO6lcAA==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.34.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.34.1.tgz", - "integrity": "sha512-+/2JBrRfISCsWE4aEFXxd+7k9nWGXA8+wh7ZUHn/u8UDXOU9LN+QYKKhd57sIn6WRcorOnlqPMYFIwie/OHXWw==", + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.40.1.tgz", + "integrity": "sha512-nIwkXafAI1/QCS7pxSpv/ZtFW6TXcNUEHAIA9EIyw5OzxJZQ1YDrX+CL6JAIQgZ33CInl1R6mHet9Y/UZTg2Bw==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "darwin" ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.34.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.34.1.tgz", - "integrity": "sha512-SUeB0pYjIXwT2vfAMQ7E4ERPq9VGRrPR7Z+S4AMssah5EHIilYqjWQoTn5dkDtuIJUSTs8H+C9dwoEcg3b0sCA==", + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.40.1.tgz", + "integrity": "sha512-BdrLJ2mHTrIYdaS2I99mriyJfGGenSaP+UwGi1kB9BLOCu9SR8ZpbkmmalKIALnRw24kM7qCN0IOm6L0S44iWw==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.34.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.34.1.tgz", - "integrity": "sha512-L3T66wAZiB/ooiPbxz0s6JEX6Sr2+HfgPSK+LMuZkaGZFAFCQAHiP3dbyqovYdNaiUXcl9TlgnIbcsIicAnOZg==", + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.40.1.tgz", + "integrity": "sha512-VXeo/puqvCG8JBPNZXZf5Dqq7BzElNJzHRRw3vjBE27WujdzuOPecDPc/+1DcdcTptNBep3861jNq0mYkT8Z6Q==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "freebsd" ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.34.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.34.1.tgz", - "integrity": "sha512-UBXdQ4+ATARuFgsFrQ+tAsKvBi/Hly99aSVdeCUiHV9dRTTpMU7OrM3WXGys1l40wKVNiOl0QYY6cZQJ2xhKlQ==", + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.40.1.tgz", + "integrity": "sha512-ehSKrewwsESPt1TgSE/na9nIhWCosfGSFqv7vwEtjyAqZcvbGIg4JAcV7ZEh2tfj/IlfBeZjgOXm35iOOjadcg==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.34.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.34.1.tgz", - "integrity": "sha512-m/yfZ25HGdcCSwmopEJm00GP7xAUyVcBPjttGLRAqZ60X/bB4Qn6gP7XTwCIU6bITeKmIhhwZ4AMh2XLro+4+w==", + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.40.1.tgz", + "integrity": "sha512-m39iO/aaurh5FVIu/F4/Zsl8xppd76S4qoID8E+dSRQvTyZTOI2gVk3T4oqzfq1PtcvOfAVlwLMK3KRQMaR8lg==", "cpu": [ "arm" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.34.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.34.1.tgz", - "integrity": "sha512-Wy+cUmFuvziNL9qWRRzboNprqSQ/n38orbjRvd6byYWridp5TJ3CD+0+HUsbcWVSNz9bxkDUkyASGP0zS7GAvg==", + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.40.1.tgz", + "integrity": "sha512-Y+GHnGaku4aVLSgrT0uWe2o2Rq8te9hi+MwqGF9r9ORgXhmHK5Q71N757u0F8yU1OIwUIFy6YiJtKjtyktk5hg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.34.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.34.1.tgz", - "integrity": "sha512-CQ3MAGgiFmQW5XJX5W3wnxOBxKwFlUAgSXFA2SwgVRjrIiVt5LHfcQLeNSHKq5OEZwv+VCBwlD1+YKCjDG8cpg==", + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.40.1.tgz", + "integrity": "sha512-jEwjn3jCA+tQGswK3aEWcD09/7M5wGwc6+flhva7dsQNRZZTe30vkalgIzV4tjkopsTS9Jd7Y1Bsj6a4lzz8gQ==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.34.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.34.1.tgz", - "integrity": "sha512-rSzb1TsY4lSwH811cYC3OC2O2mzNMhM13vcnA7/0T6Mtreqr3/qs6WMDriMRs8yvHDI54qxHgOk8EV5YRAHFbw==", + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.40.1.tgz", + "integrity": "sha512-ySyWikVhNzv+BV/IDCsrraOAZ3UaC8SZB67FZlqVwXwnFhPihOso9rPOxzZbjp81suB1O2Topw+6Ug3JNegejQ==", "cpu": [ "loong64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.34.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.34.1.tgz", - "integrity": "sha512-fwr0n6NS0pG3QxxlqVYpfiY64Fd1Dqd8Cecje4ILAV01ROMp4aEdCj5ssHjRY3UwU7RJmeWd5fi89DBqMaTawg==", + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.40.1.tgz", + "integrity": "sha512-BvvA64QxZlh7WZWqDPPdt0GH4bznuL6uOO1pmgPnnv86rpUpc8ZxgZwcEgXvo02GRIZX1hQ0j0pAnhwkhwPqWg==", "cpu": [ "ppc64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.34.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.34.1.tgz", - "integrity": "sha512-4uJb9qz7+Z/yUp5RPxDGGGUcoh0PnKF33QyWgEZ3X/GocpWb6Mb+skDh59FEt5d8+Skxqs9mng6Swa6B2AmQZg==", + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.40.1.tgz", + "integrity": "sha512-EQSP+8+1VuSulm9RKSMKitTav89fKbHymTf25n5+Yr6gAPZxYWpj3DzAsQqoaHAk9YX2lwEyAf9S4W8F4l3VBQ==", "cpu": [ "riscv64" ], "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.40.1.tgz", + "integrity": "sha512-n/vQ4xRZXKuIpqukkMXZt9RWdl+2zgGNx7Uda8NtmLJ06NL8jiHxUawbwC+hdSq1rrw/9CghCpEONor+l1e2gA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.34.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.34.1.tgz", - "integrity": "sha512-QlIo8ndocWBEnfmkYqj8vVtIUpIqJjfqKggjy7IdUncnt8BGixte1wDON7NJEvLg3Kzvqxtbo8tk+U1acYEBlw==", + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.40.1.tgz", + "integrity": "sha512-h8d28xzYb98fMQKUz0w2fMc1XuGzLLjdyxVIbhbil4ELfk5/orZlSTpF/xdI9C8K0I8lCkq+1En2RJsawZekkg==", "cpu": [ "s390x" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.34.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.34.1.tgz", - "integrity": "sha512-hzpleiKtq14GWjz3ahWvJXgU1DQC9DteiwcsY4HgqUJUGxZThlL66MotdUEK9zEo0PK/2ADeZGM9LIondE302A==", + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.40.1.tgz", + "integrity": "sha512-XiK5z70PEFEFqcNj3/zRSz/qX4bp4QIraTy9QjwJAb/Z8GM7kVUsD0Uk8maIPeTyPCP03ChdI+VVmJriKYbRHQ==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.34.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.34.1.tgz", - "integrity": "sha512-jqtKrO715hDlvUcEsPn55tZt2TEiBvBtCMkUuU0R6fO/WPT7lO9AONjPbd8II7/asSiNVQHCMn4OLGigSuxVQA==", + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.40.1.tgz", + "integrity": "sha512-2BRORitq5rQ4Da9blVovzNCMaUlyKrzMSvkVR0D4qPuOy/+pMCrh1d7o01RATwVy+6Fa1WBw+da7QPeLWU/1mQ==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.34.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.34.1.tgz", - "integrity": "sha512-RnHy7yFf2Wz8Jj1+h8klB93N0NHNHXFhNwAmiy9zJdpY7DE01VbEVtPdrK1kkILeIbHGRJjvfBDBhnxBr8kD4g==", + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.40.1.tgz", + "integrity": "sha512-b2bcNm9Kbde03H+q+Jjw9tSfhYkzrDUf2d5MAd1bOJuVplXvFhWz7tRtWvD8/ORZi7qSCy0idW6tf2HgxSXQSg==", "cpu": [ "arm64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.34.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.34.1.tgz", - "integrity": "sha512-i7aT5HdiZIcd7quhzvwQ2oAuX7zPYrYfkrd1QFfs28Po/i0q6kas/oRrzGlDhAEyug+1UfUtkWdmoVlLJj5x9Q==", + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.40.1.tgz", + "integrity": "sha512-DfcogW8N7Zg7llVEfpqWMZcaErKfsj9VvmfSyRjCyo4BI3wPEfrzTtJkZG6gKP/Z92wFm6rz2aDO7/JfiR/whA==", "cpu": [ "ia32" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.34.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.34.1.tgz", - "integrity": "sha512-k3MVFD9Oq+laHkw2N2v7ILgoa9017ZMF/inTtHzyTVZjYs9cSH18sdyAf6spBAJIGwJ5UaC7et2ZH1WCdlhkMw==", + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.40.1.tgz", + "integrity": "sha512-ECyOuDeH3C1I8jH2MK1RtBJW+YPMvSfT0a5NN0nHfQYnDSJ6tUiZH3gzwVP5/Kfh/+Tt7tpWVF9LXNTnhTJ3kA==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "win32" @@ -1532,10 +1572,11 @@ "integrity": "sha512-xeIzl3h1Znn9w/LTITqpiwag0gXjA+ldi2ZkXIBxGEppGCW211Tza+eL6D4pKqs10bj5z2umBWk5WL6spQ2OCQ==" }, "node_modules/@types/estree": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", - "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", - "dev": true + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", + "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", + "dev": true, + "license": "MIT" }, "node_modules/@types/json-schema": { "version": "7.0.15", @@ -2097,9 +2138,10 @@ } }, "node_modules/axios": { - "version": "1.7.9", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz", - "integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz", + "integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==", + "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", @@ -3142,9 +3184,9 @@ "integrity": "sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw==" }, "node_modules/fastify": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.2.1.tgz", - "integrity": "sha512-rslrNBF67eg8/Gyn7P2URV8/6pz8kSAscFL4EThZJ8JBMaXacVdVE4hmUcnPNKERl5o/xTiBSLfdowBRhVF1WA==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.3.2.tgz", + "integrity": "sha512-AIPqBgtqBAwkOkrnwesEE+dOyU30dQ4kh7udxeGVR05CRGwubZx+p2H8P0C4cRnQT0+EPK4VGea2DTL2RtWttg==", "funding": [ { "type": "github", @@ -3155,6 +3197,7 @@ "url": "https://opencollective.com/fastify" } ], + "license": "MIT", "dependencies": { "@fastify/ajv-compiler": "^4.0.0", "@fastify/error": "^4.0.0", @@ -3166,9 +3209,9 @@ "find-my-way": "^9.0.0", "light-my-request": "^6.0.0", "pino": "^9.0.0", - "process-warning": "^4.0.0", + "process-warning": "^5.0.0", "rfdc": "^1.3.1", - "secure-json-parse": "^3.0.1", + "secure-json-parse": "^4.0.0", "semver": "^7.6.0", "toad-cache": "^3.7.0" } @@ -3178,10 +3221,26 @@ "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.0.1.tgz", "integrity": "sha512-HCxs+YnRaWzCl+cWRYFnHmeRFyR5GVnJTAaCJQiYzQSDwK9MgJdyAsuL3nh0EWRCYMgQ5MeziymvmAhUHYHDUQ==" }, + "node_modules/fastify/node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/fastify/node_modules/secure-json-parse": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-3.0.2.tgz", - "integrity": "sha512-H6nS2o8bWfpFEV6U38sOSjS7bTbdgbCGU9wEM6W14P5H0QOsz94KCusifV44GpHDTu2nqZbuDNhTzu+mjDSw1w==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.0.0.tgz", + "integrity": "sha512-dxtLJO6sc35jWidmLxo7ij+Eg48PM/kleBsxpC8QJE0qJICe+KawkDQmvCMZUr9u7WKVHgMW6vy3fQ7zMiFZMA==", "funding": [ { "type": "github", @@ -3191,7 +3250,8 @@ "type": "opencollective", "url": "https://opencollective.com/fastify" } - ] + ], + "license": "BSD-3-Clause" }, "node_modules/fastq": { "version": "1.17.1", @@ -3389,6 +3449,7 @@ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", "dev": true, "hasInstallScript": true, + "license": "MIT", "optional": true, "os": [ "darwin" @@ -5555,12 +5616,13 @@ } }, "node_modules/rollup": { - "version": "4.34.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.34.1.tgz", - "integrity": "sha512-iYZ/+PcdLYSGfH3S+dGahlW/RWmsqDhLgj1BT9DH/xXJ0ggZN7xkdP9wipPNjjNLczI+fmMLmTB9pye+d2r4GQ==", + "version": "4.40.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.40.1.tgz", + "integrity": "sha512-C5VvvgCCyfyotVITIAv+4efVytl5F7wt+/I2i9q9GZcEXW9BP52YYOXC58igUi+LFZVHukErIIqQSWwv/M3WRw==", "dev": true, + "license": "MIT", "dependencies": { - "@types/estree": "1.0.6" + "@types/estree": "1.0.7" }, "bin": { "rollup": "dist/bin/rollup" @@ -5570,25 +5632,26 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.34.1", - "@rollup/rollup-android-arm64": "4.34.1", - "@rollup/rollup-darwin-arm64": "4.34.1", - "@rollup/rollup-darwin-x64": "4.34.1", - "@rollup/rollup-freebsd-arm64": "4.34.1", - "@rollup/rollup-freebsd-x64": "4.34.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.34.1", - "@rollup/rollup-linux-arm-musleabihf": "4.34.1", - "@rollup/rollup-linux-arm64-gnu": "4.34.1", - "@rollup/rollup-linux-arm64-musl": "4.34.1", - "@rollup/rollup-linux-loongarch64-gnu": "4.34.1", - "@rollup/rollup-linux-powerpc64le-gnu": "4.34.1", - "@rollup/rollup-linux-riscv64-gnu": "4.34.1", - "@rollup/rollup-linux-s390x-gnu": "4.34.1", - "@rollup/rollup-linux-x64-gnu": "4.34.1", - "@rollup/rollup-linux-x64-musl": "4.34.1", - "@rollup/rollup-win32-arm64-msvc": "4.34.1", - "@rollup/rollup-win32-ia32-msvc": "4.34.1", - "@rollup/rollup-win32-x64-msvc": "4.34.1", + "@rollup/rollup-android-arm-eabi": "4.40.1", + "@rollup/rollup-android-arm64": "4.40.1", + "@rollup/rollup-darwin-arm64": "4.40.1", + "@rollup/rollup-darwin-x64": "4.40.1", + "@rollup/rollup-freebsd-arm64": "4.40.1", + "@rollup/rollup-freebsd-x64": "4.40.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.40.1", + "@rollup/rollup-linux-arm-musleabihf": "4.40.1", + "@rollup/rollup-linux-arm64-gnu": "4.40.1", + "@rollup/rollup-linux-arm64-musl": "4.40.1", + "@rollup/rollup-linux-loongarch64-gnu": "4.40.1", + "@rollup/rollup-linux-powerpc64le-gnu": "4.40.1", + "@rollup/rollup-linux-riscv64-gnu": "4.40.1", + "@rollup/rollup-linux-riscv64-musl": "4.40.1", + "@rollup/rollup-linux-s390x-gnu": "4.40.1", + "@rollup/rollup-linux-x64-gnu": "4.40.1", + "@rollup/rollup-linux-x64-musl": "4.40.1", + "@rollup/rollup-win32-arm64-msvc": "4.40.1", + "@rollup/rollup-win32-ia32-msvc": "4.40.1", + "@rollup/rollup-win32-x64-msvc": "4.40.1", "fsevents": "~2.3.2" } }, @@ -6109,23 +6172,28 @@ "dev": true }, "node_modules/tinyglobby": { - "version": "0.2.10", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.10.tgz", - "integrity": "sha512-Zc+8eJlFMvgatPZTl6A9L/yht8QqdmUNtURHaKZLmKBE12hNPSrqNkUp2cs3M/UKmNVVAMFQYSjYIVHDjW5zew==", + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz", + "integrity": "sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==", "dev": true, + "license": "MIT", "dependencies": { - "fdir": "^6.4.2", + "fdir": "^6.4.4", "picomatch": "^4.0.2" }, "engines": { "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" } }, "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.4.3", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.3.tgz", - "integrity": "sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw==", + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", + "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", "dev": true, + "license": "MIT", "peerDependencies": { "picomatch": "^3 || ^4" }, @@ -6140,6 +6208,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -6459,15 +6528,18 @@ } }, "node_modules/vite": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.2.0.tgz", - "integrity": "sha512-7dPxoo+WsT/64rDcwoOjk76XHj+TqNTIvHKcuMQ1k4/SeHDaQt5GFAeLYzrimZrMpn/O6DtdI03WUjdxuPM0oQ==", + "version": "6.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", + "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", "postcss": "^8.5.3", - "rollup": "^4.30.1" + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" }, "bin": { "vite": "bin/vite.js" @@ -6552,6 +6624,34 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/vite/node_modules/fdir": { + "version": "6.4.4", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.4.tgz", + "integrity": "sha512-1NZP+GK4GfuAv3PqKvxQRDMjdSRZjnkq7KfhlNrCNNlZ0ygQFpebfrnfnq/W7fpUnAv9aGWmY1zKx7FYL3gwhg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/vitest": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.0.5.tgz", diff --git a/node-server/package.json b/node-server/package.json index 5a207b8..3c19c93 100644 --- a/node-server/package.json +++ b/node-server/package.json @@ -45,9 +45,9 @@ "@fastify/websocket": "11.0.2", "@sinclair/typebox": "0.34.15", "ajv": "8.17.1", - "axios": "1.7.9", + "axios": "1.9.0", "dotenv": "16.4.7", - "fastify": "5.2.1", + "fastify": "5.3.2", "http": "0.0.1-security", "pino": "9.6.0", "tiny-typed-emitter": "2.1.0", diff --git a/node-server/src/server/create_server.ts b/node-server/src/server/create_server.ts index dce8559..87f211a 100644 --- a/node-server/src/server/create_server.ts +++ b/node-server/src/server/create_server.ts @@ -10,10 +10,12 @@ import fastifySensible from '@fastify/sensible'; import TokenService from './services/token_service.js'; import accessTokenHandler from './routes/session_auth_handler.js'; import healthcheckHandler from './routes/healthcheck_handler.js'; +import AuthenticationService from './services/authentication_service.js'; declare module 'fastify' { export interface FastifyInstance { config: ConfigType; + authenticationService: AuthenticationService; transcriptionEngine: TranscriptionEngine; tokenService: TokenService; } @@ -45,6 +47,7 @@ export default function createServer(config: ConfigType, logger: Logger) { fastify.decorate('config', config); fastify.decorate('transcriptionEngine', new TranscriptionEngine(config, logger)); fastify.decorate('tokenService', new TokenService(config, logger)); + fastify.decorate('authenticationService', new AuthenticationService(config, fastify.tokenService)); // Register routes fastify.register(websocketHandler); diff --git a/node-server/src/server/hooks/create_authorize_hook.test.ts b/node-server/src/server/hooks/create_authorize_hook.test.ts index 57fca9e..27da14b 100644 --- a/node-server/src/server/hooks/create_authorize_hook.test.ts +++ b/node-server/src/server/hooks/create_authorize_hook.test.ts @@ -1,262 +1,110 @@ -import type TokenService from '@server/services/token_service.js'; -import type {ConfigType} from '@shared/config/config_schema.js'; import Fastify from 'fastify'; import {describe, expect, vi} from 'vitest'; -import createAuthorizeHook, {Identities} from './create_authorize_hook.js'; -import formatTestNames from '@test/utils/format_test_names.js'; +import createAuthorizeHook from './create_authorize_hook.js'; +import type AuthenticationService from '@server/services/authentication_service.js'; +import {Identities} from '@server/services/authentication_service.js'; describe('createAuthorizeHook', it => { - const validAccessToken = 'valid-access-token'; - const validSessionToken = 'valid-session-token'; - const validSourceToken = 'valid-source-token'; - - const fakeConfig = { - auth: { - required: true, - sourceToken: validSourceToken, - }, - } as ConfigType; - - const fakeTokenService = { - accessTokenIsValid: vi.fn(token => { - return token === validAccessToken; - }), - sessionTokenIsValid: vi.fn(token => { - return token === validSessionToken; - }), - getSessionTokenExpiry: vi.fn(() => { - return new Date(Date.now() + 10_000); - }), - } as unknown as TokenService; - - function setupTest(allowedIdentities: Array) { + function setupTest(fakeAuthenticationService: AuthenticationService, allowedIdentities: Array) { const fastify = Fastify(); const reqHandlerSpy = vi.fn((req, reply) => reply.code(200).send('hi')); fastify.post( '/test', - {preHandler: createAuthorizeHook(fakeConfig, fakeTokenService, allowedIdentities)}, + {preHandler: createAuthorizeHook(fakeAuthenticationService, allowedIdentities)}, reqHandlerSpy, ); return {fastify, reqHandlerSpy}; } - it.for( - formatTestNames([ - {name: 'AccessToken', identity: Identities.AccessToken, param: `accessToken=${validAccessToken}`}, - {name: 'SessionToken', identity: Identities.SessionToken, param: `sessionToken=${validSessionToken}`}, - {name: 'SourceToken', identity: Identities.SourceToken, param: `sourceToken=${validSourceToken}`}, - ]), - )('authorizes valid tokens in request query string for identity: %s', async ([, {identity, param}]) => { - const {fastify, reqHandlerSpy} = setupTest([identity]); - - const reply = await fastify.inject({ - method: 'post', - url: `/test?${param}`, - }); - - expect(reply.statusCode).toBe(200); - expect(reqHandlerSpy).toHaveBeenCalled(); - }); - - it.for( - formatTestNames([ - {name: 'AccessToken', identity: Identities.AccessToken, body: {accessToken: validAccessToken}}, - {name: 'SessionToken', identity: Identities.SessionToken, body: {sessionToken: validSessionToken}}, - {name: 'SourceToken', identity: Identities.SourceToken, body: {sourceToken: validSourceToken}}, - ]), - )('authorizes valid tokens in request body for identity: %s', async ([, {identity, body}]) => { - const {fastify, reqHandlerSpy} = setupTest([identity]); - - const reply = await fastify.inject({ - method: 'post', - url: '/test', - body, - }); - - expect(reply.statusCode).toBe(200); - expect(reqHandlerSpy).toHaveBeenCalled(); - }); - - it.for( - formatTestNames([ - {name: 'AccessToken', identity: Identities.AccessToken, param: `accessToken=${validSessionToken}`}, - {name: 'SessionToken', identity: Identities.SessionToken, param: `sessionToken=${validAccessToken}`}, - {name: 'SourceToken', identity: Identities.SourceToken, param: `sourceToken=${validAccessToken}`}, - ]), - )('rejects invalid tokens in request query string for identity: %s', async ([, {identity, param}]) => { - const {fastify, reqHandlerSpy} = setupTest([identity]); - - const reply = await fastify.inject({ - method: 'post', - url: `/test?${param}`, - }); - - expect(reply.statusCode).toBe(403); - expect(reqHandlerSpy).not.toHaveBeenCalled(); - }); - - it.for( - formatTestNames([ - {name: 'AccessToken', identity: Identities.AccessToken, body: {accessToken: validSourceToken}}, - {name: 'SessionToken', identity: Identities.SessionToken, body: {sessionToken: validSourceToken}}, - {name: 'SourceToken', identity: Identities.SourceToken, body: {sourceToken: validSessionToken}}, - ]), - )('rejects invalid tokens in request body for identity: %s', async ([, {identity, body}]) => { - const {fastify, reqHandlerSpy} = setupTest([identity]); - - const reply = await fastify.inject({ - method: 'post', - url: '/test', - body, + it('calls AuthenticationService with provided tokens and identities', async () => { + const fakeAuthenticationService = { + authorizeTokens: vi.fn(() => { + return {authorized: true}; + }), + } as unknown as AuthenticationService; + const identities = [Identities.AccessToken, Identities.SourceToken]; + + const {fastify} = setupTest(fakeAuthenticationService, identities); + await fastify.inject({ + method: 'POST', + path: '/test', + body: { + accessToken: 'accessToken', + sessionToken: 'sessionToken', + sourceToken: 'sourceToken', + }, }); - expect(reply.statusCode).toBe(403); - expect(reqHandlerSpy).not.toHaveBeenCalled(); + expect(fakeAuthenticationService.authorizeTokens).toHaveBeenCalledExactlyOnceWith( + 'accessToken', + 'sessionToken', + 'sourceToken', + identities, + fastify.log, + ); }); - it.for( - formatTestNames([ - { - name: 'AccessToken', - identity: Identities.AccessToken, - param: `accessToken=${validAccessToken}`, - body: {accessToken: 'invalid-token'}, + it('accepts request when AuthenticationService accepts tokens', async () => { + const fakeAuthenticationService = { + authorizeTokens: vi.fn(() => { + return {authorized: true}; + }), + } as unknown as AuthenticationService; + + const {fastify, reqHandlerSpy} = setupTest(fakeAuthenticationService, []); + await fastify.inject({ + method: 'POST', + path: '/test', + body: { + accessToken: 'accessToken', + sessionToken: 'sessionToken', + sourceToken: 'sourceToken', }, - { - name: 'SessionToken', - identity: Identities.SessionToken, - param: `sessionToken=${validSessionToken}`, - body: {sessionToken: 'invalid-token'}, - }, - { - name: 'SourceToken', - identity: Identities.SourceToken, - param: `sourceToken=${validSourceToken}`, - body: {sourceToken: 'invalid-token'}, - }, - ]), - )('prioritizes tokens in request body for identity (invalid): %s', async ([, {identity, param, body}]) => { - const {fastify, reqHandlerSpy} = setupTest([identity]); - - const reply = await fastify.inject({ - method: 'post', - url: `/test?${param}`, - body, }); - expect(reply.statusCode).toBe(403); - expect(reqHandlerSpy).not.toHaveBeenCalled(); + expect(reqHandlerSpy).toHaveBeenCalledOnce(); }); - it.for( - formatTestNames([ - { - name: 'AccessToken', - identity: Identities.AccessToken, - param: 'accessToken=invalid-token', - body: {accessToken: validAccessToken}, - }, - { - name: 'SessionToken', - identity: Identities.SessionToken, - param: 'sessionToken=invalid-token', - body: {sessionToken: validSessionToken}, + it('rejects request when AuthenticationService rejects tokens', async () => { + const fakeAuthenticationService = { + authorizeTokens: vi.fn(() => { + return {authorized: false}; + }), + } as unknown as AuthenticationService; + + const {fastify, reqHandlerSpy} = setupTest(fakeAuthenticationService, []); + await fastify.inject({ + method: 'POST', + path: '/test', + body: { + accessToken: 'accessToken', + sessionToken: 'sessionToken', + sourceToken: 'sourceToken', }, - { - name: 'SourceToken', - identity: Identities.SourceToken, - param: 'sourceToken=invalid-token', - body: {sourceToken: validSourceToken}, - }, - ]), - )('prioritizes tokens in request body for identity (valid): %s', async ([, {identity, param, body}]) => { - const {fastify, reqHandlerSpy} = setupTest([identity]); - - const reply = await fastify.inject({ - method: 'post', - url: `/test?${param}`, - body, }); - expect(reply.statusCode).toBe(200); - expect(reqHandlerSpy).toHaveBeenCalled(); + expect(reqHandlerSpy).not.toHaveBeenCalled(); }); - it.for( - formatTestNames([ - { - name: 'AccessToken, SessionToken', - identities: [Identities.AccessToken, Identities.SessionToken], - expected: [true, true, false], - }, - { - name: 'AccessToken, SourceToken', - identities: [Identities.AccessToken, Identities.SourceToken], - expected: [true, false, true], - }, - { - name: 'SessionToken, SourceToken', - identities: [Identities.SessionToken, Identities.SourceToken], - expected: [false, true, true], + it('decorates request with authorizationExpiryTimeout when provided', async () => { + const fakeAuthenticationService = { + authorizeTokens: vi.fn(() => { + return {authorized: true, expiresInMs: 1000}; + }), + } as unknown as AuthenticationService; + + const {fastify, reqHandlerSpy} = setupTest(fakeAuthenticationService, []); + await fastify.inject({ + method: 'POST', + path: '/test', + body: { + accessToken: 'accessToken', + sessionToken: 'sessionToken', + sourceToken: 'sourceToken', }, - { - name: 'AccessToken, SessionToken, SourceToken', - identities: [Identities.AccessToken, Identities.SessionToken, Identities.SourceToken], - expected: [true, true, true], - }, - ]), - )('authorizes multiple identities: %s', async ([, {identities, expected}]) => { - const {fastify, reqHandlerSpy} = setupTest(identities); - - const toTest = [ - {accessToken: validAccessToken}, - {sessionToken: validSessionToken}, - {sourceToken: validSourceToken}, - ]; - - for (let i = 0; i < toTest.length; i++) { - reqHandlerSpy.mockClear(); - - const reply = await fastify.inject({ - method: 'post', - url: '/test', - body: toTest[i], - }); - - if (expected[i]) { - expect(reply.statusCode).toBe(200); - expect(reqHandlerSpy).toHaveBeenCalled(); - } else { - expect(reply.statusCode).toBe(403); - expect(reqHandlerSpy).not.toHaveBeenCalled(); - } - } - }); - - it('authorizes if auth is disabled for identity', async () => { - const fastify = Fastify(); - const reqHandlerSpy = vi.fn((req, reply) => reply.code(200).send('hi')); - fastify.post( - '/test', - {preHandler: createAuthorizeHook({auth: {required: false}} as ConfigType, fakeTokenService, [])}, - reqHandlerSpy, - ); - - const reply = await fastify.inject({ - method: 'post', - url: '/test', }); - expect(reply.statusCode).toBe(200); - expect(reqHandlerSpy).toHaveBeenCalled(); - }); - - it('provides session expiration for sessionTokens', async () => { - const {fastify, reqHandlerSpy} = setupTest([Identities.SessionToken]); - - await fastify.inject({method: 'POST', path: '/test', body: {sessionToken: validSessionToken}}); - - expect(Math.abs(reqHandlerSpy.mock.calls[0][0]['authorizationExpiryTimeout'] - 10_000)).toBeLessThan(100); + expect(reqHandlerSpy.mock.calls[0][0].authorizationExpiryTimeout).toBe(1000); }); }); diff --git a/node-server/src/server/hooks/create_authorize_hook.ts b/node-server/src/server/hooks/create_authorize_hook.ts index 79bf0fd..b4b7529 100644 --- a/node-server/src/server/hooks/create_authorize_hook.ts +++ b/node-server/src/server/hooks/create_authorize_hook.ts @@ -1,59 +1,41 @@ -import type RequestAuthorizer from '@server/services/token_service.js'; -import type {ConfigType} from '@shared/config/config_schema.js'; +import type {Identities} from '@server/services/authentication_service.js'; +import type AuthenticationService from '@server/services/authentication_service.js'; +import type {Logger} from '@shared/logger/logger.js'; import type {DoneFuncWithErrOrRes, FastifyReply, FastifyRequest} from 'fastify'; -export enum Identities { - SourceToken, - AccessToken, - SessionToken, -} - /** * Creates a fastify preHandler hook to handle authorizing requests * Checks request query string for sessionToken or sourceToken and checks if they are valid - * A valid sessionToken corresponds to a TranscriptionSink identity - * A valid sourceToken corresponds to a Kiosk identity - * If the parsed identity is in allowedIdentities, request is authorized, otherwise it is rejected - * @param config server config object - * @param requestAuthorizer requestAuthorizer instance + + * @param authenticationService authenticationService instance * @param allowedIdentities array of identities that should be accepted * @returns fastify preHandler hook */ export default function createAuthorizeHook( - config: ConfigType, - requestAuthorizer: RequestAuthorizer, + authenticationService: AuthenticationService, allowedIdentities: Array, ) { return ( request: FastifyRequest<{ - Querystring: {accessToken?: string; sessionToken?: string; sourceToken?: string}; Body?: {accessToken?: string; sessionToken?: string; sourceToken?: string}; }>, reply: FastifyReply, done: DoneFuncWithErrOrRes, ) => { - if (!config.auth.required) return done(); - - const accessToken = request.body?.accessToken ? request.body?.accessToken : request.query.accessToken; - if (allowedIdentities.includes(Identities.AccessToken) && requestAuthorizer.accessTokenIsValid(accessToken)) { - request.log.debug('Request authorized via access token'); - return done(); - } + const accessToken = request.body?.accessToken; + const sessionToken = request.body?.sessionToken; + const sourceToken = request.body?.sourceToken; - const sessionToken = request.body?.sessionToken ? request.body?.sessionToken : request.query.sessionToken; - if (allowedIdentities.includes(Identities.SessionToken) && requestAuthorizer.sessionTokenIsValid(sessionToken)) { - const expiration = requestAuthorizer.getSessionTokenExpiry(sessionToken); - if (expiration) { - request.log.debug('Request authorized via session token'); - - request.authorizationExpiryTimeout = expiration.getTime() - new Date().getTime(); - return done(); - } - } + const {authorized, expiresInMs} = authenticationService.authorizeTokens( + accessToken, + sessionToken, + sourceToken, + allowedIdentities, + request.log as Logger, + ); - const sourceToken = request.body?.sourceToken ? request.body?.sourceToken : request.query.sourceToken; - if (allowedIdentities.includes(Identities.SourceToken) && sourceToken === config.auth.sourceToken) { - request.log.debug('Request authorized via source token'); + if (authorized) { + request.authorizationExpiryTimeout = expiresInMs; return done(); } diff --git a/node-server/src/server/routes/session_auth_handler.ts b/node-server/src/server/routes/session_auth_handler.ts index 3e84729..2dba599 100644 --- a/node-server/src/server/routes/session_auth_handler.ts +++ b/node-server/src/server/routes/session_auth_handler.ts @@ -1,4 +1,5 @@ -import createAuthorizeHook, {Identities} from '@server/hooks/create_authorize_hook.js'; +import createAuthorizeHook from '@server/hooks/create_authorize_hook.js'; +import {Identities} from '@server/services/authentication_service.js'; import {FastifyInstance} from 'fastify'; /** @@ -8,7 +9,7 @@ import {FastifyInstance} from 'fastify'; export default function sessionAuthHandler(fastify: FastifyInstance) { fastify.post( '/accessToken', - {preHandler: createAuthorizeHook(fastify.config, fastify.tokenService, [Identities.SourceToken])}, + {preHandler: createAuthorizeHook(fastify.authenticationService, [Identities.SourceToken])}, (request, reply) => { const {accessToken, expires} = fastify.tokenService.getAccessToken(); return reply.send({ @@ -21,7 +22,7 @@ export default function sessionAuthHandler(fastify: FastifyInstance) { fastify.post( '/startSession', - {preHandler: createAuthorizeHook(fastify.config, fastify.tokenService, [Identities.AccessToken])}, + {preHandler: createAuthorizeHook(fastify.authenticationService, [Identities.AccessToken])}, (request, reply) => { if (typeof request.body !== 'object') return reply.code(400).send(); const {accessToken} = request.body as {accessToken?: string}; diff --git a/node-server/src/server/routes/websocket_handler.ts b/node-server/src/server/routes/websocket_handler.ts index cd966c8..b4f3a77 100644 --- a/node-server/src/server/routes/websocket_handler.ts +++ b/node-server/src/server/routes/websocket_handler.ts @@ -1,26 +1,81 @@ -import createAuthorizeHook, {Identities} from '@server/hooks/create_authorize_hook.js'; +import AuthenticationService, {Identities} from '@server/services/authentication_service.js'; import type {BackendTranscriptBlock} from '@server/services/transcription_engine.js'; -import {FastifyInstance} from 'fastify'; +import type {Logger} from '@shared/logger/logger.js'; +import {FastifyInstance, type FastifyBaseLogger} from 'fastify'; import WebSocket from 'ws'; +/** + * Helper function for determining authorization for a websocket + * @param ws websocket to register + * @param authenticationService authenticationService instance + * @param allowedIdentities array of identities that should be accepted + * @param log logger + * @returns true if authorized, false otherwise + */ +async function authorizeWebsocket( + ws: WebSocket, + authenticationService: AuthenticationService, + allowedIdentities: Array, + log: FastifyBaseLogger, +): Promise { + const jsonMessage = new Promise(resolve => { + ws.once('message', data => { + try { + resolve(JSON.parse(data.toString())); + } catch (err) { + log.error({msg: 'Invalid authorization message received from client', err}); + } + }); + }); + + const timeout = new Promise(resolve => setTimeout(() => resolve(false), 5_000)); + + const result = await Promise.any([jsonMessage, timeout]); + if (result === false) { + log.info({msg: 'Authorization timed out'}); + return false; + } + log.debug({msg: 'Received authorization message from client', message: result}); + + if (typeof result !== 'object' || result === null) { + log.error({msg: 'Invalid authorization message received from client: Not an object'}); + return false; + } + + const sourceToken = (result as {sourceToken: string})['sourceToken']; + const sessionToken = (result as {sessionToken: string})['sessionToken']; + const {authorized} = authenticationService.authorizeTokens( + undefined, + sessionToken, + sourceToken, + allowedIdentities, + log as Logger, + ); + return authorized; +} + /** * Register a websocket that listens for transcription events and forwards them * Closes websocket when session becomes invalid * @param fastify fastify webserver instance * @param ws websocket to register + * @param log logger */ -function registerSink(fastify: FastifyInstance, ws: WebSocket) { +function registerSink(fastify: FastifyInstance, ws: WebSocket, log: FastifyBaseLogger) { + log.debug('Registering websocket as sink'); const onTranscription = (block: BackendTranscriptBlock) => { try { + log.trace({msg: 'Forwarding transcription block to client', block}); ws.send(JSON.stringify(block)); - } catch { - // + } catch (err) { + log.error({msg: 'Error sending transcription to sink', err}); } }; fastify.transcriptionEngine.on('transcription', onTranscription); ws.on('close', () => { + log.trace('Websocket closed, removing transcription event listener'); fastify.transcriptionEngine.removeListener('transcription', onTranscription); }); } @@ -30,16 +85,31 @@ function registerSink(fastify: FastifyInstance, ws: WebSocket) { * Will throw error if a second websocket is registered before first has closed * @param fastify fastify webserver instance * @param ws websocket to register + * @param log logger */ -function registerSource(fastify: FastifyInstance, ws: WebSocket) { - ws.on('message', data => { - if (data instanceof Buffer) { - try { - fastify.transcriptionEngine.sendAudioChunk(data); - } catch { - // - } +function registerSource(fastify: FastifyInstance, ws: WebSocket, log: FastifyBaseLogger) { + fastify.transcriptionEngine.connectWhisperService(); + + log.debug('Registering websocket as source'); + const onSourceMessage = (data: JSON) => { + try { + log.trace({msg: 'Forwarding source message to client', data}); + ws.send(JSON.stringify(data)); + } catch (err) { + log.error({msg: 'Error sending source message to source', err}); } + }; + + ws.on('message', (data, isBinary) => { + log.trace({msg: 'Forwarding message for transcription', data}); + fastify.transcriptionEngine.forwardMessage(data, isBinary); + }); + + fastify.transcriptionEngine.on('sourceMessage', onSourceMessage); + ws.on('close', () => { + log.trace('Websocket closed, removing sourceMessage event listener'); + fastify.transcriptionEngine.removeListener('sourceMessage', onSourceMessage); + fastify.transcriptionEngine.disconnectWhisperService(); }); } @@ -48,62 +118,58 @@ function registerSource(fastify: FastifyInstance, ws: WebSocket) { * @param fastify */ export default function websocketHandler(fastify: FastifyInstance) { - fastify.get( - '/sourcesink', - { - websocket: true, - preHandler: createAuthorizeHook(fastify.config, fastify.tokenService, [Identities.SourceToken]), - }, - (ws, req) => { - registerSink(fastify, ws); - registerSource(fastify, ws); - - ws.on('close', code => { - req.log.info({msg: 'Websocket closed', code}); - }); - }, - ); + fastify.get('/sourcesink', {websocket: true}, async (ws, req) => { + if (!(await authorizeWebsocket(ws, fastify.authenticationService, [Identities.SourceToken], fastify.log))) { + return ws.close(); + } - fastify.get( - '/source', - { - websocket: true, - preHandler: createAuthorizeHook(fastify.config, fastify.tokenService, [Identities.SourceToken]), - }, - (ws, req) => { - registerSource(fastify, ws); - - ws.on('close', code => { - req.log.info({msg: 'Websocket closed', code}); - }); - }, - ); + registerSink(fastify, ws, req.log); + registerSource(fastify, ws, req.log); - fastify.get( - '/sink', - { - websocket: true, - preHandler: createAuthorizeHook(fastify.config, fastify.tokenService, [ - Identities.SourceToken, - Identities.SessionToken, - ]), - }, - (ws, req) => { - registerSink(fastify, ws); - - // Close websocket when session expires - let expirationTimeout: NodeJS.Timeout | undefined; - if (req.authorizationExpiryTimeout) { - expirationTimeout = setTimeout(() => { - fastify.log.info('Session token expired, closing socket.'); - ws.close(3000); - }, req.authorizationExpiryTimeout); - } + ws.on('close', code => { + req.log.info({msg: 'Websocket closed', code}); + }); + }); - ws.on('close', code => { - clearTimeout(expirationTimeout); - req.log.info({msg: 'Websocket closed', code}); - }); - }, - ); + fastify.get('/source', {websocket: true}, async (ws, req) => { + if (!(await authorizeWebsocket(ws, fastify.authenticationService, [Identities.SourceToken], fastify.log))) { + return ws.close(); + } + + registerSource(fastify, ws, req.log); + + ws.on('close', code => { + req.log.info({msg: 'Websocket closed', code}); + }); + }); + + fastify.get('/sink', {websocket: true}, async (ws, req) => { + if ( + !(await authorizeWebsocket( + ws, + fastify.authenticationService, + [Identities.SourceToken, Identities.SessionToken], + fastify.log, + )) + ) { + return ws.close(); + } + + registerSink(fastify, ws, req.log); + + // Close websocket when session expires + let expirationTimeout: NodeJS.Timeout | undefined; + if (req.authorizationExpiryTimeout) { + fastify.log.debug({msg: 'Setting session expiration timeout', timeout: req.authorizationExpiryTimeout}); + expirationTimeout = setTimeout(() => { + fastify.log.info('Session token expired, closing socket.'); + ws.close(3000); + }, req.authorizationExpiryTimeout); + } + + ws.on('close', code => { + clearTimeout(expirationTimeout); + req.log.info({msg: 'Websocket closed', code}); + }); + }); } diff --git a/node-server/src/server/services/authentication_service.test.ts b/node-server/src/server/services/authentication_service.test.ts new file mode 100644 index 0000000..1423327 --- /dev/null +++ b/node-server/src/server/services/authentication_service.test.ts @@ -0,0 +1,143 @@ +import {describe, expect, vi} from 'vitest'; +import type TokenService from './token_service.js'; +import type {ConfigType} from '@shared/config/config_schema.js'; +import AuthenticationService, {Identities} from './authentication_service.js'; +import formatTestNames from '@test/utils/format_test_names.js'; +import fakeLogger from '@test/fakes/fake_logger.js'; + +describe('createAuthorizeHook', it => { + const validAccessToken = 'valid-access-token'; + const validSessionToken = 'valid-session-token'; + const validSourceToken = 'valid-source-token'; + + const fakeConfig = { + auth: { + required: true, + sourceToken: validSourceToken, + }, + } as ConfigType; + + const fakeTokenService = { + accessTokenIsValid: vi.fn(token => { + return token === validAccessToken; + }), + sessionTokenIsValid: vi.fn(token => { + return token === validSessionToken; + }), + getSessionTokenExpiry: vi.fn(() => { + return new Date(Date.now() + 10_000); + }), + } as unknown as TokenService; + + const validCases = formatTestNames([ + { + name: 'Only AccessToken allowed, only AccessToken provided', + identities: [Identities.AccessToken], + tokens: {accessToken: validAccessToken, sessionToken: undefined, sourceToken: undefined}, + }, + { + name: 'Only SessionToken allowed, only SessionToken provided', + identities: [Identities.SessionToken], + tokens: {accessToken: undefined, sessionToken: validSessionToken, sourceToken: undefined}, + }, + { + name: 'Only SourceToken allowed, only SourceToken provided', + identities: [Identities.SourceToken], + tokens: {accessToken: undefined, sessionToken: undefined, sourceToken: validSourceToken}, + }, + { + name: 'All tokens allowed, AccessToken provided', + identities: [Identities.AccessToken, Identities.SessionToken, Identities.SourceToken], + tokens: {accessToken: validAccessToken, sessionToken: undefined, sourceToken: undefined}, + }, + { + name: 'All tokens allowed, SessionToken provided', + identities: [Identities.AccessToken, Identities.SessionToken, Identities.SourceToken], + tokens: {accessToken: undefined, sessionToken: validSessionToken, sourceToken: undefined}, + }, + { + name: 'All tokens allowed, SourceToken provided', + identities: [Identities.AccessToken, Identities.SessionToken, Identities.SourceToken], + tokens: {accessToken: undefined, sessionToken: undefined, sourceToken: validSourceToken}, + }, + ]); + + const invalidCases = formatTestNames([ + { + name: 'Only AccessToken allowed, no tokens provided', + identities: [Identities.AccessToken], + tokens: {accessToken: undefined, sessionToken: undefined, sourceToken: undefined}, + }, + { + name: 'Only SessionToken allowed, no tokens provided', + identities: [Identities.SessionToken], + tokens: {accessToken: undefined, sessionToken: undefined, sourceToken: undefined}, + }, + { + name: 'Only SourceToken allowed, no tokens provided', + identities: [Identities.SourceToken], + tokens: {accessToken: undefined, sessionToken: undefined, sourceToken: undefined}, + }, + { + name: 'All tokens allowed, no tokens provided', + identities: [Identities.AccessToken, Identities.SessionToken, Identities.SourceToken], + tokens: {accessToken: undefined, sessionToken: undefined, sourceToken: undefined}, + }, + { + name: 'Only AccessToken allowed, SessionToken provided', + identities: [Identities.AccessToken], + tokens: {accessToken: undefined, sessionToken: validSessionToken, sourceToken: undefined}, + }, + { + name: 'Only SessionToken allowed, SourceToken provided', + identities: [Identities.SessionToken], + tokens: {accessToken: undefined, sessionToken: undefined, sourceToken: validSourceToken}, + }, + { + name: 'Only SourceToken allowed, AccessToken provided', + identities: [Identities.SessionToken], + tokens: {accessToken: validAccessToken, sessionToken: undefined, sourceToken: undefined}, + }, + ]); + + it.for(validCases)('approves authorization whe: %s', ([, {identities, tokens}]) => { + const {accessToken, sessionToken, sourceToken} = tokens; + const as = new AuthenticationService(fakeConfig, fakeTokenService); + + const {authorized} = as.authorizeTokens(accessToken, sessionToken, sourceToken, identities, fakeLogger()); + + expect(authorized).toBe(true); + }); + + it.for(invalidCases)('rejects authentication when: %s', ([, {identities, tokens}]) => { + const {accessToken, sessionToken, sourceToken} = tokens; + const as = new AuthenticationService(fakeConfig, fakeTokenService); + + const {authorized} = as.authorizeTokens(accessToken, sessionToken, sourceToken, identities, fakeLogger()); + + expect(authorized).toBe(false); + }); + + it('accepts authentication when authentication is turned off', () => { + const as = new AuthenticationService({auth: {required: false}} as ConfigType, fakeTokenService); + + const {authorized} = as.authorizeTokens(undefined, undefined, undefined, [], fakeLogger()); + + expect(authorized).toBe(true); + }); + + it('returns expiration timeout for session token', () => { + const as = new AuthenticationService(fakeConfig, fakeTokenService); + + const {expiresInMs} = as.authorizeTokens( + undefined, + validSessionToken, + undefined, + [Identities.SessionToken], + fakeLogger(), + ); + + expect(typeof expiresInMs).toBe('number'); + expect(Math.abs((expiresInMs ? expiresInMs : 0) - 10_000)).toBeLessThan(100); + }); +}); diff --git a/node-server/src/server/services/authentication_service.ts b/node-server/src/server/services/authentication_service.ts new file mode 100644 index 0000000..826886e --- /dev/null +++ b/node-server/src/server/services/authentication_service.ts @@ -0,0 +1,58 @@ +import type {ConfigType} from '@shared/config/config_schema.js'; +import type {Logger} from '@shared/logger/logger.js'; +import type TokenService from './token_service.js'; + +export enum Identities { + SourceToken, + AccessToken, + SessionToken, +} + +export default class AuthenticationService { + constructor( + private _config: ConfigType, + private _tokenService: TokenService, + ) {} + + /** + * Checks if provided tokens correspond to an allowed identity + * Token must be valid in order for identity to be considered + * If the parsed identity is in allowedIdentities, request is authorized, otherwise it is rejected + * @param accessToken + * @param sessionToken + * @param sourceToken + * @param allowedIdentities + * @param log + * @returns object with authorized property indicating result + * if identity was a SourceToken, expiresInMs property is also provided + */ + authorizeTokens( + accessToken: string | undefined, + sessionToken: string | undefined, + sourceToken: string | undefined, + allowedIdentities: Array, + log: Logger, + ): {authorized: boolean; expiresInMs?: number} { + if (!this._config.auth.required) return {authorized: true}; + + if (allowedIdentities.includes(Identities.AccessToken) && this._tokenService.accessTokenIsValid(accessToken)) { + log.debug('Request authorized via access token'); + return {authorized: true}; + } + + if (allowedIdentities.includes(Identities.SessionToken) && this._tokenService.sessionTokenIsValid(sessionToken)) { + const expiration = this._tokenService.getSessionTokenExpiry(sessionToken); + if (expiration) { + log.debug('Request authorized via session token'); + const authorizationExpiryTimeout = expiration.getTime() - new Date().getTime(); + return {authorized: true, expiresInMs: authorizationExpiryTimeout}; + } + } + + if (allowedIdentities.includes(Identities.SourceToken) && sourceToken === this._config.auth.sourceToken) { + log.debug('Request authorized via source token'); + return {authorized: true}; + } + return {authorized: false}; + } +} diff --git a/node-server/src/server/services/token_service.ts b/node-server/src/server/services/token_service.ts index 78142e5..47aaf70 100644 --- a/node-server/src/server/services/token_service.ts +++ b/node-server/src/server/services/token_service.ts @@ -5,14 +5,17 @@ import crypto from 'node:crypto'; const MAX_TIMESTAMP = 8640000000000000; export default class TokenService { + private _log: Logger; private _validAccessTokens: {[key: string]: Date} = {}; private _validSessionTokens: {[key: string]: Date} = {}; private _currentAccessToken = ' '; constructor( private _config: ConfigType, - private _log: Logger, + log: Logger, ) { + this._log = log.child({service: 'TokenService'}); + this._updateAccessTokens(); if (this._config.auth.required) { diff --git a/node-server/src/server/services/transcription_engine.test.ts b/node-server/src/server/services/transcription_engine.test.ts index 257982b..044362c 100644 --- a/node-server/src/server/services/transcription_engine.test.ts +++ b/node-server/src/server/services/transcription_engine.test.ts @@ -21,62 +21,74 @@ function createWebsocketServer(): Promise<{wss: WebSocketServer; address: string } describe('Transcription engine', it => { - async function connectEngine() { + const apiKey = 'SOME_KEY'; + async function createTranscriptionEngine() { const {wss, address} = await createWebsocketServer(); const websocketConnected = new Promise(resolve => { - wss.once('connection', socket => setTimeout(() => resolve(socket), 1000)); + wss.once('connection', socket => setImmediate(() => resolve(socket))); }); - const te = new TranscriptionEngine( - { - whisper: { - endpoint: address, - reconnectIntervalSec: 1, - }, - } as ConfigType, - fakeLogger(), - ); + const te = new TranscriptionEngine({whisper: {endpoint: address, apiKey}} as ConfigType, fakeLogger()); return {wss, websocketConnected, te}; } function cleanup(te: TranscriptionEngine, wss: WebSocketServer) { - te.destroy(); + te.disconnectWhisperService(); wss.close(); } it('connects to whisper service', async () => { - const {wss, websocketConnected, te} = await connectEngine(); + const {wss, websocketConnected, te} = await createTranscriptionEngine(); + te.connectWhisperService(); await expect(websocketConnected).resolves.toBeTruthy(); cleanup(te, wss); }); - it('reconnects to whisper service on disconnect', async () => { - const {wss, websocketConnected, te} = await connectEngine(); + it('disconnects existing connection if connect is called again', async () => { + const {wss, websocketConnected, te} = await createTranscriptionEngine(); + te.connectWhisperService(); const socket = await websocketConnected; - socket.close(); + const socketClose = new Promise(resolve => socket.on('close', () => resolve(true))); - const websocketReconnected = new Promise(resolve => { - wss.once('connection', socket => setTimeout(() => resolve(socket), 1000)); + // Wait for transcription engine's socket to be in ready state + await new Promise(r => setTimeout(r, 1000)); + + te.connectWhisperService(); + await expect(socketClose).resolves.toBeTruthy(); + + cleanup(te, wss); + }); + + it('sends apiKey after connecting to whisper service', async () => { + const {wss, websocketConnected, te} = await createTranscriptionEngine(); + + te.connectWhisperService(); + const socket = await websocketConnected; + + const receivedMessage = new Promise(resolve => { + socket.once('message', data => resolve(JSON.parse(data.toString()))); }); - await expect(websocketReconnected).resolves.toBeTruthy(); + await expect(receivedMessage).resolves.toEqual({api_key: apiKey}); cleanup(te, wss); }); - it('sends audio chunks to whisper service', async () => { - const {wss, websocketConnected, te} = await connectEngine(); + it('forwards audio chunks to whisper service', async () => { + const {wss, websocketConnected, te} = await createTranscriptionEngine(); + te.connectWhisperService(); const socket = await websocketConnected; + // Wait for transcription engine's socket to be in ready state + await new Promise(r => setTimeout(r, 1000)); + const receivedChunks: Array = []; - socket.on('message', data => { - receivedChunks.push(data as Buffer); - }); + socket.on('message', data => receivedChunks.push(data as Buffer)); const audioFileDir = path.join(__dirname, '../../../../test-audio-files/wikipedia-.fun/chunked'); const chunkFiles = fs.readdirSync(audioFileDir); @@ -84,12 +96,12 @@ describe('Transcription engine', it => { for (const file of chunkFiles) { const chunk = fs.readFileSync(path.join(audioFileDir, file)); chunks.push(chunk); - te.sendAudioChunk(chunk); + te.forwardMessage(chunk, true); } await new Promise(r => setTimeout(r, 1000)); - expect(chunks.length).toEqual(receivedChunks.length); + expect(receivedChunks.length).toBe(chunks.length); for (let i = 0; i < chunks.length; i++) { expect(chunks[i].compare(receivedChunks[i])).toBe(0); } @@ -97,15 +109,36 @@ describe('Transcription engine', it => { cleanup(te, wss); }); - it('recieves transcription events', async () => { - const {wss, websocketConnected, te} = await connectEngine(); + it('forwards model selection message to whisper service', async () => { + const {wss, websocketConnected, te} = await createTranscriptionEngine(); + te.connectWhisperService(); const socket = await websocketConnected; - const recievedBlocks: Array = []; - te.on('transcription', block => { - recievedBlocks.push(block); + // Wait for transcription engine's socket to be in ready state + await new Promise(r => setTimeout(r, 1000)); + + const receivedMessage = new Promise(resolve => { + socket.once('message', data => resolve(JSON.parse(data.toString()))); }); + const message = {model_key: 'selected_key', feature_selection: {}}; + te.forwardMessage(Buffer.from(JSON.stringify(message)), false); + + await new Promise(r => setTimeout(r, 1000)); + + await expect(receivedMessage).resolves.toEqual(message); + + cleanup(te, wss); + }); + + it('emits transcription events when transcriptions are received', async () => { + const {wss, websocketConnected, te} = await createTranscriptionEngine(); + te.connectWhisperService(); + const socket = await websocketConnected; + + const receivedBlocks: Array = []; + te.on('transcription', block => receivedBlocks.push(block)); + const transcriptions: Array = [ {type: BackendTranscriptBlockType.InProgress, text: 'Hello', start: 0, end: 1}, {type: BackendTranscriptBlockType.InProgress, text: 'this is some', start: 1, end: 2}, @@ -119,7 +152,33 @@ describe('Transcription engine', it => { await new Promise(r => setTimeout(r, 1000)); - expect(recievedBlocks).toEqual(transcriptions); + expect(receivedBlocks).toEqual(transcriptions); + + cleanup(te, wss); + }); + + it('emits sourceMessage but not transcription event when non transcription JSON message is received', async () => { + const {wss, websocketConnected, te} = await createTranscriptionEngine(); + te.connectWhisperService(); + const socket = await websocketConnected; + + const receivedBlocks: Array = []; + const receivedMessages: Array = []; + te.on('transcription', block => receivedBlocks.push(block)); + te.on('sourceMessage', message => receivedMessages.push(message)); + + const sentMessages: Array = [ + {some: 'other', message: 'object'}, + {other: 'message', from: {whisper: ['service']}}, + ]; + for (const block of sentMessages) { + socket.send(JSON.stringify(block)); + } + + await new Promise(r => setTimeout(r, 1000)); + + expect(receivedBlocks).toEqual([]); + expect(receivedMessages).toEqual(sentMessages); cleanup(te, wss); }); diff --git a/node-server/src/server/services/transcription_engine.ts b/node-server/src/server/services/transcription_engine.ts index 2b944cd..1580dec 100644 --- a/node-server/src/server/services/transcription_engine.ts +++ b/node-server/src/server/services/transcription_engine.ts @@ -1,5 +1,7 @@ import type {ConfigType} from '@shared/config/config_schema.js'; import type {Logger} from '@shared/logger/logger.js'; +import {Type, type Static} from '@sinclair/typebox'; +import {Value} from '@sinclair/typebox/value'; import {TypedEmitter} from 'tiny-typed-emitter'; import WebSocket from 'ws'; @@ -8,84 +10,121 @@ export enum BackendTranscriptBlockType { InProgress = 1, } -export type BackendTranscriptBlock = { - type: BackendTranscriptBlockType; - start: number; - end: number; - text: string; -}; +const BACKEND_TRANSCRIPT_BLOCK_SCHEMA = Type.Object( + { + type: Type.Enum(BackendTranscriptBlockType), + start: Type.Number(), + end: Type.Number(), + text: Type.String(), + }, + {additionalProperties: false}, +); + +export type BackendTranscriptBlock = Static; export type AudioTranscriptEvents = { transcription: (block: BackendTranscriptBlock) => unknown; + sourceMessage: (message: JSON) => unknown; }; export default class TranscriptionEngine extends TypedEmitter { private _ws?: WebSocket; - private _reconnectInterval: number; - private _resetReconnectIntervalTimeout?: NodeJS.Timeout; + private _log: Logger; constructor( private _config: ConfigType, - private _log: Logger, + log: Logger, ) { super(); - this._reconnectInterval = this._config.whisper.reconnectIntervalSec * 1000; - this._connectWhisperService(); + this._log = log.child({service: 'TranscriptionEngine'}); } /** - * Connects to whisper service via websocket connection + * Initializes a new connection to whisper service */ - private _connectWhisperService() { - clearTimeout(this._resetReconnectIntervalTimeout); - this._ws = new WebSocket(this._config.whisper.endpoint); - - this._ws.on('error', err => { - this._log.error({msg: 'Error on whisper service connection', err}); - }); + connectWhisperService() { + if (this._ws) { + this._log.debug('Closing existing websocket before initializing new connection'); - // Reconnect to whisper service automatically after an exponentially increasing timeout - this._ws.on('close', () => { - this._log.info(`Whisper service connection closed, reconnecting in ${this._reconnectInterval}ms`); - setTimeout(() => this._connectWhisperService(), this._reconnectInterval); - this._reconnectInterval = Math.min(30_000, 2 * this._reconnectInterval); - }); + try { + this._ws.close(); + } catch (err) { + this._log.warn({msg: 'Failed to close existing websocket before initializing new connection', err}); + } + } - this._ws.on('open', () => { + const ws = new WebSocket(this._config.whisper.endpoint); + ws.once('open', () => { this._log.info('Connected to whisper service'); + ws.send( + JSON.stringify({ + api_key: this._config.whisper.apiKey, + }), + ); - this._resetReconnectIntervalTimeout = setTimeout(() => { - this._reconnectInterval = this._config.whisper.reconnectIntervalSec * 1000; - }, 30_000); + this._ws = ws; + ws.on('message', data => { + let message; + try { + message = JSON.parse(data.toString()); + } catch (err) { + this._log.error({msg: 'Failed to parse message from whisper service', err, message: data.toString()}); + return; + } + const isTranscriptBlock = Value.Check(BACKEND_TRANSCRIPT_BLOCK_SCHEMA, message); + + if (isTranscriptBlock) { + this._log.trace({msg: 'Emiting transcript transcript event', block: message}); + this.emit('transcription', message); + } else { + this._log.trace({msg: 'Emiting source message event', message}); + this.emit('sourceMessage', message); + } + }); + }); + + ws.on('error', err => { + this._log.error({msg: 'Whisper service websocket encountered an error', err}); }); - this._ws.on('message', data => { - // TODO: Check data format? - const block = JSON.parse(data.toString()); - this.emit('transcription', block); + ws.on('close', code => { + this._log.info(`Whisper service connection closed with code ${code}`); }); } /** - * Send an audio chunk to the backend - * Each chunk should be buffer containing wav audio - * @param chunk + * Disconnects the existing whisper service connection */ - sendAudioChunk(chunk: Buffer) { - try { - this._ws?.send(chunk); - } catch (err) { - this._log.trace({msg: 'Error while sending audio chunk to whisper server', err}); + disconnectWhisperService() { + if (this._ws) { + this._log.debug('Closing existing websocket'); + + try { + this._ws.close(); + } catch (err) { + this._log.warn({msg: 'Failed to close existing websocket', err}); + } + } else { + this._log.trace('No existing websocket to disconnect'); } } /** - * Closes connection to whisper serverice permanently + * Forward a message to whisper service + * @param message message to send + * @param isBinary if message is binary or not */ - destroy() { - this._log.info('Destroying transcription engine'); - this._ws?.removeAllListeners('close'); - this._ws?.close(); - this.removeAllListeners(); + forwardMessage(data: WebSocket.RawData, isBinary: boolean) { + if (this._ws) { + const message = isBinary ? data : data.toString(); + try { + this._log.trace({msg: 'Forwarding message to whisper server', message}); + this._ws.send(message); + } catch (err) { + this._log.error({msg: 'Error while forwarding message to whisper server', data, err}); + } + } else { + this._log.debug({msg: "Can't forward message to whisper service because websocket doesn't exist", data}); + } } } diff --git a/node-server/src/shared/config/config_schema.ts b/node-server/src/shared/config/config_schema.ts index 9490035..dc7f1e6 100644 --- a/node-server/src/shared/config/config_schema.ts +++ b/node-server/src/shared/config/config_schema.ts @@ -30,7 +30,7 @@ const SERVER_CONFIG = Type.Object({ const WHISPER_CONFIG = Type.Object({ WHISPER_SERVICE_ENDPOINT: Type.String({minLength: 1}), - WHISPER_RECONNECT_INTERVAL_SEC: Type.Number({default: 1}), + API_KEY: Type.String({minLength: 1}), }); const AUTH_CONFIG = Type.Union([ @@ -66,7 +66,7 @@ export type ConfigType = Readonly<{ }; whisper: { endpoint: string; - reconnectIntervalSec: number; + apiKey: string; }; auth: | {required: false} diff --git a/node-server/src/shared/config/load_config.ts b/node-server/src/shared/config/load_config.ts index cdad816..f8279ab 100644 --- a/node-server/src/shared/config/load_config.ts +++ b/node-server/src/shared/config/load_config.ts @@ -46,7 +46,7 @@ export default function loadConfig(path?: string): ConfigType { }, whisper: { endpoint: env.WHISPER_SERVICE_ENDPOINT, - reconnectIntervalSec: env.WHISPER_RECONNECT_INTERVAL_SEC, + apiKey: env.API_KEY, }, auth: { required: env.REQUIRE_AUTH, diff --git a/node-server/template.env b/node-server/template.env index ce55711..e187e4d 100644 --- a/node-server/template.env +++ b/node-server/template.env @@ -1,21 +1,21 @@ -NODE_ENV="development" -LOG_LEVEL="debug" +NODE_ENV=development +LOG_LEVEL=info # ### Host and port API webserver should listen on -HOST="0.0.0.0" +HOST=0.0.0.0 PORT=8080 -CORS_ORIGIN='*' -SERVER_ADDRESS="127.0.0.1:8080" +CORS_ORIGIN=* +SERVER_ADDRESS=127.0.0.1:8080 # ### URL to whisper service -WHISPER_SERVICE_ENDPOINT="ws://127.0.0.1:8000/whisper?api_key=CHANGEME&model_key=faster-whisper:cpu-tiny-en" -WHISPER_RECONNECT_INTERVAL_SEC=1 +WHISPER_SERVICE_ENDPOINT=ws://127.0.0.1:8000/sourcesink +API_KEY=CHANGEME # ### Authentication settings # Enable or disable authentication REQUIRE_AUTH=true # Key used by frontend to connect as audio source -SOURCE_TOKEN="CHANGEME" +SOURCE_TOKEN=CHANGEME # How many bytes of random data should be used to generate access token ACCESS_TOKEN_BYTES=8 # How often access token should be refreshed in seconds diff --git a/whisper-service/init_device_config.py b/whisper-service/app_config/init_device_config.py similarity index 82% rename from whisper-service/init_device_config.py rename to whisper-service/app_config/init_device_config.py index ff68f9b..9062fa0 100644 --- a/whisper-service/init_device_config.py +++ b/whisper-service/app_config/init_device_config.py @@ -11,32 +11,13 @@ ''' import json import logging -from typing import Any, TypedDict +from typing import Any from model_implementations.import_model_implementation import \ ModelImplementationId, import_model_implementation from utils.config_dict_contains import \ config_dict_contains_dict, config_dict_contains_one_of, config_dict_contains_str - - -class AvailableFeaturesConfig(TypedDict): - ''' - Type hint for available features configuration dict - ''' - - -class ModelConfig(TypedDict): - ''' - Type hint for model configuration dict - ''' - display_name: str - description: str - implementation_id: ModelImplementationId - implementation_configuration: dict - available_features: AvailableFeaturesConfig - - -# Type hint for loaded device configuration dict -type DeviceConfig = dict[str, ModelConfig] +from custom_types.config_types import ModelConfig, DeviceConfig +from custom_types.model_selection_types import SelectionOptions def init_model(device_config: dict[str, Any], key: str) -> ModelConfig: @@ -51,7 +32,7 @@ def init_model(device_config: dict[str, Any], key: str) -> ModelConfig: key (str) : model_key to initialize Return: - Validated ModelConfig dict + Validated ModelConfig object ''' logger = logging.getLogger('uvicorn.error') @@ -92,13 +73,16 @@ def init_model(device_config: dict[str, Any], key: str) -> ModelConfig: } -def init_device_config(device_config_path: str) -> DeviceConfig: +def init_device_config(device_config_path: str) -> tuple[DeviceConfig, SelectionOptions]: ''' Loads device config file from provided path then initializes configured models. Parameters: device_config_path (str): Path to device config file + + Returns: + DeviceConfig object and SelectionOptions object ''' logger = logging.getLogger('uvicorn.error') @@ -110,8 +94,17 @@ def init_device_config(device_config_path: str) -> DeviceConfig: raise ValueError('Device config must an object') device_config: DeviceConfig = {} + selection_options: SelectionOptions = [] for key in loaded_config.keys(): model_config = init_model(loaded_config, key) + device_config[key] = model_config - return device_config + selection_options.append({ + 'model_key': key, + 'display_name': model_config['display_name'], + 'description': model_config['description'], + 'available_features': model_config['available_features'] + }) + + return device_config, selection_options diff --git a/whisper-service/app_config/load_config.py b/whisper-service/app_config/load_config.py new file mode 100644 index 0000000..6bd6fd6 --- /dev/null +++ b/whisper-service/app_config/load_config.py @@ -0,0 +1,35 @@ +''' +Helper function to load application configuration + +Functions: + load_config + +Classes: + AppConfig +''' +import os +from dotenv import load_dotenv +from custom_types.config_types import AppConfig + + +def load_config() -> AppConfig: + ''' + Loads application config from .env file. + + Returns: + AppConfig object + ''' + load_dotenv() + + config = AppConfig() + config['API_KEY'] = os.environ.get('API_KEY') + assert len(config['API_KEY']) > 0, 'API_KEY must be non-empty string' + + config['LOG_LEVEL'] = os.environ.get('LOG_LEVEL', 'info') + assert config['LOG_LEVEL'] in ['trace', 'debug', 'info'], \ + 'LOG_LEVEL must be one of: trace, debug, info' + + config['PORT'] = int(os.environ.get('PORT', 8000)) + config['HOST'] = os.environ.get('HOST', '127.0.0.1') + + return config diff --git a/whisper-service/create_server.py b/whisper-service/create_server.py deleted file mode 100644 index d29c1eb..0000000 --- a/whisper-service/create_server.py +++ /dev/null @@ -1,78 +0,0 @@ -''' -Function to instantiate FastAPI webserver. -Creates executes the function if this file is run directly using the fastapi CLI. - -Functions: - create_server -''' -import io -import asyncio -from typing import Annotated, Callable -from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query -from model_bases.transcription_model_base import TranscriptionModelBase -from load_config import AppConfig -from init_device_config import DeviceConfig - - -def create_server( - config: AppConfig, - device_config: DeviceConfig, - model_factory_func: Callable[[DeviceConfig, - str, WebSocket], TranscriptionModelBase] -) -> FastAPI: - ''' - Instanciates FastAPI webserver. - - Parameters: - config (AppConfig) : Application configuration object - device_config (DeviceConfig): Application device configuration object - model_factory_func (function) : Function that takes in a modelKey and a WebSocket and - returns the corresponding model implementation - - Returns: - FastAPI webserver - ''' - fastapi_app = FastAPI() - - @fastapi_app.get("/healthcheck") - def healthcheck(): - return 'ok' - - @fastapi_app.websocket("/whisper") - async def whisper( - websocket: WebSocket, - api_key: Annotated[str | None, Query()] = None, - model_key: Annotated[str | None, Query()] = None - ): - ''' - Parameters: - api_key (str): Secret API key passed in through URL query parameters - model_key (str): Unique model key passed in through URL query parameters - ''' - await websocket.accept() - - # Reject invalid API keys after a timeout - if api_key != config.API_KEY: - await asyncio.sleep(5) - await websocket.send_text('Invalid API key!') - await websocket.close() - return - - # Intanciate and setup requested model - transcription_model = model_factory_func( - device_config, - model_key, - websocket - ) - transcription_model.load_model() - - # Send any audio chunks to transcription model - while True: - try: - data = await websocket.receive_bytes() - await transcription_model.queue_audio_chunk(io.BytesIO(data)) - except WebSocketDisconnect: - transcription_model.unload_model() - return - - return fastapi_app diff --git a/whisper-service/create_server_test.py b/whisper-service/create_server_test.py deleted file mode 100644 index f406b6c..0000000 --- a/whisper-service/create_server_test.py +++ /dev/null @@ -1,136 +0,0 @@ -''' -Unit tests for create_server function. -''' -# pylint: disable=redefined-outer-name,too-many-locals -import os -from unittest import mock -import pytest -from fastapi import WebSocket -from fastapi.websockets import WebSocketDisconnect -from fastapi.testclient import TestClient -from load_config import AppConfig -from model_bases.transcription_model_base import TranscriptionModelBase -from create_server import create_server - - -@pytest.fixture(scope='function') -def fake_config(): - ''' - Create a fake configuration object for each test - ''' - config = AppConfig() - config.API_KEY = 'SOME_API_KEY' - config.LOG_LEVEL = 'info' - config.PORT = -1 - config.HOST = '127.0.0.1' - return config - - -@pytest.fixture(scope='function') -def fake_transcription_model(): - ''' - Create a fake transcription model for each test - ''' - class Fake(TranscriptionModelBase): - ''' - Fake transcription model to track how object's methods are called - ''' - - def __init__(self): - super().__init__(None, {}) - - @staticmethod - def validate_config(config): - return config - - def load_model(self): - return None - - def unload_model(self): - return None - - async def queue_audio_chunk(self, audio_chunk): - return None - - return mock.Mock(wraps=Fake()) - - -@pytest.fixture(scope='function') -def test_client(fake_config, fake_transcription_model): - ''' - Create a FastAPI test client for each test - ''' - fake_device_config = { - 'test-model': {} - } - - def fake_factory(device_config, model_key: str, ws: WebSocket): - if (isinstance(ws, WebSocket) and - model_key == 'test-model' and - fake_device_config != device_config - ): - return fake_transcription_model - - raise NotImplementedError( - 'Invalid model key or invalid websocket argument.' - ) - app = create_server(fake_config, {}, fake_factory) - return TestClient(app) - - -def test_accepts_valid_api_key(test_client, fake_config, fake_transcription_model): - ''' - Test that websocket handler passes audio chunks to transcription model - ''' - wav_files = [ - "../test-audio-files/wikipedia-.fun/chunked/chunk_000.wav", - "../test-audio-files/wikipedia-.fun/chunked/chunk_001.wav", - "../test-audio-files/wikipedia-.fun/chunked/chunk_002.wav", - "../test-audio-files/wikipedia-.fun/chunked/chunk_003.wav", - "../test-audio-files/wikipedia-.fun/chunked/chunk_004.wav", - "../test-audio-files/wikipedia-.fun/chunked/chunk_005.wav", - "../test-audio-files/wikipedia-.fun/chunked/chunk_006.wav", - "../test-audio-files/wikipedia-.fun/chunked/chunk_007.wav", - ] - wav_data = [] - for wav_file in wav_files: - file_path = os.path.join(os.path.dirname(__file__), wav_file) - assert os.path.exists(file_path), "Test wav file not found" - - with open(file_path, "rb") as f: - wav_data.append(f.read()) - - url = f"/whisper?api_key={fake_config.API_KEY}&model_key=test-model" - with test_client.websocket_connect(url) as websocket: - for data in wav_data: - websocket.send_bytes(data) - websocket.close() - - fake_transcription_model.load_model.assert_called_once() - fake_transcription_model.unload_model.assert_called_once() - - call_count = fake_transcription_model.queue_audio_chunk.call_args_list - assert len(call_count) == len( - wav_data), "queueAudioChunk called correct number of times" - - for i, data in enumerate(wav_data): - method_call = fake_transcription_model.queue_audio_chunk.call_args_list[i] - call_args = method_call.args - assert len(call_args) == 1, "Called with correct number of arguments" - - bytes_io_arg = call_args[0] - assert bytes_io_arg.getvalue() == data, "Correct data transferred" - - -def test_rejects_invalid_api_key(test_client): - ''' - Test that websocket handler closes connection if invalid api key is given - ''' - url = "/whisper?api_key=NOT_API_KEY&model_key=test-model" - with test_client.websocket_connect(url) as websocket: - response = websocket.receive_text() - assert response == 'Invalid API key!', "Rejects invalid API key" - - # Should disconnect socket after - with pytest.raises(WebSocketDisconnect): - websocket.receive_json() diff --git a/whisper-service/custom_types/authentication_types.py b/whisper-service/custom_types/authentication_types.py new file mode 100644 index 0000000..8321e91 --- /dev/null +++ b/whisper-service/custom_types/authentication_types.py @@ -0,0 +1,14 @@ +''' +Type definitions for authentication messages + +Types: + WhisperAuthMessage +''' +from typing import TypedDict + + +class WhisperAuthMessage(TypedDict): + ''' + Type hint for message send by frontnend to authenticate websocket + ''' + api_key: str diff --git a/whisper-service/custom_types/config_types.py b/whisper-service/custom_types/config_types.py new file mode 100644 index 0000000..b526100 --- /dev/null +++ b/whisper-service/custom_types/config_types.py @@ -0,0 +1,66 @@ +''' +Type definitions for objects used to configure whisper service + +Enums: + ModelImplementationId + +Types: + JsonType + ImplementationModelConfig + AppConfig + AvailableFeaturesConfig + ModelConfig + DeviceConfig +''' +from enum import StrEnum +from typing import TypedDict, Union, List, Dict + + +class AppConfig(TypedDict): + ''' + Object to hold application config loaded from .env file + ''' + API_KEY: str + LOG_LEVEL: str + PORT: int + HOST: str + + +class AvailableFeaturesConfig(TypedDict): + ''' + Type hint for available features configuration dict + Nested within ModelConfig + ''' + + +class ModelImplementationId(StrEnum): + ''' + Unique keys for all available implementations of TranscriptionModelBase + Device config should only select ids from this enum + ''' + MOCK_TRANSCRIPTION_DURATION = "mock_transcription_duration" + FASTER_WHISPER = "faster_whisper" + + +type JsonType = Union[None, int, str, bool, + List[JsonType], Dict[str, JsonType]] + +# Type hint for object used for configuring model implementations +# Used by model implementations and nested in ModelConfig +type ImplementationModelConfig = Dict[str, JsonType] + + +class ModelConfig(TypedDict): + ''' + Type hint for model configuration dict + Nested within DeviceConfig + ''' + display_name: str + description: str + implementation_id: ModelImplementationId + implementation_configuration: ImplementationModelConfig + available_features: AvailableFeaturesConfig + + +# Type hint for loaded device configuration dict +type DeviceConfig = dict[str, ModelConfig] diff --git a/whisper-service/custom_types/model_selection_types.py b/whisper-service/custom_types/model_selection_types.py new file mode 100644 index 0000000..ed8bbe2 --- /dev/null +++ b/whisper-service/custom_types/model_selection_types.py @@ -0,0 +1,41 @@ +''' +Type definitions for messages used for negotiating model selection + +Types: + ModelOption + SelectionOptions + FeatureSelection + ModelSelection +''' +from typing import TypedDict +from custom_types.config_types import AvailableFeaturesConfig + + +class ModelOption(TypedDict): + ''' + Type hint for a model option available to frontend + Nested within SelectionOptions + ''' + model_key: str + display_name: str + description: str + available_features: AvailableFeaturesConfig + + +# Type hint for available models that is presented to the frontend +type SelectionOptions = list[ModelOption] + + +class FeatureSelection(TypedDict): + ''' + Type hint for user's choice for what features to use + Nested within ModelSelection + ''' + + +class SelectedOption(TypedDict): + ''' + Type hint for message frontend sends to select a model + ''' + model_key: str + feature_selection: FeatureSelection diff --git a/whisper-service/custom_types/transcription_types.py b/whisper-service/custom_types/transcription_types.py new file mode 100644 index 0000000..76296a1 --- /dev/null +++ b/whisper-service/custom_types/transcription_types.py @@ -0,0 +1,28 @@ +''' +Type definitions for transcription messages + +Enums: + BackendTranscriptionBlockType + BackendTranscriptBlockType +''' +from enum import IntEnum +from typing import TypedDict + + +class BackendTranscriptionBlockType(IntEnum): + ''' + Possible values for transcription block type value + Enum literal values must match values in node server/frontend + ''' + FINAL = 0 + IN_PROGRESS = 1 + + +class BackendTranscriptBlock(TypedDict): + ''' + Type hint for transcription block messages passed to node server/frontend + ''' + type: BackendTranscriptionBlockType + text: str + start: float + end: float diff --git a/whisper-service/index.py b/whisper-service/index.py index f04db8c..64be60b 100644 --- a/whisper-service/index.py +++ b/whisper-service/index.py @@ -3,14 +3,25 @@ ''' import sys import uvicorn -from load_config import load_config -from create_server import create_server -from model_factory import model_factory -from init_device_config import init_device_config +from app_config.load_config import load_config +from app_config.init_device_config import init_device_config +from server.create_server import create_server +from server.helpers.authenticate_websocket import authenticate_websocket +from server.helpers.select_model import select_model +from model_implementations.import_model_implementation import import_model_implementation + config = load_config() -device_config = init_device_config('device_config.json') -APP = create_server(config, device_config, model_factory) +device_config, selection_options = init_device_config('device_config.json') + +APP = create_server( + config, + device_config, + selection_options, + import_model_implementation, + authenticate_websocket, + select_model +) if __name__ == '__main__': dev_mode = len(sys.argv) > 1 and sys.argv[1] == '--dev' @@ -22,9 +33,9 @@ uvicorn.run( APP, - log_level=config.LOG_LEVEL, - port=config.PORT, - host=config.HOST, + log_level=config['LOG_LEVEL'], + port=config['PORT'], + host=config['HOST'], use_colors=dev_mode, reload=dev_mode ) diff --git a/whisper-service/load_config.py b/whisper-service/load_config.py deleted file mode 100644 index a302f44..0000000 --- a/whisper-service/load_config.py +++ /dev/null @@ -1,41 +0,0 @@ -''' -Helper function to load application configuration - -Functions: - load_config - -Classes: - AppConfig -''' -import os -from dataclasses import dataclass -from dotenv import load_dotenv - -# pylint: disable=invalid-name -@dataclass -class AppConfig: - ''' - Object to hold application config loaded from .env file - ''' - API_KEY: str = '' - LOG_LEVEL: str = '' - PORT: int = 0 - HOST: str = '' - - -def load_config() -> AppConfig: - ''' - Loads application config from .env file. - - Returns: - AppConfig object - ''' - load_dotenv() - - config = AppConfig() - config.API_KEY = os.environ.get('API_KEY', '') - config.LOG_LEVEL = os.environ.get('LOG_LEVEL', 'info') - config.PORT = int(os.environ.get('PORT', 8000)) - config.HOST = os.environ.get('HOST', '127.0.0.1') - - return config diff --git a/whisper-service/model_bases/buffer_audio_model_base.py b/whisper-service/model_bases/buffer_audio_model_base.py index 1a4d2a0..3894a7d 100644 --- a/whisper-service/model_bases/buffer_audio_model_base.py +++ b/whisper-service/model_bases/buffer_audio_model_base.py @@ -11,6 +11,7 @@ from utils.decode_wav import decode_wav from utils.np_circular_buffer import NPCircularBuffer from model_bases.transcription_model_base import TranscriptionModelBase +from custom_types.config_types import ImplementationModelConfig class BufferAudioModelBase(TranscriptionModelBase): @@ -49,7 +50,7 @@ def __init__(self, ws, config): ) @staticmethod - def validate_config(config): + def validate_config(config: dict) -> ImplementationModelConfig: ''' Should check if loaded JSON config is valid. Called model is instantiated. Throw an error if provided config is not valid diff --git a/whisper-service/model_bases/local_agree_model_base.py b/whisper-service/model_bases/local_agree_model_base.py index 888e97a..9611dbb 100644 --- a/whisper-service/model_bases/local_agree_model_base.py +++ b/whisper-service/model_bases/local_agree_model_base.py @@ -10,6 +10,7 @@ import numpy.typing as npt from model_bases.buffer_audio_model_base import BufferAudioModelBase from utils.config_dict_contains import config_dict_contains_int +from custom_types.config_types import ImplementationModelConfig class TranscriptionSegment: @@ -98,7 +99,7 @@ def __init__(self, ws, *args, local_agree_dim=2, **kwargs): self.prev_transcriptions: list[list[TranscriptionSegment]] = [] @staticmethod - def validate_config(config): + def validate_config(config: dict) -> ImplementationModelConfig: ''' Should check if loaded JSON config is valid. Called model is instantiated. Throw an error if provided config is not valid diff --git a/whisper-service/model_bases/transcription_model_base.py b/whisper-service/model_bases/transcription_model_base.py index ceb9b10..a7d8abb 100644 --- a/whisper-service/model_bases/transcription_model_base.py +++ b/whisper-service/model_bases/transcription_model_base.py @@ -10,24 +10,10 @@ ''' import io import logging -from typing import Union, List, Dict from abc import ABC, abstractmethod -from enum import IntEnum from fastapi import WebSocket - - -class BackendTranscriptionBlockType(IntEnum): - ''' - Possible values for transcription block type value - Should match values that node-server expects - ''' - FINAL = 0 - IN_PROGRESS = 1 - - -type JsonType = Union[None, int, str, bool, - List[JsonType], Dict[str, JsonType]] -type ImplementationModelConfig = JsonType +from custom_types.config_types import ImplementationModelConfig +from custom_types.transcription_types import BackendTranscriptionBlockType, BackendTranscriptBlock class TranscriptionModelBase(ABC): @@ -45,9 +31,9 @@ def __init__(self, ws: WebSocket, config: ImplementationModelConfig): Called when a websocket requests a transcription model. Parameters: - ws (WebSocket) : FastAPI websocket that requested the model - config (TranscriptionModelConfig): Custom JSON object containing configuration for model - Defined by implementation + ws (WebSocket): FastAPI websocket that requested the model + config (TranscriptionModelConfig): Custom JSON object containing configuration for model + Defined by implementation ''' self.ws = ws self.config = self.validate_config(config) @@ -101,38 +87,40 @@ async def queue_audio_chunk(self, audio_chunk: io.BytesIO) -> None: ''' raise NotImplementedError('Must implement per model') - async def on_final_transcript_block(self, text: str, start=-1, end=-1) -> None: + async def on_final_transcript_block(self, text: str, start=-1.0, end=-1.0) -> None: ''' Call this when a block of finalized transcription is ready Parameters: - text (str): Finalized transcribed text - start (int): Start time of this transcription chunk [Optional] - end (int): End time of this transcription chunk [Optional] + text (str) : Finalized transcribed text + start (float): Start time of this transcription chunk [Optional] + end (float): End time of this transcription chunk [Optional] ''' self.logger.info('[%6.2f - %6.2f] Final : %s', start, end, text) - await self.ws.send_json({ + transcript_block: BackendTranscriptBlock = { 'type': BackendTranscriptionBlockType.FINAL, 'text': text, 'start': start, 'end': end - }) + } + await self.ws.send_json(transcript_block) - async def on_in_progress_transcript_block(self, text: str, start=-1, end=-1) -> None: + async def on_in_progress_transcript_block(self, text: str, start=-1.0, end=-1.0) -> None: ''' Call this when a block of in progress transcription is ready. This is used to provide a lower latency transcription at the cost of some accuracy. If model does not support in progress guesses, only call on_final_transcript_chunk(). Parameters: - text (str): Finalized transcribed text - start (int): Start time of this transcription block [Optional] - end (int): End time of this transcription block [Optional] + text (str) : Finalized transcribed text + start (float): Start time of this transcription block [Optional] + end (float): End time of this transcription block [Optional] ''' self.logger.info('[%6.2f - %6.2f] In Progress: %s', start, end, text) - await self.ws.send_json({ + transcript_block: BackendTranscriptBlock = { 'type': BackendTranscriptionBlockType.IN_PROGRESS, 'text': text, 'start': start, 'end': end - }) + } + await self.ws.send_json(transcript_block) diff --git a/whisper-service/model_bases/transcription_model_base_test.py b/whisper-service/model_bases/transcription_model_base_test.py index c9381e3..55d15a0 100644 --- a/whisper-service/model_bases/transcription_model_base_test.py +++ b/whisper-service/model_bases/transcription_model_base_test.py @@ -3,8 +3,8 @@ ''' # pylint: disable=redefined-outer-name import pytest -from model_bases.transcription_model_base import \ - TranscriptionModelBase, BackendTranscriptionBlockType +from model_bases.transcription_model_base import TranscriptionModelBase +from custom_types.transcription_types import BackendTranscriptionBlockType fake_config = { 'some_param': 'string', diff --git a/whisper-service/model_factory.py b/whisper-service/model_factory.py deleted file mode 100644 index 74b7d1d..0000000 --- a/whisper-service/model_factory.py +++ /dev/null @@ -1,36 +0,0 @@ -''' -Function for instantiating a specified model - -Functions: - model_factory -''' -from fastapi import WebSocket -from model_bases.transcription_model_base import TranscriptionModelBase -from model_implementations.import_model_implementation import import_model_implementation -from init_device_config import DeviceConfig - - -def model_factory( - device_config: DeviceConfig, - model_key: str, - websocket: WebSocket -) -> TranscriptionModelBase: - ''' - Instantiates model with corresponding model_key. - - Parameters: - device_config (DeviceConfig): Device config object - model_key (str) : Unique identifier for model to instantiate - websocket (WebSocket) : Websocket requesting model - - Returns: - A TranscriptionModelBase instance - ''' - if model_key not in device_config: - raise KeyError('No model matching model_key') - - implementation = import_model_implementation( - device_config[model_key]['implementation_id'] - ) - - return implementation(websocket, device_config[model_key]['implementation_configuration']) diff --git a/whisper-service/model_implementations/import_model_implementation.py b/whisper-service/model_implementations/import_model_implementation.py index 7a7f812..e71e81f 100644 --- a/whisper-service/model_implementations/import_model_implementation.py +++ b/whisper-service/model_implementations/import_model_implementation.py @@ -2,24 +2,19 @@ Function for importing specified model implementation Functions: - import_model_implementation - -Enums: - ModelImplementationId + import_model_implementation ''' # pylint: disable=import-outside-toplevel -from enum import StrEnum - - -class ModelImplementationId(StrEnum): - ''' - Unique keys for all available implementations of TranscriptionModelBase - ''' - MOCK_TRANSCRIPTION_DURATION = "mock_transcription_duration" - FASTER_WHISPER = "faster_whisper" +# Disable linter rule so we can do imports in import_model_implementation +# Avoids needing to import every model even if unused +# Can potentially help with dealing with conflicting dependencies) +from model_bases.transcription_model_base import TranscriptionModelBase +from custom_types.config_types import ModelImplementationId -def import_model_implementation(model_implementation_id: ModelImplementationId): +def import_model_implementation( + model_implementation_id: ModelImplementationId +) -> TranscriptionModelBase: ''' Imports model with corresponding model_implementation_id. diff --git a/whisper-service/requirements.txt b/whisper-service/requirements.txt index c3e2b9c..5a60801 100644 --- a/whisper-service/requirements.txt +++ b/whisper-service/requirements.txt @@ -9,6 +9,7 @@ pytest-cov==6.1.1 pylint==3.3.6 httpx==0.28.1 pytest-asyncio==0.25.3 +pytest-mock==3.14.0 # faster-whisper models faster-whisper==1.1.1 diff --git a/whisper-service/server/create_server.py b/whisper-service/server/create_server.py new file mode 100644 index 0000000..ed0dec6 --- /dev/null +++ b/whisper-service/server/create_server.py @@ -0,0 +1,90 @@ +''' +Function to instantiate FastAPI webserver. +Creates executes the function if this file is run directly using the fastapi CLI. + +Functions: + create_server +''' +# pylint: disable=too-many-arguments,too-many-positional-arguments +import io +from typing import Callable, Type, Literal +from fastapi import FastAPI, WebSocket, WebSocketDisconnect +from model_bases.transcription_model_base import TranscriptionModelBase +from custom_types.config_types import AppConfig, DeviceConfig, ModelImplementationId +from custom_types.model_selection_types import SelectionOptions, SelectedOption + + +def create_server( + config: AppConfig, + device_config: DeviceConfig, + selection_options: SelectionOptions, + import_implementation_fun: Callable[[ModelImplementationId], Type[TranscriptionModelBase]], + authenticate_websocket_fun: Callable[[WebSocket, AppConfig], bool], + select_model_fun: Callable[ + [WebSocket, DeviceConfig, SelectionOptions], SelectedOption | Literal[False] + ] +) -> FastAPI: + ''' + Instanciates FastAPI webserver. + + Parameters: + config (AppConfig): Application configuration object + device_config (DeviceConfig): Application device configuration object + selection_options (SelectionOptions): Available selection options to send to frontend + import_implementation_fun (function): Function that takes in a modelKey and a WebSocket and + returns the corresponding model implementation class + authenticate_websocket_fun (function): Function that authenticates a websocket + returns True is successful, False otherwise + select_model_fun (function): Function that get model selection from websocket + returns received selection, False on error + + Returns: + FastAPI webserver + ''' + fastapi_app = FastAPI() + + @fastapi_app.websocket("/sourcesink") + async def sourcesink(websocket: WebSocket): + ''' + Parameters: + api_key (str): Secret API key passed in through URL query parameters + ''' + await websocket.accept() + + if not await authenticate_websocket_fun(websocket, config): + return await websocket.close() + + selected_option = await select_model_fun(websocket, device_config, selection_options) + if not selected_option: + return await websocket.close() + + model_key = selected_option['model_key'] + + # Create and setup requested model + model_config = device_config[model_key] + implementation = import_implementation_fun( + model_config['implementation_id'] + ) + transcription_model = implementation( + websocket, + model_config['implementation_configuration'] + ) + transcription_model.load_model() + + # Send any audio chunks to transcription model + while True: + try: + data = await websocket.receive_bytes() + await transcription_model.queue_audio_chunk(io.BytesIO(data)) + except WebSocketDisconnect: + transcription_model.unload_model() + return + + @fastapi_app.get("/healthcheck") + def healthcheck(): + ''' + Simple healthcheck endpoint to see if server is alive + ''' + return 'ok' + + return fastapi_app diff --git a/whisper-service/server/create_server_test.py b/whisper-service/server/create_server_test.py new file mode 100644 index 0000000..93a0448 --- /dev/null +++ b/whisper-service/server/create_server_test.py @@ -0,0 +1,157 @@ +''' +Unit tests for create_server function. +''' +# pylint: disable=redefined-outer-name,too-many-locals,unused-argument +import os +from pytest_mock import MockerFixture +from fastapi import WebSocket +from fastapi.testclient import TestClient +from app_config.load_config import AppConfig +from model_bases.transcription_model_base import TranscriptionModelBase +from server.create_server import create_server + + +# Load some test files to send through websocket +wav_files = [ + "../../test-audio-files/wikipedia-.fun/chunked/chunk_000.wav", + "../../test-audio-files/wikipedia-.fun/chunked/chunk_001.wav", + "../../test-audio-files/wikipedia-.fun/chunked/chunk_002.wav", + "../../test-audio-files/wikipedia-.fun/chunked/chunk_003.wav", + "../../test-audio-files/wikipedia-.fun/chunked/chunk_004.wav", + "../../test-audio-files/wikipedia-.fun/chunked/chunk_005.wav", + "../../test-audio-files/wikipedia-.fun/chunked/chunk_006.wav", + "../../test-audio-files/wikipedia-.fun/chunked/chunk_007.wav", +] +wav_data = [] +for wav_file in wav_files: + file_path = os.path.join(os.path.dirname(__file__), wav_file) + assert os.path.exists(file_path), "Test wav file not found" + + with open(file_path, "rb") as f: + wav_data.append(f.read()) + + +fake_config = AppConfig() +fake_config['API_KEY'] = 'SOME_API_KEY' +fake_config['LOG_LEVEL'] = 'info' +fake_config['PORT'] = -1 +fake_config['HOST'] = '127.0.0.1' + +fake_device_config = { + 'model_key_1': { + 'display_name': 'Model Name', + 'description': 'Some description', + 'implementation_id': 'implementation_id_1', + 'implementation_configuration': { + 'some': 'config', + 'key': 10, + }, + 'available_features': {} + }, +} + +fake_selection_options = [{ + 'model_key': 'model_key_1' +}] + + +class FakeModelImplementation(TranscriptionModelBase): + ''' + Create a fake transcription model implementation for each test + ''' + @staticmethod + def validate_config(config): + return config + + def load_model(self): + return None + + def unload_model(self): + return None + + async def queue_audio_chunk(self, audio_chunk): + return None + + +def import_fun(key): + ''' + Fake import function to return FakeModelImplementation + ''' + if key == 'implementation_id_1': + return FakeModelImplementation + raise KeyError('Invalid Key') + + +async def auth_fun(*args): + ''' + Fake authenticate websocket function to skip authentication + ''' + return True + + +async def select_model(*args): + ''' + Fake select model function to skip model selection + ''' + return { + 'model_key': 'model_key_1', + 'feature_selection': {} + } + + +def test_loads_unloads_model(mocker: MockerFixture,): + ''' + Test that websocket handler instanciates and loads model correctly + ''' + init_spy = mocker.spy(FakeModelImplementation, '__init__') + load_spy = mocker.spy(FakeModelImplementation, 'load_model') + unload_spy = mocker.spy(FakeModelImplementation, 'unload_model') + + app = create_server( + fake_config, + fake_device_config, + fake_selection_options, + import_fun, + auth_fun, + select_model + ) + test_client = TestClient(app) + + with test_client.websocket_connect("/sourcesink") as websocket: + websocket.close() + + load_spy.assert_called_once() + unload_spy.assert_called_once() + + assert init_spy.call_args_list[0].args[1].__class__ == WebSocket, \ + 'Model initialized with websocket' + assert init_spy.call_args_list[0].args[2] == \ + fake_device_config['model_key_1']['implementation_configuration'], \ + 'Model initinalized with implementation config' + + +def test_queues_audio_chunks(mocker: MockerFixture,): + ''' + Test that websocket handler instanciates and loads model correctly + ''' + queue_spy = mocker.spy(FakeModelImplementation, 'queue_audio_chunk') + + app = create_server( + fake_config, + fake_device_config, + fake_selection_options, + import_fun, + auth_fun, + select_model + ) + test_client = TestClient(app) + + with test_client.websocket_connect("/sourcesink") as websocket: + for wav in wav_data: + websocket.send_bytes(wav) + + assert queue_spy.call_count == len(wav_data), \ + "queue_audio_chunk called correct number of times" + for i, data in enumerate(wav_data): + assert queue_spy.call_args_list[i].args[1].getvalue() == data, \ + "Correct data transferred" diff --git a/whisper-service/server/helpers/authenticate_websocket.py b/whisper-service/server/helpers/authenticate_websocket.py new file mode 100644 index 0000000..ccf7b8f --- /dev/null +++ b/whisper-service/server/helpers/authenticate_websocket.py @@ -0,0 +1,58 @@ +''' +Helper function to simplify authenticating websocket connections + +Functions: + authenticate_websocket +''' +import json +import logging +import asyncio +from fastapi import WebSocket, WebSocketDisconnect +from custom_types.config_types import AppConfig +from custom_types.authentication_types import WhisperAuthMessage +from server.helpers.receive_json_timeout import receive_json_timeout + + +async def authenticate_websocket(websocket: WebSocket, config: AppConfig) -> bool: + ''' + Helper function to authenticate a new websocket + + Parameters: + websocket (WebSocket): Opened FastAPI websocket + config (AppConfig): Application configuration object + + Returns: + True is successfully authenticated, False otherwise + ''' + logger = logging.getLogger('uvicorn.error') + try: + auth_message: WhisperAuthMessage = await receive_json_timeout(websocket) + except json.JSONDecodeError: + logger.info( + 'Authentication Failed: Invalid authentication message') + await websocket.send_json({ + 'error': True, + 'msg': 'Authentication Failed: Invalid authentication message' + }) + return False + except asyncio.TimeoutError: + logger.info('Authentication Timeout: No api_key received in time') + await websocket.send_json({ + 'error': True, + 'msg': 'Authentication Timeout: No api_key received in time' + }) + return False + except WebSocketDisconnect: + logger.info('Authentication Failed: Websocket closed') + return False + + # Reject invalid API keys + if 'api_key' not in auth_message or auth_message['api_key'] != config['API_KEY']: + logger.info('Authentication Failed: Invalid key') + await websocket.send_json({ + 'error': True, + 'msg': 'Authentication Failed: Invalid key' + }) + return False + + return True diff --git a/whisper-service/server/helpers/receive_json_timeout.py b/whisper-service/server/helpers/receive_json_timeout.py new file mode 100644 index 0000000..5609955 --- /dev/null +++ b/whisper-service/server/helpers/receive_json_timeout.py @@ -0,0 +1,27 @@ +''' +Helper function for receving JSON data from a websocket with a timeout + +Functions: + receive_json_timeout +''' +import json +import asyncio +from fastapi import WebSocket + + +async def receive_json_timeout(websocket: WebSocket): + ''' + Helper function for receving JSON data from a websocket with a timeout + + Parameters: + websocket (WebSocket) : Opened FastAPI websocket + + Returns: + Parsed JSON object + ''' + return json.loads( + await asyncio.wait_for( + websocket.receive_text(), + timeout=5 + ) + ) diff --git a/whisper-service/server/helpers/select_model.py b/whisper-service/server/helpers/select_model.py new file mode 100644 index 0000000..0d7948a --- /dev/null +++ b/whisper-service/server/helpers/select_model.py @@ -0,0 +1,75 @@ +''' +Helper function to simplify model selection over a websocket connection + +Functions: + select_model +''' +import json +import asyncio +import logging +from typing import Literal +from fastapi import WebSocket, WebSocketDisconnect +from custom_types.config_types import DeviceConfig +from custom_types.model_selection_types import SelectionOptions, SelectedOption +from server.helpers.receive_json_timeout import receive_json_timeout + + +async def select_model( + websocket: WebSocket, + device_config: DeviceConfig, + selection_options: SelectionOptions +) -> SelectedOption | Literal[False]: + ''' + Helper function to get model selection from a new websocket + + Parameters: + websocket (WebSocket) : Opened FastAPI websocket + device_config (DeviceConfig) : Application device configuration object + selection_options (SelectionOptions): Available selection options to send to frontend + + Returns: + SelectOption is successfully parsed selection, False otherwise + ''' + logger = logging.getLogger('uvicorn.error') + + await websocket.send_json(selection_options) + + try: + model_selection: SelectedOption = await receive_json_timeout(websocket) + except json.JSONDecodeError: + logger.info( + 'Model Selection Failed: Invalid model selection message') + await websocket.send_json({ + 'error': True, + 'msg': 'Model Selection Failed: Invalid model selection message' + }) + return False + except asyncio.TimeoutError: + logger.info( + 'Model Selection Timeout: No selection received in time') + await websocket.send_json({ + 'error': True, + 'msg': 'Model Selection Timeout: No selection received in time' + }) + return False + except WebSocketDisconnect: + logger.info('Model Selection Failed: Websocket closed') + return False + + if 'model_key' not in model_selection: + logger.info('Model Selection Failed: No model_key provided') + await websocket.send_json({ + 'error': True, + 'msg': 'Model Selection Failed: No model_key provided' + }) + return False + + if model_selection['model_key'] not in device_config: + logger.info('Model Selection Failed: Invalid model_key provided') + await websocket.send_json({ + 'error': True, + 'msg': 'Model Selection Failed: Invalid model_key provided' + }) + return False + + return model_selection