Merge pull request #164 from ets-cfuhrman-pfe/socket-image

QuizRoom separation and duplication
This commit is contained in:
Gabriel Moisan Matte 2024-11-12 12:01:58 -05:00 committed by GitHub
commit 706308d54f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 3246 additions and 5755 deletions

View file

@ -103,4 +103,35 @@ jobs:
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max
build-quizroom:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to the Container registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Quizroom Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}-quizroom
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
- name: Build and push Quizroom Docker image
uses: docker/build-push-action@v5
with:
context: ./quizRoom
push: ${{ github.event_name != 'pull_request' }}
platforms: linux/amd64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max

25
.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,25 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Debug backend",
"skipFiles": [
"<node_internals>/**"
],
"program": "${workspaceFolder}/server/app.js",
"cwd":"${workspaceFolder}/server/"
},
{
"type": "msedge",
"request": "launch",
"name": "Debug frontend",
"url": "http://localhost:5173",
"webRoot": "${workspaceFolder}/client/"
}
]
}

309
client/package-lock.json generated
View file

@ -19,6 +19,7 @@
"@mui/material": "^6.1.0", "@mui/material": "^6.1.0",
"@types/uuid": "^9.0.7", "@types/uuid": "^9.0.7",
"axios": "^1.6.7", "axios": "^1.6.7",
"dockerode": "^4.0.2",
"esbuild": "^0.23.1", "esbuild": "^0.23.1",
"gift-pegjs": "^1.0.2", "gift-pegjs": "^1.0.2",
"jest-environment-jsdom": "^29.7.0", "jest-environment-jsdom": "^29.7.0",
@ -1897,6 +1898,12 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@balena/dockerignore": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@balena/dockerignore/-/dockerignore-1.0.2.tgz",
"integrity": "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==",
"license": "Apache-2.0"
},
"node_modules/@bcoe/v8-coverage": { "node_modules/@bcoe/v8-coverage": {
"version": "0.2.3", "version": "0.2.3",
"resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz",
@ -5190,6 +5197,15 @@
"dequal": "^2.0.3" "dequal": "^2.0.3"
} }
}, },
"node_modules/asn1": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz",
"integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": "~2.1.0"
}
},
"node_modules/async": { "node_modules/async": {
"version": "3.2.6", "version": "3.2.6",
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
@ -5458,6 +5474,35 @@
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
}, },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/bcrypt-pbkdf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz",
"integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==",
"license": "BSD-3-Clause",
"dependencies": {
"tweetnacl": "^0.14.3"
}
},
"node_modules/binary-extensions": { "node_modules/binary-extensions": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@ -5469,6 +5514,17 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/bl": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"license": "MIT",
"dependencies": {
"buffer": "^5.5.0",
"inherits": "^2.0.4",
"readable-stream": "^3.4.0"
}
},
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
@ -5542,12 +5598,45 @@
"node-int64": "^0.4.0" "node-int64": "^0.4.0"
} }
}, },
"node_modules/buffer": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.1",
"ieee754": "^1.1.13"
}
},
"node_modules/buffer-from": { "node_modules/buffer-from": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"dev": true "dev": true
}, },
"node_modules/buildcheck": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.6.tgz",
"integrity": "sha512-8f9ZJCUXyT1M35Jx7MkBgmBMo3oHTTBIPLiY9xyL0pl3T5RwcPEY8cUHr5LBNfu/fk6c2T4DJZuVM/8ZZT2D2A==",
"optional": true,
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/callsites": { "node_modules/callsites": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@ -5658,6 +5747,12 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/chownr": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/ci-info": { "node_modules/ci-info": {
"version": "3.9.0", "version": "3.9.0",
"resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz",
@ -5786,6 +5881,20 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/cpu-features": {
"version": "0.0.10",
"resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz",
"integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==",
"hasInstallScript": true,
"optional": true,
"dependencies": {
"buildcheck": "~0.0.6",
"nan": "^2.19.0"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/create-jest": { "node_modules/create-jest": {
"version": "29.7.0", "version": "29.7.0",
"resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz",
@ -6057,6 +6166,35 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0" "node": "^14.15.0 || ^16.10.0 || >=18.0.0"
} }
}, },
"node_modules/docker-modem": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.3.tgz",
"integrity": "sha512-89zhop5YVhcPEt5FpUFGr3cDyceGhq/F9J+ZndQ4KfqNvfbJpPMfgeixFgUj5OjCYAboElqODxY5Z1EBsSa6sg==",
"license": "Apache-2.0",
"dependencies": {
"debug": "^4.1.1",
"readable-stream": "^3.5.0",
"split-ca": "^1.0.1",
"ssh2": "^1.15.0"
},
"engines": {
"node": ">= 8.0"
}
},
"node_modules/dockerode": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.2.tgz",
"integrity": "sha512-9wM1BVpVMFr2Pw3eJNXrYYt6DT9k0xMcsSCjtPvyQ+xa1iPg/Mo3T/gUcwI0B2cczqCeCYRPF8yFYDwtFXT0+w==",
"license": "Apache-2.0",
"dependencies": {
"@balena/dockerignore": "^1.0.2",
"docker-modem": "^5.0.3",
"tar-fs": "~2.0.1"
},
"engines": {
"node": ">= 8.0"
}
},
"node_modules/dom-accessibility-api": { "node_modules/dom-accessibility-api": {
"version": "0.5.16", "version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
@ -6123,6 +6261,15 @@
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true "dev": true
}, },
"node_modules/end-of-stream": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
"integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==",
"license": "MIT",
"dependencies": {
"once": "^1.4.0"
}
},
"node_modules/engine.io-client": { "node_modules/engine.io-client": {
"version": "6.5.4", "version": "6.5.4",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.4.tgz", "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.4.tgz",
@ -6797,6 +6944,12 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/fs-constants": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"license": "MIT"
},
"node_modules/fs-extra": { "node_modules/fs-extra": {
"version": "11.2.0", "version": "11.2.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz",
@ -7078,6 +7231,26 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "BSD-3-Clause"
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@ -7153,8 +7326,7 @@
"node_modules/inherits": { "node_modules/inherits": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
"dev": true
}, },
"node_modules/is-arrayish": { "node_modules/is-arrayish": {
"version": "0.2.1", "version": "0.2.1",
@ -10208,11 +10380,24 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/mkdirp-classic": {
"version": "0.5.3",
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
"license": "MIT"
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
}, },
"node_modules/nan": {
"version": "2.22.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.22.0.tgz",
"integrity": "sha512-nbajikzWTMwsW+eSsNm3QwlOs7het9gGJU5dDZzRTQGk03vyBOauxgI4VakDzE0PtsGTmXPsXTbbjVhRwR5mpw==",
"license": "MIT",
"optional": true
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "5.0.7", "version": "5.0.7",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.7.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.7.tgz",
@ -10284,7 +10469,6 @@
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"dev": true,
"dependencies": { "dependencies": {
"wrappy": "1" "wrappy": "1"
} }
@ -10661,6 +10845,16 @@
"resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz",
"integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==" "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag=="
}, },
"node_modules/pump": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz",
"integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==",
"license": "MIT",
"dependencies": {
"end-of-stream": "^1.1.0",
"once": "^1.3.1"
}
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@ -10805,6 +10999,20 @@
"react-dom": ">=16.6.0" "react-dom": ">=16.6.0"
} }
}, },
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/readdirp": { "node_modules/readdirp": {
"version": "3.6.0", "version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
@ -11047,6 +11255,26 @@
"queue-microtask": "^1.2.2" "queue-microtask": "^1.2.2"
} }
}, },
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": { "node_modules/safer-buffer": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
@ -11182,12 +11410,35 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/split-ca": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/split-ca/-/split-ca-1.0.1.tgz",
"integrity": "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==",
"license": "ISC"
},
"node_modules/sprintf-js": { "node_modules/sprintf-js": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"dev": true "dev": true
}, },
"node_modules/ssh2": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.16.0.tgz",
"integrity": "sha512-r1X4KsBGedJqo7h8F5c4Ybpcr5RjyP+aWIG007uBPRjmdQWfEiVLzSK71Zji1B9sKxwaCvD8y8cwSkYrlLiRRg==",
"hasInstallScript": true,
"dependencies": {
"asn1": "^0.2.6",
"bcrypt-pbkdf": "^1.0.2"
},
"engines": {
"node": ">=10.16.0"
},
"optionalDependencies": {
"cpu-features": "~0.0.10",
"nan": "^2.20.0"
}
},
"node_modules/stack-utils": { "node_modules/stack-utils": {
"version": "2.0.6", "version": "2.0.6",
"resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz",
@ -11207,6 +11458,15 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/string-length": { "node_modules/string-length": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz",
@ -11319,6 +11579,34 @@
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==" "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="
}, },
"node_modules/tar-fs": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.0.1.tgz",
"integrity": "sha512-6tzWDMeroL87uF/+lin46k+Q+46rAJ0SyPGz7OW7wTgblI273hsBqk2C1j0/xNadNLKDTUL9BukSjB7cwgmlPA==",
"license": "MIT",
"dependencies": {
"chownr": "^1.1.1",
"mkdirp-classic": "^0.5.2",
"pump": "^3.0.0",
"tar-stream": "^2.0.0"
}
},
"node_modules/tar-stream": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"license": "MIT",
"dependencies": {
"bl": "^4.0.3",
"end-of-stream": "^1.4.1",
"fs-constants": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.1.1"
},
"engines": {
"node": ">=6"
}
},
"node_modules/test-exclude": { "node_modules/test-exclude": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
@ -11539,6 +11827,12 @@
} }
} }
}, },
"node_modules/tweetnacl": {
"version": "0.14.5",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
"integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==",
"license": "Unlicense"
},
"node_modules/type-check": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
@ -11765,6 +12059,12 @@
"requires-port": "^1.0.0" "requires-port": "^1.0.0"
} }
}, },
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/uuid": { "node_modules/uuid": {
"version": "9.0.1", "version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
@ -12637,8 +12937,7 @@
"node_modules/wrappy": { "node_modules/wrappy": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
"dev": true
}, },
"node_modules/write-file-atomic": { "node_modules/write-file-atomic": {
"version": "4.0.2", "version": "4.0.2",

View file

@ -23,6 +23,7 @@
"@mui/material": "^6.1.0", "@mui/material": "^6.1.0",
"@types/uuid": "^9.0.7", "@types/uuid": "^9.0.7",
"axios": "^1.6.7", "axios": "^1.6.7",
"dockerode": "^4.0.2",
"esbuild": "^0.23.1", "esbuild": "^0.23.1",
"gift-pegjs": "^1.0.2", "gift-pegjs": "^1.0.2",
"jest-environment-jsdom": "^29.7.0", "jest-environment-jsdom": "^29.7.0",

View file

@ -44,7 +44,7 @@ describe('WebSocketService', () => {
test('createRoom should emit create-room event', () => { test('createRoom should emit create-room event', () => {
WebsocketService.connect(ENV_VARIABLES.VITE_BACKEND_URL); WebsocketService.connect(ENV_VARIABLES.VITE_BACKEND_URL);
WebsocketService.createRoom(); WebsocketService.createRoom('test');
expect(mockSocket.emit).toHaveBeenCalledWith('create-room'); expect(mockSocket.emit).toHaveBeenCalledWith('create-room');
}); });

View file

@ -1,7 +1,7 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Socket } from 'socket.io-client'; import { Socket } from 'socket.io-client';
import { ENV_VARIABLES } from '../../../constants'; //import { ENV_VARIABLES } from '../../../constants';
import StudentModeQuiz from '../../../components/StudentModeQuiz/StudentModeQuiz'; import StudentModeQuiz from '../../../components/StudentModeQuiz/StudentModeQuiz';
import TeacherModeQuiz from '../../../components/TeacherModeQuiz/TeacherModeQuiz'; import TeacherModeQuiz from '../../../components/TeacherModeQuiz/TeacherModeQuiz';
@ -27,14 +27,14 @@ const JoinRoom: React.FC = () => {
const [isConnecting, setIsConnecting] = useState<boolean>(false); const [isConnecting, setIsConnecting] = useState<boolean>(false);
useEffect(() => { useEffect(() => {
handleCreateSocket(); //handleCreateSocket();
return () => { return () => {
disconnect(); disconnect();
}; };
}, []); }, []);
const handleCreateSocket = () => { const handleCreateSocket = () => {
const socket = webSocketService.connect(ENV_VARIABLES.VITE_BACKEND_URL); const socket = webSocketService.connect(`/api/room/${roomName}/socket`);
socket.on('join-success', () => { socket.on('join-success', () => {
setIsWaitingForTeacher(true); setIsWaitingForTeacher(true);

View file

@ -10,7 +10,7 @@ import webSocketService, { AnswerReceptionFromBackendType } from '../../../servi
import { QuizType } from '../../../Types/QuizType'; import { QuizType } from '../../../Types/QuizType';
import './manageRoom.css'; import './manageRoom.css';
import { ENV_VARIABLES } from '../../../constants'; //import { ENV_VARIABLES } from '../../../constants';
import { StudentType, Answer } from '../../../Types/StudentType'; import { StudentType, Answer } from '../../../Types/StudentType';
import { Button } from '@mui/material'; import { Button } from '@mui/material';
import LoadingCircle from '../../../components/LoadingCircle/LoadingCircle'; import LoadingCircle from '../../../components/LoadingCircle/LoadingCircle';
@ -79,13 +79,19 @@ const ManageRoom: React.FC = () => {
} }
}; };
const createWebSocketRoom = () => { const createWebSocketRoom = async () => {
setConnectingError(''); setConnectingError('');
const socket = webSocketService.connect(ENV_VARIABLES.VITE_BACKEND_URL); const room = await ApiService.createRoom();
const socket = webSocketService.connect(`/api/room/${room.id}/socket`);
socket.on('connect', () => { socket.on('connect', () => {
webSocketService.createRoom(); webSocketService.createRoom(room.id);
}); });
socket.on("error", (error) => {
console.error("WebSocket server error:", error);
});
socket.on('connect_error', (error) => { socket.on('connect_error', (error) => {
setConnectingError('Erreur lors de la connexion... Veuillez réessayer'); setConnectingError('Erreur lors de la connexion... Veuillez réessayer');
console.error('WebSocket connection error:', error); console.error('WebSocket connection error:', error);
@ -142,7 +148,7 @@ const ManageRoom: React.FC = () => {
console.log('Quiz questions not found (cannot update answers without them).'); console.log('Quiz questions not found (cannot update answers without them).');
return; return;
} }
// Update the students state using the functional form of setStudents // Update the students state using the functional form of setStudents
setStudents((prevStudents) => { setStudents((prevStudents) => {
// print the list of current student names // print the list of current student names
@ -150,7 +156,7 @@ const ManageRoom: React.FC = () => {
prevStudents.forEach((student) => { prevStudents.forEach((student) => {
console.log(student.name); console.log(student.name);
}); });
let foundStudent = false; let foundStudent = false;
const updatedStudents = prevStudents.map((student) => { const updatedStudents = prevStudents.map((student) => {
console.log(`Comparing ${student.id} to ${idUser}`); console.log(`Comparing ${student.id} to ${idUser}`);
@ -170,7 +176,7 @@ const ManageRoom: React.FC = () => {
updatedAnswers = [...student.answers, newAnswer]; updatedAnswers = [...student.answers, newAnswer];
} }
return { ...student, answers: updatedAnswers }; return { ...student, answers: updatedAnswers };
} }
return student; return student;
}); });
if (!foundStudent) { if (!foundStudent) {

View file

@ -80,6 +80,78 @@ class ApiService {
return localStorage.removeItem("jwt"); return localStorage.removeItem("jwt");
} }
//Socket Route
/**
* Creates a new room.
* @returns The room object if successful
* @returns An error string if unsuccessful
*/
public async createRoom(): Promise<any> {
try {
const url: string = this.constructRequestUrl(`/room`);
const headers = this.constructRequestHeaders();
const response = await fetch(url, {
method: 'POST',
headers: headers,
});
if (!response.ok) {
throw new Error(`La création de la salle a échoué. Status: ${response.status}`);
}
const room = await response.json();
return room;
} catch (error) {
console.log("Error details: ", error);
if (error instanceof Error) {
return error.message || 'Erreur serveur inconnue lors de la requête.';
}
return `Une erreur inattendue s'est produite.`;
}
}
/**
* Deletes a room by its name.
* @param roomName - The name of the room to delete.
* @returns true if successful
* @returns An error string if unsuccessful
*/
public async deleteRoom(roomName: string): Promise<any> {
try {
if (!roomName) {
throw new Error(`Le nom de la salle est requis.`);
}
const url = this.constructRequestUrl(`/room/${roomName}`);
const headers = this.constructRequestHeaders();
fetch(url, {
method: 'DELETE',
headers: headers,
});
return true;
} catch (error) {
console.log("Error details: ", error);
if (error instanceof Error) {
return error.message || 'Erreur serveur inconnue lors de la requête.';
}
return `Une erreur inattendue s'est produite.`;
}
}
// User Routes // User Routes
/** /**
@ -302,6 +374,7 @@ class ApiService {
} }
} }
/** /**
* @returns folder array if successful * @returns folder array if successful
* @returns A error string if unsuccessful, * @returns A error string if unsuccessful,

View file

@ -1,5 +1,6 @@
// WebSocketService.tsx // WebSocketService.tsx
import { io, Socket } from 'socket.io-client'; import { io, Socket } from 'socket.io-client';
import apiService from './ApiService';
// Must (manually) sync these types to server/socket/socket.js // Must (manually) sync these types to server/socket/socket.js
@ -21,10 +22,14 @@ class WebSocketService {
private socket: Socket | null = null; private socket: Socket | null = null;
connect(backendUrl: string): Socket { connect(backendUrl: string): Socket {
// console.log(backendUrl); this.socket = io( '/',{
this.socket = io(`${backendUrl}`, { path: backendUrl,
transports: ['websocket'], transports: ['websocket'],
reconnectionAttempts: 1 autoConnect: true,
reconnection: true,
reconnectionAttempts: 10,
reconnectionDelay: 10000,
timeout: 20000,
}); });
return this.socket; return this.socket;
} }
@ -37,9 +42,9 @@ class WebSocketService {
} }
} }
createRoom() { createRoom(roomName: string) {
if (this.socket) { if (this.socket) {
this.socket.emit('create-room'); this.socket.emit('create-room', roomName || undefined);
} }
} }
@ -58,6 +63,8 @@ class WebSocketService {
endQuiz(roomName: string) { endQuiz(roomName: string) {
if (this.socket) { if (this.socket) {
this.socket.emit('end-quiz', { roomName }); this.socket.emit('end-quiz', { roomName });
//Delete room in mongoDb, roomContainer will be deleted in cleanup
apiService.deleteRoom(roomName);
} }
} }

100
docker-compose.local.yaml Normal file
View file

@ -0,0 +1,100 @@
version: '3'
services:
frontend:
build:
context: ./client
dockerfile: Dockerfile
container_name: frontend
ports:
- "5173:5173"
networks:
- quiz_network
restart: always
backend:
build:
context: ./server
dockerfile: Dockerfile
container_name: backend
ports:
- "3000:3000"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
PORT: 3000
MONGO_URI: "mongodb://mongo:27017/evaluetonsavoir"
MONGO_DATABASE: evaluetonsavoir
EMAIL_SERVICE: gmail
SENDER_EMAIL: infoevaluetonsavoir@gmail.com
EMAIL_PSW: 'vvml wmfr dkzb vjzb'
JWT_SECRET: haQdgd2jp09qb897GeBZyJetC8ECSpbFJe
FRONTEND_URL: "http://localhost:5173"
depends_on:
- mongo
networks:
- quiz_network
restart: always
quizroom: # Forces image to update
build:
context: ./quizRoom
dockerfile: Dockerfile
container_name: quizroom
ports:
- "4500:4500"
depends_on:
- backend
networks:
- quiz_network
restart: always
nginx:
build:
context: ./nginx
dockerfile: Dockerfile
container_name: nginx
ports:
- "80:80"
depends_on:
- backend
- frontend
networks:
- quiz_network
restart: always
mongo:
image: mongo
container_name: mongo
ports:
- "27017:27017"
tty: true
volumes:
- mongodb_data:/data/db
networks:
- quiz_network
restart: always
watchtower:
image: containrrr/watchtower
container_name: watchtower
volumes:
- /var/run/docker.sock:/var/run/docker.sock
environment:
- TZ=America/Montreal
- WATCHTOWER_CLEANUP=true
- WATCHTOWER_DEBUG=true
- WATCHTOWER_INCLUDE_RESTARTING=true
- WATCHTOWER_SCHEDULE=0 0 5 * * * # At 5 am everyday
networks:
- quiz_network
restart: always
networks:
quiz_network:
driver: bridge
volumes:
mongodb_data:
external: false

View file

@ -27,6 +27,17 @@ services:
- mongo - mongo
restart: always restart: always
quizroom:
build:
context: ./quizRoom
dockerfile: Dockerfile
container_name: quizroom
ports:
- "4500:4500"
depends_on:
- backend
restart: always
# Ce conteneur sert de routeur pour assurer le bon fonctionnement de l'application # Ce conteneur sert de routeur pour assurer le bon fonctionnement de l'application
nginx: nginx:
image: fuhrmanator/evaluetonsavoir-routeur:latest image: fuhrmanator/evaluetonsavoir-routeur:latest
@ -49,13 +60,6 @@ services:
- mongodb_data:/data/db - mongodb_data:/data/db
restart: always restart: always
# Ce conteneur contient la base de donnée cache nécessaire au bon fonctionnement des salles de quiz
valkey:
image: valkey/valkey:alpine
container_name: valkey
volumes:
- ./deployments/valkey.conf:/usr/local/etc/valkey/valkey.conf
# Ce conteneur assure que l'application est à jour en allant chercher s'il y a des mises à jours à chaque heure # Ce conteneur assure que l'application est à jour en allant chercher s'il y a des mises à jours à chaque heure
watchtower: watchtower:
image: containrrr/watchtower image: containrrr/watchtower

View file

@ -1,3 +1,69 @@
FROM nginx # Stage 1: Build stage
FROM nginx:1.27-alpine AS builder
COPY ./default.conf /etc/nginx/conf.d/default.conf # Install required packages
RUN apk add --no-cache nginx-mod-http-js nginx-mod-http-keyval
# Stage 2: Final stage
FROM alpine:3.19
# Copy Nginx and NJS modules from builder
COPY --from=builder /usr/sbin/nginx /usr/sbin/
COPY --from=builder /usr/lib/nginx/modules/ /usr/lib/nginx/modules/
COPY --from=builder /etc/nginx/ /etc/nginx/
COPY --from=builder /usr/lib/nginx/ /usr/lib/nginx/
# Install required runtime dependencies
RUN apk add --no-cache \
pcre2 \
ca-certificates \
pcre \
libgcc \
libstdc++ \
zlib \
libxml2 \
libedit \
geoip \
libxslt \
&& mkdir -p /var/cache/nginx \
&& mkdir -p /var/log/nginx \
&& mkdir -p /etc/nginx/conf.d \
&& mkdir -p /etc/nginx/njs \
&& ln -sf /dev/stdout /var/log/nginx/access.log \
&& ln -sf /dev/stderr /var/log/nginx/error.log \
&& addgroup -S nginx \
&& adduser -D -S -h /var/cache/nginx -s /sbin/nologin -G nginx nginx
# Copy necessary libraries from builder
COPY --from=builder /usr/lib/libxml2.so* /usr/lib/
COPY --from=builder /usr/lib/libexslt.so* /usr/lib/
COPY --from=builder /usr/lib/libgd.so* /usr/lib/
COPY --from=builder /usr/lib/libxslt.so* /usr/lib/
# Modify nginx.conf to load modules
RUN echo 'load_module modules/ngx_http_js_module.so;' > /tmp/nginx.conf && \
cat /etc/nginx/nginx.conf >> /tmp/nginx.conf && \
mv /tmp/nginx.conf /etc/nginx/nginx.conf
# Copy our configuration
COPY conf.d/default.conf /etc/nginx/conf.d/
COPY njs/main.js /etc/nginx/njs/
# Set proper permissions
RUN chown -R nginx:nginx /var/cache/nginx \
&& chown -R nginx:nginx /var/log/nginx \
&& chown -R nginx:nginx /etc/nginx/conf.d \
&& touch /var/run/nginx.pid \
&& chown -R nginx:nginx /var/run/nginx.pid
# Verify the configuration
# RUN nginx -t --dry-run
# Switch to non-root user
USER nginx
# Expose HTTP port
EXPOSE 80
# Start Nginx
CMD ["nginx", "-g", "daemon off;"]

59
nginx/conf.d/default.conf Normal file
View file

@ -0,0 +1,59 @@
js_import njs/main.js;
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
upstream frontend {
server frontend:5173;
}
upstream backend {
server backend:3000;
}
server {
listen 80;
set $proxy_target "";
location /api {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Game WebSocket routing
location ~/api/room/([^/]+)/socket {
set $room_id $1;
js_content main.routeWebSocket;
}
# WebSocket proxy location
location @websocket_proxy {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# Timeouts
proxy_connect_timeout 7m;
proxy_send_timeout 7m;
proxy_read_timeout 7m;
proxy_buffering off;
proxy_pass $proxy_target;
}
location / {
proxy_pass http://frontend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}

View file

@ -1,31 +0,0 @@
upstream frontend {
server frontend:5173;
}
upstream backend {
server backend:3000;
}
server {
listen 80;
location /api {
rewrite /backend/(.*) /$1 break;
proxy_pass http://backend;
}
location /socket.io {
rewrite /backend/(.*) /$1 break;
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
proxy_set_header Host $host;
proxy_hide_header 'Access-Control-Allow-Origin';
}
location / {
proxy_pass http://frontend;
}
}

54
nginx/njs/main.js Normal file
View file

@ -0,0 +1,54 @@
async function fetchRoomInfo(r) {
try {
// Make request to API to get room info
let res = await r.subrequest('/api/room/' + r.variables.room_id, {
method: 'GET'
});
if (res.status !== 200) {
r.error(`Failed to fetch room info: ${res.status}`);
return null;
}
let room = JSON.parse(res.responseText);
r.error(`Debug: Room info: ${JSON.stringify(room)}`); // Debug log
return room;
} catch (error) {
r.error(`Error fetching room info: ${error}`);
return null;
}
}
async function routeWebSocket(r) {
try {
const roomInfo = await fetchRoomInfo(r);
if (!roomInfo || !roomInfo.host) {
r.error(`Debug: Invalid room info: ${JSON.stringify(roomInfo)}`);
r.return(404, 'Room not found or invalid');
return;
}
// Make sure the host includes protocol if not already present
let proxyUrl = roomInfo.host;
if (!proxyUrl.startsWith('http://') && !proxyUrl.startsWith('https://')) {
proxyUrl = 'http://' + proxyUrl;
}
r.error(`Debug: Original URL: ${r.uri}`);
r.error(`Debug: Setting proxy target to: ${proxyUrl}`);
r.error(`Debug: Headers: ${JSON.stringify(r.headersIn)}`);
// Set the proxy target variable
r.variables.proxy_target = proxyUrl;
// Redirect to the websocket proxy
r.internalRedirect('@websocket_proxy');
} catch (error) {
r.error(`WebSocket routing error: ${error}`);
r.return(500, 'Internal routing error');
}
}
export default { routeWebSocket };

2
quizRoom/.dockerignore Normal file
View file

@ -0,0 +1,2 @@
Dockerfile
docker-compose.yml

View file

@ -1,18 +1,29 @@
# Use the Node base image # Use the Node base image
FROM node:18 FROM node:18 AS quizroom
ARG PORT=4500
ENV PORT=${PORT}
ENV ROOM_ID=${ROOM_ID}
# Create a working directory # Create a working directory
WORKDIR /usr/src/app WORKDIR /usr/src/app
# Copy package.json and install dependencies # Copy package.json and package-lock.json (if available) and install dependencies
COPY package*.json ./ COPY package*.json ./
RUN npm install RUN npm install
# Copy all source code to the container # Copy the rest of the source code to the container
COPY . . COPY . .
# Expose WebSocket server port # Build the TypeScript code
EXPOSE 4500 RUN npm run build
# Start the WebSocket server # Expose WebSocket server port
CMD ["node", "app.js"] EXPOSE ${PORT}
# Add healthcheck
HEALTHCHECK --interval=30s --timeout=30s --start-period=30s --retries=3 \
CMD /usr/src/app/healthcheck.sh
# Start the server using the compiled JavaScript file
CMD ["node", "dist/app.js"]

View file

@ -1,15 +1,43 @@
import http from "http"; import http from "http";
import { Server, ServerOptions } from "socket.io"; import { Server, ServerOptions } from "socket.io";
// Import setupWebsocket
import { setupWebsocket } from "./socket/setupWebSocket"; import { setupWebsocket } from "./socket/setupWebSocket";
import dotenv from "dotenv";
import express from 'express';
const port = process.env.WS_PORT ? parseInt(process.env.WS_PORT) : 4500; // Load environment variables
dotenv.config();
const port = process.env.PORT || 4500;
const roomId = process.env.ROOM_ID;
console.log(`I am: /api/room/${roomId}/socket`);
// Create Express app for health check
const app = express();
const server = http.createServer(app);
// Health check endpoint
app.get('/health', (_, res) => {
try {
if (io.engine?.clientsCount !== undefined) {
res.status(200).json({
status: 'healthy',
path: `/api/room/${roomId}/socket`,
connections: io.engine.clientsCount,
uptime: process.uptime()
});
} else {
throw new Error('Socket.io server not initialized');
}
} catch (error: Error | any) {
res.status(500).json({
status: 'unhealthy',
error: error.message
});
}
});
// Create HTTP and WebSocket server
const server = http.createServer();
const ioOptions: Partial<ServerOptions> = { const ioOptions: Partial<ServerOptions> = {
path: "/socket.io", path: `/api/room/${roomId}/socket`,
cors: { cors: {
origin: "*", origin: "*",
methods: ["GET", "POST"], methods: ["GET", "POST"],
@ -24,4 +52,4 @@ setupWebsocket(io);
server.listen(port, () => { server.listen(port, () => {
console.log(`WebSocket server is running on port ${port}`); console.log(`WebSocket server is running on port ${port}`);
}); });

View file

@ -0,0 +1,19 @@
version: '3.8'
services:
quizroom:
build:
context: .
args:
- PORT=${PORT:-4500}
ports:
- "${PORT:-4500}:${PORT:-4500}"
environment:
- PORT=${PORT:-4500}
- ROOM_ID=${ROOM_ID}
healthcheck:
test: curl -f http://localhost:${PORT:-4500}/health || exit 1
interval: 30s
timeout: 30s
retries: 3
start_period: 30s

2
quizRoom/healthcheck.sh Normal file
View file

@ -0,0 +1,2 @@
#!/bin/sh
curl -f "http://0.0.0.0:${PORT}/health" || exit 1

File diff suppressed because it is too large Load diff

View file

@ -1,21 +1,25 @@
{ {
"name": "ets-pfe004-evaluetonsavoir-quizroom", "name": "quizroom",
"version": "1.0.0", "version": "1.0.0",
"main": "app.js", "main": "index.js",
"scripts": { "scripts": {
"start": "node app.js", "start": "node dist/app.js",
"test": "jest --colors" "build": "tsc",
"dev": "ts-node app.ts"
}, },
"keywords": [],
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"description": "", "description": "",
"dependencies": {
"express": "^4.18.2",
"jsonwebtoken": "^9.0.2",
"socket.io": "^4.7.2"
},
"devDependencies": { "devDependencies": {
"jest": "^29.7.0", "@types/express": "^5.0.0",
"jest-mock": "^29.7.0" "ts-node": "^10.9.2",
"typescript": "^5.6.3"
},
"dependencies": {
"dotenv": "^16.4.5",
"express": "^4.21.1",
"http": "^0.0.1-security",
"socket.io": "^4.8.1"
} }
} }

View file

@ -0,0 +1,2 @@
ROOM_ID=123456
PORT=4500

View file

@ -17,8 +17,12 @@ export const setupWebsocket = (io: Server): void => {
totalConnections++; totalConnections++;
console.log("A user connected:", socket.id, "| Total connections:", totalConnections); console.log("A user connected:", socket.id, "| Total connections:", totalConnections);
socket.on("create-room", (sentRoomName?: string) => { socket.on("create-room", (sentRoomName) => {
const roomName = sentRoomName ? sentRoomName.toUpperCase() : generateRoomName(); // Ensure sentRoomName is a string before applying toUpperCase()
const roomName = (typeof sentRoomName === "string" && sentRoomName.trim() !== "")
? sentRoomName.toUpperCase()
: generateRoomName();
if (!io.sockets.adapter.rooms.get(roomName)) { if (!io.sockets.adapter.rooms.get(roomName)) {
socket.join(roomName); socket.join(roomName);
socket.emit("create-success", roomName); socket.emit("create-success", roomName);
@ -88,8 +92,14 @@ export const setupWebsocket = (io: Server): void => {
idQuestion, idQuestion,
}); });
}); });
socket.on("error", (error) => {
console.error("WebSocket server error:", error);
});
}); });
const generateRoomName = (length = 6): string => { const generateRoomName = (length = 6): string => {
const characters = "0123456789"; const characters = "0123456789";
let result = ""; let result = "";

14
quizRoom/tsconfig.json Normal file
View file

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES6",
"module": "commonjs",
"outDir": "./dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": ["./**/*"],
"exclude": ["node_modules"]
}

View file

@ -3,10 +3,6 @@ const express = require("express");
const http = require("http"); const http = require("http");
const dotenv = require('dotenv'); const dotenv = require('dotenv');
// Import Sockets
const { setupWebsocket } = require("./socket/socket");
const { Server } = require("socket.io");
// instantiate the db // instantiate the db
const db = require('./config/db.js'); const db = require('./config/db.js');
// instantiate the models // instantiate the models
@ -18,6 +14,13 @@ const users = require('./models/users.js');
const userModel = new users(db, foldersModel); const userModel = new users(db, foldersModel);
const images = require('./models/images.js'); const images = require('./models/images.js');
const imageModel = new images(db); const imageModel = new images(db);
const {RoomRepository} = require('./models/room.js');
const roomRepModel = new RoomRepository(db);
// Instantiate the controllers
const QuizProviderOptions = {
provider: 'docker'
};
// instantiate the controllers // instantiate the controllers
const usersController = require('./controllers/users.js'); const usersController = require('./controllers/users.js');
@ -28,18 +31,22 @@ const quizController = require('./controllers/quiz.js');
const quizControllerInstance = new quizController(quizModel, foldersModel); const quizControllerInstance = new quizController(quizModel, foldersModel);
const imagesController = require('./controllers/images.js'); const imagesController = require('./controllers/images.js');
const imagesControllerInstance = new imagesController(imageModel); const imagesControllerInstance = new imagesController(imageModel);
const roomsController = require('./controllers/rooms.js');
const roomsControllerInstance = new roomsController(QuizProviderOptions,roomRepModel);
// export the controllers // export the controllers
module.exports.users = usersControllerInstance; module.exports.users = usersControllerInstance;
module.exports.folders = foldersControllerInstance; module.exports.folders = foldersControllerInstance;
module.exports.quizzes = quizControllerInstance; module.exports.quizzes = quizControllerInstance;
module.exports.images = imagesControllerInstance; module.exports.images = imagesControllerInstance;
module.exports.rooms = roomsControllerInstance;
//import routers (instantiate controllers as side effect) //import routers (instantiate controllers as side effect)
const userRouter = require('./routers/users.js'); const userRouter = require('./routers/users.js');
const folderRouter = require('./routers/folders.js'); const folderRouter = require('./routers/folders.js');
const quizRouter = require('./routers/quiz.js'); const quizRouter = require('./routers/quiz.js');
const imagesRouter = require('./routers/images.js'); const imagesRouter = require('./routers/images.js');
const roomRouter = require('./routers/rooms.js');
// Setup environment // Setup environment
dotenv.config(); dotenv.config();
@ -50,27 +57,10 @@ const app = express();
const cors = require("cors"); const cors = require("cors");
const bodyParser = require('body-parser'); const bodyParser = require('body-parser');
const configureServer = (httpServer, isDev) => { let server = http.createServer(app);
return new Server(httpServer, {
path: "/socket.io",
cors: {
origin: "*",
methods: ["GET", "POST"],
credentials: true,
},
secure: !isDev, // true for https, false for http
});
};
// Start sockets (depending on the dev or prod environment)
let server = http.createServer(app);
let isDev = process.env.NODE_ENV === 'development'; let isDev = process.env.NODE_ENV === 'development';
console.log(`Environnement: ${process.env.NODE_ENV} (${isDev ? 'dev' : 'prod'})`); console.log(`Environnement: ${process.env.NODE_ENV} (${isDev ? 'dev' : 'prod'})`);
const io = configureServer(server);
setupWebsocket(io);
app.use(cors()); app.use(cors());
app.use(bodyParser.urlencoded({ extended: true })); app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json()); app.use(bodyParser.json());
@ -80,6 +70,7 @@ app.use('/api/user', userRouter);
app.use('/api/folder', folderRouter); app.use('/api/folder', folderRouter);
app.use('/api/quiz', quizRouter); app.use('/api/quiz', quizRouter);
app.use('/api/image', imagesRouter); app.use('/api/image', imagesRouter);
app.use('/api/room', roomRouter);
app.use(errorHandler); app.use(errorHandler);

View file

@ -0,0 +1,94 @@
const {Room} = require('../models/room.js');
const BaseRoomProvider = require('../roomsProviders/base-provider.js');
//const ClusterRoomProvider = require('../roomsProviders/cluster-provider.js');
const DockerRoomProvider = require('../roomsProviders/docker-provider.js');
//const KubernetesRoomProvider = require('../roomsProviders/kubernetes-provider');
const NB_CODE_CHARS = 6;
const NB_MS_UPDATE_ROOM = 1000;
const NB_MS_CLEANUP = 30000;
class RoomsController {
constructor(options = {}, roomRepository) {
this.provider = this.createProvider(
options.provider || process.env.ROOM_PROVIDER || 'cluster',
options.providerOptions,
roomRepository
);
this.roomRepository = roomRepository;
this.setupTasks();
}
createProvider(type, options, repository) {
switch (type) {
/*
case 'cluster':
return new ClusterRoomProvider(options, this.roomRepository);
*/
// Uncomment these as needed
case 'docker':
return new DockerRoomProvider(options, repository);
/*
case 'kubernetes':
return new KubernetesRoomProvider(options);
*/
default:
throw new Error(`Type d'approvisionement inconnu: ${type}`);
}
}
async setupTasks(){
await this.provider.syncInstantiatedRooms();
// Update rooms
setInterval(() => {
this.provider.updateRoomsInfo().catch(console.error);
}, NB_MS_UPDATE_ROOM);
// Cleanup rooms
setInterval(() => {
this.provider.cleanup().catch(console.error);
}, NB_MS_CLEANUP);
}
async createRoom(options = {}) {
let roomIdValid = false
let roomId;
while(!roomIdValid){
roomId = options.roomId || this.generateRoomId();
roomIdValid = !(await this.provider.getRoomInfo(roomId));
}
return await this.provider.createRoom(roomId,options);
}
async updateRoom(roomId, info) {
return await this.provider.updateRoomInfo(roomId, {});
}
async deleteRoom(roomId) {
return await this.provider.deleteRoom(roomId);
}
async getRoomStatus(roomId) {
return await this.provider.getRoomStatus(roomId);
}
async listRooms() {
return await this.provider.listRooms();
}
generateRoomId() {
const characters = "0123456789";
let result = "";
for (let i = 0; i < NB_CODE_CHARS; i++) {
result += characters.charAt(
Math.floor(Math.random() * characters.length)
);
}
return result;
}
}
module.exports = RoomsController;

View file

@ -1,75 +0,0 @@
import { GlideClient, GlideClientConfiguration } from '@valkey/valkey-glide';
import {
RoomInfo,
RoomOptions,
ProviderType,
ProviderConfig
} from '../../types/room';
import { BaseRoomProvider } from './providers/base-provider';
import { ClusterRoomProvider } from './providers/cluster-provider';
import { DockerRoomProvider } from './providers/docker-provider';
import { KubernetesRoomProvider } from './providers/kubernetes-provider';
interface RoomManagerOptions {
valkeyConfig?: GlideClientConfiguration;
provider?: ProviderType;
providerOptions?: ProviderConfig;
}
export class RoomManager {
private valkey: GlideClient;
private provider: BaseRoomProvider<RoomInfo>;
constructor(options: RoomManagerOptions = {}) {
this.valkey = new GlideClient();
this.provider = this.createProvider(
options.provider || process.env.ROOM_PROVIDER as ProviderType || 'cluster',
options.providerOptions
);
this.setupCleanup();
}
private createProvider(
type: ProviderType,
options?: ProviderConfig
): BaseRoomProvider<RoomInfo> {
switch (type) {
case 'cluster':
return new ClusterRoomProvider(this.redis, options);
case 'docker':
return new DockerRoomProvider(this.redis, options);
case 'kubernetes':
return new KubernetesRoomProvider(this.redis, options);
default:
throw new Error(`Unknown provider type: ${type}`);
}
}
private setupCleanup(): void {
setInterval(() => {
this.provider.cleanup().catch(console.error);
}, 30000);
}
async createRoom(options: RoomOptions = {}): Promise<RoomInfo> {
const roomId = options.roomId || this.generateRoomId();
return await this.provider.createRoom(roomId, options);
}
async deleteRoom(roomId: string): Promise<void> {
return await this.provider.deleteRoom(roomId);
}
async getRoomStatus(roomId: string): Promise<RoomInfo | null> {
return await this.provider.getRoomStatus(roomId);
}
async listRooms(): Promise<RoomInfo[]> {
return await this.provider.listRooms();
}
private generateRoomId(): string {
return `room-${Math.random().toString(36).substr(2, 9)}`;
}
}

View file

@ -22,7 +22,7 @@ class Token {
if (error) { if (error) {
throw new AppError(UNAUTHORIZED_INVALID_TOKEN) throw new AppError(UNAUTHORIZED_INVALID_TOKEN)
} }
req.user = payload; req.user = payload;
}); });

View file

@ -51,6 +51,7 @@ class Quiz {
await this.db.connect() await this.db.connect()
const conn = this.db.getConnection(); const conn = this.db.getConnection();
const quizCollection = conn.collection('files'); const quizCollection = conn.collection('files');
const quiz = await quizCollection.findOne({ _id: ObjectId.createFromHexString(quizId) }); const quiz = await quizCollection.findOne({ _id: ObjectId.createFromHexString(quizId) });

87
server/models/room.js Normal file
View file

@ -0,0 +1,87 @@
class Room {
constructor(id, name, host, nbStudents = 0,) { // Default nbStudents to 0
this.id = id;
this.name = name;
if (!host.startsWith('http://') && !host.startsWith('https://')) {
host = 'http://' + host;
}
this.host = host;
this.nbStudents = nbStudents;
this.mustBeCleaned = false;
}
}
class RoomRepository {
constructor(db) {
this.db = db;
this.connection = null;
this.collection = null;
}
async init() {
if (!this.connection) {
await this.db.connect();
this.connection = this.db.getConnection();
}
if (!this.collection) this.collection = this.connection.collection('rooms');
}
async create(room) {
await this.init();
const existingRoom = await this.collection.findOne({ id: room.id });
if (existingRoom) {
throw new Error(`Érreur: la salle ${room.id} existe déja`);
}
const returnedId = await this.collection.insertOne(room);
return await this.collection.findOne({ _id: returnedId.insertedId });
}
async get(id) {
await this.init();
const existingRoom = await this.collection.findOne({ id: id });
if (!existingRoom) {
console.warn(`La sale avec l'identifiant ${id} n'as pas été trouvé.`);
return null;
}
return existingRoom;
}
async getAll() {
await this.init();
return await this.collection.find().toArray();
}
async update(room,roomId = null) {
await this.init();
const searchId = roomId ?? room.id;
const result = await this.collection.updateOne(
{ id: searchId },
{ $set: room },
{ upsert: false }
);
if (result.modifiedCount === 0) {
if (result.matchedCount > 0) {
return true; // Document exists but no changes needed
}
return false;
}
return true;
}
async delete(id) {
await this.init();
const result = await this.collection.deleteOne({ id: id });
if (result.deletedCount === 0) {
console.warn(`La salle ${id} n'as pas été trouvée pour éffectuer sa suppression.`);
return false;
}
return true;
}
}
module.exports = { Room, RoomRepository };

2770
server/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -14,14 +14,15 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@valkey/valkey-glide": "^1.1.0",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"cors": "^2.8.5", "cors": "^2.8.5",
"dockerode": "^4.0.2",
"dotenv": "^16.4.4", "dotenv": "^16.4.4",
"express": "^4.18.2", "express": "^4.18.2",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"mongodb": "^6.3.0", "mongodb": "^6.3.0",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"net": "^1.0.2",
"nodemailer": "^6.9.9", "nodemailer": "^6.9.9",
"socket.io": "^4.7.2", "socket.io": "^4.7.2",
"socket.io-client": "^4.7.2" "socket.io-client": "^4.7.2"

View file

@ -0,0 +1,75 @@
/**
* @template T
* @typedef {import('../../types/room').RoomInfo} RoomInfo
* @typedef {import('../../types/room').RoomOptions} RoomOptions
* @typedef {import('../../types/room').BaseProviderConfig} BaseProviderConfig
*/
const MIN_NB_SECONDS_BEFORE_CLEANUP = process.env.MIN_NB_SECONDS_BEFORE_CLEANUP || 60
class BaseRoomProvider {
constructor(config = {}, roomRepository) {
this.config = config;
this.roomRepository = roomRepository;
this.quiz_docker_image = process.env.QUIZROOM_IMAGE || "evaluetonsavoir-quizroom";
this.quiz_docker_port = process.env.QUIZROOM_PORT || 4500;
this.quiz_expose_port = process.env.QUIZROOM_EXPOSE_PORT || false;
}
async createRoom(roomId, options) {
throw new Error("Fonction non-implantée - classe abstraite");
}
async deleteRoom(roomId) {
throw new Error("Fonction non-implantée - classe abstraite");
}
async getRoomStatus(roomId) {
throw new Error("Fonction non-implantée - classe abstraite");
}
async listRooms() {
throw new Error("Fonction non-implantée - classe abstraite");
}
async cleanup() {
throw new Error("Fonction non-implantée - classe abstraite");
}
async syncInstantiatedRooms(){
throw new Error("Fonction non-implantée - classe abstraite");
}
async updateRoomsInfo() {
const rooms = await this.roomRepository.getAll();
for(var room of rooms){
const url = `${room.host}/health`;
try {
const response = await fetch(url);
if (!response.ok) {
room.mustBeCleaned = true;
await this.roomRepository.update(room);
continue;
}
const json = await response.json();
room.nbStudents = json.connections;
room.mustBeCleaned = room.nbStudents === 0 && json.uptime >MIN_NB_SECONDS_BEFORE_CLEANUP;
await this.roomRepository.update(room);
} catch (error) {
room.mustBeCleaned = true;
await this.roomRepository.update(room);
}
}
}
async getRoomInfo(roomId) {
const info = await this.roomRepository.get(roomId);
return info;
}
}
module.exports = BaseRoomProvider;

View file

@ -1,34 +0,0 @@
import { GlideClient, GlideString } from "@valkey/valkey-glide";
import { RoomInfo, RoomOptions, BaseProviderConfig } from '../../types/room';
export abstract class BaseRoomProvider<T extends RoomInfo> {
protected valkey: GlideClient;
constructor(valkey: GlideClient, protected config: BaseProviderConfig = {}) {
this.valkey = valkey;
}
abstract createRoom(roomId: string, options?: RoomOptions): Promise<T>;
abstract deleteRoom(roomId: string): Promise<void>;
abstract getRoomStatus(roomId: string): Promise<T | null>;
abstract listRooms(): Promise<T[]>;
abstract cleanup(): Promise<void>;
protected async updateRoomInfo(roomId: string, info: Partial<RoomInfo>): Promise<boolean> {
let room = await this.getRoomInfo(roomId);
if(!room) return false;
for(let key in Object.keys(room)){
room[key]= info[key];
}
const result = await this.valkey.set(`room:${roomId}`,room as Object as GlideString);
return result != null;
}
protected async getRoomInfo(roomId: string): Promise<RoomInfo | null> {
const info = (await this.valkey.get(`room:${roomId}`)) as RoomInfo | null;
return info;
}
}

View file

@ -0,0 +1,110 @@
const cluster = require("node:cluster");
const { cpus } = require("node:os");
const BaseRoomProvider = require("./base-provider.js");
class ClusterRoomProvider extends BaseRoomProvider {
constructor(config = {}, roomRepository) {
super(config, roomRepository);
this.workers = new Map();
if (cluster.isPrimary) {
this.initializeCluster();
}
}
initializeCluster() {
const numCPUs = cpus().length;
for (let i = 0; i < numCPUs; i++) {
const worker = cluster.fork();
this.handleWorkerMessages(worker);
}
cluster.on("exit", (worker, code, signal) => {
console.log(`Worker ${worker.process.pid} died. Starting new worker...`);
const newWorker = cluster.fork();
this.handleWorkerMessages(newWorker);
});
}
handleWorkerMessages(worker) {
worker.on("message", async (msg) => {
if (msg.type === "room_status") {
await this.updateRoomInfo(msg.roomId, {
status: msg.status,
workerId: worker.id,
lastUpdate: Date.now(),
});
}
});
}
async createRoom(roomId, options = {}) {
const workerLoads = Array.from(this.workers.entries())
.map(([id, data]) => ({
id,
rooms: data.rooms.size,
}))
.sort((a, b) => a.rooms - b.rooms);
const workerId = workerLoads[0].id;
const worker = cluster.workers[workerId];
if (!worker) {
throw new Error("No available workers");
}
worker.send({ type: "create_room", roomId, options });
const roomInfo = {
roomId,
provider: "cluster",
status: "creating",
workerId,
pid: worker.process.pid,
createdAt: Date.now(),
};
await this.updateRoomInfo(roomId, roomInfo);
return roomInfo;
}
async deleteRoom(roomId) {
const roomInfo = await this.getRoomInfo(roomId);
if (roomInfo?.workerId && cluster.workers[roomInfo.workerId]) {
cluster.workers[roomInfo.workerId].send({
type: "delete_room",
roomId,
});
}
//await this.valkey.del(["room", roomId]);
}
async getRoomStatus(roomId) {
return await this.getRoomInfo(roomId);
}
async listRooms() {
let rooms = [];
/*
const keys = await this.valkey.hkeys("room:*");
const rooms = await Promise.all(
keys.map((key) => this.getRoomInfo(key.split(":")[1]))
);
*/
return rooms.filter((room) => room !== null);
}
async cleanup() {
const rooms = await this.listRooms();
const staleTimeout = 30000;
for (const room of rooms) {
if (Date.now() - (room.lastUpdate || room.createdAt) > staleTimeout) {
await this.deleteRoom(room.roomId);
}
}
}
}
module.exports = ClusterRoomProvider;

View file

@ -1,117 +0,0 @@
import * as cluster from 'node:cluster';
import { cpus } from 'node:os';
import { GlideClient } from "@valkey/valkey-glide";
import {
ClusterRoomInfo,
RoomOptions,
ClusterProviderConfig
} from '../../types/room';
import { BaseRoomProvider } from './base-provider';
export class ClusterRoomProvider extends BaseRoomProvider<ClusterRoomInfo> {
private workers: Map<number, { rooms: Set<string> }>;
constructor(valkey: GlideClient, config: ClusterProviderConfig = {}) {
super(valkey, config);
this.workers = new Map();
if (cluster.default.isPrimary) {
this.initializeCluster();
}
}
private initializeCluster(): void {
const numCPUs = cpus().length;
for (let i = 0; i < numCPUs; i++) {
const worker = cluster.default.fork();
this.handleWorkerMessages(worker);
}
cluster.default.on('exit', (worker, code, signal) => {
console.log(`Worker ${worker.process.pid} died. Starting new worker...`);
const newWorker = cluster.default.fork();
this.handleWorkerMessages(newWorker);
});
}
private handleWorkerMessages(worker: cluster.Worker): void {
worker.on('message', async (msg: {
type: string;
roomId: string;
status: string;
}) => {
if (msg.type === 'room_status') {
await this.updateRoomInfo(msg.roomId, {
status: msg.status as any,
workerId: worker.id,
lastUpdate: Date.now()
} as Partial<ClusterRoomInfo>);
}
});
}
async createRoom(roomId: string, options: RoomOptions = {}): Promise<ClusterRoomInfo> {
const workerLoads = Array.from(this.workers.entries())
.map(([id, data]) => ({
id,
rooms: data.rooms.size
}))
.sort((a, b) => a.rooms - b.rooms);
const workerId = workerLoads[0].id;
const worker = cluster.default.workers?.[workerId];
if (!worker) {
throw new Error('No available workers');
}
worker.send({ type: 'create_room', roomId, options });
const roomInfo: ClusterRoomInfo = {
roomId,
provider: 'cluster',
status: 'creating',
workerId,
pid: worker.process.pid!,
createdAt: Date.now()
};
await this.updateRoomInfo(roomId, roomInfo);
return roomInfo;
}
async deleteRoom(roomId: string): Promise<void> {
const roomInfo = await this.getRoomInfo(roomId) as ClusterRoomInfo;
if (roomInfo?.workerId && cluster.Worker?.[roomInfo.workerId]) {
cluster.Worker[roomInfo.workerId].send({
type: 'delete_room',
roomId
});
}
await this.valkey.del(['room',roomId]);
}
async getRoomStatus(roomId: string): Promise<ClusterRoomInfo | null> {
return await this.getRoomInfo(roomId) as ClusterRoomInfo | null;
}
async listRooms(): Promise<ClusterRoomInfo[]> {
const keys = await this.valkey.hkeys('room:*');
const rooms = await Promise.all(
keys.map(key => this.getRoomInfo(key.toString().split(':')[1]))
);
return rooms.filter((room): room is ClusterRoomInfo => room !== null);
}
async cleanup(): Promise<void> {
const rooms = await this.listRooms();
const staleTimeout = 30000; // 30 seconds
for (const room of rooms) {
if (Date.now() - (room.lastUpdate || room.createdAt) > staleTimeout) {
await this.deleteRoom(room.roomId);
}
}
}
}

View file

@ -0,0 +1,230 @@
const Docker = require("dockerode");
const { Room } = require("../models/room.js");
const BaseRoomProvider = require("./base-provider.js");
class DockerRoomProvider extends BaseRoomProvider {
constructor(config, roomRepository) {
super(config, roomRepository);
const dockerSocket = process.env.DOCKER_SOCKET || "/var/run/docker.sock";
this.docker = new Docker({ socketPath: dockerSocket });
this.docker_network = 'evaluetonsavoir_quiz_network';
}
async syncInstantiatedRooms() {
let containers = await this.docker.listContainers();
containers = containers.filter(container => container.Image === this.quiz_docker_image);
const containerIds = new Set(containers.map(container => container.Id));
for (let container of containers) {
const container_name = container.Names[0].slice(1);
if (!container_name.startsWith("room_")) {
console.warn(`Le conteneur ${container_name} ne suit pas la convention de nommage, il sera supprimé.`);
const curContainer = this.docker.getContainer(container.Id);
await curContainer.stop();
await curContainer.remove();
containerIds.delete(container.Id);
console.warn(`Le conteneur ${container_name} a été supprimé.`);
}
else {
console.warn(`Conteneur orphelin trouvé : ${container_name}`);
const roomId = container_name.slice(5);
const room = await this.roomRepository.get(roomId);
if (!room) {
console.warn(`Le conteneur n'est pas dans notre base de données.`);
const containerInfo = await this.docker.getContainer(container.Id).inspect();
const containerIP = containerInfo.NetworkSettings.Networks.evaluetonsavoir_quiz_network.IPAddress;
const host = `${containerIP}:4500`;
console.warn(`Création de la salle ${roomId} dans notre base de donnée - hôte : ${host}`);
return await this.roomRepository.create(new Room(roomId, container_name, host));
}
console.warn(`La salle ${roomId} est déjà dans notre base de données.`);
}
}
}
async createRoom(roomId, options) {
const container_name = `room_${roomId}`;
try {
const containerConfig = {
Image: this.quiz_docker_image,
name: container_name,
HostConfig: {
NetworkMode: this.docker_network,
RestartPolicy: {
Name: 'unless-stopped'
}
},
Env: [
`ROOM_ID=${roomId}`,
`PORT=${this.quiz_docker_port}`,
...(options.env || [])
]
};
if (this.quiz_expose_port) {
containerConfig.ExposedPorts = {
[`${this.quiz_docker_port}/tcp`]: {}
};
containerConfig.HostConfig.PortBindings = {
[`${this.quiz_docker_port}/tcp`]: [{ HostPort: '' }] // Empty string for random port
};
}
const container = await this.docker.createContainer(containerConfig);
await container.start();
const containerInfo = await container.inspect();
const networkInfo = containerInfo.NetworkSettings.Networks[this.docker_network];
if (!networkInfo) {
throw new Error(`Le conteneur n'as pu se connecter au réseau: ${this.docker_network}`);
}
const containerIP = networkInfo.IPAddress;
const host = `http://${containerIP}:${this.quiz_docker_port}`;
let health = false;
let attempts = 0;
const maxAttempts = 15;
while (!health && attempts < maxAttempts) {
try {
const response = await fetch(`${host}/health`, {
timeout: 1000
});
if (response.ok) {
health = true;
console.log(`Le conteneur ${container_name} est tombé actif en ${attempts + 1} tentatives`);
} else {
throw new Error(`Health check failed with status ${response.status}`);
}
} catch (error) {
attempts++;
console.log(`Attente du conteneur: ${container_name} (tentative ${attempts}/${maxAttempts})`);
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
if (!health) {
console.error(`Container ${container_name} failed health check after ${maxAttempts} attempts`);
await container.stop();
await container.remove();
throw new Error(`Room ${roomId} did not respond within acceptable timeout`);
}
return await this.roomRepository.create(new Room(roomId, container_name, host, 0));
} catch (error) {
console.error(`Échec de la création de la salle ${roomId}:`, error);
throw error;
}
}
async deleteRoom(roomId) {
const container_name = `room_${roomId}`;
await this.roomRepository.delete(roomId);
try {
const container = this.docker.getContainer(container_name);
const containerInfo = await container.inspect();
if (containerInfo) {
await container.stop();
await container.remove();
console.log(`Le conteneur pour la salle ${roomId} a été arrêté et supprimé.`);
}
} catch (error) {
if (error.statusCode === 404) {
console.warn(`Le conteneur pour la salle ${roomId} n'as pas été trouvé, la salle sera supprimée de la base de données.`);
} else {
console.error(`Erreur pour la salle ${roomId}:`, error);
throw new Error("La salle :${roomId} n'as pas pu être supprimée.");
}
}
console.log(`La salle ${roomId} a été supprimée.`);
}
async getRoomStatus(roomId) {
const room = await this.roomRepository.get(roomId);
if (!room) return null;
try {
const container = this.docker.getContainer(room.containerId || `room_${roomId}`);
const info = await container.inspect();
const updatedRoomInfo = {
...room,
status: info.State.Running ? "running" : "terminated",
containerStatus: {
Running: info.State.Running,
StartedAt: info.State.StartedAt,
FinishedAt: info.State.FinishedAt,
},
lastUpdate: Date.now(),
};
await this.roomRepository.update(updatedRoomInfo);
return updatedRoomInfo;
} catch (error) {
if (error.statusCode === 404) {
console.warn(`Le conteneur pour la salle ${roomId} n'as pas été trouvé, il sera mis en état "terminé".`);
const terminatedRoomInfo = {
...room,
status: "terminated",
containerStatus: {
Running: false,
StartedAt: room.containerStatus?.StartedAt || null,
FinishedAt: Date.now(),
},
lastUpdate: Date.now(),
};
await this.roomRepository.update(terminatedRoomInfo);
return terminatedRoomInfo;
} else {
console.error(`Une érreur s'est produite lors de l'obtention de l'état de la salle ${roomId}:`, error);
return null;
}
}
}
async listRooms() {
const rooms = await this.roomRepository.getAll();
return rooms;
}
async cleanup() {
const rooms = await this.roomRepository.getAll();
for (let room of rooms) {
if (room.mustBeCleaned) {
try {
await this.deleteRoom(room.id);
} catch (error) {
console.error(`Érreur lors du néttoyage de la salle ${room.id}:`, error);
}
}
}
let containers = await this.docker.listContainers();
containers = containers.filter(container => container.Image === this.quiz_docker_image);
const roomIds = rooms.map(room => room.id);
for (let container of containers) {
if (!roomIds.includes(container.Names[0].slice(6))) {
const curContainer = this.docker.getContainer(container.Id);
await curContainer.stop();
await curContainer.remove();
console.warn(`Conteneur orphelin ${container.Names[0]} supprimé.`);
}
}
}
}
module.exports = DockerRoomProvider;

54
server/routers/rooms.js Normal file
View file

@ -0,0 +1,54 @@
const { Router } = require("express");
const roomsController = require('../app.js').rooms;
const jwt = require('../middleware/jwtToken.js');
const router = Router();
router.get("/",jwt.authenticate, async (req, res)=> {
try {
const data = await roomsController.listRooms();
res.json(data);
} catch (error) {
res.status(500).json({ error: "Échec de listage des salle" });
}
});
router.post("/",jwt.authenticate, async (req, res) => {
try {
const data = await roomsController.createRoom();
res.json(data);
} catch (error) {
console.log(error);
res.status(500).json({ error: "Échec de la création de salle :" + error });
}
});
router.put("/:id",jwt.authenticate, async (req, res) => {
try {
const data = await roomsController.updateRoom(req.params.id);
res.json(data);
} catch (error) {
res.status(500).json({ error: "Échec de la mise a jour de salle : "+error });
}
});
router.delete("/:id",jwt.authenticate, async (req, res) => {
try {
const data = await roomsController.deleteRoom(req.params.id);
res.json(data);
} catch (error) {
res.status(500).json({ error: `Échec de suppression de la salle: `+error });
}
});
router.get("/:id", async (req, res) => {
try {
const data = await roomsController.getRoomStatus(req.params.id);
res.json(data);
} catch (error) {
res.status(500).json({ error: "Impossible d'afficher les informations de la salle: " + error });
}
});
module.exports = router;

View file

@ -1 +0,0 @@
import {express} from 'express'

View file

@ -1,125 +0,0 @@
const MAX_USERS_PER_ROOM = 60;
const MAX_TOTAL_CONNECTIONS = 2000;
const setupWebsocket = (io) => {
let totalConnections = 0;
io.on("connection", (socket) => {
if (totalConnections >= MAX_TOTAL_CONNECTIONS) {
console.log("Connection limit reached. Disconnecting client.");
socket.emit(
"join-failure",
"Le nombre maximum de connexions a été atteint"
);
socket.disconnect(true);
return;
}
totalConnections++;
// console.log(
// "A user connected:",
// socket.id,
// "| Total connections:",
// totalConnections
// );
socket.on("create-room", (sentRoomName) => {
if (sentRoomName) {
const roomName = sentRoomName.toUpperCase();
if (!io.sockets.adapter.rooms.get(roomName)) {
socket.join(roomName);
socket.emit("create-success", roomName);
} else {
socket.emit("create-failure");
}
} else {
const roomName = generateRoomName();
if (!io.sockets.adapter.rooms.get(roomName)) {
socket.join(roomName);
socket.emit("create-success", roomName);
} else {
socket.emit("create-failure");
}
}
});
socket.on("join-room", ({ enteredRoomName, username }) => {
if (io.sockets.adapter.rooms.has(enteredRoomName)) {
const clientsInRoom =
io.sockets.adapter.rooms.get(enteredRoomName).size;
if (clientsInRoom <= MAX_USERS_PER_ROOM) {
const newStudent = {
id: socket.id,
name: username,
answers: [],
};
socket.join(enteredRoomName);
socket
.to(enteredRoomName)
.emit("user-joined", newStudent);
socket.emit("join-success");
} else {
socket.emit("join-failure", "La salle est remplie");
}
} else {
socket.emit("join-failure", "Le nom de la salle n'existe pas");
}
});
socket.on("next-question", ({ roomName, question }) => {
// console.log("next-question", roomName, question);
socket.to(roomName).emit("next-question", question);
});
socket.on("launch-student-mode", ({ roomName, questions }) => {
socket.to(roomName).emit("launch-student-mode", questions);
});
socket.on("end-quiz", ({ roomName }) => {
socket.to(roomName).emit("end-quiz");
});
socket.on("message", (data) => {
console.log("Received message from", socket.id, ":", data);
});
socket.on("disconnect", () => {
totalConnections--;
console.log(
"A user disconnected:",
socket.id,
"| Total connections:",
totalConnections
);
for (const [room] of io.sockets.adapter.rooms) {
if (room !== socket.id) {
io.to(room).emit("user-disconnected", socket.id);
}
}
});
socket.on("submit-answer", ({ roomName, username, answer, idQuestion }) => {
socket.to(roomName).emit("submit-answer-room", {
idUser: socket.id,
username,
answer,
idQuestion,
});
});
});
const generateRoomName = (length = 6) => {
const characters = "0123456789";
let result = "";
for (let i = 0; i < length; i++) {
result += characters.charAt(
Math.floor(Math.random() * characters.length)
);
}
return result;
};
};
module.exports = { setupWebsocket };