diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6e7a2406..ab79dacc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,10 @@ on: branches: [ "main" ] pull_request: branches: [ "main" ] + # Lets this workflow be run manually against any branch (Actions tab -> + # "Run workflow", or `gh workflow run`), without needing to push/PR to + # main first. Doesn't change push/pull_request behavior at all. + workflow_dispatch: {} jobs: build: @@ -28,6 +32,10 @@ jobs: run: npm run build --if-present working-directory: ./middlewareNode + - name: Test middlewareNode + run: npm test + working-directory: ./middlewareNode + # chessServer - name: Install dependencies (chessServer) run: npm ci @@ -59,31 +67,20 @@ jobs: run: npm ci working-directory: ./react-ystemandchess - - name: Create environment file - run: | - mkdir -p src/core/environments - cat < src/core/environments/environment.ts - export const environment = { - production: false, - agora: { - appId: '${{ secrets.APP_ID }}', - }, - email: { - user: '${{ secrets.EMAIL_USER || '' }}', - pass: '${{ secrets.EMAIL_PASS || '' }}' - }, - urls: { - middlewareURL: '${{ secrets.MIDDLEWARE_URL }}', - stockFishURL: '${{ secrets.STOCKFISH_URL }}', - chessServer: '${{ secrets.CHESS_SERVER }}', - }, - }; - EOF - working-directory: ./react-ystemandchess - - name: Build react-ystemandchess run: CI=false npm run build --if-present working-directory: ./react-ystemandchess + env: + # These are read by src/environments/environment.prod.js, which is + # what the app actually imports (via src/environments/index.js). + # The previous "Create environment file" step wrote to + # src/core/environments/environment.ts, a path nothing in the app + # imports, so these secrets never reached the built app. + REACT_APP_MIDDLEWARE_URL: ${{ secrets.MIDDLEWARE_URL || 'https://ystemandchess.com/middleware' }} + REACT_APP_STOCKFISH_SERVER_URL: ${{ secrets.STOCKFISH_URL || 'https://ystemandchess.com/stockfishserver' }} + REACT_APP_CHESS_SERVER_URL: ${{ secrets.CHESS_SERVER || 'https://ystemandchess.com/chessserver' }} + REACT_APP_CHESS_CLIENT_URL: ${{ secrets.CHESS_CLIENT_URL || 'https://ystemandchess.com/chessclient' }} + REACT_APP_AGORA_APP_ID: ${{ secrets.APP_ID || '' }} - name: Test react-ystemandchess run: npm test diff --git a/chessServer/.env.example b/chessServer/.env.example new file mode 100644 index 00000000..4e25e7cc --- /dev/null +++ b/chessServer/.env.example @@ -0,0 +1,5 @@ +NODE_ENV=development +PORT=3001 +CORS_ORIGIN=http://localhost:3000,http://localhost:3002 +ALLOWED_ORIGINS= +MIDDLEWARE_URL=http://localhost:8000 diff --git a/chessServer/package-lock.json b/chessServer/package-lock.json index 4a737c7f..6caf4ff7 100644 --- a/chessServer/package-lock.json +++ b/chessServer/package-lock.json @@ -10,6 +10,7 @@ "license": "ISC", "dependencies": { "chess.js": "^1.0.0-beta.8", + "cors": "^2.8.5", "dotenv": "^8.6.0", "express": "^4.21.0", "jest": "^29.7.0", diff --git a/chessServer/package.json b/chessServer/package.json index 15ae95cb..713b577b 100644 --- a/chessServer/package.json +++ b/chessServer/package.json @@ -12,6 +12,7 @@ "license": "ISC", "dependencies": { "chess.js": "^1.0.0-beta.8", + "cors": "^2.8.5", "dotenv": "^8.6.0", "express": "^4.21.0", "jest": "^29.7.0", diff --git a/chessServer/src/index.js b/chessServer/src/index.js index ea50058c..e8b7c91e 100644 --- a/chessServer/src/index.js +++ b/chessServer/src/index.js @@ -1,5 +1,8 @@ require("dotenv").config(); +const validateEnvironment = require("./validateEnvironment"); +validateEnvironment(); + const express = require("express"); const http = require("http"); const socketIo = require("socket.io"); @@ -13,28 +16,53 @@ const server = http.createServer(app); // Add logging functionaility to the server app.use(morgan("dev")); // dev -> preset format -const allowedOriginsSetting = process.env.CORS_ORIGIN || process.env.ALLOWED_ORIGINS; -const allowedOrigins = allowedOriginsSetting +const isProduction = process.env.NODE_ENV === "production"; + +const allowedOriginsSetting = + process.env.CORS_ORIGIN || process.env.ALLOWED_ORIGINS; + +const allowedOrigins = (allowedOriginsSetting ? allowedOriginsSetting.split(",").map((o) => o.trim()) : [ "https://ystemandchess.com", "https://www.ystemandchess.com", - "http://localhost:3000", - "http://localhost:3002", - "http://localhost:4200", - ]; + ...(isProduction + ? [] + : [ + "http://localhost:3000", + "http://localhost:3002", + "http://localhost:4200", + ]), + ] +).map((origin) => { + if (origin !== "*" && origin.endsWith("/")) { + const normalized = origin.slice(0, -1); + console.warn( + `CORS: "${origin}" has a trailing slash, which never matches a browser's Origin header — using "${normalized}" instead` + ); + return normalized; + } + return origin; +}); + +const hasWildcard = allowedOrigins.includes("*"); const corsOptions = { origin: function (origin, callback) { if (!origin) return callback(null, true); - if (allowedOrigins.indexOf(origin) !== -1 || allowedOrigins.includes("*")) { + if (hasWildcard) { + return callback(null, true); + } + if (allowedOrigins.indexOf(origin) !== -1) { callback(null, true); } else { - callback(new Error(`Origin ${origin} not allowed by CORS`)); + console.warn(`CORS: rejected origin ${origin}`); + callback(null, false); } }, methods: ["GET", "POST"], - credentials: true, + // When wildcard is configured, disallow credentials to prevent unsafe CORS configuration + credentials: !hasWildcard, }; // Apply CORS middleware to handle cross-origin requests diff --git a/chessServer/src/tests/cors.test.js b/chessServer/src/tests/cors.test.js new file mode 100644 index 00000000..1a9393e8 --- /dev/null +++ b/chessServer/src/tests/cors.test.js @@ -0,0 +1,67 @@ +/** + * Verifies the CORS origin/credentials behavior in src/index.js. + * index.js can't be imported directly (it calls server.listen() as a + * module-load side effect), so this mirrors its corsOptions logic in an + * isolated app, the same approach the other tests in this file already use + * for their own standalone servers. Keep this in sync with src/index.js if + * that logic changes. + */ + +const express = require("express"); +const cors = require("cors"); +const request = require("supertest"); + +function buildApp(allowedOriginsCsv) { + const allowedOrigins = allowedOriginsCsv.split(",").map((o) => o.trim()); + const hasWildcard = allowedOrigins.includes("*"); + + const app = express(); + app.use( + cors({ + origin: function (origin, callback) { + if (!origin) return callback(null, true); + if (hasWildcard) { + return callback(null, true); + } + if (allowedOrigins.indexOf(origin) !== -1) { + callback(null, true); + } else { + callback(null, false); + } + }, + methods: ["GET", "POST"], + credentials: !hasWildcard, + }) + ); + app.get("/ping", (req, res) => res.json({ ok: true })); + return app; +} + +describe("chessServer CORS origin/credentials behavior", () => { + test("allowed origin (no wildcard): credentials allowed, header set to the origin", async () => { + const app = buildApp("https://ystemandchess.com,http://localhost:3000"); + const res = await request(app).get("/ping").set("Origin", "http://localhost:3000"); + + expect(res.status).toBe(200); + expect(res.headers["access-control-allow-origin"]).toBe("http://localhost:3000"); + expect(res.headers["access-control-allow-credentials"]).toBe("true"); + }); + + test("disallowed origin (no wildcard): rejected without a 500, no CORS header", async () => { + const app = buildApp("https://ystemandchess.com,http://localhost:3000"); + const res = await request(app).get("/ping").set("Origin", "https://evil.example.com"); + + expect(res.status).not.toBe(500); + expect(res.status).toBe(200); + expect(res.headers["access-control-allow-origin"]).toBeUndefined(); + }); + + test("wildcard configured: any origin allowed, but credentials are disabled", async () => { + const app = buildApp("*"); + const res = await request(app).get("/ping").set("Origin", "https://anything.example.com"); + + expect(res.status).toBe(200); + expect(res.headers["access-control-allow-origin"]).toBe("https://anything.example.com"); + expect(res.headers["access-control-allow-credentials"]).toBeUndefined(); + }); +}); diff --git a/chessServer/src/validateEnvironment.js b/chessServer/src/validateEnvironment.js new file mode 100644 index 00000000..14a9bdc7 --- /dev/null +++ b/chessServer/src/validateEnvironment.js @@ -0,0 +1,36 @@ +const REQUIRED_PRODUCTION_VARS = [ + "MIDDLEWARE_URL", +]; + +function hasCorsConfiguration() { + return Boolean( + process.env.CORS_ORIGIN?.trim() || + process.env.ALLOWED_ORIGINS?.trim() + ); +} + +function validateEnvironment() { + if (process.env.NODE_ENV !== "production") { + return; + } + + const missing = REQUIRED_PRODUCTION_VARS.filter((name) => { + const value = process.env[name]; + return !value || !value.trim(); + }); + + if (!hasCorsConfiguration()) { + missing.push("CORS_ORIGIN or ALLOWED_ORIGINS"); + } + + if (missing.length > 0) { + console.error( + `[chessServer] Missing required production environment variables: ${missing.join(", ")}` + ); + process.exit(1); + } + + console.log("[chessServer] Required production environment variables validated"); +} + +module.exports = validateEnvironment; diff --git a/deploy/dev/docker-compose.yml b/deploy/dev/docker-compose.yml index f21cf090..c72417aa 100644 --- a/deploy/dev/docker-compose.yml +++ b/deploy/dev/docker-compose.yml @@ -12,14 +12,16 @@ services: - stockfishserver react-app: - build: ../../react-ystemandchess + build: + context: ../../react-ystemandchess + args: + REACT_APP_CHESS_SERVER_URL: http://localhost:3001 + REACT_APP_MIDDLEWARE_URL: http://localhost:8000 + REACT_APP_STOCKFISH_SERVER_URL: http://localhost:9324 + REACT_APP_AGORA_APP_ID: "" container_name: react-app ports: - "3000:3000" - environment: - - REACT_APP_CHESS_SERVER_URL=http://localhost:3001 - - REACT_APP_MIDDLEWARE_URL=http://localhost:8000 - - REACT_APP_STOCKFISH_URL=http://localhost:8080 middlewarenode: build: ../../middlewareNode @@ -27,10 +29,13 @@ services: ports: - "8000:8000" environment: + - NODE_ENV=development - PORT=8000 + - INDEX_KEY=dev-index-key-replace-in-prod + - CORS_ORIGIN=http://localhost,http://localhost:3000 - SESSION_SECRET=dev-secret-replace-in-prod - # Analytics rate limit — requests per 15-minute window per IP (default: 100) - ANALYTICS_RATE_LIMIT_MAX=100 + - LEADERBOARD_RATE_LIMIT_MAX=60 chessserver: build: ../../chessServer @@ -38,8 +43,10 @@ services: ports: - "3001:3001" environment: + - NODE_ENV=development - PORT=3001 - MIDDLEWARE_URL=http://middlewarenode:8000 + - CORS_ORIGIN=http://localhost,http://localhost:3000,http://localhost:3002 stockfishserver: build: ../../stockfishServer @@ -47,7 +54,9 @@ services: ports: - "9324:9324" environment: + - NODE_ENV=development - PORT=9324 + - CORS_ORIGIN=http://localhost,http://localhost:3000 # Usage: docker-compose up --build # Access at: http://localhost diff --git a/deploy/prod/docker-compose.yml b/deploy/prod/docker-compose.yml index e9bcf5e4..5a21e72a 100644 --- a/deploy/prod/docker-compose.yml +++ b/deploy/prod/docker-compose.yml @@ -1,17 +1,14 @@ -version: '3.4' - networks: ysc-net: - external: - name: ysc-net + name: ysc-net + external: true services: nginx: image: nginx:1.19.2-alpine container_name: reverse-proxy-server volumes: - - ./deploy/prod/nginx.conf:/etc/nginx/nginx.conf - - /home/azureuser/ysc-2/app.ystemandchess.com/YStemAndChess/dist_new/YStemAndChess:/var/www/html + - ./nginx.conf:/etc/nginx/nginx.conf - /etc/letsencrypt/live/ystemandchess.com/fullchain.pem:/etc/ysc-certs/ysc-cert.pem - /etc/letsencrypt/live/ystemandchess.com/privkey.pem:/etc/ysc-certs/ysc-key.pem - /etc/letsencrypt/options-ssl-nginx.conf:/etc/ysc-certs/options-ssl-nginx.conf @@ -29,20 +26,25 @@ services: - ystemandchess chessserver: - image: chessserver:${TAG} + image: chessserver:${TAG:-latest} container_name: chessserver environment: - - PORT=${PORT} + - NODE_ENV=production + - PORT=3001 + - MIDDLEWARE_URL=${MIDDLEWARE_URL} + - CORS_ORIGIN=${CORS_ORIGIN} networks: - ysc-net expose: - "3001" stockfishserver: - image: stockfishserver:${TAG} + image: stockfishserver:${TAG:-latest} container_name: stockfishserver environment: + - NODE_ENV=production - PORT=8080 + - CORS_ORIGIN=${CORS_ORIGIN} networks: - ysc-net expose: @@ -52,48 +54,42 @@ services: image: middlewarenode container_name: middleware environment: + - NODE_ENV=production - PORT=8000 - - indexKey=${indexKey} + - MONGO_URI=${MONGO_URI} + - INDEX_KEY=${INDEX_KEY} + - CORS_ORIGIN=${CORS_ORIGIN} - AZURE_STORAGE_ACCOUNT=${AZURE_STORAGE_ACCOUNT} - AZURE_STORAGE_KEY=${AZURE_STORAGE_KEY} - AZURE_STORAGE_CONTAINER=${AZURE_STORAGE_CONTAINER} - AZURE_STORAGE_REGION=${AZURE_STORAGE_REGION} - - mongoURI=${mongoURI} - - appID=${appID} - - auth=${auth} - - channel=${channel} - - uid=${uid} - - jwtSecret=${jwtSecret} - - NODE_ENV=${NODE_ENV} - - basepath=${basepath} - - clientId=${clientId} - - clientSecret=${clientSecret} - - redirectUri=${redirectUri} - - refreshToken=${refreshToken} - - user=${user} - - senderEmail=${senderEmail} + - AGORA_APP_ID=${AGORA_APP_ID} + - AGORA_UID=${AGORA_UID} + - AGORA_CUSTOMER_ID=${AGORA_CUSTOMER_ID} + - AGORA_CUSTOMER_CERT=${AGORA_CUSTOMER_CERT} + - BASEPATH=${BASEPATH} + - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID} + - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET} + - GOOGLE_REDIRECT_URI=${GOOGLE_REDIRECT_URI} + - GOOGLE_REFRESH_TOKEN=${GOOGLE_REFRESH_TOKEN} + - EMAIL_USER=${EMAIL_USER} + - SENDER_EMAIL=${SENDER_EMAIL} - SESSION_SECRET=${SESSION_SECRET} - ANALYTICS_RATE_LIMIT_MAX=${ANALYTICS_RATE_LIMIT_MAX:-100} + - LEADERBOARD_RATE_LIMIT_MAX=${LEADERBOARD_RATE_LIMIT_MAX:-60} networks: - ysc-net expose: - '8000' ystemandchess: - image: ystemandchess:${TAG} + image: ystemandchess:${TAG:-latest} container_name: ystemandchess - environment: - - agora:appID=${appID} - - urls:middlewareURL=52.249.251.163/middleware - - urls:stockFishURL=52.249.251.163/stockfishserver - - urls:chessServerURL=52.249.251.163/chessserver networks: - ysc-net expose: - - "80" + - "3000" depends_on: - middleware - stockfishserver - chessserver - volumes: - - ../YStemAndChess:/usr/src/app \ No newline at end of file diff --git a/deploy/prod/nginx.conf b/deploy/prod/nginx.conf index ee628adf..4ab81918 100644 --- a/deploy/prod/nginx.conf +++ b/deploy/prod/nginx.conf @@ -39,7 +39,7 @@ http { } upstream react_app { - server ystemandchess:80; + server ystemandchess:3000; } server { @@ -51,7 +51,7 @@ http { add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always; location / { - try_files $uri $uri/ /index.html =404; + proxy_pass http://react_app; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header Host $host; diff --git a/deploy/prod/tag_build_containers.sh b/deploy/prod/tag_build_containers.sh old mode 100644 new mode 100755 index fe00c20a..3f33a67c --- a/deploy/prod/tag_build_containers.sh +++ b/deploy/prod/tag_build_containers.sh @@ -1,52 +1,52 @@ #!/bin/bash -# Build Docker images for production deployment -# Usage: cd scripts && ./tag_build_containers.sh +set -eu + +TAG=${TAG:-latest} echo "==========================================" echo "Building Docker images for PRODUCTION" +echo "TAG=$TAG" echo "==========================================" -echo "" -# Move to parent directory (where service folders are) -cd ../.. || exit 1 +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +cd "$REPO_ROOT" || exit 1 -services=(react-ystemandchess chessServer middlewareNode stockfishServer) +build_image() { + service_dir="$1" + image_name="$2" -for service in "${services[@]}" -do echo "==========================================" - echo "Building: $service" + echo "Building: $service_dir" + echo "Image: $image_name" echo "==========================================" - if [ ! -d "$service" ]; then - echo "ERROR: Directory $service not found!" + if [ ! -d "$service_dir" ]; then + echo "ERROR: Directory $service_dir not found!" exit 1 fi - cd $service || exit 1 - - # Convert to lowercase for image name - imagename=$(echo "$service" | awk '{ print tolower($0) }') - - echo "Image name: $imagename" + docker build -t "$image_name" "$service_dir" +} - # Build Docker image - docker build -t $imagename . || { - echo "ERROR: Failed to build $imagename" - cd .. - exit 1 - } +echo "==========================================" +echo "Building: react-ystemandchess" +echo "Image: ystemandchess:${TAG}" +echo "==========================================" - cd .. - echo "Successfully built $imagename" - echo "" -done +docker build \ + --build-arg REACT_APP_MIDDLEWARE_URL="${REACT_APP_MIDDLEWARE_URL}" \ + --build-arg REACT_APP_STOCKFISH_SERVER_URL="${REACT_APP_STOCKFISH_SERVER_URL}" \ + --build-arg REACT_APP_CHESS_SERVER_URL="${REACT_APP_CHESS_SERVER_URL}" \ + --build-arg REACT_APP_CHESS_CLIENT_URL="${REACT_APP_CHESS_CLIENT_URL}" \ + --build-arg REACT_APP_AGORA_APP_ID="${REACT_APP_AGORA_APP_ID}" \ + -t "ystemandchess:${TAG}" \ + react-ystemandchess +build_image "chessServer" "chessserver:${TAG}" +build_image "middlewareNode" "middlewarenode:${TAG}" +build_image "stockfishServer" "stockfishserver:${TAG}" echo "==========================================" echo "All production images built!" echo "==========================================" -echo "" -echo "To deploy:" -echo " cd scripts" -echo " docker-compose up -d" \ No newline at end of file diff --git a/middlewareNode/.env.example b/middlewareNode/.env.example new file mode 100644 index 00000000..6bd6f59a --- /dev/null +++ b/middlewareNode/.env.example @@ -0,0 +1,64 @@ +# Runtime environment +NODE_ENV=development +PORT=8000 + +# Required core backend configuration +MONGO_URI= +INDEX_KEY= +SESSION_SECRET= +CORS_ORIGIN=http://localhost:3000 + +# Feature-specific email / Google OAuth configuration +EMAIL_USER= +SENDER_EMAIL= +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GOOGLE_REDIRECT_URI= +GOOGLE_REFRESH_TOKEN= + +# Application base URL +BASEPATH=http://localhost:3000 + +# Azure storage +AZURE_STORAGE_ACCOUNT= +AZURE_STORAGE_KEY= +AZURE_STORAGE_CONTAINER= +AZURE_STORAGE_REGION= + +# Agora recording +AGORA_APP_ID= +AGORA_UID= +AGORA_CUSTOMER_ID= +AGORA_CUSTOMER_CERT= + +# API / route limits +ANALYTICS_RATE_LIMIT_MAX=100 +LEADERBOARD_RATE_LIMIT_MAX=60 +CHAT_RATE_LIMIT=30 + +# AI provider configuration +GEMINI_API_KEY= +GEMINI_BASE_URL= +GEMINI_MODEL= + +OPENAI_API_KEY= +OPENAI_BASE_URL= +OPENAI_MODEL= + +# Leaderboard scoring +LEADERBOARD_WEIGHT_TIME=1 +LEADERBOARD_WEIGHT_STREAK=5 +LEADERBOARD_WEIGHT_BADGE=10 +LEADERBOARD_WEIGHT_ACTIVITY=3 + +# PVP scoring +PVP_WEIGHT_WIN=3 +PVP_WEIGHT_DRAW=1 +PVP_WEIGHT_LOSS=0 + +# Script-only variable used by src/scripts/provisionAdmin.js +ADMIN_USERNAME= + +# Present in config mapping but not currently consumed by middleware source +JWT_SECRET= +EMAIL_PASS= diff --git a/middlewareNode/.gitignore b/middlewareNode/.gitignore index a6cb6c5f..1cb3554b 100644 --- a/middlewareNode/.gitignore +++ b/middlewareNode/.gitignore @@ -3,3 +3,4 @@ node_modules/ logs/ config/default.json +config/.dev-index-key diff --git a/middlewareNode/config/default.js b/middlewareNode/config/default.js new file mode 100644 index 00000000..e051578b --- /dev/null +++ b/middlewareNode/config/default.js @@ -0,0 +1,82 @@ +const crypto = require("crypto"); +const fs = require("fs"); +const path = require("path"); + +// Fallback so jwt.sign() (used for login) doesn't throw "secretOrPrivateKey +// must have a value" in dev/test when INDEX_KEY isn't set — without checking +// a fixed signing key into git history. custom-environment-variables.json +// already maps INDEX_KEY onto this key, so any real deployment overrides it; +// validateEnvironment.js still requires INDEX_KEY in production, and runs +// before anything require()s this file, so this never touches disk in prod. +// +// Persisted (gitignored) rather than regenerated per boot, so dev JWTs and +// password-reset links survive a nodemon restart instead of invalidating on +// every file save. +const DEV_INDEX_KEY_PATH = path.join(__dirname, ".dev-index-key"); + +function getOrCreateDevIndexKey() { + try { + const existing = fs.readFileSync(DEV_INDEX_KEY_PATH, "utf8").trim(); + if (existing) return existing; + } catch (_) { + // File doesn't exist yet (or is unreadable) — generate below. + } + + const generated = crypto.randomBytes(32).toString("hex"); + try { + fs.writeFileSync(DEV_INDEX_KEY_PATH, generated, { mode: 0o600 }); + } catch (err) { + console.warn( + `[config] Failed to persist dev indexKey (${err.message}) — it will regenerate on restart.` + ); + } + return generated; +} + +let devIndexKey = ""; +if (!process.env.INDEX_KEY) { + devIndexKey = getOrCreateDevIndexKey(); + console.warn( + `[config] INDEX_KEY not set — using a dev-only key persisted at ${DEV_INDEX_KEY_PATH}. ` + + "Set INDEX_KEY in your environment for production or shared setups." + ); +} + +module.exports = { + mongoURI: "", + jwtSecret: "", + indexKey: devIndexKey, + + corsOptions: { + origin: "http://localhost:3000", + }, + + email: { + user: "", + pass: "", + }, + + user: "", + senderEmail: "", + + clientId: "", + clientSecret: "", + redirectUri: "", + refreshToken: "", + + basepath: "http://localhost:3000", + + azureStorageAccount: "", + azureStorageKey: "", + azureContainer: "", + azureStorageRegion: "", + + appID: "", + uid: "", + customerId: "", + customerCertificate: "", + + server: { + port: 8000, + }, +}; diff --git a/middlewareNode/src/config/custom-environment-variables.json b/middlewareNode/src/config/custom-environment-variables.json deleted file mode 100644 index 73430b29..00000000 --- a/middlewareNode/src/config/custom-environment-variables.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "mongoURI": "MONGO_URI", - "jwtSecret": "JWT_SECRET", - "indexKey": "INDEX_KEY", - - "corsOptions": { "origin": "CORS_ORIGIN" }, - - "email": { - "user": "EMAIL_USER", - "pass": "EMAIL_PASS" - }, - - "user": "EMAIL_USER", - "senderEmail": "SENDER_EMAIL", - - "clientId": "GOOGLE_CLIENT_ID", - "clientSecret": "GOOGLE_CLIENT_SECRET", - "redirectUri": "GOOGLE_REDIRECT_URI", - "refreshToken": "GOOGLE_REFRESH_TOKEN", - - "basepath": "BASEPATH", - - "azureStorageAccount": "AZURE_STORAGE_ACCOUNT", - "azureStorageKey": "AZURE_STORAGE_KEY", - "azureContainer": "AZURE_STORAGE_CONTAINER", - "azureStorageRegion": "AZURE_STORAGE_REGION", - - "appID": "AGORA_APP_ID", - "uid": "AGORA_UID", - "customerId": "AGORA_CUSTOMER_ID", - "customerCertificate": "AGORA_CUSTOMER_CERT", - - "server": { "port": "PORT" } -} diff --git a/middlewareNode/src/config/db.js b/middlewareNode/src/config/db.js index b06145d0..57cdbde9 100644 --- a/middlewareNode/src/config/db.js +++ b/middlewareNode/src/config/db.js @@ -249,19 +249,49 @@ async function ensureIndexes() { } } +const IS_PRODUCTION = process.env.NODE_ENV === "production"; + const connectDB = async () => { + try { + const target = new URL( + db.replace("mongodb+srv://", "https://").replace("mongodb://", "http://") + ); + console.log( + `[boot] env=${process.env.NODE_ENV || "undefined"} db_host=${target.hostname}${target.pathname}` + ); + } catch (_) { + console.log( + `[boot] env=${process.env.NODE_ENV || "undefined"} db_host=` + ); + } + try { console.log(`Connecting to MongoDB...`); await mongoose.connect(db, { useNewUrlParser: true, useUnifiedTopology: true, - serverSelectionTimeoutMS: 1000, // Timeout after 1 second instead of hanging + // Production has no fallback (a failure here calls process.exit(1)), + // so it gets a longer timeout to ride out a network blip or an Atlas + // cold-start during deploy. Dev/test fail fast to the in-memory + // fallback below. + serverSelectionTimeoutMS: IS_PRODUCTION ? 10000 : 1000, }); console.log("MongoDB Connected..."); await ensureIndexes(); - await seedTestUsers(); + + if (!IS_PRODUCTION) { + await seedTestUsers(); + } } catch (err) { console.warn(`Connection to configured MongoDB failed: ${err.message}`); + + if (IS_PRODUCTION) { + console.error( + "Refusing to fall back to in-memory MongoDB in production. Exiting." + ); + process.exit(1); + } + console.warn("Starting local in-memory MongoDB server as fallback..."); try { const { MongoMemoryServer } = require("mongodb-memory-server"); @@ -281,7 +311,7 @@ const connectDB = async () => { await seedTestUsers(); } catch (fallbackErr) { console.error("In-memory MongoDB startup failed:", fallbackErr.message); - process.exit(1); // Exit process if connection fails + process.exit(1); } } }; diff --git a/middlewareNode/src/config/validateEnvironment.js b/middlewareNode/src/config/validateEnvironment.js new file mode 100644 index 00000000..c6eb8267 --- /dev/null +++ b/middlewareNode/src/config/validateEnvironment.js @@ -0,0 +1,28 @@ +const REQUIRED_PRODUCTION_VARS = [ + "MONGO_URI", + "INDEX_KEY", + "CORS_ORIGIN", + "SESSION_SECRET", +]; + +function validateEnvironment() { + if (process.env.NODE_ENV !== "production") { + return; + } + + const missing = REQUIRED_PRODUCTION_VARS.filter((name) => { + const value = process.env[name]; + return !value || !value.trim(); + }); + + if (missing.length > 0) { + console.error( + `[boot] Missing required production environment variables: ${missing.join(", ")}` + ); + process.exit(1); + } + + console.log("[boot] Required production environment variables validated"); +} + +module.exports = validateEnvironment; diff --git a/middlewareNode/src/server.js b/middlewareNode/src/server.js index 0f130a30..5167de20 100644 --- a/middlewareNode/src/server.js +++ b/middlewareNode/src/server.js @@ -1,4 +1,13 @@ // Main server configuration for the Node.js middleware API + +// Load local environment variables before importing modules that read config. +require("dotenv").config(); + +// Validate production configuration before db.js, passport.js, or other +// modules can call config.get(). +const validateEnvironment = require("./config/validateEnvironment"); +validateEnvironment(); + const express = require("express"); const session = require("express-session"); const connectDB = require("./config/db"); @@ -33,7 +42,39 @@ require("./scheduler/activitiesScheduler.js"); require("./scheduler/analyticsSummaryScheduler.js"); // Enable CORS for cross-origin requests -app.use(cors(config.get("corsOptions"))); +const configuredCorsOrigin = config.get("corsOptions.origin"); + +const allowedOrigins = String(configuredCorsOrigin || "") + .split(",") + .map((origin) => origin.trim()) + .filter(Boolean) + .map((origin) => { + if (origin !== "*" && origin.endsWith("/")) { + const normalized = origin.slice(0, -1); + console.warn( + `CORS: "${origin}" has a trailing slash, which never matches a browser's Origin header — using "${normalized}" instead` + ); + return normalized; + } + return origin; + }); + +const hasWildcard = allowedOrigins.includes("*"); + +app.use( + cors({ + origin(origin, callback) { + if (!origin || hasWildcard || allowedOrigins.includes(origin)) { + return callback(null, true); + } + + console.warn(`CORS: rejected origin ${origin}`); + return callback(null, false); + }, + // When wildcard is configured, disallow credentials to prevent unsafe CORS configuration + credentials: !hasWildcard, + }) +); // Connect to MongoDB database connectDB(); @@ -44,7 +85,11 @@ app.use(express.json({ extended: false })); // Configure session middleware app.use( session({ - secret: process.env.SESSION_SECRET || "dev-secret-change-in-prod", + secret: + process.env.SESSION_SECRET || + (process.env.NODE_ENV !== "production" + ? "dev-secret-change-in-prod" + : undefined), resave: false, saveUninitialized: false, }) diff --git a/middlewareNode/tests/cors.test.js b/middlewareNode/tests/cors.test.js new file mode 100644 index 00000000..f17afcd7 --- /dev/null +++ b/middlewareNode/tests/cors.test.js @@ -0,0 +1,55 @@ +/** + * Verifies the CORS origin-rejection behavior in src/server.js. + * server.js can't be imported directly (it calls connectDB()/app.listen() and + * requires SESSION_SECRET/Mongo at module load), so this mirrors its origin + * callback logic in an isolated app, the same approach already used by the + * chessServer/stockfishServer test suites. Keep this in sync with server.js + * if that logic changes. + */ + +const express = require("express"); +const cors = require("cors"); +const request = require("supertest"); + +function buildApp(allowedOriginsCsv) { + const allowedOrigins = String(allowedOriginsCsv || "") + .split(",") + .map((o) => o.trim()) + .filter(Boolean); + + const app = express(); + app.use( + cors({ + origin(origin, callback) { + if (!origin || allowedOrigins.includes("*") || allowedOrigins.includes(origin)) { + return callback(null, true); + } + return callback(null, false); + }, + }) + ); + app.get("/ping", (req, res) => res.json({ ok: true })); + return app; +} + +describe("CORS origin callback", () => { + const app = buildApp("https://ystemandchess.com,http://localhost:3000"); + + test("allowed origin gets the Access-Control-Allow-Origin header", async () => { + const res = await request(app).get("/ping").set("Origin", "http://localhost:3000"); + expect(res.status).toBe(200); + expect(res.headers["access-control-allow-origin"]).toBe("http://localhost:3000"); + }); + + test("disallowed origin is rejected without throwing a 500", async () => { + const res = await request(app).get("/ping").set("Origin", "https://evil.example.com"); + expect(res.status).not.toBe(500); + expect(res.status).toBe(200); + expect(res.headers["access-control-allow-origin"]).toBeUndefined(); + }); + + test("requests with no Origin header (curl, server-to-server) are unaffected", async () => { + const res = await request(app).get("/ping"); + expect(res.status).toBe(200); + }); +}); diff --git a/react-ystemandchess/Dockerfile b/react-ystemandchess/Dockerfile index bd8e975c..9e71c6d4 100644 --- a/react-ystemandchess/Dockerfile +++ b/react-ystemandchess/Dockerfile @@ -8,6 +8,18 @@ RUN npm install COPY . . +ARG REACT_APP_MIDDLEWARE_URL +ARG REACT_APP_STOCKFISH_SERVER_URL +ARG REACT_APP_CHESS_SERVER_URL +ARG REACT_APP_CHESS_CLIENT_URL +ARG REACT_APP_AGORA_APP_ID + +ENV REACT_APP_MIDDLEWARE_URL=$REACT_APP_MIDDLEWARE_URL +ENV REACT_APP_STOCKFISH_SERVER_URL=$REACT_APP_STOCKFISH_SERVER_URL +ENV REACT_APP_CHESS_SERVER_URL=$REACT_APP_CHESS_SERVER_URL +ENV REACT_APP_CHESS_CLIENT_URL=$REACT_APP_CHESS_CLIENT_URL +ENV REACT_APP_AGORA_APP_ID=$REACT_APP_AGORA_APP_ID + RUN npm run build FROM node:18.20.8-alpine @@ -23,4 +35,4 @@ EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=3s \ CMD node -e "require('http').get('http://localhost:3000', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})" -CMD ["serve", "-s", "build", "-l", "3000"] \ No newline at end of file +CMD ["serve", "-s", "build", "-l", "3000"] diff --git a/react-ystemandchess/src/App.tsx b/react-ystemandchess/src/App.tsx index 53281882..8275273e 100644 --- a/react-ystemandchess/src/App.tsx +++ b/react-ystemandchess/src/App.tsx @@ -1,6 +1,6 @@ import { useEffect } from "react"; import { BrowserRouter as Router } from "react-router-dom"; -import { environment } from "./environments/environment"; +import { environment } from "./environments"; import { useCookies } from "react-cookie"; import { SetPermissionLevel } from "./globals"; import NavBar from "./components/navbar/NavBar"; diff --git a/react-ystemandchess/src/Pages/Admin/Admin.tsx b/react-ystemandchess/src/Pages/Admin/Admin.tsx index 7ba570a5..80482e09 100644 --- a/react-ystemandchess/src/Pages/Admin/Admin.tsx +++ b/react-ystemandchess/src/Pages/Admin/Admin.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState } from "react"; import { useNavigate } from "react-router"; import { useCookies } from "react-cookie"; -import { environment } from "../../environments/environment"; +import { environment } from "../../environments"; const Admin = () => { const navigate = useNavigate(); // verify admin status diff --git a/react-ystemandchess/src/Pages/Admin/admin.test.tsx b/react-ystemandchess/src/Pages/Admin/admin.test.tsx index 41facd9e..ce7500a7 100644 --- a/react-ystemandchess/src/Pages/Admin/admin.test.tsx +++ b/react-ystemandchess/src/Pages/Admin/admin.test.tsx @@ -5,7 +5,7 @@ import { CookiesProvider } from "react-cookie"; import Admin from "./Admin"; // Mock environment -jest.mock("../../environments/environment", () => ({ +jest.mock("../../environments", () => ({ environment: { urls: { middlewareURL: "http://mock-api.com", diff --git a/react-ystemandchess/src/Pages/Analytics/ActivityFeed.tsx b/react-ystemandchess/src/Pages/Analytics/ActivityFeed.tsx index 4e57fd1f..1a28065d 100644 --- a/react-ystemandchess/src/Pages/Analytics/ActivityFeed.tsx +++ b/react-ystemandchess/src/Pages/Analytics/ActivityFeed.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState, useCallback } from 'react'; import { useCookies } from 'react-cookie'; -import { environment } from '../../environments/environment'; +import { environment } from '../../environments'; import LoadingSpinner from '../../components/Analytics/LoadingSpinner'; import ErrorBanner from '../../components/Analytics/ErrorBanner'; import { DateRange } from '../../components/Analytics/DateRangeFilter'; diff --git a/react-ystemandchess/src/Pages/Analytics/AnalyticsLayout.test.tsx b/react-ystemandchess/src/Pages/Analytics/AnalyticsLayout.test.tsx index c8769f39..c581d1aa 100644 --- a/react-ystemandchess/src/Pages/Analytics/AnalyticsLayout.test.tsx +++ b/react-ystemandchess/src/Pages/Analytics/AnalyticsLayout.test.tsx @@ -1,7 +1,7 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import AnalyticsLayout from "./AnalyticsLayout"; -jest.mock("../../environments/environment", () => ({ +jest.mock("../../environments", () => ({ environment: { urls: { middlewareURL: "http://mockurl.com" } }, })); diff --git a/react-ystemandchess/src/Pages/Analytics/GlobalView.tsx b/react-ystemandchess/src/Pages/Analytics/GlobalView.tsx index cbf4bd23..3755031b 100644 --- a/react-ystemandchess/src/Pages/Analytics/GlobalView.tsx +++ b/react-ystemandchess/src/Pages/Analytics/GlobalView.tsx @@ -10,7 +10,7 @@ import { Legend, } from 'chart.js'; import { Pie, Bar } from 'react-chartjs-2'; -import { environment } from '../../environments/environment'; +import { environment } from '../../environments'; import { DateRange } from '../../components/Analytics/DateRangeFilter'; import LoadingSpinner from '../../components/Analytics/LoadingSpinner'; import ErrorBanner from '../../components/Analytics/ErrorBanner'; diff --git a/react-ystemandchess/src/Pages/Analytics/IndividualView.tsx b/react-ystemandchess/src/Pages/Analytics/IndividualView.tsx index 4313eb2a..20ef0250 100644 --- a/react-ystemandchess/src/Pages/Analytics/IndividualView.tsx +++ b/react-ystemandchess/src/Pages/Analytics/IndividualView.tsx @@ -1,6 +1,6 @@ import React, { useState, useCallback } from 'react'; import { useCookies } from 'react-cookie'; -import { environment } from '../../environments/environment'; +import { environment } from '../../environments'; import { DateRange } from '../../components/Analytics/DateRangeFilter'; import LoadingSpinner from '../../components/Analytics/LoadingSpinner'; import ErrorBanner from '../../components/Analytics/ErrorBanner'; diff --git a/react-ystemandchess/src/Pages/Analytics/StudentTimeChart.tsx b/react-ystemandchess/src/Pages/Analytics/StudentTimeChart.tsx index eb2e0221..18eea828 100644 --- a/react-ystemandchess/src/Pages/Analytics/StudentTimeChart.tsx +++ b/react-ystemandchess/src/Pages/Analytics/StudentTimeChart.tsx @@ -1,6 +1,6 @@ import React, { useEffect } from 'react'; import { useCookies } from 'react-cookie'; -import { environment } from '../../environments/environment'; +import { environment } from '../../environments'; import StatsChart from '../../features/student/student-profile/StatsChart'; import LoadingSpinner from '../../components/Analytics/LoadingSpinner'; import ErrorBanner from '../../components/Analytics/ErrorBanner'; diff --git a/react-ystemandchess/src/Pages/Analytics/TrendChart.tsx b/react-ystemandchess/src/Pages/Analytics/TrendChart.tsx index 5379613c..1511760e 100644 --- a/react-ystemandchess/src/Pages/Analytics/TrendChart.tsx +++ b/react-ystemandchess/src/Pages/Analytics/TrendChart.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from 'react'; import { useCookies } from 'react-cookie'; -import { environment } from '../../environments/environment'; +import { environment } from '../../environments'; import StatsChart from '../../features/student/student-profile/StatsChart'; import LoadingSpinner from '../../components/Analytics/LoadingSpinner'; import ErrorBanner from '../../components/Analytics/ErrorBanner'; diff --git a/react-ystemandchess/src/Pages/Analytics/ZipcodeView.tsx b/react-ystemandchess/src/Pages/Analytics/ZipcodeView.tsx index 156da169..96f3356d 100644 --- a/react-ystemandchess/src/Pages/Analytics/ZipcodeView.tsx +++ b/react-ystemandchess/src/Pages/Analytics/ZipcodeView.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState, useCallback } from 'react'; import { useCookies } from 'react-cookie'; -import { environment } from '../../environments/environment'; +import { environment } from '../../environments'; import { DateRange } from '../../components/Analytics/DateRangeFilter'; import LoadingSpinner from '../../components/Analytics/LoadingSpinner'; import ErrorBanner from '../../components/Analytics/ErrorBanner'; diff --git a/react-ystemandchess/src/components/ChatWidget/ChatWidget.tsx b/react-ystemandchess/src/components/ChatWidget/ChatWidget.tsx index ccaaa192..a26dcbab 100644 --- a/react-ystemandchess/src/components/ChatWidget/ChatWidget.tsx +++ b/react-ystemandchess/src/components/ChatWidget/ChatWidget.tsx @@ -1,6 +1,6 @@ import React, { useState, useRef, useEffect } from 'react'; import './ChatWidget.scss'; -import { environment } from '../../environments/environment'; +import { environment } from '../../environments'; import { useCookies } from 'react-cookie'; import { CoachMascot, CoachExpression } from '../animations/CoachMascot/CoachMascot'; diff --git a/react-ystemandchess/src/core/hooks/useAnalyticsApi.test.ts b/react-ystemandchess/src/core/hooks/useAnalyticsApi.test.ts index 582e7666..495a8fab 100644 --- a/react-ystemandchess/src/core/hooks/useAnalyticsApi.test.ts +++ b/react-ystemandchess/src/core/hooks/useAnalyticsApi.test.ts @@ -1,7 +1,7 @@ import { renderHook, waitFor } from "@testing-library/react"; import { useAnalyticsApi } from "./useAnalyticsApi"; -jest.mock("../../environments/environment", () => ({ +jest.mock("../../environments", () => ({ environment: { urls: { middlewareURL: "http://mockurl.com" } }, })); diff --git a/react-ystemandchess/src/core/hooks/useAnalyticsApi.ts b/react-ystemandchess/src/core/hooks/useAnalyticsApi.ts index b7ec88c7..262652e8 100644 --- a/react-ystemandchess/src/core/hooks/useAnalyticsApi.ts +++ b/react-ystemandchess/src/core/hooks/useAnalyticsApi.ts @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback } from 'react'; import { useCookies } from 'react-cookie'; -import { environment } from '../../environments/environment'; +import { environment } from '../../environments'; export interface AnalyticsParams { from?: string; diff --git a/react-ystemandchess/src/core/services/badgesApi.ts b/react-ystemandchess/src/core/services/badgesApi.ts index 99a96f8c..57c044ad 100644 --- a/react-ystemandchess/src/core/services/badgesApi.ts +++ b/react-ystemandchess/src/core/services/badgesApi.ts @@ -9,7 +9,7 @@ * - getUserBadges: Fetches badges earned by a specific user */ -import { environment } from "../../environments/environment"; +import { environment } from "../../environments"; /** * Fetches the complete catalog of all available badges * diff --git a/react-ystemandchess/src/environments/environment.prod.js b/react-ystemandchess/src/environments/environment.prod.js index 0f1a1460..1249fbd5 100644 --- a/react-ystemandchess/src/environments/environment.prod.js +++ b/react-ystemandchess/src/environments/environment.prod.js @@ -1,12 +1,33 @@ +// Production environment config. +// +// Create React App injects REACT_APP_* variables at build time. +// Production builds must provide all service URLs explicitly. + +const requiredProductionEnv = (name) => { + const value = process.env[name]; + + if (process.env.NODE_ENV === 'production' && !value) { + throw new Error(`Missing required production environment variable: ${name}`); + } + + return value || ''; +}; + export const environment = { - production: false, - agora: { - appId: '6c368b93b82a4b3e9fb8e57da830f2a4', - }, - urls: { - middlewareURL: 'http://localhost/middleware/', - stockfishServerURL: 'http://localhost/stockfishserver/', - chessClientURL: 'http://localhost/chessclient/', - chessServer: 'http://localhost/chessserver/', - }, -}; \ No newline at end of file + production: true, + agora: { + appId: process.env.REACT_APP_AGORA_APP_ID || '', + }, + urls: { + // No trailing slash. Consumers append their own route paths. + middlewareURL: requiredProductionEnv('REACT_APP_MIDDLEWARE_URL'), + stockfishServerURL: requiredProductionEnv('REACT_APP_STOCKFISH_SERVER_URL'), + chessServerURL: requiredProductionEnv('REACT_APP_CHESS_SERVER_URL'), + // Optional, not required: only gates the "open board" button in + // PlayStudent.tsx, which already handles an empty value gracefully. + // Unlike the three URLs above, a missing value here shouldn't take + // down the whole app for every visitor. + chessClientURL: process.env.REACT_APP_CHESS_CLIENT_URL || '', + }, + productionType: 'production', +}; diff --git a/react-ystemandchess/src/features/admin/AdminProfile.tsx b/react-ystemandchess/src/features/admin/AdminProfile.tsx index a60ace1b..2afd1a76 100644 --- a/react-ystemandchess/src/features/admin/AdminProfile.tsx +++ b/react-ystemandchess/src/features/admin/AdminProfile.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from "react"; import { useCookies } from "react-cookie"; import { useNavigate } from "react-router"; import { SetPermissionLevel } from "../../globals"; -import { environment } from "../../environments/environment"; +import { environment } from "../../environments"; import userPortraitImg from "../../assets/images/user-portrait-placeholder.svg"; interface Template { diff --git a/react-ystemandchess/src/features/auth/login/Login.tsx b/react-ystemandchess/src/features/auth/login/Login.tsx index 507a1cac..66f5970b 100644 --- a/react-ystemandchess/src/features/auth/login/Login.tsx +++ b/react-ystemandchess/src/features/auth/login/Login.tsx @@ -1,6 +1,6 @@ import React from "react"; import { useState } from 'react'; -import { environment } from "../../../environments/environment"; +import { environment } from "../../../environments"; import { useCookies } from 'react-cookie'; import stemmy from "../../../assets/images/StreakProgressAssets/stemmy.svg"; import stemette from "../../../assets/images/StreakProgressAssets/stemette.svg"; diff --git a/react-ystemandchess/src/features/auth/signup/AddChild.tsx b/react-ystemandchess/src/features/auth/signup/AddChild.tsx index 7f555e21..31f55ab1 100644 --- a/react-ystemandchess/src/features/auth/signup/AddChild.tsx +++ b/react-ystemandchess/src/features/auth/signup/AddChild.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { useCookies } from "react-cookie"; -import { environment } from "../../../environments/environment"; +import { environment } from "../../../environments"; import AuthLayout from "./AuthLayout"; import stemette from "../../../assets/images/StreakProgressAssets/stemette.svg"; diff --git a/react-ystemandchess/src/features/auth/signup/MentorSignUp.tsx b/react-ystemandchess/src/features/auth/signup/MentorSignUp.tsx index 9ef0b3b1..f7f138ca 100644 --- a/react-ystemandchess/src/features/auth/signup/MentorSignUp.tsx +++ b/react-ystemandchess/src/features/auth/signup/MentorSignUp.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { useNavigate } from "react-router-dom"; import { useCookies } from "react-cookie"; -import { environment } from "../../../environments/environment"; +import { environment } from "../../../environments"; import AuthLayout from "./AuthLayout"; import stemmyVine from "../../../assets/images/ActivitiesAssets/stemmy.svg"; diff --git a/react-ystemandchess/src/features/auth/signup/ParentSection.tsx b/react-ystemandchess/src/features/auth/signup/ParentSection.tsx index 84358347..68a2d342 100644 --- a/react-ystemandchess/src/features/auth/signup/ParentSection.tsx +++ b/react-ystemandchess/src/features/auth/signup/ParentSection.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import { useCookies } from "react-cookie"; -import { environment } from "../../../environments/environment"; +import { environment } from "../../../environments"; import treesGroup from "../../../assets/images/Trees-Group.png"; import stemmyVine from "../../../assets/images/ActivitiesAssets/stemmy.svg"; import OnboardingSteps from "./OnboardingSteps"; diff --git a/react-ystemandchess/src/features/auth/signup/ParentSignUp.tsx b/react-ystemandchess/src/features/auth/signup/ParentSignUp.tsx index 3413e9cf..c9284bca 100644 --- a/react-ystemandchess/src/features/auth/signup/ParentSignUp.tsx +++ b/react-ystemandchess/src/features/auth/signup/ParentSignUp.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { useNavigate } from "react-router-dom"; import { useCookies } from "react-cookie"; -import { environment } from "../../../environments/environment"; +import { environment } from "../../../environments"; import AuthLayout from "./AuthLayout"; import stemmyVine from "../../../assets/images/ActivitiesAssets/stemmy.svg"; diff --git a/react-ystemandchess/src/features/engine/PlayComputer.test.tsx b/react-ystemandchess/src/features/engine/PlayComputer.test.tsx index 64ef01da..86d6e5fd 100644 --- a/react-ystemandchess/src/features/engine/PlayComputer.test.tsx +++ b/react-ystemandchess/src/features/engine/PlayComputer.test.tsx @@ -42,7 +42,7 @@ jest.mock('../../components/ChessBoard/ChessBoard', () => { }); // Mock environment -jest.mock('../../environments/environment', () => ({ +jest.mock('../../environments', () => ({ environment: { urls: { stockfishServerURL: 'http://localhost:8080', diff --git a/react-ystemandchess/src/features/engine/PlayComputer.tsx b/react-ystemandchess/src/features/engine/PlayComputer.tsx index 03c63f59..4719ff7b 100644 --- a/react-ystemandchess/src/features/engine/PlayComputer.tsx +++ b/react-ystemandchess/src/features/engine/PlayComputer.tsx @@ -6,7 +6,7 @@ import { io } from 'socket.io-client'; import { useLocation } from 'react-router'; import { Move } from '../../core/types/chess'; import ChessBoard, { ChessBoardRef } from '../../components/ChessBoard/ChessBoard'; -import { environment } from "../../environments/environment"; +import { environment } from "../../environments"; // Module styles (placeholder file should exist at the same folder) import styles from './PlayComputer.module.scss'; import StockfishTutor from './StockfishTutor'; diff --git a/react-ystemandchess/src/features/engine/StockfishTutor.tsx b/react-ystemandchess/src/features/engine/StockfishTutor.tsx index 24b186fd..5394bf3b 100644 --- a/react-ystemandchess/src/features/engine/StockfishTutor.tsx +++ b/react-ystemandchess/src/features/engine/StockfishTutor.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { environment } from '../../environments/environment'; +import { environment } from '../../environments'; import styles from './StockfishTutor.module.scss'; import { CoachMascot, CoachExpression } from '../../components/animations/CoachMascot/CoachMascot'; import { Chess as ChessClass } from 'chess.js'; diff --git a/react-ystemandchess/src/features/lessons/lessons-selection/LessonsSelection.jsx b/react-ystemandchess/src/features/lessons/lessons-selection/LessonsSelection.jsx index 648c1c23..727ba591 100644 --- a/react-ystemandchess/src/features/lessons/lessons-selection/LessonsSelection.jsx +++ b/react-ystemandchess/src/features/lessons/lessons-selection/LessonsSelection.jsx @@ -1,6 +1,6 @@ import { useNavigate } from "react-router"; import { useState, useEffect, useRef, useCallback, useMemo, memo } from 'react'; -import { environment } from "../../../environments/environment"; +import { environment } from "../../../environments"; import { getAllScenarios } from "../lessons-main/Scenarios"; import { useCookies } from "react-cookie"; diff --git a/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/Lesson-overlay.test.tsx b/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/Lesson-overlay.test.tsx index 55b2fb2f..de3e4ab7 100644 --- a/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/Lesson-overlay.test.tsx +++ b/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/Lesson-overlay.test.tsx @@ -4,7 +4,7 @@ jest.mock("socket.io-client"); // Mock environment -jest.mock("../../../../environments/environment"); +jest.mock("../../../../environments"); // Mock utility modules jest.mock("../../../../core/utils/goalEvaluator"); @@ -103,7 +103,7 @@ const mockIo = io as jest.MockedFunction; // Import mocked modules const goalEvaluator = require("../../../../core/utils/goalEvaluator"); const eventLogger = require("../../../../core/utils/eventLogger"); -const { environment } = require("../../../../environments/environment"); +const { environment } = require("../../../../environments"); // Create socket factory const createMockSocket = () => { diff --git a/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/Lesson-overlay.tsx b/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/Lesson-overlay.tsx index ca31ced4..122f1f65 100644 --- a/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/Lesson-overlay.tsx +++ b/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/Lesson-overlay.tsx @@ -6,7 +6,7 @@ import { io } from 'socket.io-client'; import ChessBoard, { ChessBoardRef } from '../../../../components/ChessBoard/ChessBoard'; import PromotionPopup from '../../lessons-main/PromotionPopup'; import MoveTracker from '../move-tracker/MoveTracker'; -import { environment } from "../../../../environments/environment"; +import { environment } from "../../../../environments"; import { Move } from "../../../../core/types/chess"; import { EvaluationContext, Goal } from '../../../../core/types/goals'; diff --git a/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/hooks/useLessonManager.ts b/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/hooks/useLessonManager.ts index 71dd2194..3a45d1c7 100644 --- a/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/hooks/useLessonManager.ts +++ b/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/hooks/useLessonManager.ts @@ -1,5 +1,5 @@ import { useState, useCallback } from "react"; -import { environment } from "../../../../../environments/environment"; +import { environment } from "../../../../../environments"; export function useLessonManager(piece: string, cookies: any, initialLessonNum?: number) { const [lessonNum, setLessonNum] = useState(initialLessonNum ?? 0); diff --git a/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/hooks/useSocketChessEngine.ts b/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/hooks/useSocketChessEngine.ts index 2b17234e..1bc8ea76 100644 --- a/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/hooks/useSocketChessEngine.ts +++ b/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/hooks/useSocketChessEngine.ts @@ -1,4 +1,4 @@ -import { environment } from "../../../../../environments/environment"; +import { environment } from "../../../../../environments"; import { useEffect, useRef } from "react"; import io from "socket.io-client"; diff --git a/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/hooks/useTimeTracking.test.ts b/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/hooks/useTimeTracking.test.ts index 37cd0a90..cfba444d 100644 --- a/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/hooks/useTimeTracking.test.ts +++ b/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/hooks/useTimeTracking.test.ts @@ -8,7 +8,7 @@ jest.mock("../../../../../globals", () => ({ })); // mock environment URL -jest.mock("../../../../../environments/environment", () => ({ +jest.mock("../../../../../environments", () => ({ environment: { urls: { middlewareURL: "http://mockurl.com" } }, })); diff --git a/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/hooks/useTimeTracking.ts b/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/hooks/useTimeTracking.ts index 15f728bb..c1f70db3 100644 --- a/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/hooks/useTimeTracking.ts +++ b/react-ystemandchess/src/features/lessons/piece-lessons/lesson-overlay/hooks/useTimeTracking.ts @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from "react"; -import { environment } from "../../../../../environments/environment"; +import { environment } from "../../../../../environments"; import { SetPermissionLevel } from "../../../../../globals"; export function useTimeTracking(piece: string, cookies: any) { diff --git a/react-ystemandchess/src/features/mentor/mentor-profile/NewMentorProfile.tsx b/react-ystemandchess/src/features/mentor/mentor-profile/NewMentorProfile.tsx index 4e6429f1..f0210a5c 100644 --- a/react-ystemandchess/src/features/mentor/mentor-profile/NewMentorProfile.tsx +++ b/react-ystemandchess/src/features/mentor/mentor-profile/NewMentorProfile.tsx @@ -3,7 +3,7 @@ import "./NewMentorProfile.scss"; import Images from "../../../assets/images/imageImporter"; import { SetPermissionLevel } from '../../../globals'; import { useCookies } from 'react-cookie'; -import { environment } from "../../../environments/environment"; +import { environment } from "../../../environments"; import { useNavigate } from "react-router"; import StatsChart from "../../student/student-profile/StatsChart"; import Lessons from "../../lessons/lessons-main/Lessons"; diff --git a/react-ystemandchess/src/features/puzzles/Puzzles.tsx b/react-ystemandchess/src/features/puzzles/Puzzles.tsx index bce687f7..3a22153a 100644 --- a/react-ystemandchess/src/features/puzzles/Puzzles.tsx +++ b/react-ystemandchess/src/features/puzzles/Puzzles.tsx @@ -4,7 +4,7 @@ import { themesDescription, } from "../../core/services/themesService"; import Modal, { ModalProps } from "../../components/modal/Modal"; -import { environment } from "../../environments/environment"; +import { environment } from "../../environments"; import { v4 as uuidv4 } from "uuid"; import { SetPermissionLevel } from "../../globals"; import { useCookies } from "react-cookie"; diff --git a/react-ystemandchess/src/features/student/student-page/Student.tsx b/react-ystemandchess/src/features/student/student-page/Student.tsx index 4d6889ed..a8e28196 100644 --- a/react-ystemandchess/src/features/student/student-page/Student.tsx +++ b/react-ystemandchess/src/features/student/student-page/Student.tsx @@ -2,7 +2,7 @@ import React, { useState, useRef } from "react"; import "./Student.scss"; import ChessBoard, { ChessBoardRef } from "../../../components/ChessBoard/ChessBoard"; import { useChessSocket } from "../../lessons/piece-lessons/lesson-overlay/hooks/useChessSocket"; -import { environment } from "../../../environments/environment"; +import { environment } from "../../../environments"; import { Move } from "../../../core/types/chess"; import { v4 as uuidv4 } from "uuid"; import ChatWidget from '../../../components/ChatWidget/ChatWidget'; diff --git a/react-ystemandchess/src/features/student/student-profile/Modals/ActivitiesModal.tsx b/react-ystemandchess/src/features/student/student-profile/Modals/ActivitiesModal.tsx index 0cf8910f..999175fc 100644 --- a/react-ystemandchess/src/features/student/student-profile/Modals/ActivitiesModal.tsx +++ b/react-ystemandchess/src/features/student/student-profile/Modals/ActivitiesModal.tsx @@ -26,7 +26,7 @@ import { ReactComponent as TopicBag } from "../../../../assets/images/Activities import { ReactComponent as ShortBottomVine} from "../../../../assets/images/ActivitiesAssets/short_bottom_vine.svg"; import { ReactComponent as BottomVine} from "../../../../assets/images/ActivitiesAssets/bottom_vine.svg"; import { ReactComponent as Stemmy} from "../../../../assets/images/ActivitiesAssets/stemmy.svg"; -import { environment } from "../../../../environments/environment"; +import { environment } from "../../../../environments"; import { useCookies } from "react-cookie"; import { parseActivities } from "../../../../core/utils/activityNames"; diff --git a/react-ystemandchess/src/features/student/student-profile/Modals/LeaderboardModal.test.tsx b/react-ystemandchess/src/features/student/student-profile/Modals/LeaderboardModal.test.tsx index 99fe398f..b778f42b 100644 --- a/react-ystemandchess/src/features/student/student-profile/Modals/LeaderboardModal.test.tsx +++ b/react-ystemandchess/src/features/student/student-profile/Modals/LeaderboardModal.test.tsx @@ -17,7 +17,7 @@ jest.mock("react-cookie", () => ({ useCookies: () => [{ login: "test-token" }], })); -jest.mock("../../../../environments/environment", () => ({ +jest.mock("../../../../environments", () => ({ environment: { urls: { middlewareURL: "http://mw" } }, })); diff --git a/react-ystemandchess/src/features/student/student-profile/Modals/LeaderboardModal.tsx b/react-ystemandchess/src/features/student/student-profile/Modals/LeaderboardModal.tsx index a95f3b52..31d402d5 100644 --- a/react-ystemandchess/src/features/student/student-profile/Modals/LeaderboardModal.tsx +++ b/react-ystemandchess/src/features/student/student-profile/Modals/LeaderboardModal.tsx @@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom"; import { useCookies } from "react-cookie"; import "./LeaderboardModal.scss"; import { ReactComponent as LeaderboardIcon } from "../../../../assets/images/student/leaderboard_sidebar_icon.svg"; -import { environment } from "../../../../environments/environment"; +import { environment } from "../../../../environments"; import rank1Img from "../../../../assets/images/student/Leaderboard_rank_1.svg"; import rank2Img from "../../../../assets/images/student/Leaderboard_rank_2.svg"; diff --git a/react-ystemandchess/src/features/student/student-profile/NewStudentProfile.tsx b/react-ystemandchess/src/features/student/student-profile/NewStudentProfile.tsx index 9ebfc156..de971eba 100644 --- a/react-ystemandchess/src/features/student/student-profile/NewStudentProfile.tsx +++ b/react-ystemandchess/src/features/student/student-profile/NewStudentProfile.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef, useMemo, lazy, Suspense } from "react"; import { SetPermissionLevel } from '../../../globals'; import { useCookies } from 'react-cookie'; -import { environment } from "../../../environments/environment"; +import { environment } from "../../../environments"; import { useNavigate } from "react-router"; import StatsChart from "./StatsChart"; import Puzzles from "../../puzzles/Puzzles"; diff --git a/react-ystemandchess/src/globals.ts b/react-ystemandchess/src/globals.ts index ae5fd9ad..d17f9df9 100644 --- a/react-ystemandchess/src/globals.ts +++ b/react-ystemandchess/src/globals.ts @@ -5,7 +5,7 @@ * components, particularly for authentication and permission management. */ -import { environment } from "./environments/environment"; +import { environment } from "./environments"; /** * Global variable to store user information after authentication diff --git a/stockfishServer/.env.example b/stockfishServer/.env.example new file mode 100644 index 00000000..7d7bca16 --- /dev/null +++ b/stockfishServer/.env.example @@ -0,0 +1,4 @@ +NODE_ENV=development +PORT=8080 +CORS_ORIGIN=http://localhost:3000 +ALLOWED_ORIGINS= diff --git a/stockfishServer/package-lock.json b/stockfishServer/package-lock.json index e699f8c4..13f628a9 100644 --- a/stockfishServer/package-lock.json +++ b/stockfishServer/package-lock.json @@ -10,6 +10,7 @@ "license": "ISC", "dependencies": { "chess.js": "^0.11.0", + "cors": "^2.8.5", "dotenv": "^8.6.0", "express": "^4.21.2", "express-rate-limit": "^7.5.0", @@ -20,7 +21,8 @@ "devDependencies": { "jest": "^30.0.4", "nodemon": "^3.1.10", - "socket.io-client": "^4.8.1" + "socket.io-client": "^4.8.1", + "supertest": "^7.2.2" } }, "node_modules/@babel/code-frame": { @@ -988,6 +990,29 @@ "@tybys/wasm-util": "^0.10.0" } }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -1507,6 +1532,20 @@ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/babel-jest": { "version": "30.1.2", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.1.2.tgz", @@ -1972,6 +2011,29 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2016,6 +2078,13 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -2073,6 +2142,16 @@ "node": ">=0.10.0" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -2099,6 +2178,17 @@ "node": ">=8" } }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, "node_modules/dotenv": { "version": "8.6.0", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.6.0.tgz", @@ -2291,6 +2381,22 @@ "node": ">= 0.4" } }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -2455,6 +2561,13 @@ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "dev": true }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -2522,6 +2635,41 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -2734,10 +2882,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", "dependencies": { "function-bind": "^1.1.2" }, @@ -4523,13 +4688,14 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -4541,12 +4707,13 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -4981,6 +5148,107 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/superagent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/superagent/node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/superagent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/superagent/node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest/node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", diff --git a/stockfishServer/package.json b/stockfishServer/package.json index 976c34bd..b2ba097a 100644 --- a/stockfishServer/package.json +++ b/stockfishServer/package.json @@ -11,6 +11,7 @@ "license": "ISC", "dependencies": { "chess.js": "^0.11.0", + "cors": "^2.8.5", "dotenv": "^8.6.0", "express": "^4.21.2", "express-rate-limit": "^7.5.0", @@ -24,6 +25,7 @@ "devDependencies": { "jest": "^30.0.4", "nodemon": "^3.1.10", - "socket.io-client": "^4.8.1" + "socket.io-client": "^4.8.1", + "supertest": "^7.2.2" } } diff --git a/stockfishServer/src/index.js b/stockfishServer/src/index.js index 9be03d65..774b9f82 100644 --- a/stockfishServer/src/index.js +++ b/stockfishServer/src/index.js @@ -1,5 +1,8 @@ require("dotenv").config(); +const validateEnvironment = require("./validateEnvironment"); +validateEnvironment(); + const express = require("express"); const http = require("http"); const { Server } = require("socket.io"); @@ -18,16 +21,32 @@ process.on('unhandledRejection', (reason) => { process.exit(1); }); +const isProduction = process.env.NODE_ENV === "production"; + const allowedOriginsSetting = process.env.CORS_ORIGIN || process.env.ALLOWED_ORIGINS; -const allowedOrigins = allowedOriginsSetting +const allowedOrigins = (allowedOriginsSetting ? allowedOriginsSetting.split(",").map((o) => o.trim()) : [ "https://ystemandchess.com", "https://www.ystemandchess.com", - "http://localhost:3000", - "http://localhost:3002", - "http://localhost:4200", - ]; + ...(isProduction + ? [] + : [ + "http://localhost:3000", + "http://localhost:3002", + "http://localhost:4200", + ]), + ] +).map((origin) => { + if (origin !== "*" && origin.endsWith("/")) { + const normalized = origin.slice(0, -1); + console.warn( + `CORS: "${origin}" has a trailing slash, which never matches a browser's Origin header — using "${normalized}" instead` + ); + return normalized; + } + return origin; +}); const hasWildcard = allowedOrigins.includes("*"); @@ -40,7 +59,8 @@ const corsOptions = { if (allowedOrigins.indexOf(origin) !== -1) { callback(null, true); } else { - callback(new Error(`Origin ${origin} not allowed by CORS`)); + console.warn(`CORS: rejected origin ${origin}`); + callback(null, false); } }, methods: ["GET", "POST"], diff --git a/stockfishServer/src/tests/cors.test.js b/stockfishServer/src/tests/cors.test.js new file mode 100644 index 00000000..8a2e09b8 --- /dev/null +++ b/stockfishServer/src/tests/cors.test.js @@ -0,0 +1,66 @@ +/** + * Verifies the CORS origin/credentials behavior in src/index.js. + * index.js can't be imported directly (it calls server.listen() as a + * module-load side effect), so this mirrors its corsOptions logic in an + * isolated app, the same approach chessServer/src/tests/cors.test.js uses. + * Keep this in sync with src/index.js if that logic changes. + */ + +const express = require("express"); +const cors = require("cors"); +const request = require("supertest"); + +function buildApp(allowedOriginsCsv) { + const allowedOrigins = allowedOriginsCsv.split(",").map((o) => o.trim()); + const hasWildcard = allowedOrigins.includes("*"); + + const app = express(); + app.use( + cors({ + origin: function (origin, callback) { + if (!origin) return callback(null, true); + if (hasWildcard) { + return callback(null, true); + } + if (allowedOrigins.indexOf(origin) !== -1) { + callback(null, true); + } else { + callback(null, false); + } + }, + methods: ["GET", "POST"], + credentials: !hasWildcard, + }) + ); + app.get("/ping", (req, res) => res.json({ ok: true })); + return app; +} + +describe("stockfishServer CORS origin/credentials behavior", () => { + test("allowed origin (no wildcard): credentials allowed, header set to the origin", async () => { + const app = buildApp("https://ystemandchess.com,http://localhost:3000"); + const res = await request(app).get("/ping").set("Origin", "http://localhost:3000"); + + expect(res.status).toBe(200); + expect(res.headers["access-control-allow-origin"]).toBe("http://localhost:3000"); + expect(res.headers["access-control-allow-credentials"]).toBe("true"); + }); + + test("disallowed origin (no wildcard): rejected without a 500, no CORS header", async () => { + const app = buildApp("https://ystemandchess.com,http://localhost:3000"); + const res = await request(app).get("/ping").set("Origin", "https://evil.example.com"); + + expect(res.status).not.toBe(500); + expect(res.status).toBe(200); + expect(res.headers["access-control-allow-origin"]).toBeUndefined(); + }); + + test("wildcard configured: any origin allowed, but credentials are disabled", async () => { + const app = buildApp("*"); + const res = await request(app).get("/ping").set("Origin", "https://anything.example.com"); + + expect(res.status).toBe(200); + expect(res.headers["access-control-allow-origin"]).toBe("https://anything.example.com"); + expect(res.headers["access-control-allow-credentials"]).toBeUndefined(); + }); +}); diff --git a/stockfishServer/src/validateEnvironment.js b/stockfishServer/src/validateEnvironment.js new file mode 100644 index 00000000..4c94408f --- /dev/null +++ b/stockfishServer/src/validateEnvironment.js @@ -0,0 +1,29 @@ +function hasCorsConfiguration() { + return Boolean( + process.env.CORS_ORIGIN?.trim() || + process.env.ALLOWED_ORIGINS?.trim() + ); +} + +function validateEnvironment() { + if (process.env.NODE_ENV !== "production") { + return; + } + + const missing = []; + + if (!hasCorsConfiguration()) { + missing.push("CORS_ORIGIN or ALLOWED_ORIGINS"); + } + + if (missing.length > 0) { + console.error( + `[stockfishServer] Missing required production environment variables: ${missing.join(", ")}` + ); + process.exit(1); + } + + console.log("[stockfishServer] Required production environment variables validated"); +} + +module.exports = validateEnvironment;