diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..187c637 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +services: + postgres: + image: postgres:17.5 + environment: + POSTGRES_USER: eraser_user + POSTGRES_PASSWORD: eraser_pass + POSTGRES_DB: eraser_db + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + +volumes: + postgres_data: diff --git a/package-lock.json b/package-lock.json index 759ca58..930b711 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,10 @@ "cors": "^2.8.5", "dotenv": "^17.2.0", "express": "^5.1.0", - "jsonwebtoken": "^9.0.2" + "jsonwebtoken": "^9.0.2", + "pg": "^8.16.3", + "socket.io": "^4.8.1", + "socket.io-client": "^4.8.1" }, "devDependencies": { "jest": "^30.0.4", @@ -1182,6 +1185,12 @@ "@sinonjs/commons": "^3.0.1" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.0.tgz", @@ -1238,6 +1247,15 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -1269,7 +1287,6 @@ "version": "24.0.14", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.14.tgz", "integrity": "sha512-4zXMWD91vBLGRtHK3YbIoFMia+1nqEz72coM42C5ETjnNCa/heoj7NT1G67iAfOqMmcfhuCZ4uNpyz8EjlAejw==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.8.0" @@ -1776,6 +1793,15 @@ "dev": true, "license": "MIT" }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, "node_modules/bcrypt": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", @@ -2473,6 +2499,125 @@ "node": ">= 0.8" } }, + "node_modules/engine.io": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.4.tgz", + "integrity": "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.17.1" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-client": { + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz", + "integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.17.1", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-client/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/engine.io/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -4688,6 +4833,95 @@ "node": ">=16" } }, + "node_modules/pg": { + "version": "8.16.3", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", + "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.9.1", + "pg-pool": "^3.10.1", + "pg-protocol": "^1.10.3", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.2.7" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz", + "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz", + "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz", + "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz", + "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4731,6 +4965,45 @@ "node": ">=8" } }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz", + "integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/pretty-format": { "version": "30.0.2", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.2.tgz", @@ -5142,6 +5415,173 @@ "node": ">=8" } }, + "node_modules/socket.io": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz", + "integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.3.2", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz", + "integrity": "sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==", + "license": "MIT", + "dependencies": { + "debug": "~4.3.4", + "ws": "~8.17.1" + } + }, + "node_modules/socket.io-adapter/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-client": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz", + "integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.2", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-client/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io/node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/socket.io/node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -5163,6 +5603,15 @@ "source-map": "^0.6.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", @@ -5562,7 +6011,6 @@ "version": "7.8.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", - "dev": true, "license": "MIT" }, "node_modules/unpipe": { @@ -5805,6 +6253,44 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index a47eb83..5f63233 100644 --- a/package.json +++ b/package.json @@ -22,16 +22,21 @@ "homepage": "https://github.com/abhinav29102005/eraser-backend#readme", "dependencies": { "@prisma/client": "^6.11.1", + "@y/websocket-server": "^0.1.1", "bcrypt": "^6.0.0", "cors": "^2.8.5", "dotenv": "^17.2.0", "express": "^5.1.0", - "jsonwebtoken": "^9.0.2" + "jsonwebtoken": "^9.0.2", + "pg": "^8.16.3", + "socket.io": "^4.8.1", + "socket.io-client": "^4.8.1" + }, "devDependencies": { "jest": "^30.0.4", "nodemon": "^3.1.10", - "prisma": "^6.11.1", + "prisma": "^6.12.0", "supertest": "^7.1.3" } } diff --git a/prisma/dev.db b/prisma/dev.db index b8fccf1..fe55340 100644 Binary files a/prisma/dev.db and b/prisma/dev.db differ diff --git a/prisma/migrations/20250720170023_init/migration.sql b/prisma/migrations/20250720170023_init/migration.sql deleted file mode 100644 index 89c74c6..0000000 --- a/prisma/migrations/20250720170023_init/migration.sql +++ /dev/null @@ -1,22 +0,0 @@ --- CreateTable -CREATE TABLE "Stroke" ( - "id" TEXT NOT NULL PRIMARY KEY, - "tool" TEXT NOT NULL, - "color" TEXT NOT NULL, - "strokeWidth" INTEGER NOT NULL, - "points" JSONB NOT NULL, - "user" TEXT NOT NULL, - "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - "boardId" TEXT NOT NULL, - CONSTRAINT "Stroke_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "Board" ("id") ON DELETE RESTRICT ON UPDATE CASCADE -); - --- CreateTable -CREATE TABLE "Board" ( - "id" TEXT NOT NULL PRIMARY KEY, - "name" TEXT NOT NULL, - "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP -); - --- CreateIndex -CREATE INDEX "Stroke_boardId_idx" ON "Stroke"("boardId"); diff --git a/prisma/migrations/20250720170223_init_full_schema/migration.sql b/prisma/migrations/20250720170223_init_full_schema/migration.sql deleted file mode 100644 index c6600f6..0000000 --- a/prisma/migrations/20250720170223_init_full_schema/migration.sql +++ /dev/null @@ -1,54 +0,0 @@ -/* - Warnings: - - - You are about to drop the column `name` on the `Board` table. All the data in the column will be lost. - - You are about to drop the column `user` on the `Stroke` table. All the data in the column will be lost. - - Added the required column `updatedAt` to the `Board` table without a default value. This is not possible if the table is not empty. - - Added the required column `userId` to the `Board` table without a default value. This is not possible if the table is not empty. - - Added the required column `updatedAt` to the `Stroke` table without a default value. This is not possible if the table is not empty. - -*/ --- CreateTable -CREATE TABLE "User" ( - "id" TEXT NOT NULL PRIMARY KEY, - "email" TEXT NOT NULL, - "name" TEXT, - "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" DATETIME NOT NULL -); - --- RedefineTables -PRAGMA defer_foreign_keys=ON; -PRAGMA foreign_keys=OFF; -CREATE TABLE "new_Board" ( - "id" TEXT NOT NULL PRIMARY KEY, - "title" TEXT DEFAULT 'Untitled Board', - "isDeleted" BOOLEAN NOT NULL DEFAULT false, - "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" DATETIME NOT NULL, - "userId" TEXT NOT NULL, - CONSTRAINT "Board_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE -); -INSERT INTO "new_Board" ("createdAt", "id") SELECT "createdAt", "id" FROM "Board"; -DROP TABLE "Board"; -ALTER TABLE "new_Board" RENAME TO "Board"; -CREATE TABLE "new_Stroke" ( - "id" TEXT NOT NULL PRIMARY KEY, - "points" JSONB NOT NULL, - "tool" TEXT, - "color" TEXT, - "strokeWidth" INTEGER, - "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" DATETIME NOT NULL, - "boardId" TEXT NOT NULL, - CONSTRAINT "Stroke_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "Board" ("id") ON DELETE CASCADE ON UPDATE CASCADE -); -INSERT INTO "new_Stroke" ("boardId", "color", "createdAt", "id", "points", "strokeWidth", "tool") SELECT "boardId", "color", "createdAt", "id", "points", "strokeWidth", "tool" FROM "Stroke"; -DROP TABLE "Stroke"; -ALTER TABLE "new_Stroke" RENAME TO "Stroke"; -CREATE INDEX "Stroke_boardId_idx" ON "Stroke"("boardId"); -PRAGMA foreign_keys=ON; -PRAGMA defer_foreign_keys=OFF; - --- CreateIndex -CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); diff --git a/prisma/migrations/20250721173603_add_password/migration.sql b/prisma/migrations/20250721173603_add_password/migration.sql deleted file mode 100644 index df92ba7..0000000 --- a/prisma/migrations/20250721173603_add_password/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "User" ADD COLUMN "password" TEXT DEFAULT 'changeme' NOT NULL; diff --git a/prisma/migrations/20250721173710_add_password_to_user/migration.sql b/prisma/migrations/20250721173710_add_password_to_user/migration.sql deleted file mode 100644 index 356ab39..0000000 --- a/prisma/migrations/20250721173710_add_password_to_user/migration.sql +++ /dev/null @@ -1,23 +0,0 @@ -/* - Warnings: - - - Made the column `password` on table `User` required. This step will fail if there are existing NULL values in that column. - -*/ --- RedefineTables -PRAGMA defer_foreign_keys=ON; -PRAGMA foreign_keys=OFF; -CREATE TABLE "new_User" ( - "id" TEXT NOT NULL PRIMARY KEY, - "email" TEXT NOT NULL, - "password" TEXT NOT NULL, - "name" TEXT, - "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" DATETIME NOT NULL -); -INSERT INTO "new_User" ("createdAt", "email", "id", "name", "password", "updatedAt") SELECT "createdAt", "email", "id", "name", "password", "updatedAt" FROM "User"; -DROP TABLE "User"; -ALTER TABLE "new_User" RENAME TO "User"; -CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); -PRAGMA foreign_keys=ON; -PRAGMA defer_foreign_keys=OFF; diff --git a/prisma/migrations/20250804120524_init_v2_schema/migration.sql b/prisma/migrations/20250804120524_init_v2_schema/migration.sql new file mode 100644 index 0000000..164eef2 --- /dev/null +++ b/prisma/migrations/20250804120524_init_v2_schema/migration.sql @@ -0,0 +1,35 @@ +/* + Warnings: + + - You are about to drop the `Stroke` table. If the table is not empty, all the data it contains will be lost. + - You are about to drop the column `password` on the `User` table. All the data in the column will be lost. + +*/ +-- DropIndex +DROP INDEX "Stroke_boardId_idx"; + +-- AlterTable +ALTER TABLE "Board" ADD COLUMN "documentState" BLOB; + +-- DropTable +PRAGMA foreign_keys=off; +DROP TABLE "Stroke"; +PRAGMA foreign_keys=on; + +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_User" ( + "id" TEXT NOT NULL PRIMARY KEY, + "email" TEXT NOT NULL, + "name" TEXT, + "hashedPassword" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); +INSERT INTO "new_User" ("createdAt", "email", "id", "name", "updatedAt") SELECT "createdAt", "email", "id", "name", "updatedAt" FROM "User"; +DROP TABLE "User"; +ALTER TABLE "new_User" RENAME TO "User"; +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/prisma/migrations/20250807065039_init/migration.sql b/prisma/migrations/20250807065039_init/migration.sql new file mode 100644 index 0000000..db95bde --- /dev/null +++ b/prisma/migrations/20250807065039_init/migration.sql @@ -0,0 +1,49 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "password" TEXT NOT NULL, + "name" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Board" ( + "id" TEXT NOT NULL, + "title" TEXT DEFAULT 'Untitled Board', + "isDeleted" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "userId" TEXT NOT NULL, + + CONSTRAINT "Board_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Stroke" ( + "id" TEXT NOT NULL, + "points" JSONB NOT NULL, + "tool" TEXT, + "color" TEXT, + "strokeWidth" INTEGER, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "boardId" TEXT NOT NULL, + + CONSTRAINT "Stroke_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE INDEX "Stroke_boardId_idx" ON "Stroke"("boardId"); + +-- AddForeignKey +ALTER TABLE "Board" ADD CONSTRAINT "Board_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Stroke" ADD CONSTRAINT "Stroke_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "Board"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml index 2a5a444..044d57c 100644 --- a/prisma/migrations/migration_lock.toml +++ b/prisma/migrations/migration_lock.toml @@ -1,3 +1,3 @@ # Please do not edit this file manually # It should be added in your version-control system (e.g., Git) -provider = "sqlite" +provider = "postgresql" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fe9f9a0..51e2361 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -5,11 +5,11 @@ generator client { } datasource db { - provider = "sqlite" // for local use, or "postgresql" if later switching to Neon + provider = "postgresql" // for local use, or "postgresql" if later switching to Neon url = env("DATABASE_URL") } -// --- NEW/CORRECTED: User Model --- +// User Model model User { id String @id @default(cuid()) // Using cuid() for shorter, URL-safe IDs, as previously discussed. email String @unique // User's email, must be unique @@ -20,7 +20,7 @@ model User { boards Board[] // Relation to Board model: A User can have many Boards } -// --- CORRECTED: Board Model --- +// Board Model model Board { id String @id @default(cuid()) // Using cuid() for shorter, URL-safe IDs title String? @default("Untitled Board") // Using 'title' for consistency and default value @@ -34,7 +34,7 @@ model Board { strokes Stroke[] // Relation to Stroke model: A Board can have many Strokes } -// --- CORRECTED: Stroke Model --- +// Stroke Model model Stroke { id String @id @default(cuid()) // Using cuid() for consistency points Json // Storing XY points as JSON diff --git a/src/app.js b/src/app.js new file mode 100644 index 0000000..6825994 --- /dev/null +++ b/src/app.js @@ -0,0 +1,34 @@ +// --- src/app.js --- +// This file sets up the Express application with all its middleware and routes. +// It keeps the Express app setup clean and delegates to separate route files. +const express = require("express"); +const cors = require("cors"); +const routes = require("./routes"); +const authRoutes = require("./routes/auth.routes"); + +const app = express(); + +app.use(cors({ + origin: ["http://localhost:5173", "YOUR_PROD_FRONTEND_URL"], + methods: ["GET", "POST", "PUT", "PATCH", "DELETE"], + allowedHeaders: ["Content-Type", "Authorization"], +})); +app.use(express.json()); + +// --- Public Authentication Routes --- +// These routes are unprotected and are defined in a separate file as requested. +app.use("/api/auth", authRoutes); + +// --- Protected Application Routes --- +app.use("/api", routes); + +// --- Global Error Handler --- +app.use((err, req, res, next) => { + console.error("Global Error Handler:", err.stack); + res.status(err.statusCode || 500).json({ + message: err.message || "Something went wrong!", + error: process.env.NODE_ENV === "development" ? err : {}, + }); +}); + +module.exports = app; \ No newline at end of file diff --git a/src/config/config.js b/src/config/config.js new file mode 100644 index 0000000..4383a05 --- /dev/null +++ b/src/config/config.js @@ -0,0 +1,8 @@ +// --- src/config/config.js --- +// This file centralizes application configuration, loading environment variables. +require('dotenv').config(); + +module.exports = { + PORT: process.env.PORT || 5000, + JWT_SECRET: process.env.JWT_SECRET, +}; \ No newline at end of file diff --git a/src/config/index.js b/src/config/index.js deleted file mode 100644 index 76382f4..0000000 --- a/src/config/index.js +++ /dev/null @@ -1,8 +0,0 @@ - -require('dotenv').config(); - -module.exports = { - PORT: process.env.PORT || 5000, - JWT_SECRET: process.env.JWT_SECRET, - -}; \ No newline at end of file diff --git a/src/index.js b/src/index.js index c506037..09059cd 100644 --- a/src/index.js +++ b/src/index.js @@ -1,15 +1,30 @@ // src/index.js const express = require("express"); const cors = require("cors"); -const prisma = require('./services/prisma'); // Import the single Prisma instance +const prisma = require("./services/prisma"); // Import the single Prisma instance\ +const { Server } = require("socket.io"); +const { createServer } = require("http"); const app = express(); +const server = createServer(app); -app.use(cors({ - origin: ['http://localhost:5173', 'YOUR_PROD_FRONTEND_URL'], // Crucial: ensure frontend URL is allowed - methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], - allowedHeaders: ['Content-Type', 'Authorization'], -})); +//configure socket.io +const io = new Server(server, { + cors: { + origin: ['http://localhost:5173', 'YOUR_PROD_FRONTEND_URL'], + methods: ['GET', 'POST'], + credentials: true + }, + transports: ['websocket', 'polling'] +}); + +app.use( + cors({ + origin: ["http://localhost:5173", "YOUR_PROD_FRONTEND_URL"], // Crucial: ensure frontend URL is allowed + methods: ["GET", "POST", "PUT", "PATCH", "DELETE"], + allowedHeaders: ["Content-Type", "Authorization"], + }) +); app.use(express.json()); // --- Import and Register Routes --- @@ -27,21 +42,27 @@ app.get("/", (req, res) => { res.send("Eraser v1 backend running"); }); +const socketHandler = require('../websocket/src/socketHandler') ; +io.on('connection', (socket) => { + console.log('User connected', socket.id); + socketHandler(io,socket); +}) + // Global error handler app.use((err, req, res, next) => { - console.error("Global Error Handler:", err.stack); - res.status(err.statusCode || 500).json({ - message: err.message || 'Something went wrong!', - error: process.env.NODE_ENV === 'development' ? err : {}, - }); + console.error("Global Error Handler:", err.stack); + res.status(err.statusCode || 500).json({ + message: err.message || "Something went wrong!", + error: process.env.NODE_ENV === "development" ? err : {}, + }); }); // Conditional Server Start const PORT = process.env.PORT || 5000; -if (process.env.NODE_ENV !== 'test') { +if (process.env.NODE_ENV !== "test") { app.listen(PORT, () => { console.log(`🚀 Server ready at http://localhost:${PORT}`); }); } -module.exports = app; \ No newline at end of file +module.exports = app; diff --git a/src/lib/prisma.js b/src/lib/prisma.js deleted file mode 100644 index e9ae4c4..0000000 --- a/src/lib/prisma.js +++ /dev/null @@ -1,3 +0,0 @@ -import { PrismaClient } from '@prisma/client'; -const prisma = new PrismaClient(); -export default prisma; diff --git a/src/middlewares/auth.js b/src/middlewares/auth.js index d9e2883..9e06ee7 100644 --- a/src/middlewares/auth.js +++ b/src/middlewares/auth.js @@ -1,22 +1,20 @@ -const { verifyToken } = require('../services/jwt'); // Correct path to src/services/jwt.js -const prisma = require('../services/prisma'); // Correct path to src/services/prisma.js +// --- src/middlewares/auth.js --- +const { verifyToken } = require("../services/jwt"); +const prisma = require("../services/prisma"); const authMiddleware = async (req, res, next) => { - const authHeader = req.headers.authorization; - if (!authHeader || !authHeader.startsWith('Bearer ')) { - return res.status(401).json({ message: 'No token provided or invalid format' }); - } - const token = authHeader.split(' ')[1]; - try { - const decoded = verifyToken(token); - const user = await prisma.user.findUnique({ where: { id: decoded.userId } }); - if (!user) { return res.status(401).json({ message: 'User not found' }); } - req.user = user; // Attach user object to request - next(); - } catch (error) { - console.error('JWT Verification Error:', error); - if (error.name === 'TokenExpiredError') { return res.status(401).json({ message: 'Token expired' }); } - return res.status(401).json({ message: 'Invalid token' }); - } + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return res.status(401).json({ message: "No token provided or invalid format" });} + const token = authHeader.split(" ")[1]; + try { + const decoded = verifyToken(token); + const user = await prisma.user.findUnique({ where: { id: decoded.userId } }); + if (!user) {return res.status(401).json({ message: "User not found" });} + req.user = user; + next();} catch (error) { + console.error("JWT Verification Error:", error); + if (error.name === "TokenExpiredError") {return res.status(401).json({ message: "Token expired" });} + return res.status(401).json({ message: "Invalid token" });} }; module.exports = authMiddleware; \ No newline at end of file diff --git a/src/routes/auth.routes.js b/src/routes/auth.routes.js index 9a708fa..e1bee4b 100644 --- a/src/routes/auth.routes.js +++ b/src/routes/auth.routes.js @@ -1,24 +1,24 @@ -// src/routes/auth.routes.js +// --- src/routes/auth.routes.js --- +// This file contains all public authentication routes, including login and registration. +// It is explicitly for unprotected endpoints. const express = require("express"); -const prisma = require("../services/prisma"); // Import prisma -const { generateToken } = require("../services/jwt"); // Import jwt utility +const prisma = require("../services/prisma"); +const { generateToken } = require("../services/jwt"); const bcrypt = require("bcrypt"); const router = express.Router(); -// Temporary route to get a test JWT for local development (UNPROTECTED) -// This is the /api/auth/test-token endpoint router.get("/test-token", async (req, res, next) => { try { - let user = await prisma.user.findFirst(); // Try to find any existing user + let user = await prisma.user.findFirst(); if (!user) { - // If no user exists, create a dummy one for testing + const hashedPassword = await bcrypt.hash("Test1234", 10); user = await prisma.user.create({ data: { email: "testuser@example.com", name: "Test User", - password: "Test123", + hashedPassword, }, }); console.log("Created a dummy user for testing:", user.email); @@ -32,39 +32,34 @@ router.get("/test-token", async (req, res, next) => { }); } catch (error) { console.error("Error generating test token:", error); - next(error); // Pass error to global error handler + res.status(500).json({ message: "Failed to generate test token", error: error.message }); } }); - -//register route -router.post("/register", async (req, res) => { +router.post("/register", async (req, res, next) => { try { - //regex for valid email - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const { name, email, password } = req.body; if (!email || !password) { - return res - .status(400) - .json({ message: "Email and password are required" }); + return res.status(400).json({ message: "Email and password are required" }); } + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(email)) { - return res.status(400).json({ message: "Please provide valid email" }); + return res.status(400).json({ message: "Please provide a valid email" }); } + const existingUser = await prisma.user.findUnique({ where: { email } }); if (existingUser) { return res.status(409).json({ message: "Email already registered" }); } - //hash password const hashedPassword = await bcrypt.hash(password, 10); const newUser = await prisma.user.create({ data: { name: name || "New User", email, - password: hashedPassword, + hashedPassword, }, }); @@ -76,26 +71,24 @@ router.post("/register", async (req, res) => { user: { id: newUser.id, email: newUser.email, name: newUser.name }, }); } catch (err) { - console.error("Error registering: ", err); + console.error("Error registering:", err); + res.status(500).json({ message: "Failed to register user" }); } }); - -//login route -router.post("/login", async (req, res) => { +router.post("/login", async (req, res, next) => { try { - const { name, email, password } = req.body; + const { email, password } = req.body; if (!email || !password) { return res.status(401).json({ message: "Please provide credentials" }); } + const user = await prisma.user.findUnique({ where: { email } }); if (!user) { - return res - .status(401) - .json({ message: "Invaild Credentials Email not registered" }); + return res.status(401).json({ message: "Invalid Credentials: Email not registered" }); } - const isPasswordValid = await bcrypt.compare(password, user.password); + const isPasswordValid = await bcrypt.compare(password, user.hashedPassword); if (!isPasswordValid) { return res.status(401).json({ message: "Invalid credentials" }); } @@ -108,8 +101,8 @@ router.post("/login", async (req, res) => { user: { id: user.id, email: user.email, name: user.name }, }); } catch (err) { - console.err("Error: ", err); + console.error("Error during login:", err); + res.status(500).json({ message: "Failed to login" }); } }); - module.exports = router; \ No newline at end of file diff --git a/src/routes/index.js b/src/routes/index.js new file mode 100644 index 0000000..6a6bf1c --- /dev/null +++ b/src/routes/index.js @@ -0,0 +1,86 @@ +// this is src/routes/index.js 2nd pillar of backend design and protected routes management +const express = require("express"); +const prisma = require("../services/prisma"); +const authMiddleware = require("../middlewares/auth"); + +const router = express.Router(); + +router.use(authMiddleware); + +// --- User Routes --- +router.get("/users/me", async (req, res, next) => { + const { id, email, name, createdAt } = req.user; + res.json({ id, email, name, createdAt }); +}); + +// --- Board Routes --- +router.get("/boards", async (req, res, next) => { + try { + const boards = await prisma.board.findMany({ + where: { + userId: req.user.id, + isDeleted: false, + }, + orderBy: { createdAt: "desc" }, + }); + res.json(boards); + } catch (err) { next(err); } +}); + +router.get("/boards/:id", async (req, res, next) => { + try { + const { id } = req.params; + if (!id) { return res.status(400).json({ error: "Please provide board ID" }); } + const board = await prisma.board.findUnique({ + where: { id: id, userId: req.user.id, isDeleted: false }, + }); + if (!board) { return res.status(404).json({ error: "Board not found or not accessible" }); } + res.json(board); + } catch (error) { next(error); } +}); + +router.post("/boards", async (req, res, next) => { + try { + const { name } = req.body; + const boardTitle = name ? name.trim() : "Untitled Board"; + if (!boardTitle) { return res.status(400).json({ error: "Board name cannot be empty" }); } + const board = await prisma.board.create({ + data: { title: boardTitle, userId: req.user.id }, + }); + res.status(201).json({ board }); + } catch (err) { next(err); } +}); + +router.put("/boards/:id", async (req, res, next) => { + try { + const { id } = req.params; + const { name } = req.body; + const boardTitle = name ? name.trim() : ""; + if (!id) { return res.status(400).json({ error: "Please provide board ID" }); } + if (!boardTitle) { return res.status(400).json({ error: "Please provide a valid board name" }); } + const board = await prisma.board.update({ + where: { id: id, userId: req.user.id, isDeleted: false }, + data: { title: boardTitle }, + }); + res.json({ msg: `${id} updated with name: ${boardTitle}`, board }); + } catch (err) { + if (err.code === 'P2025') { return res.status(404).json({ error: "Board not found or not accessible" }); } + next(err); + } +}); + +router.delete("/boards/:id", async (req, res, next) => { + try { + const { id } = req.params; + if (!id) { return res.status(400).json({ error: "Please provide a valid board ID" }); } + const board = await prisma.board.update({ + where: { id: id, userId: req.user.id, isDeleted: false }, + data: { isDeleted: true }, + }); + res.json({ msg: `Board ${id} soft-deleted successfully` }); + } catch (err) { + if (err.code === 'P2025') { return res.status(404).json({ error: "Board not found or not accessible" }); } + next(err); + } +}); +module.exports = router; \ No newline at end of file diff --git a/src/services/jwt.js b/src/services/jwt.js index 9d4d746..f4e707d 100644 --- a/src/services/jwt.js +++ b/src/services/jwt.js @@ -1,5 +1,7 @@ -const jwt = require('jsonwebtoken'); -const config = require('../config'); -const generateToken = (payload) => {return jwt.sign(payload, config.JWT_SECRET, { expiresIn: '7d' });}; +// --- src/services/jwt.js --- +// This utility file contains functions for generating and verifying JWTs. +const jwt = require("jsonwebtoken"); +const config = require("../config/config"); +const generateToken = (payload) => {return jwt.sign(payload, config.JWT_SECRET, { expiresIn: "7d" });}; const verifyToken = (token) => {return jwt.verify(token, config.JWT_SECRET);}; module.exports = { generateToken, verifyToken }; \ No newline at end of file diff --git a/src/services/prisma.js b/src/services/prisma.js index 50bdbb7..de876ea 100644 --- a/src/services/prisma.js +++ b/src/services/prisma.js @@ -1,3 +1,5 @@ +// --- src/services/prisma.js --- +// This file exports a single, shared Prisma client instance for the entire application. const { PrismaClient } = require('@prisma/client'); const prisma = new PrismaClient(); -module.exports = prisma; \ No newline at end of file +module.exports = prisma; diff --git a/src/services/websocket.js b/src/services/websocket.js new file mode 100644 index 0000000..5770d7b --- /dev/null +++ b/src/services/websocket.js @@ -0,0 +1,72 @@ +// --- src/services/websocket.js --- +// This module contains all the real-time WebSocket logic, now in the services folder. +const WebSocket = require("ws"); +const { Server } = require("y-websocket"); +const prisma = require("./services/prisma"); +const { Doc } = require("yjs"); +const jwt = require("jsonwebtoken"); +const config = require("./config/config"); + +const docs = new Map(); + +const persistence = { + writeState: async (doc, boardId) => { + try { + const state = Doc.encodeStateAsUpdate(doc); + await prisma.board.update({ + where: { id: boardId }, + data: { documentState: Buffer.from(state) }, + }); + console.log(`[WS] Persisted state for board ${boardId}`); + } catch (error) { + console.error(`[WS] Failed to persist state for board ${boardId}:`, error); + } + }, + + readState: async (boardId) => { + try { + const board = await prisma.board.findUnique({ + where: { id: boardId }, + select: { documentState: true }, + }); + + if (board && board.documentState) { + const doc = new Doc(); + Doc.applyUpdate(doc, new Uint8Array(board.documentState)); + return doc; + } + } catch (error) { + console.error(`[WS] Failed to read state for board ${boardId}:`, error); + } + return new Doc(); + }, +}; + +const initializeWebSocketServer = (server) => { + const wsServer = new Server({ + server: server, + }); + + wsServer.on('connection', (ws, req) => { + const urlParams = new URLSearchParams(req.url.split("?")[1]); + const token = urlParams.get("token"); + const boardId = urlParams.get("boardId"); + + if (!token || !boardId) { + console.log("[WS] Connection rejected: Missing token or boardId."); + ws.close(1008, "Missing token or boardId"); + return; + } + try { + const decoded = jwt.verify(token, config.JWT_SECRET); + console.log(`[WS] Connection authenticated for user ${decoded.userId}`); + } catch (error) { + console.error("[WS] Connection rejected: Invalid token.", error); + ws.close(1008, "Invalid token"); + } + }); + + wsServer.setPersistence(persistence); +}; + +module.exports = { initializeWebSocketServer }; \ No newline at end of file diff --git a/websocket/src/boardEvents.js b/websocket/src/boardEvents.js new file mode 100644 index 0000000..e69de29 diff --git a/websocket/src/config.js b/websocket/src/config.js new file mode 100644 index 0000000..3ad891b --- /dev/null +++ b/websocket/src/config.js @@ -0,0 +1,19 @@ +require('dotenv').config(); + +module.exports = { + JWT_SECRET: process.env.JWT_SECRET || 'your-default-secret-key', + WS_PORT: process.env.WS_PORT || 4000, + DATABASE_URL: process.env.DATABASE_URL, + NODE_ENV: process.env.NODE_ENV || 'development', + + // CORS settings for WebSocket + CORS_ORIGIN: process.env.CORS_ORIGIN || 'http://localhost:3001', + + // Yjs specific settings + YJS_GC_ENABLED: process.env.YJS_GC_ENABLED === 'true', + YJS_PERSISTENCE_INTERVAL: parseInt(process.env.YJS_PERSISTENCE_INTERVAL) || 5000, + + // Connection limits + MAX_CONNECTIONS_PER_BOARD: parseInt(process.env.MAX_CONNECTIONS_PER_BOARD) || 50, + CONNECTION_TIMEOUT: parseInt(process.env.CONNECTION_TIMEOUT) || 30000, +}; \ No newline at end of file diff --git a/websocket/src/socketHandler.js b/websocket/src/socketHandler.js new file mode 100644 index 0000000..396dbf0 --- /dev/null +++ b/websocket/src/socketHandler.js @@ -0,0 +1,331 @@ +// websocket/src/sockethandler.js +const prisma = require('../../src/services/prisma'); +const { verifyToken } = require('../../src/services/jwt'); + +// Store active connections per board +const boardConnections = new Map(); // boardId -> Set of socket objects +const userBoards = new Map(); // socketId -> boardId + +const socketHandler = (io, socket) => { + console.log(`User connected: ${socket.id}`); + + +// // Handle authentication +// socket.on('authenticate', async (data) => { +// try { +// const { token } = data; +// if (!token) { +// socket.emit('auth_error', { message: 'Token required' }); +// return; +// } + +// const decoded = verifyToken(token); +// const user = await prisma.user.findUnique({ +// where: { id: decoded.userId } +// }); + +// if (!user) { +// socket.emit('auth_error', { message: 'Invalid token' }); +// return; +// } + +// socket.authenticated = true; +// socket.userId = user.id; +// socket.userEmail = user.email; +// socket.userName = user.name; + +// socket.emit('authenticated', { +// user: { +// id: user.id, +// email: user.email, +// name: user.name +// } +// }); + +// console.log(`User authenticated: ${user.email} (${socket.id})`); +// } catch (error) { +// console.error('Authentication error:', error); +// socket.emit('auth_error', { message: 'Authentication failed' }); +// } +// }); + + // Join a board room for real-time collaboration + socket.on('join_board', async (data) => { + try { + const { boardId } = data; + + if (!boardId) { + socket.emit('error', { message: 'Board ID required' }); + return; + } + + // Verify user has access to this board + const board = await prisma.board.findUnique({ + where: { + id: boardId, + userId: socket.userId, + isDeleted: false + } + }); + + if (!board) { + socket.emit('error', { message: 'Board not found or access denied' }); + return; + } + + // Leave previous board if any + const previousBoard = userBoards.get(socket.id); + if (previousBoard) { + socket.leave(previousBoard); + const prevConnections = boardConnections.get(previousBoard); + if (prevConnections) { + prevConnections.delete(socket); + if (prevConnections.size === 0) { + boardConnections.delete(previousBoard); + } + } + } + + // Join new board + socket.join(boardId); + userBoards.set(socket.id, boardId); + + // Track connection + if (!boardConnections.has(boardId)) { + boardConnections.set(boardId, new Set()); + } + boardConnections.get(boardId).add(socket); + + // Notify others in the board + socket.to(boardId).emit('user_joined', { + userId: socket.userId, + userName: socket.userName, + socketId: socket.id + }); + + // Send current active users to the joining user + const activeUsers = Array.from(boardConnections.get(boardId)) + .filter(s => s.id !== socket.id) + .map(s => ({ + userId: s.userId, + userName: s.userName, + socketId: s.id + })); + + socket.emit('board_joined', { + boardId, + activeUsers, + userCount: boardConnections.get(boardId).size + }); + + console.log(`User ${socket.userEmail} joined board ${boardId}`); + } catch (error) { + console.error('Error joining board:', error); + socket.emit('error', { message: 'Failed to join board' }); + } + }); + + // Handle new stroke creation + socket.on('stroke_start', async (data) => { + try { + const { boardId, stroke } = data; + const currentBoard = userBoards.get(socket.id); + + if (currentBoard !== boardId) { + socket.emit('error', { message: 'Not joined to this board' }); + return; + } + + // Broadcast to other users in the board + socket.to(boardId).emit('stroke_start', { + stroke, + userId: socket.userId, + userName: socket.userName + }); + } catch (error) { + console.error('Error handling stroke start:', error); + } + }); + + // Handle stroke updates (while drawing) + socket.on('stroke_update', async (data) => { + try { + const { boardId, stroke } = data; + const currentBoard = userBoards.get(socket.id); + + if (currentBoard !== boardId) { + socket.emit('error', { message: 'Not joined to this board' }); + return; + } + + // Broadcast to other users in the board + socket.to(boardId).emit('stroke_update', { + stroke, + userId: socket.userId, + userName: socket.userName + }); + } catch (error) { + console.error('Error handling stroke update:', error); + } + }); + + // Handle stroke completion and save to database + socket.on('stroke_end', async (data) => { + try { + const { boardId, stroke } = data; + const currentBoard = userBoards.get(socket.id); + + if (currentBoard !== boardId) { + socket.emit('error', { message: 'Not joined to this board' }); + return; + } + + // Save stroke to database + const savedStroke = await prisma.stroke.create({ + data: { + tool: stroke.tool || 'pen', + color: stroke.color || 'black', + strokeWidth: stroke.strokeWidth || 2, + points: JSON.stringify(stroke.points), + boardId: boardId + } + }); + + // Broadcast to all users in the board (including sender) + io.to(boardId).emit('stroke_saved', { + stroke: { + ...savedStroke, + points: JSON.parse(savedStroke.points) + }, + userId: socket.userId, + userName: socket.userName + }); + } catch (error) { + console.error('Error saving stroke:', error); + socket.emit('error', { message: 'Failed to save stroke' }); + } + }); + + // Handle stroke deletion + socket.on('stroke_delete', async (data) => { + try { + const { boardId, strokeId } = data; + const currentBoard = userBoards.get(socket.id); + + if (currentBoard !== boardId) { + socket.emit('error', { message: 'Not joined to this board' }); + return; + } + + // Delete from database + await prisma.stroke.delete({ + where: { + id: strokeId, + board: { + userId: socket.userId, + isDeleted: false + } + } + }); + + // Broadcast to all users in the board + io.to(boardId).emit('stroke_deleted', { + strokeId, + userId: socket.userId, + userName: socket.userName + }); + } catch (error) { + console.error('Error deleting stroke:', error); + socket.emit('error', { message: 'Failed to delete stroke' }); + } + }); + + // Handle board clear + socket.on('board_clear', async (data) => { + try { + const { boardId } = data; + const currentBoard = userBoards.get(socket.id); + + if (currentBoard !== boardId) { + socket.emit('error', { message: 'Not joined to this board' }); + return; + } + + // Clear all strokes from database + await prisma.stroke.deleteMany({ + where: { + boardId: boardId, + board: { + userId: socket.userId, + isDeleted: false + } + } + }); + + // Broadcast to all users in the board + io.to(boardId).emit('board_cleared', { + userId: socket.userId, + userName: socket.userName + }); + } catch (error) { + console.error('Error clearing board:', error); + socket.emit('error', { message: 'Failed to clear board' }); + } + }); + + // Handle cursor movement for showing other users' cursors + socket.on('cursor_move', (data) => { + try { + const { boardId, x, y } = data; + const currentBoard = userBoards.get(socket.id); + + if (currentBoard !== boardId) { + return; + } + + // Broadcast cursor position to other users + socket.to(boardId).emit('cursor_moved', { + userId: socket.userId, + userName: socket.userName, + x, + y + }); + } catch (error) { + console.error('Error handling cursor move:', error); + } + }); + + + // Handle disconnection + socket.on('disconnect', () => { + console.log(`User disconnected: ${socket.id}`); + + const boardId = userBoards.get(socket.id); + if (boardId) { + // Remove from board connections + const connections = boardConnections.get(boardId); + if (connections) { + connections.delete(socket); + if (connections.size === 0) { + boardConnections.delete(boardId); + } + } + + // Notify other users in the board + socket.to(boardId).emit('user_left', { + userId: socket.userId, + userName: socket.userName, + socketId: socket.id + }); + } + + userBoards.delete(socket.id); + }); + + // Handle connection errors + socket.on('error', (error) => { + console.error(`Socket error for ${socket.id}:`, error); + }); +}; + +module.exports = socketHandler; \ No newline at end of file