diff --git a/api/Dockerfile b/api/Dockerfile index 0fafd47..4ccd361 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -1,11 +1,8 @@ -FROM node:20-slim -RUN corepack enable && corepack prepare pnpm@latest - +FROM node:20-alpine WORKDIR /app - +RUN corepack enable && corepack prepare pnpm@9.15.0 --activate COPY package.json pnpm-lock.yaml ./ RUN pnpm install --frozen-lockfile - COPY . . EXPOSE 3000 -CMD ["node", "src/index.js"] \ No newline at end of file +CMD ["pnpm", "exec", "nodemon", "src/index.js"] \ No newline at end of file diff --git a/api/package.json b/api/package.json index 6f63e92..f37f457 100644 --- a/api/package.json +++ b/api/package.json @@ -1,18 +1,25 @@ { "name": "api", + "type": "module", "version": "1.0.0", - "description": "", - "main": "index.js", + "private": true, + "packageManager": "pnpm@9.15.0", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "dev": "nodemon src/index.js", + "start": "node src/index.js" }, - "keywords": [], - "author": "", - "license": "ISC", - "packageManager": "pnpm@11.1.3", "dependencies": { + "@influxdata/influxdb-client": "^1.35.0", + "@msgpack/msgpack": "^3.1.2", + "cookie-parser": "^1.4.7", + "cors": "^2.8.5", "express": "^5.2.1", + "helmet": "^8.1.0", "ioredis": "^5.10.1", - "pg": "^8.21.0" + "pg": "^8.21.0", + "zod": "^4.4.3" + }, + "devDependencies": { + "nodemon": "^3.1.14" } -} +} \ No newline at end of file diff --git a/api/pnpm-lock.yaml b/api/pnpm-lock.yaml index 0419ab9..769e5cb 100644 --- a/api/pnpm-lock.yaml +++ b/api/pnpm-lock.yaml @@ -8,29 +8,78 @@ importers: .: dependencies: + '@influxdata/influxdb-client': + specifier: ^1.35.0 + version: 1.35.0 + '@msgpack/msgpack': + specifier: ^3.1.2 + version: 3.1.3 + cookie-parser: + specifier: ^1.4.7 + version: 1.4.7 + cors: + specifier: ^2.8.5 + version: 2.8.6 express: specifier: ^5.2.1 version: 5.2.1 + helmet: + specifier: ^8.1.0 + version: 8.1.0 ioredis: specifier: ^5.10.1 version: 5.10.1 pg: specifier: ^8.21.0 version: 8.21.0 + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + nodemon: + specifier: ^3.1.14 + version: 3.1.14 packages: + '@influxdata/influxdb-client@1.35.0': + resolution: {integrity: sha512-woWMi8PDpPQpvTsRaUw4Ig+nOGS/CWwAwS66Fa1Vr/EkW+NEwxI8YfPBsdBMn33jK2Y86/qMiiuX/ROHIkJLTw==} + '@ioredis/commands@1.5.1': resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==} + '@msgpack/msgpack@3.1.3': + resolution: {integrity: sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==} + engines: {node: '>= 18'} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + body-parser@2.2.2: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -43,6 +92,10 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + cluster-key-slot@1.1.2: resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} engines: {node: '>=0.10.0'} @@ -59,6 +112,13 @@ packages: resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} engines: {node: '>=18'} + cookie-parser@1.4.7: + resolution: {integrity: sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==} + engines: {node: '>= 0.8.0'} + + cookie-signature@1.0.6: + resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -67,6 +127,10 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -118,6 +182,10 @@ packages: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + finalhandler@2.1.1: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} @@ -130,6 +198,11 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -141,10 +214,18 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -153,6 +234,10 @@ packages: resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} engines: {node: '>= 0.4'} + helmet@8.1.0: + resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==} + engines: {node: '>=18.0.0'} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -161,6 +246,9 @@ packages: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} + ignore-by-default@1.0.1: + resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==} + inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -172,6 +260,22 @@ packages: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} @@ -201,6 +305,10 @@ packages: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -208,6 +316,19 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} + nodemon@3.1.14: + resolution: {integrity: sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==} + engines: {node: '>=10'} + hasBin: true + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -260,6 +381,10 @@ packages: pgpass@1.0.5: resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + postgres-array@2.0.0: resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} engines: {node: '>=4'} @@ -280,6 +405,9 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + pstree.remy@1.1.8: + resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} + qs@6.15.2: resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} engines: {node: '>=0.6'} @@ -292,6 +420,10 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + redis-errors@1.2.0: resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} engines: {node: '>=4'} @@ -307,6 +439,11 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + semver@7.8.0: + resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==} + engines: {node: '>=10'} + hasBin: true + send@1.2.1: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} @@ -334,6 +471,10 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + simple-update-notifier@2.0.0: + resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} + engines: {node: '>=10'} + split2@4.2.0: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} @@ -345,14 +486,29 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + touch@3.1.1: + resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==} + hasBin: true + type-is@2.1.0: resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} engines: {node: '>= 18'} + undefsafe@2.0.5: + resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==} + unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} @@ -368,20 +524,36 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + snapshots: + '@influxdata/influxdb-client@1.35.0': {} + '@ioredis/commands@1.5.1': {} + '@msgpack/msgpack@3.1.3': {} + accepts@2.0.0: dependencies: mime-types: 3.0.2 negotiator: 1.0.0 + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + balanced-match@4.0.4: {} + + binary-extensions@2.3.0: {} + body-parser@2.2.2: dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 @@ -391,6 +563,14 @@ snapshots: transitivePeerDependencies: - supports-color + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + bytes@3.1.2: {} call-bind-apply-helpers@1.0.2: @@ -403,6 +583,18 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + cluster-key-slot@1.1.2: {} content-disposition@1.1.0: {} @@ -411,13 +603,27 @@ snapshots: content-type@2.0.0: {} + cookie-parser@1.4.7: + dependencies: + cookie: 0.7.2 + cookie-signature: 1.0.6 + + cookie-signature@1.0.6: {} + cookie-signature@1.2.2: {} cookie@0.7.2: {} - debug@4.4.3: + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + debug@4.4.3(supports-color@5.5.0): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 5.5.0 denque@2.1.0: {} @@ -453,7 +659,7 @@ snapshots: content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 @@ -478,9 +684,13 @@ snapshots: transitivePeerDependencies: - supports-color + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + finalhandler@2.1.1: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -493,6 +703,9 @@ snapshots: fresh@2.0.0: {} + fsevents@2.3.3: + optional: true + function-bind@1.1.2: {} get-intrinsic@1.3.0: @@ -513,14 +726,22 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.1 + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + gopd@1.2.0: {} + has-flag@3.0.0: {} + has-symbols@1.1.0: {} hasown@2.0.3: dependencies: function-bind: 1.1.2 + helmet@8.1.0: {} + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -533,13 +754,15 @@ snapshots: dependencies: safer-buffer: 2.1.2 + ignore-by-default@1.0.1: {} + inherits@2.0.4: {} ioredis@5.10.1: dependencies: '@ioredis/commands': 1.5.1 cluster-key-slot: 1.1.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) denque: 2.1.0 lodash.defaults: 4.2.0 lodash.isarguments: 3.1.0 @@ -551,6 +774,18 @@ snapshots: ipaddr.js@1.9.1: {} + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + is-promise@4.0.0: {} lodash.defaults@4.2.0: {} @@ -569,10 +804,31 @@ snapshots: dependencies: mime-db: 1.54.0 + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 + ms@2.1.3: {} negotiator@1.0.0: {} + nodemon@3.1.14: + dependencies: + chokidar: 3.6.0 + debug: 4.4.3(supports-color@5.5.0) + ignore-by-default: 1.0.1 + minimatch: 10.2.5 + pstree.remy: 1.1.8 + semver: 7.8.0 + simple-update-notifier: 2.0.0 + supports-color: 5.5.0 + touch: 3.1.1 + undefsafe: 2.0.5 + + normalize-path@3.0.0: {} + + object-assign@4.1.1: {} + object-inspect@1.13.4: {} on-finished@2.4.1: @@ -622,6 +878,8 @@ snapshots: dependencies: split2: 4.2.0 + picomatch@2.3.2: {} + postgres-array@2.0.0: {} postgres-bytea@1.0.1: {} @@ -637,6 +895,8 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + pstree.remy@1.1.8: {} + qs@6.15.2: dependencies: side-channel: 1.1.0 @@ -650,6 +910,10 @@ snapshots: iconv-lite: 0.7.2 unpipe: 1.0.0 + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 + redis-errors@1.2.0: {} redis-parser@3.0.0: @@ -658,7 +922,7 @@ snapshots: router@2.2.0: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -668,9 +932,11 @@ snapshots: safer-buffer@2.1.2: {} + semver@7.8.0: {} + send@1.2.1: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -723,20 +989,36 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + simple-update-notifier@2.0.0: + dependencies: + semver: 7.8.0 + split2@4.2.0: {} standard-as-callback@2.1.0: {} statuses@2.0.2: {} + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + toidentifier@1.0.1: {} + touch@3.1.1: {} + type-is@2.1.0: dependencies: content-type: 2.0.0 media-typer: 1.1.0 mime-types: 3.0.2 + undefsafe@2.0.5: {} + unpipe@1.0.0: {} vary@1.1.2: {} @@ -744,3 +1026,5 @@ snapshots: wrappy@1.0.2: {} xtend@4.0.2: {} + + zod@4.4.3: {} diff --git a/api/src/core/cors.js b/api/src/core/cors.js new file mode 100644 index 0000000..0f258b1 --- /dev/null +++ b/api/src/core/cors.js @@ -0,0 +1,16 @@ +import cors from 'cors'; + +const dev = { + origin: true, + credentials: true, +}; + +const prod = { + origin: (process.env.ALLOWED_ORIGINS ?? '').split(','), + credentials: true, + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'X-Internal-Token'], +}; + +export const corsMiddleware = cors(process.env.NODE_ENV === 'production' ? prod : dev); + diff --git a/api/src/core/msgpackcore.js b/api/src/core/msgpackcore.js new file mode 100644 index 0000000..b86333c --- /dev/null +++ b/api/src/core/msgpackcore.js @@ -0,0 +1,37 @@ +import { encode, decode } from '@msgpack/msgpack'; + +/* + Un middleware da interporre in una richiesta HTTP che converte il corpo della richiesta in un buffer MSGPack compatto. +*/ +export function bodyToMSGPack() { + return (req, _res, next) => { + if (!req.is('application/msgpack')) return next(); + const chunks = []; + req.on('data', chunk => chunks.push(chunk)); + req.on('end', () => { + try { + req.body = decode(Buffer.concat(chunks)); + next(); + } catch (e) { + next(new Error('bad_msgpack_error')); + } + }) + } +} + +/* + Invia un oggetto come risposta in formato JSON o MSGPack, a seconda delle preferenze del client, specificate nelle intestazioni Accept. +*/ +export function send(req, res, obj, status = 200) { + if (req.accepts(['json', 'application/msgpack']) === 'application/msgpack') { + return sendAsMsgpack(res, obj, status); + } + res.status(status).json(obj); +} + +/* + Invia un oggetto come risposta in formato MSGPack. +*/ +export function sendAsMsgpack(res, obj, status = 200) { + res.status(status).type('application/msgpack').send(Buffer.from(encode(obj))); +} diff --git a/api/src/core/rulesetscore.js b/api/src/core/rulesetscore.js new file mode 100644 index 0000000..5b197b2 --- /dev/null +++ b/api/src/core/rulesetscore.js @@ -0,0 +1,111 @@ +import { rulesets } from "../data/db.js"; +import { redis } from "../data/redis.js"; + +const kinds = new Set([ + 'telemetry', + 'forecasts' +]); + +/* + Esegue un controllo sul tipo di ruleset ricevuto per verificare che sia valido e evitare SQL injection +*/ +function checkKind(kind) { + if (!kinds.has(kind)) throw new Error(`Invalid kind: ${kind}`); +} + + +/* + Restituisce le versioni di un ruleset. +*/ +export async function getVersions(kind) { + checkKind(kind); + const { rows } = await rulesets.query(`select id, version_major, version_minor, version_patch, is_active, created_at, deprecated_at from ${kind} order by version_major desc, version_minor desc, version_patch desc`) + return rows; +} + +/* + Restituisce le versioni attive di un ruleset. +*/ +export async function getActive(kind) { + checkKind(kind); + const { rows } = await rulesets.query(`select id, version_major, version_minor, version_patch, content, created_at from ${kind} where is_active = true limit 1`); + return rows[0] ?? null; +} + +/* + Restituisce una versione di un ruleset dato il suo ID. +*/ +export async function getByID(kind, id) { + checkKind(kind); + const { rows } = await rulesets.query(`select * from ${kind} where id = $1`, [id]); + return rows[0] ?? null; +} + +/* + Crea una nuova versione di un ruleset. + @param {string} kind - Il tipo di ruleset (telemetry o forecasts). + @param {Object} content - Il contenuto della nuova versione in formaot JSON. + @param {Object} version - La versione della nuova versione con formato: { major: 1, minor: 0, patch: 0 }. + @returns {Promise} - Risultato della query. +*/ +export async function newVersion(kind, content, version) { + checkKind(kind); + let { major, minor, patch } = version; + + if (!version) { + const { rows } = await rulesets.query(`select version_major, version_minor, version_patch from ${kind} order by version_major desc, version_minor desc, version_patch desc limit 1`); + if (rows[0]) { + major = rows[0].version_major; + minor = rows[0].version_minor; + patch = rows[0].version_patch + 1; + } else { + major = 1; + minor = 0; + patch = 0; + } + } + + const { rows } = await rulesets.query(`insert into ${kind} (version_major, version_minor, version_patch, content) values ($1, $2, $3, $4) returning *`, [major, minor, patch, content]); + return rows[0]; +} + +export async function activate(kind, id) { + checkKind(kind); + const client = await rulesets.connect(); + try { + await client.query('begin'); + await client.query(`update ${kind} set is_active = false where is_active = true`); + const r = await client.query(`update ${kind} set is_active = true where id = $1 and deprecated_at is null returning *`, [id]); + if (!r.rows[0]) { + await client.query('rollback'); + return null; + } + + await client.query('commit'); + await redis.publish(`ruleset:update:${kind}`, JSON.stringify({ id, active: true })); + return r.rows[0]; + } catch (error) { + await client.query('rollback'); + throw error; + } finally { + client.release(); + } +} + + +export async function deprecate(kind, id) { + checkKind(kind); + const client = await rulesets.connect(); + try { + await client.query('begin'); + const r = await client.query(`update ${kind} set deprecated_at = now(), is_active = case when is_active then false else is_active end where id = $1`, [id]); + await client.query('commit'); + await redis.publish(`ruleset:update:${kind}`, JSON.stringify({ id, deprecated: true })); + return r.rows[0]; + } catch (error) { + await client.query('rollback'); + throw error; + } finally { + client.release(); + } +} \ No newline at end of file diff --git a/api/src/data/db.js b/api/src/data/db.js new file mode 100644 index 0000000..d9963e9 --- /dev/null +++ b/api/src/data/db.js @@ -0,0 +1,25 @@ +import pg from 'pg'; + +const client = { + host: process.env.DB_HOST, + port: Number(process.env.DB_PORT), + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + max: 10, + idleTimeoutMillis: 30_000 +}; + +/* The sensors database client */ +const sensors = new pg.Pool({ ...client, database: 'sensors' }); + +/* The rulesets database client */ +const rulesets = new pg.Pool({ ...client, database: 'rulesets' }); + +/* The data database client */ +const data = new pg.Pool({ ...client, database: 'data' }); + +export async function query(from, text, params) { + return await from.query(text, params); +}; + +export { sensors, rulesets, data }; \ No newline at end of file diff --git a/api/src/data/influx.js b/api/src/data/influx.js new file mode 100644 index 0000000..a3f4250 --- /dev/null +++ b/api/src/data/influx.js @@ -0,0 +1,26 @@ +import { InfluxDB, Point } from '@influxdata/influxdb-client'; + +const url = process.env.INFLX_URL; +const token = process.env.INFLX_TOKEN; +const org = process.env.INFLX_ORG; + +const boatTelemetry = 'boat_telemetry'; + +const client = new InfluxDB({ url, token }); + +const writeApi = client.getWriteApi(org, boatTelemetry, 'ns', { + batchSize: 1, + flushInterval: 0, + maxRetries: 0, + maxBufferLines: 1, +}); + +/* + * Aggiunge un punto al database + */ +export async function write(point) { + writeApi.writePoint(point); + await writeApi.flush(true); +} + +export { Point }; \ No newline at end of file diff --git a/api/src/data/redis.js b/api/src/data/redis.js new file mode 100644 index 0000000..20847e4 --- /dev/null +++ b/api/src/data/redis.js @@ -0,0 +1,9 @@ +import Redis from 'ioredis'; + +const client = new Redis({ + host: process.env.REDIS_HOST, + port: Number(process.env.REDIS_PORT), + password: process.env.REDIS_PASSWORD, +}); + +export { client as redis }; \ No newline at end of file diff --git a/api/src/index.js b/api/src/index.js index ffe03ff..ea1b503 100644 --- a/api/src/index.js +++ b/api/src/index.js @@ -1,7 +1,13 @@ import express from 'express'; - const app = express(); +import { sensorsAPIs } from '../src/routes/sensors.js'; +app.use('/sensors', sensorsAPIs) + +app.get('/', (req, res) => { + res.redirect('/health') +}) + app.get('/health', (req, res) => { res.json({ service: "api", @@ -14,6 +20,6 @@ app.get('/health', (req, res) => { }); -app.listen(3000, '0.0.0.0', () => { +app.listen('3000', '0.0.0.0', () => { console.log('API started') }) diff --git a/api/src/middlewares/internalware.js b/api/src/middlewares/internalware.js new file mode 100644 index 0000000..ac54256 --- /dev/null +++ b/api/src/middlewares/internalware.js @@ -0,0 +1,20 @@ +const interalToken = process.env.INTERNAL_API_TOKEN; + +export function internalware(req, res, next) { + if (req.headers['x-internal-token'] === interalToken) { + req.internal = true; // La richiesta è interna + return next(); + } + return res.status(403).json({error: 'not-internal'}); +} + +export function userOrInternal(userware) { + return (req, res, next) => { + if (req.headers['x-internal-token'] === interalToken) { + req.internal = true; + return next(); + } + return userware(req, res, next); + }; +} + diff --git a/api/src/middlewares/userware.js b/api/src/middlewares/userware.js new file mode 100644 index 0000000..1f7d50f --- /dev/null +++ b/api/src/middlewares/userware.js @@ -0,0 +1,47 @@ +import { redis } from "../data/redis.js"; + +const authURL = process.env.AUTH_INTERNAL_URL; +const cookieName = process.env.COOKIE_NAME; +const cacheTTL = 30; + +function hashCookie(cookie) { + return crypto.createHash('sha256').update(cookie).digest('hex').slice(0, 32); +} + +export async function userware(req, res, next) { + const token = req.cookies?.[cookieName]; + if (!token) return res.status(401).json({ message: 'not authenticated' }) + + const cacheKey = `auth:cookie:${hashCookie(token)}` + const cached = await redis.get(cacheKey).catch( ()=> null); + if (cached) { + req.user = JSON.parse(cached) + return next(); + } + + try { + const r = await fetch(`${authURL}/api/users/me`, { + headers: { + cookie: `${cookieName}=${token}` + }, + }); + if (!r.ok) throw new Error('unauthorized'); + const user = await r.json(); + req.user = { + id: body.user.id, + sessionId: body.thisSession?.id + }; + await redis.set(cacheKey, JSON.stringify(req.user), 'EX', cacheTTL).catch(() => { }); + return next(); + + } catch (error) { + console.error('Userware Middleware: errore in auth:', error.message); + return res.status(503).json({ message: 'Error in auth service', error: error}) + } + +} + +//TODO: Da finire + +//TODO: Capire perche le versioni del package manager pnpm sono diverse tra i vari servizi +// TODO: Aggiungere 'private' ai package.json per rendere privati i pacchetti \ No newline at end of file diff --git a/api/src/routes/sensors.js b/api/src/routes/sensors.js new file mode 100644 index 0000000..9ac89c7 --- /dev/null +++ b/api/src/routes/sensors.js @@ -0,0 +1,38 @@ +import { Router } from "express"; +import crypto from "crypto"; +import { sensors } from "../data/db.js"; +import { redis } from "../data/redis.js"; +import { userware } from "../middlewares/userware.js"; +const router = Router(); + +//TODO: Add sensors routes + +router.get('/', async (req, res) => { + const { rows } = await sensors.query('select * from sensors') + res.json(rows) +}) + +/* + Restituisce tutti i sensori attivi e attualmente connessi da redis. +*/ +router.get('/actives', async (req, res) => { + const sensors = redis.scanStream({ match: 'sensor:online:*', count: 100 }); + const ids = [] + for await (const sensor of sensors) { + for (const key of sensor) ids.push(key.slice('sensor:online'.length)); + } + res.json({ + sensors: ids, + count: ids.length, + }) +}) + +router.get('/:id', async (req, res) => { + const { id } = req.params + const { rows } = await sensors.query('select * from sensors where id = $1', [id]) + res.json(rows[0]) +}) + + + +export { router as sensorsAPIs } \ No newline at end of file diff --git a/auth/Dockerfile b/auth/Dockerfile index acd23cc..4ccd361 100644 --- a/auth/Dockerfile +++ b/auth/Dockerfile @@ -1,6 +1,6 @@ FROM node:20-alpine WORKDIR /app -RUN corepack enable && corepack prepare pnpm@10.26.2 --activate +RUN corepack enable && corepack prepare pnpm@9.15.0 --activate COPY package.json pnpm-lock.yaml ./ RUN pnpm install --frozen-lockfile COPY . . diff --git a/auth/package.json b/auth/package.json index 4f29c51..bdcbab9 100644 --- a/auth/package.json +++ b/auth/package.json @@ -10,7 +10,7 @@ "author": "", "type": "module", "license": "ISC", - "packageManager": "pnpm@10.26.2", + "packageManager": "pnpm@9.15.0", "dependencies": { "bcrypt": "^6.0.0", "cookie-parser": "^1.4.7", diff --git a/auth/src/core/jwt.js b/auth/src/core/jwt.js index 882a84b..8e6dda9 100644 --- a/auth/src/core/jwt.js +++ b/auth/src/core/jwt.js @@ -15,11 +15,18 @@ export async function verify(token) { return jwt.verify(token, secret); } +// In dev (localhost): nessun domain → il cookie è scopato al singolo host (localhost), +// ma viene comunque inviato a tutte le porte (4001 auth, 4003 console, 4000 api). +// In prod: COOKIE_DOMAIN=.server.com → cookie condiviso fra tutti i sottodomini +// (auth.server.com, console.server.com, api.server.com). +const cookieDomain = process.env.COOKIE_DOMAIN || undefined; + export const cookieOptions = { httpOnly: true, - secure: process.env.NODE_ENV, + secure: process.env.NODE_ENV === 'production', sameSite: 'lax', path: '/', maxAge: ttl_seconds * 1000, + ...(cookieDomain ? { domain: cookieDomain } : {}), }; diff --git a/auth/src/data/redis.js b/auth/src/data/redis.js index a46f281..20847e4 100644 --- a/auth/src/data/redis.js +++ b/auth/src/data/redis.js @@ -2,7 +2,7 @@ import Redis from 'ioredis'; const client = new Redis({ host: process.env.REDIS_HOST, - port: process.env.REDIS_PORT, + port: Number(process.env.REDIS_PORT), password: process.env.REDIS_PASSWORD, }); diff --git a/auth/src/index.js b/auth/src/index.js index 8366ba9..43c1351 100644 --- a/auth/src/index.js +++ b/auth/src/index.js @@ -1,35 +1,52 @@ import express from 'express'; import cookieParser from 'cookie-parser'; +import path from 'path'; +import { fileURLToPath } from 'url'; import { authRouter } from './routes/auth.js'; +import { userAPIs } from './routes/users.js'; +import { sessionsAPIs } from './routes/sessions.js'; +import { pagesAPIs } from './routes/pages.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); const app = express(); -app.use(express.json()); +app.use(express.json({ limit: '64kb' })); app.use(cookieParser()); -app.get('/health', (req, res) => { +// Asset statici (CSS, font, JS client). Servito a /static/* +app.use('/static', express.static(path.join(__dirname, 'static'), { + maxAge: process.env.NODE_ENV === 'production' ? '30d' : 0, + fallthrough: true, +})); + +app.get('/health', (_req, res) => { res.send({ status: 'ok', service: 'auth', version: { - 'major': process.env.V_MAJOR, - 'minor': process.env.V_MINOR, - 'patch': process.env.V_PATCH, + major: process.env.V_MAJOR, + minor: process.env.V_MINOR, + patch: process.env.V_PATCH, }, timestamp: new Date().toISOString(), }); }); -// Public web pages -// app.use('/login', authRouter); -// app.use('/profile', profileRouter); -// app.use('/profile/sessions', sessionRouter); +// Pagine web pubbliche (HTML) — /login, /profile, /config.js +app.use('/', pagesAPIs); +// API JSON app.use('/api', authRouter); -// app.use('/api/users', usersRouter); -// app.use('/api/sessions', sessionRouter); -// +app.use('/api/users', userAPIs); +app.use('/api/sessions', sessionsAPIs); -app.listen('3000', '0.0.0.0', () => { +// Error handler globale +app.use((err, _req, res, _next) => { + console.error('[auth] errore non gestito:', err); + res.status(500).json({ error: 'internal_error' }); +}); + +app.listen(Number(process.env.PORT ?? 3000), '0.0.0.0', () => { console.log('Auth started'); -}) \ No newline at end of file +}); diff --git a/auth/src/middleware/auth.js b/auth/src/middleware/auth.js deleted file mode 100644 index 89ba55b..0000000 --- a/auth/src/middleware/auth.js +++ /dev/null @@ -1,35 +0,0 @@ -import { verify } from "../core/jwt"; -import { query } from "../data/db"; -import { redis } from '../data/redis'; - -const cookieName = process.env.COOKIE_NAME; - -export async function requireUserAuth(req, res, next) { - const token = req.cookies?.[cookieName]; - if (!token) return res.status(401).json({ message: 'No token' }); - - let payload; - try { - payload = await verify(token); - } catch (error) { - return res.status(401).json({ message: 'Invalid token' }); - } - - // Session - const { rows } = await query( - 'select id, user_id, expires_at from sessions where id = $1', - [payload.sessionId] - ); - if (!rows[0]) return res.status(401).json({ message: 'Invalid session' }); - - await query('update sessions set last_activity = now() where id = $1', [payload.sessionId]).catch(() => { }); - redis.set(`onlineuser:${payload.sub}`, '1', 'EX', 60).catch(() => { }); - - req.user = { - id: payload.sub, - name: payload.sessionId, - } - next(); - - -} \ No newline at end of file diff --git a/auth/src/middlewares/internalware.js b/auth/src/middlewares/internalware.js new file mode 100644 index 0000000..69077e5 --- /dev/null +++ b/auth/src/middlewares/internalware.js @@ -0,0 +1,9 @@ +const token = process.env.INTERNAL_API_TOKEN; + +export function internalware(req, res, next) { + const header = req.get('X-Internal-Token'); + if (header !== token) { + return res.status(401).json({ message: 'Unauthorized' }) + } + next(); +} diff --git a/auth/src/middlewares/userware.js b/auth/src/middlewares/userware.js new file mode 100644 index 0000000..11af21d --- /dev/null +++ b/auth/src/middlewares/userware.js @@ -0,0 +1,40 @@ +import { verify } from "../core/jwt.js"; +import { query } from "../data/db.js"; +import { redis } from '../data/redis.js'; + +const cookieName = process.env.COOKIE_NAME; + +export async function requireUserAuth(req, res, next) { + const token = req.cookies?.[cookieName]; + if (!token) return res.status(401).json({ message: 'No token' }); + + let payload; + try { + payload = await verify(token); + } catch (error) { + return res.status(401).json({ message: 'Invalid token' }); + } + + // Session + const { rows } = await query( + 'select id, user_id, expires_at from sessions where id = $1 and expires_at > now()', + [payload.sessionId] + ); + if (!rows[0]) return res.status(401).json({ message: 'Invalid session' }); + + const writeKey = `user:lastonline:${payload.sessionId}`; + const acquired = await redis.set(writeKey, '1', 'EX', 30, 'NX').catch(() => null); + if (acquired === 'OK') { + await query('update sessions set last_activity = now() where id = $1', [payload.sessionId]).catch((err) => { + console.error('auth error in last_activity update', err.message); + }); + redis.set(`user:online:${payload.sub}`, '1', 'EX', 60).catch(() => { }); + } + + req.user = { + id: payload.sub, + sessionId: payload.sessionId, + } + next(); + +} \ No newline at end of file diff --git a/auth/src/pages/login.html b/auth/src/pages/login.html index 9ffddc9..97091e5 100644 --- a/auth/src/pages/login.html +++ b/auth/src/pages/login.html @@ -1,12 +1,93 @@ - - - - Ciao - - -

Ciao

-
- -
- + + + + + + + MEB — Accedi + + +
+ +

Bentornato

+

Inserisci le tue credenziali per accedere alla console.

+ + +
+ + + diff --git a/auth/src/pages/profile.html b/auth/src/pages/profile.html new file mode 100644 index 0000000..d942048 --- /dev/null +++ b/auth/src/pages/profile.html @@ -0,0 +1,249 @@ + + + + + + + + MEB — Profilo + + +
+
+
MEB
+ + +
+ +
+
+
?
+
+

+

Caricamento…

+
+
+ +
+
+

Sessioni attive

+ +
+
+
Caricamento sessioni…
+
+
+ +
+
+

Informazioni account

+
+
+
ID utente
+
+
Sessione corrente
+
+
Ambiente
+
+
+
+
+ +
+
+ + + + diff --git a/auth/src/routes/auth.js b/auth/src/routes/auth.js index 8761895..a059fc0 100644 --- a/auth/src/routes/auth.js +++ b/auth/src/routes/auth.js @@ -3,7 +3,8 @@ import { query } from "../data/db.js"; import { hash, verify } from "../core/securitycore.js"; import { sign, cookieOptions } from "../core/jwt.js"; import crypto from "crypto"; -import {redis} from "../data/redis.js"; +import { redis } from "../data/redis.js"; +import { requireUserAuth } from "../middlewares/userware.js"; const router = Router(); const cookieName = process.env.COOKIE_NAME @@ -35,7 +36,7 @@ router.post('/login', async (req, res) => { const user = rows[0]; const ok = user ? await verify(password, user.password_hash) : false; if (!ok) { - return res.status(400).json({ message: 'Invalid username or password' }); + return res.status(401).json({ message: 'Invalid username or password' }); } const ua = req.headers['user-agent']; @@ -43,28 +44,36 @@ router.post('/login', async (req, res) => { const sessionToken = crypto.randomUUID(); const ttlDays = 360; + const expiresAt = new Date(Date.now() + ttlDays * 86_400_000); //expires in 360 days - const { rows: srow } = await query('insert into sessions (user_id, session_token, device_name, device_os, ip_address, expires_at) values ($1, $2, $3, $4, $5, $6) returning id', [user.id, sessionToken, ua.slice(0, 100), '', ip?.slice(0, 45), ttlDays]); - const session_id = srow[0].id; - const jtoken = sign({ sub: user.id, session_id }); + const { rows: srow } = await query( + `insert into sessions + (user_id, session_token, device_name, device_os, ip_address, expires_at) + values ($1, $2, $3, $4, $5, $6) + returning id, expires_at`, + [user.id, sessionToken, ua?.slice(0, 100) ?? '', 'macos', ip?.slice(0, 45), expiresAt] + ); + const sessionId = srow[0].id; + const jtoken = sign({ sub: user.id, sessionId }); - await redis.set(`usersession:${session_id}`, user.id, 'EX', ttlDays * 24 * 3600); - await redis.set(`online:${user.id}`, '1', 'EX', 60); + await redis.set(`user:session:${sessionId}`, user.id, 'EX', ttlDays * 24 * 3600); + await redis.set(`user:online:${user.id}`, '1', 'EX', 60); res.cookie(cookieName, jtoken, cookieOptions); res.json({ ok: true, user: user.id, - session: session_id + session: sessionId }); }) -router.post('/logout', async (req, res) => { - await query('delete from sessions where id = $1', [req.user.sessionID]); - await redis.del(`online:${req.user.id}`); - res.clearCookie(cookieName); +router.post('/logout', requireUserAuth, async (req, res) => { + await query('delete from sessions where id = $1', [req.user.sessionId]); + await redis.del(`user:online:${req.user.id}`); + await redis.del(`user:session:${req.user.sessionId}`); + res.clearCookie(cookieName, { path: '/' }); res.json({ loggedOut: true }); }) -export { router as authRouter }; +export { router as authRouter }; \ No newline at end of file diff --git a/auth/src/routes/pages.js b/auth/src/routes/pages.js new file mode 100644 index 0000000..a3b7ee7 --- /dev/null +++ b/auth/src/routes/pages.js @@ -0,0 +1,45 @@ +import { Router } from "express"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const pagesDirectory = path.join(__dirname, "../pages"); + +const router = Router(); + +// Redirect intelligente sulla root: se non loggato → login, altrimenti profile. +// Il check vero è fatto client-side dalla pagina target tramite /api/users/me; +// qui ci basiamo solo sulla presenza del cookie per scegliere la destinazione. +router.get('/', (req, res) => { + const cookieName = process.env.COOKIE_NAME; + if (req.cookies?.[cookieName]) return res.redirect('/profile'); + return res.redirect('/login'); +}); + +router.get('/login', (_req, res) => { + res.sendFile(path.join(pagesDirectory, 'login.html')); +}); + +router.get('/profile', (_req, res) => { + // L'auth è verificata client-side: la pagina fetch-a /api/users/me + // e se 401 redirige a /login. Pattern semplice da SPA. + res.sendFile(path.join(pagesDirectory, 'profile.html')); +}); + +// Endpoint dinamico che espone la config runtime alle pagine HTML. +// Le pagine fanno e poi leggono window.MEB_CONFIG. +// In questo modo gli URL dei servizi (console, api) sono iniettati dal server e +// cambiano automaticamente fra dev e prod senza toccare l'HTML. +router.get('/config.js', (_req, res) => { + const config = { + env: process.env.NODE_ENV || 'development', + console: process.env.CONSOLE_PUBLIC_URL || 'http://localhost:4003', + api: process.env.API_PUBLIC_URL || 'http://localhost:4000', + auth: process.env.AUTH_PUBLIC_URL || '', // vuoto = same-origin (la pagina è servita da auth) + }; + res.type('application/javascript') + .set('Cache-Control', 'no-store') + .send(`window.MEB_CONFIG = Object.freeze(${JSON.stringify(config)});`); +}); + +export { router as pagesAPIs }; diff --git a/auth/src/routes/sessions.js b/auth/src/routes/sessions.js new file mode 100644 index 0000000..899d6c2 --- /dev/null +++ b/auth/src/routes/sessions.js @@ -0,0 +1,27 @@ +import { Router } from 'express'; +import { query } from '../data/db.js'; +import { redis } from '../data/redis.js'; +import { requireUserAuth} from '../middlewares/userware.js'; + +const router = Router(); + +router.get('/', requireUserAuth, async (req, res) => { + const { rows } = await query('select id, device_name, device_os, ip_address, created_at, last_activity, expires_at, (id = $2) as is_current from sessions where user_id = $1 and expires_at > now() order by created_at desc', [req.user.id, req.user.sessionId]); + res.json(rows); +}); + +router.delete('/:id', requireUserAuth, async (req, res) => { + + const sessionID = req.params.id; + + const { rows } = await query('select id from sessions where id = $1 and user_id = $2', [sessionID, req.user.id]); + if (!rows[0]) return res.status(404).json({ error: 'session not found' }); + + await query('delete from sessions where id = $1', [sessionID]); + await redis.del(`user:session:${sessionID}`); + await redis.publish(`user:session:revoked`, sessionID); + + res.sendStatus(200); +}); + +export { router as sessionsAPIs }; diff --git a/auth/src/routes/users.js b/auth/src/routes/users.js new file mode 100644 index 0000000..8a43844 --- /dev/null +++ b/auth/src/routes/users.js @@ -0,0 +1,31 @@ +import { Router } from 'express'; +import { query } from '../data/db.js'; +import { requireUserAuth } from '../middlewares/userware.js'; + +const router = Router(); + +router.get('/me', requireUserAuth, async (req, res) => { + const { rows } = await query('select id, username, created_at from users where id = $1', [req.user.id]); + if (!rows[0]) return res.status(404).json({ message: 'User not found' }); + res.json({ + user: rows[0], + thisSession: { id: req.user.sessionId } + }); +}); + + +//ADMIN ONLY +// TODO: require admin-only auth + +router.get('/', async (req, res) => { + const { rows } = await query('select id, username, created_at from users'); + res.json(rows); +}); + +router.get('/:id', async (req, res) => { + const { rows } = await query('select id, username, created_at from users where id = $1', [req.params.id]); + if (!rows[0]) return res.status(404).json({ message: 'User not found' }); + res.json(rows[0]); +}); + +export { router as userAPIs }; \ No newline at end of file diff --git a/auth/src/static/fonts/elmssans.ttf b/auth/src/static/fonts/elmssans.ttf new file mode 100644 index 0000000..66d0dca Binary files /dev/null and b/auth/src/static/fonts/elmssans.ttf differ diff --git a/auth/src/static/styles/style.css b/auth/src/static/styles/style.css new file mode 100644 index 0000000..211c651 --- /dev/null +++ b/auth/src/static/styles/style.css @@ -0,0 +1,585 @@ +@font-face { + font-family: "Elms"; + src: url("../fonts/elmssans.ttf"); + font-weight: normal; + font-style: normal; +} + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +/* ── Variabili tema ── */ +:root { + --bg-body: #0f1117; + --bg-card: #1a1d27; + --border: #2a2d3a; + --text-primary: #f0f0f0; + --text-secondary: #6b7280; + --text-label: #9ca3af; + --input-bg: #0f1117; + --input-placeholder: #3d4150; + --accent: #4f8ef7; + --accent-hover: #3b7de8; + --accent-active: #2f6dd4; + --error: #f87171; + --shadow: rgba(0, 0, 0, 0.4); + + --grad-1: #0f1117; + --grad-2: #1a1d27; + --grad-3: #0d1520; + --grad-4: #111827; +} + +@media (prefers-color-scheme: light) { + :root { + --bg-body: #eef1f7; + --bg-card: #ffffff; + --border: #dde1ec; + --text-primary: #111827; + --text-secondary: #6b7280; + --text-label: #4b5563; + --input-bg: #f5f7fc; + --input-placeholder: #b0b8cc; + --accent: #3b7de8; + --accent-hover: #2f6dd4; + --accent-active: #2260be; + --error: #dc2626; + --shadow: rgba(0, 0, 0, 0.1); + + --grad-1: #dce8ff; + --grad-2: #eef1f7; + --grad-3: #d4e4fb; + --grad-4: #e8edf8; + } +} + +/* ── Sfondo animato ── */ +@keyframes gradientShift { + 0% { + background-position: 0% 50%; + } + 50% { + background-position: 100% 50%; + } + 100% { + background-position: 0% 50%; + } +} + +body { + font-family: "Elms", sans-serif; + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient( + 135deg, + var(--grad-1), + var(--grad-2), + var(--grad-3), + var(--grad-4) + ); + background-size: 400% 400%; + animation: gradientShift 12s ease infinite; +} + +/* ── Card ── */ +.login-container { + width: 100%; + max-width: 400px; + padding: 48px 40px; + background-color: var(--bg-card); + border-radius: 16px; + border: 1px solid var(--border); + box-shadow: 0 24px 48px var(--shadow); + transition: + background-color 0.3s, + border-color 0.3s; +} + +.login-logo { + font-size: 13px; + font-weight: bold; + letter-spacing: 4px; + color: var(--accent); + text-transform: uppercase; + margin-bottom: 24px; +} + +.login-container h1 { + font-size: 26px; + color: var(--text-primary); + margin-bottom: 8px; + transition: color 0.3s; +} + +.login-container p { + font-size: 14px; + color: var(--text-secondary); + margin-bottom: 32px; + line-height: 1.5; + transition: color 0.3s; +} + +/* ── Form ── */ +.login-form { + display: flex; + flex-direction: column; + gap: 20px; +} + +.input-group { + display: flex; + flex-direction: column; + gap: 6px; +} + +.input-group label { + font-size: 13px; + color: var(--text-label); + letter-spacing: 0.3px; + transition: color 0.3s; +} + +.input-group input { + width: 100%; + padding: 12px 14px; + background-color: var(--input-bg); + border: 1px solid var(--border); + border-radius: 8px; + color: var(--text-primary); + font-family: "Elms", sans-serif; + font-size: 15px; + outline: none; + transition: + border-color 0.2s, + background-color 0.3s, + color 0.3s; +} + +.input-group input::placeholder { + color: var(--input-placeholder); +} + +.input-group input:focus { + border-color: var(--accent); +} + +.error-message { + font-size: 13px; + color: var(--error); + min-height: 18px; + transition: color 0.3s; +} + +/* ── Bottone ── */ +@keyframes btnPulse { + 0% { + box-shadow: 0 0 0 0 rgba(79, 142, 247, 0.5); + } + 70% { + box-shadow: 0 0 0 10px rgba(79, 142, 247, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(79, 142, 247, 0); + } +} + +@keyframes btnSpin { + to { + transform: rotate(360deg); + } +} + +.btn-login { + width: 100%; + padding: 13px; + background-color: var(--accent); + color: #fff; + border: none; + border-radius: 8px; + font-family: "Elms", sans-serif; + font-size: 15px; + cursor: pointer; + position: relative; + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + transition: + background-color 0.2s, + transform 0.15s, + box-shadow 0.2s; +} + +.btn-login::after { + content: ""; + position: absolute; + width: 100%; + height: 100%; + background: rgba(255, 255, 255, 0.08); + top: 0; + left: -100%; + transition: left 0.3s ease; +} + +.btn-login:hover::after { + left: 0; +} + +.btn-login:hover { + background-color: var(--accent-hover); + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(79, 142, 247, 0.35); +} + +.btn-login:active { + background-color: var(--accent-active); + transform: translateY(0px); + box-shadow: none; + animation: btnPulse 0.4s ease-out; +} + +.btn-login:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.btn-spinner { + width: 16px; + height: 16px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-top-color: #fff; + border-radius: 50%; + animation: btnSpin 0.7s linear infinite; + display: none; + flex-shrink: 0; +} + +.btn-login.loading .btn-spinner { + display: block; +} + +.btn-login.loading .btn-label { + opacity: 0.7; +} + +/* ════════════════════════════════════════════════════════════ + PAGINA PROFILO + ════════════════════════════════════════════════════════════ */ + +body.page-profile { + display: block; + align-items: stretch; + justify-content: stretch; +} + +.profile-shell { + max-width: 960px; + margin: 0 auto; + padding: 32px 20px 80px; +} + +/* ── Topbar ── */ +.profile-topbar { + display: flex; + align-items: center; + gap: 24px; + padding: 12px 0 28px; + border-bottom: 1px solid var(--border); + margin-bottom: 32px; +} + +.profile-brand { + font-size: 13px; + font-weight: bold; + letter-spacing: 4px; + color: var(--accent); + text-transform: uppercase; +} + +.profile-nav { + display: flex; + gap: 8px; + flex: 1; +} + +.nav-link { + padding: 8px 14px; + border-radius: 8px; + border: 1px solid var(--border); + color: var(--text-primary); + text-decoration: none; + font-size: 13px; + transition: + background 0.15s, + border-color 0.15s, + transform 0.1s; +} + +.nav-link:hover { + border-color: var(--accent); + background: var(--input-bg); + transform: translateY(-1px); +} + +.btn-ghost { + padding: 8px 16px; + background: transparent; + color: var(--text-primary); + border: 1px solid var(--border); + border-radius: 8px; + cursor: pointer; + font-family: "Elms", sans-serif; + font-size: 13px; + transition: + background 0.15s, + border-color 0.15s, + color 0.15s; +} + +.btn-ghost:hover { + border-color: var(--error); + color: var(--error); +} + +/* ── Main grid ── */ +.profile-main { + display: grid; + gap: 24px; +} + +/* ── Card base ── */ +.card { + background-color: var(--bg-card); + border: 1px solid var(--border); + border-radius: 16px; + padding: 28px; + box-shadow: 0 8px 24px var(--shadow); +} + +.card-header { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: 20px; +} + +.card-header h2 { + font-size: 16px; + font-weight: 500; + color: var(--text-primary); +} + +.muted { + color: var(--text-secondary); + font-size: 13px; +} + +/* ── Hero ── */ +.profile-hero { + display: flex; + align-items: center; + gap: 24px; +} + +.avatar { + width: 72px; + height: 72px; + border-radius: 50%; + background: linear-gradient( + 135deg, + var(--accent), + var(--accent-active) + ); + color: #fff; + display: flex; + align-items: center; + justify-content: center; + font-size: 30px; + font-weight: bold; + flex-shrink: 0; + box-shadow: 0 4px 14px rgba(79, 142, 247, 0.35); +} + +.hero-info h1 { + font-size: 24px; + color: var(--text-primary); + margin-bottom: 4px; +} + +/* ── Sessions list ── */ +.sessions-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.session-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 16px; + border: 1px solid var(--border); + border-radius: 10px; + background: var(--input-bg); + transition: border-color 0.15s; +} + +.session-row.is-current { + border-color: var(--accent); + background: rgba(79, 142, 247, 0.05); +} + +.session-info { + flex: 1; + min-width: 0; +} + +.session-title { + font-size: 14px; + color: var(--text-primary); + margin-bottom: 4px; + display: flex; + align-items: center; + gap: 8px; +} + +.session-meta { + font-size: 12px; + line-height: 1.6; +} + +.badge { + font-size: 10px; + padding: 2px 8px; + border-radius: 10px; + background: var(--accent); + color: #fff; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.btn-revoke { + padding: 8px 14px; + background: transparent; + color: var(--error); + border: 1px solid var(--border); + border-radius: 8px; + cursor: pointer; + font-family: "Elms", sans-serif; + font-size: 12px; + transition: + background 0.15s, + border-color 0.15s; +} + +.btn-revoke:hover:not(:disabled) { + background: rgba(248, 113, 113, 0.1); + border-color: var(--error); +} + +.btn-revoke:disabled { + opacity: 0.6; + cursor: wait; +} + +/* ── kv grid (account info) ── */ +.kv-grid { + display: grid; + grid-template-columns: 180px 1fr; + gap: 12px 20px; + font-size: 13px; +} + +.kv-grid dt { + color: var(--text-label); +} + +.kv-grid dd { + color: var(--text-primary); +} + +.mono { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; + word-break: break-all; +} + +/* ── Toast ── */ +.toast { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%) translateY(20px); + padding: 12px 20px; + border-radius: 10px; + background: var(--bg-card); + border: 1px solid var(--border); + color: var(--text-primary); + box-shadow: 0 8px 24px var(--shadow); + opacity: 0; + pointer-events: none; + transition: + opacity 0.2s, + transform 0.2s; + font-size: 13px; +} + +.toast.show { + opacity: 1; + transform: translateX(-50%) translateY(0); +} + +.toast.ok { + border-color: #22c55e; +} + +.toast.err { + border-color: var(--error); +} + +/* ── Responsive ── */ +@media (max-width: 600px) { + .profile-topbar { + flex-wrap: wrap; + gap: 12px; + } + + .profile-nav { + order: 3; + width: 100%; + } + + .profile-hero { + flex-direction: column; + text-align: center; + } + + .kv-grid { + grid-template-columns: 1fr; + gap: 4px 0; + } + + .kv-grid dt { + margin-top: 12px; + } + + .session-row { + flex-direction: column; + align-items: stretch; + } + + .btn-revoke { + width: 100%; + } +} diff --git a/console/package.json b/console/package.json new file mode 100644 index 0000000..146829f --- /dev/null +++ b/console/package.json @@ -0,0 +1,12 @@ +{ + "name": "console", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC" +} diff --git a/console/src/index.js b/console/src/index.js new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/console/src/index.js @@ -0,0 +1 @@ + diff --git a/stream/Dockerfile b/stream/Dockerfile new file mode 100644 index 0000000..c1428e9 --- /dev/null +++ b/stream/Dockerfile @@ -0,0 +1,8 @@ +FROM node:20-alpine +WORKDIR /app +RUN corepack enable && corepack prepare pnpm@9.15.0 --activate +COPY package.json ./ +RUN pnpm install +COPY . . +EXPOSE 3000 +CMD ["pnpm", "exec", "nodemon", "src/index.js"] diff --git a/stream/package.json b/stream/package.json new file mode 100644 index 0000000..71f37f8 --- /dev/null +++ b/stream/package.json @@ -0,0 +1,24 @@ +{ + "name": "stream", + "version": "1.0.0", + "description": "MEB stream service — sensor WebSocket ingest to InfluxDB", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "dev": "nodemon src/index.js" + }, + "type": "module", + "license": "ISC", + "packageManager": "pnpm@9.15.0", + "dependencies": { + "@influxdata/influxdb-client": "^1.35.0", + "@msgpack/msgpack": "^3.1.2", + "express": "^5.2.1", + "ioredis": "^5.10.1", + "pg": "^8.21.0", + "ws": "^8.18.0" + }, + "devDependencies": { + "nodemon": "^3.1.14" + } +} diff --git a/stream/src/core/securitycore.js b/stream/src/core/securitycore.js new file mode 100644 index 0000000..8e7cfc9 --- /dev/null +++ b/stream/src/core/securitycore.js @@ -0,0 +1,28 @@ +import crypto from 'crypto'; + +const SECRET = process.env.SENSOR_SECURITY_SECRET; + +/** + * Calcola l'HMAC-SHA256 del codice sensore con il secret token server-side. + * - return {String} l'hash in formato hex + */ +export function getHmac(code) { + return crypto.createHmac('sha256', SECRET || '').update(code).digest('hex'); +} + +/** + * Verifica timing-safe del codice a partire dal suo hash salvato.. + * - return {Boolean} true se il codice è valido, false altrimenti + */ +export function verify(code, hash) { + if (!code || !hash || !SECRET) return false; + try { + const computed = getHmac(code); + const a = Buffer.from(computed, 'hex'); + const b = Buffer.from(hash, 'hex'); + if (a.length !== b.length) return false; + return crypto.timingSafeEqual(a, b); + } catch { + return false; + } +} diff --git a/stream/src/core/sessioncore.js b/stream/src/core/sessioncore.js new file mode 100644 index 0000000..a0844df --- /dev/null +++ b/stream/src/core/sessioncore.js @@ -0,0 +1,26 @@ +import { queryData as data } from '../data/db.js' + +const maxTries = 10; + +/* + Generates a random, unique session ID like `s00123`. +*/ +function makeID() { + const n = Math.floor(Math.random() * 100_000).toString().padStart(5, '0'); + return `s${n}`; +} + + +/* + Creates a new session by generating a unique ID and checking for conflicts in the database. +*/ +export async function newSession() { + for (let i = 0; i < maxTries; i++) { + const id = makeID(); + const { rows } = await data(`select 1 from telemetrysessions where session_id = $1 and ended_at is null`, [id]); + if (rows.length === 0) { + return id; + } + } + throw new Error('Failed to create session'); +} \ No newline at end of file diff --git a/stream/src/data/db.js b/stream/src/data/db.js new file mode 100644 index 0000000..d476b41 --- /dev/null +++ b/stream/src/data/db.js @@ -0,0 +1,32 @@ +import pg from 'pg'; + +// Pool per il database "sensors": lookup sensori + verifica code_hash +export const sensorsDb = new pg.Pool({ + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + host: process.env.DB_HOST, + port: process.env.DB_PORT, + database: 'sensors', + max: 5, + idleTimeoutMillis: 30_000, +}); + +// Pool per il database "data": gestione tabella telemetrysessions +export const dataDb = new pg.Pool({ + user: process.env.DB_USER, + password: process.env.DB_PASSWORD, + host: process.env.DB_HOST, + port: process.env.DB_PORT, + database: 'data', + max: 10, + idleTimeoutMillis: 30_000, +}); + + +export function querySensors(text, params) { + return sensorsDb.query(text, params); +} + +export function queryData(text, params) { + return dataDb.query(text, params); +} diff --git a/stream/src/data/influx.js b/stream/src/data/influx.js new file mode 100644 index 0000000..de57c99 --- /dev/null +++ b/stream/src/data/influx.js @@ -0,0 +1,32 @@ +import { InfluxDB, Point } from '@influxdata/influxdb-client'; + +const url = process.env.INFLUX_URL; +const token = process.env.INFLUX_TOKEN; +const org = process.env.INFLUX_ORG; +const bucket = process.env.INFLUX_BUCKET; + +if (!url || !token || !org || !bucket) { + console.error('[influx] configurazione mancante — verifica INFLUX_URL/INFLUX_TOKEN/INFLUX_ORG/INFLUX_BUCKET'); +} + +const influxDB = new InfluxDB({ url, token }); + +// Configurazione write API: sincrono, no batching, no retry interno (lasciamo che il +// fallimento si propaghi così possiamo chiudere il WS e far ripartire il plugin) +const writeApi = influxDB.getWriteApi(org, bucket, 'ns', { + batchSize: 1, + flushInterval: 0, + maxRetries: 0, + maxBufferLines: 1, // niente accumulo locale +}); + +/** + * Scrive un Point su InfluxDB in modo sincrono. + * Lancia su errore — il chiamante deve gestire (chiusura WS). + */ +export async function writePoint(point) { + writeApi.writePoint(point); + await writeApi.flush(true); // true = throw on error +} + +export { Point }; diff --git a/stream/src/data/redis.js b/stream/src/data/redis.js new file mode 100644 index 0000000..d19ccae --- /dev/null +++ b/stream/src/data/redis.js @@ -0,0 +1,18 @@ +import Redis from 'ioredis'; + +const baseOpts = { + host: process.env.REDIS_HOST, + port: Number(process.env.REDIS_PORT), + password: process.env.REDIS_PASSWORD, +}; + +// Client principale: SET/GET/SETEX/GETDEL/INCR/PUBLISH +const client = new Redis(baseOpts); + +// Client dedicato per SUBSCRIBE (ioredis non permette comandi normali su un client subscribed) +const sub = new Redis(baseOpts); + +client.on('error', (e) => console.error('[redis] client error', e.message)); +sub.on('error', (e) => console.error('[redis] sub error', e.message)); + +export { client as redis, sub as redisSub }; diff --git a/stream/src/routes/connect.js b/stream/src/routes/connect.js new file mode 100644 index 0000000..f772a29 --- /dev/null +++ b/stream/src/routes/connect.js @@ -0,0 +1,44 @@ +import { Router } from 'express'; +import crypto from 'crypto'; +import { querySensors as sensors } from '../data/db.js'; +import { redis } from '../data/redis.js'; +import { verify } from '../core/securitycore.js'; + +const router = Router(); +const rateLimiter = 10; +const rateLimitWindow = 60; + +router.post('/connect', async (req, res) => { + const { sensorID, code } = req.body; + + const ip = (req.headers['x-forwarded-for']?.split(',')[0]?.trim()) || req.socket.remoteAddress || 'unknown'; + const tryKey = `streamconnect:fail:${ip}`; + const fails = Number(await redis.get(tryKey).catch(() => 0)); + if (fails >= rateLimiter) { + return res.status(429).json({ error: 'Too many failed attempts' }); + } + + if (!sensorID || !code) { + await redis.multi().incr(tryKey).expire(tryKey, rateLimitWindow).exec().catch(() => { }); + return res.status(400).json({ error: 'sensor and code are required' }); + } + + const { rows } = await sensors('select id, name, code_hash from sensors where id = $1', [sensorID]); + if (rows.length === 0) { + return res.status(404).json({ error: 'sensor not found' }); + } + if (!rows[0] || !verify(code, rows[0].code_hash)) { + await redis.multi().incr(tryKey).expire(tryKey, rateLimitWindow).exec().catch(() => { }); + return res.status(401).json({ error: 'invalid code' }); + } + + const token = crypto.randomUUID(); + await redis.set(`sensor:pending:${token}`, rows[0].id, 'EX', 5); + res.json({ + token, + expiresIn: 5 + }) + +}) + +export { router as connectsAPI } \ No newline at end of file diff --git a/stream/src/ws/connection.js b/stream/src/ws/connection.js new file mode 100644 index 0000000..cbcb985 --- /dev/null +++ b/stream/src/ws/connection.js @@ -0,0 +1,3 @@ +import { encode, decode } from '@msgpack/msgpack'; +import { queryData as datas } from '../data/db.js'; +import { write, point } from ''; diff --git a/stream/src/ws/upgrade.js b/stream/src/ws/upgrade.js new file mode 100644 index 0000000..ce0d8cb --- /dev/null +++ b/stream/src/ws/upgrade.js @@ -0,0 +1,44 @@ +import { URL } from 'url'; +import { redis } from '../data/redis'; +import { querySensors as sensors } from '../data/db'; + +export function buildUpgradeHandler(wss) { + return async function upgradeHandler(req, socket, head) { + try { + const url = new URL(req.url, 'http://localhost'); + const token = url.searchParams.get('token'); + + if (!token) { + socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n'); + return socket.destroy(); + } + + const pendingSensor = await redis.getdel(`sensors:pending:${token}`); + if (!pendingSensor) { + socket.write('HTTP/1.1 404 Not Found\r\n\r\n'); + return socket.destroy(); + } + + const { rows } = await sensors('select id, name from sensors where id = $1', [pendingSensor]); + const sensor = rows[0]; + if (!sensor) { + socket.write('HTTP/1.1 404 Not Found\r\n\r\n'); + return socket.destroy(); + } + + wss.handleUpgrade(req, socket, head, (ws) => { + ws._sensor = sensor; + wss.emit('connection', ws, req); + }); + + } catch (error) { + console.error('error in upgrading conenction with sensor to ws with error: ', error); + + try { + socket.destroy(); + } catch (destroyError) { + console.error('error destroying socket: ', destroyError); + } + } + }; +} \ No newline at end of file