Compare commits
2 Commits
main
...
auth-servi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0cb98bd06 | ||
|
|
47faa41eb9 |
@@ -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"]
|
||||
CMD ["pnpm", "exec", "nodemon", "src/index.js"]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
298
api/pnpm-lock.yaml
generated
298
api/pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
16
api/src/core/cors.js
Normal file
16
api/src/core/cors.js
Normal file
@@ -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);
|
||||
|
||||
37
api/src/core/msgpackcore.js
Normal file
37
api/src/core/msgpackcore.js
Normal file
@@ -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)));
|
||||
}
|
||||
111
api/src/core/rulesetscore.js
Normal file
111
api/src/core/rulesetscore.js
Normal file
@@ -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<object>} - 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();
|
||||
}
|
||||
}
|
||||
25
api/src/data/db.js
Normal file
25
api/src/data/db.js
Normal file
@@ -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 };
|
||||
26
api/src/data/influx.js
Normal file
26
api/src/data/influx.js
Normal file
@@ -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 };
|
||||
9
api/src/data/redis.js
Normal file
9
api/src/data/redis.js
Normal file
@@ -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 };
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
20
api/src/middlewares/internalware.js
Normal file
20
api/src/middlewares/internalware.js
Normal file
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
47
api/src/middlewares/userware.js
Normal file
47
api/src/middlewares/userware.js
Normal file
@@ -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
|
||||
38
api/src/routes/sensors.js
Normal file
38
api/src/routes/sensors.js
Normal file
@@ -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 }
|
||||
@@ -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 . .
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,35 +1,53 @@
|
||||
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';
|
||||
|
||||
|
||||
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
|
||||
import { pagesAPIs } from './routes/pages.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(3000, '0.0.0.0', () => {
|
||||
console.log('Auth started');
|
||||
})
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
}
|
||||
9
auth/src/middlewares/internalware.js
Normal file
9
auth/src/middlewares/internalware.js
Normal file
@@ -0,0 +1,9 @@
|
||||
const token = process.env.INTERNAL_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();
|
||||
}
|
||||
40
auth/src/middlewares/userware.js
Normal file
40
auth/src/middlewares/userware.js
Normal file
@@ -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();
|
||||
|
||||
}
|
||||
@@ -1,12 +1,100 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Ciao</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Ciao</h1>
|
||||
<form>
|
||||
|
||||
</form>
|
||||
</body>
|
||||
<!doctype html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="stylesheet" href="../static/styles/style.css" />
|
||||
<title>MEB — Accedi</title>
|
||||
</head>
|
||||
<body>
|
||||
<div class="login-container">
|
||||
<!--<div class="login-logo">MEB</div>-->
|
||||
<div class="login-logo">
|
||||
<img src="../static/imgs/logo.svg" alt="Logo MEB" width="60" height="60">
|
||||
</div>
|
||||
<h1>Accedi</h1>
|
||||
<p>Inserisci le tue credenziali per accedere ai servizi.</p>
|
||||
|
||||
<form class="login-form" id="loginForm">
|
||||
<div class="input-group">
|
||||
<label for="username">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
autocomplete="username"
|
||||
/>
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label for="password">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
<div class="error-message" id="errorMsg"></div>
|
||||
<button type="submit" class="btn-login" id="loginBtn">
|
||||
<span class="btn-spinner"></span>
|
||||
<span class="btn-label">Accedi</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document
|
||||
.getElementById("loginForm")
|
||||
.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const errorMsg = document.getElementById("errorMsg");
|
||||
const btn = document.getElementById("loginBtn");
|
||||
errorMsg.textContent = "";
|
||||
|
||||
btn.disabled = true;
|
||||
btn.classList.add("loading");
|
||||
|
||||
const username = document.getElementById("username").value;
|
||||
const password = document.getElementById("password").value;
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await fetch("/api/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
} catch (err) {
|
||||
btn.disabled = false;
|
||||
btn.classList.remove("loading");
|
||||
errorMsg.textContent = "Errore di rete. Riprova.";
|
||||
return;
|
||||
}
|
||||
|
||||
btn.disabled = false;
|
||||
btn.classList.remove("loading");
|
||||
|
||||
if (res.ok) {
|
||||
// Redirect al profilo. Il cookie httpOnly è già stato impostato
|
||||
// dal server nella response; sarà valido anche per console
|
||||
// (stesso host:port in dev, stesso dominio padre in prod).
|
||||
const next = new URLSearchParams(location.search).get(
|
||||
"next",
|
||||
);
|
||||
window.location.href = next || "/profile";
|
||||
} else {
|
||||
let data = {};
|
||||
try {
|
||||
data = await res.json();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
errorMsg.textContent =
|
||||
data.message || "Errore durante il login.";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
258
auth/src/pages/profile.html
Normal file
258
auth/src/pages/profile.html
Normal file
@@ -0,0 +1,258 @@
|
||||
<!doctype html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="stylesheet" href="../static/styles/style.css" />
|
||||
<title>MEB — Profilo</title>
|
||||
</head>
|
||||
<body class="page-profile">
|
||||
<div class="profile-shell">
|
||||
<header class="profile-topbar">
|
||||
<img src="../static/imgs/logo.svg" alt="Logo MEB" width="30" height="30">
|
||||
<nav class="profile-nav" id="quickLinks">
|
||||
<!-- popolato da JS in base a window.MEB_CONFIG -->
|
||||
</nav>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-ghost"
|
||||
id="logoutBtn"
|
||||
title="Esci"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<main class="profile-main">
|
||||
<section class="profile-hero card">
|
||||
<div class="avatar" id="avatar">?</div>
|
||||
<div class="hero-info">
|
||||
<h1 id="username">…</h1>
|
||||
<p class="muted" id="memberSince">Caricamento…</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-header">
|
||||
<h2>Sessioni attive</h2>
|
||||
<span class="muted" id="sessionsCount"></span>
|
||||
</div>
|
||||
<div id="sessionsList" class="sessions-list">
|
||||
<div class="muted">Caricamento sessioni…</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-header">
|
||||
<h2>Informazioni account</h2>
|
||||
</div>
|
||||
<dl class="kv-grid">
|
||||
<dt>ID utente</dt>
|
||||
<dd id="userId" class="mono">—</dd>
|
||||
<dt>Sessione corrente</dt>
|
||||
<dd id="currentSessionId" class="mono">—</dd>
|
||||
<dt>Ambiente</dt>
|
||||
<dd id="envBadge">—</dd>
|
||||
</dl>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const cfg = {
|
||||
env: process.env.NODE_ENV,
|
||||
console: process.env.CONSOLE_INTERNAL_URL,
|
||||
api: process.env.API_INTERNAL_URL,
|
||||
ml: process.env.ML_INTERNAL_URL,
|
||||
auth: process.env.AUTH_INTERNAL_URL,
|
||||
}
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const fmtDate = (iso) => {
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
return new Date(iso).toLocaleString("it-IT", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
};
|
||||
|
||||
const toast = (msg, kind = "info") => {
|
||||
const el = $("toast");
|
||||
el.textContent = msg;
|
||||
el.className = "toast show " + kind;
|
||||
clearTimeout(toast._t);
|
||||
toast._t = setTimeout(() => (el.className = "toast"), 2500);
|
||||
};
|
||||
|
||||
// ──────────────────────────────────────────────────────
|
||||
// Quick links verso gli altri servizi (env-aware)
|
||||
// ──────────────────────────────────────────────────────
|
||||
function renderQuickLinks() {
|
||||
const nav = $("quickLinks");
|
||||
const links = [];
|
||||
if (cfg.console)
|
||||
links.push({ label: "Console", url: cfg.console });
|
||||
if (cfg.api) links.push({ label: "ML", url: cfg.ml });
|
||||
nav.innerHTML = links
|
||||
.map(
|
||||
(l) =>
|
||||
`<a class="nav-link" href="${l.url}" target="_blank" rel="noopener">${l.label} ↗</a>`,
|
||||
)
|
||||
.join("");
|
||||
$("envBadge").textContent = cfg.env || "development";
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────
|
||||
// Carica dati utente. Se 401 → redirect a /login?next=/profile
|
||||
// ──────────────────────────────────────────────────────
|
||||
async function loadMe() {
|
||||
const r = await fetch("/api/users/me", {
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (r.status === 401) {
|
||||
location.href =
|
||||
"/login?next=" + encodeURIComponent(location.pathname);
|
||||
return null;
|
||||
}
|
||||
if (!r.ok) throw new Error("Errore caricamento profilo");
|
||||
return r.json();
|
||||
}
|
||||
|
||||
async function loadSessions() {
|
||||
const r = await fetch("/api/sessions", {
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (!r.ok) throw new Error("Errore caricamento sessioni");
|
||||
return r.json();
|
||||
}
|
||||
|
||||
function renderProfile(payload) {
|
||||
const user = payload.user || {};
|
||||
const thisSession = payload.thisSession || {};
|
||||
$("username").textContent = user.username || "—";
|
||||
$("memberSince").textContent =
|
||||
"Creato il " + fmtDate(user.created_at);
|
||||
$("userId").textContent = user.id || "—";
|
||||
$("currentSessionId").textContent = thisSession.id || "—";
|
||||
const initial = (user.username || "?")
|
||||
.slice(0, 1)
|
||||
.toUpperCase();
|
||||
$("avatar").textContent = initial;
|
||||
}
|
||||
|
||||
function renderSessions(sessions) {
|
||||
const list = $("sessionsList");
|
||||
$("sessionsCount").textContent =
|
||||
sessions.length +
|
||||
" session" +
|
||||
(sessions.length === 1 ? "e" : "i");
|
||||
|
||||
if (!sessions.length) {
|
||||
list.innerHTML =
|
||||
'<div class="muted">Nessuna sessione attiva.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = sessions
|
||||
.map(
|
||||
(s) => `
|
||||
<div class="session-row ${s.is_current ? "is-current" : ""}" data-id="${s.id}">
|
||||
<div class="session-info">
|
||||
<div class="session-title">
|
||||
${escapeHtml(s.device_name || "Dispositivo sconosciuto")}
|
||||
${s.is_current ? '<span class="badge">attuale</span>' : ""}
|
||||
</div>
|
||||
<div class="session-meta muted">
|
||||
${escapeHtml(s.device_os || "—")} · ${escapeHtml(s.ip_address || "—")}
|
||||
</div>
|
||||
<div class="session-meta muted">
|
||||
Ultima attività: ${fmtDate(s.last_activity)}
|
||||
</div>
|
||||
</div>
|
||||
${
|
||||
s.is_current
|
||||
? ""
|
||||
: `<button class="btn-revoke" data-id="${s.id}">Revoca</button>`
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
list.querySelectorAll(".btn-revoke").forEach((btn) => {
|
||||
btn.addEventListener("click", () =>
|
||||
revokeSession(btn.dataset.id, btn),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(
|
||||
/[&<>"']/g,
|
||||
(c) =>
|
||||
({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
})[c],
|
||||
);
|
||||
}
|
||||
|
||||
async function revokeSession(id, btn) {
|
||||
if (!confirm("Vuoi davvero revocare questa sessione?")) return;
|
||||
btn.disabled = true;
|
||||
btn.textContent = "...";
|
||||
try {
|
||||
const r = await fetch("/api/sessions/" + id, {
|
||||
method: "DELETE",
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (!r.ok) throw new Error();
|
||||
toast("Sessione revocata", "ok");
|
||||
const sessions = await loadSessions();
|
||||
renderSessions(sessions);
|
||||
} catch {
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Revoca";
|
||||
toast("Errore durante la revoca", "err");
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await fetch("/api/logout", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
});
|
||||
} catch {
|
||||
/* ignore: redirigi comunque */
|
||||
}
|
||||
location.href = "/login";
|
||||
}
|
||||
|
||||
$("logoutBtn").addEventListener("click", logout);
|
||||
|
||||
// Bootstrap
|
||||
(async () => {
|
||||
renderQuickLinks();
|
||||
try {
|
||||
const me = await loadMe();
|
||||
if (!me) return; // già redirezionato
|
||||
renderProfile(me);
|
||||
const sessions = await loadSessions();
|
||||
renderSessions(sessions);
|
||||
} catch (err) {
|
||||
toast(err.message || "Errore", "err");
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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,39 @@ 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);
|
||||
|
||||
//TODO: Rimuovere, solo per test, basta inviare sendCode(200)
|
||||
|
||||
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 };
|
||||
46
auth/src/routes/pages.js
Normal file
46
auth/src/routes/pages.js
Normal file
@@ -0,0 +1,46 @@
|
||||
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 root: se non loggato → login, altrimenti profile.
|
||||
router.get('/', (req, res) => {
|
||||
const cookieName = process.env.COOKIE_NAME;
|
||||
if (req.cookies?.[cookieName]) return res.redirect('/profile');
|
||||
return res.redirect('/login');
|
||||
});
|
||||
|
||||
// Pagine WEB
|
||||
router.get('/login', (_req, res) => {
|
||||
res.sendFile(path.join(pagesDirectory, 'login.html'));
|
||||
});
|
||||
|
||||
router.get('/profile', (_req, res) => {
|
||||
res.sendFile(path.join(pagesDirectory, 'profile.html'));
|
||||
});
|
||||
|
||||
|
||||
//TODO: Vedere se serve davvero
|
||||
|
||||
// API di configurazione per le pagine HTML
|
||||
// Le pagine fanno <script src="/config.js"></script> 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',
|
||||
// ml: process.env.ML_PUBLIC_URL || 'http://localhost:4005',
|
||||
// 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 };
|
||||
27
auth/src/routes/sessions.js
Normal file
27
auth/src/routes/sessions.js
Normal file
@@ -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 };
|
||||
31
auth/src/routes/users.js
Normal file
31
auth/src/routes/users.js
Normal file
@@ -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 };
|
||||
BIN
auth/src/static/fonts/elmssans.ttf
Normal file
BIN
auth/src/static/fonts/elmssans.ttf
Normal file
Binary file not shown.
11
auth/src/static/imgs/logo.svg
Normal file
11
auth/src/static/imgs/logo.svg
Normal file
@@ -0,0 +1,11 @@
|
||||
<svg width="60" height="60" viewBox="0 0 165 165" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4.41268 115.182C-2.24481 105.815 -0.286055 84.9776 3.2232 74.5486C10.3874 53.2568 32.4719 37.0773 55.1657 45.4929C65.7258 49.4086 73.7361 55.616 78.603 65.8989C79.3478 67.3026 79.8223 67.5953 79.8913 68.9309C79.298 72.6449 74.0964 71.9417 70.9252 72.4412C65.0975 73.3595 60.3911 76.1645 55.8237 79.8009C50.6258 84.0946 44.3683 90.9825 40.9808 96.8873C34.9317 107.43 29.3003 123.961 13.8678 121.222C11.0472 120.722 5.60492 117.891 4.41268 115.182Z" fill="#4AB1D5"/>
|
||||
<path d="M4.41268 115.182C6.06876 112.482 8.57956 106.551 10.2436 103.42C15.7428 93.0657 23.3266 83.9577 32.5191 76.6677C46.201 65.7929 61.8319 63.9885 78.603 65.8989C79.3478 67.3026 79.8223 67.5953 79.8913 68.9309C79.298 72.6449 74.0964 71.9417 70.9252 72.4412C65.0975 73.3595 60.3911 76.1645 55.8237 79.8009C50.6258 84.0946 44.3683 90.9825 40.9808 96.8873C34.9317 107.43 29.3003 123.961 13.8678 121.222C11.0472 120.722 5.60492 117.891 4.41268 115.182Z" fill="#025492"/>
|
||||
<path d="M49.9604 4.7052C79.7744 -11.3406 131.163 15.8119 120.046 53.4879C116.6 65.1691 108.75 73.6614 98.3518 79.7457C92.3064 84.2288 92.565 72.7179 92.1158 70.289C89.76 58.7621 80.2046 49.6204 70.6012 43.0095C60.5516 36.0915 38.7257 29.6601 43.4209 13.0162C44.317 9.83926 46.756 5.90833 49.9604 4.7052Z" fill="#4AB1D5"/>
|
||||
<path d="M98.3518 79.7457C92.3064 84.2288 92.565 72.7179 92.1158 70.289C89.76 58.7621 80.2046 49.6204 70.6012 43.0095C60.5516 36.0915 38.7257 29.6601 43.4209 13.0162C44.317 9.83926 46.756 5.90833 49.9604 4.7052C52.8382 7.12963 61.4979 10.782 65.1317 13.0455C82.3846 23.7917 96.58 39.2595 98.8098 60.2267C99.8646 68.5559 98.111 73.348 98.3518 79.7457Z" fill="#025492"/>
|
||||
<path d="M65.9204 85.7895C67.0091 85.2138 67.9348 84.6711 69.0217 84.6711C72.9398 86.1186 71.0808 92.0714 72.1099 95.2869C73.5986 99.9374 76.8791 106.052 79.9865 109.783C84.2954 114.938 89.4004 119.372 95.1084 122.921C97.1012 124.182 101.169 126.588 103.382 127.392C116.08 132.003 130.562 148.765 115.05 160.09C104.949 166.488 88.0815 165.341 76.8479 162.167C39.2599 151.545 29.3155 105.145 65.9204 85.7895Z" fill="#4AB1D5"/>
|
||||
<path d="M65.9204 85.7895C67.0091 85.2138 67.9348 84.6711 69.0217 84.6711C72.9398 86.1186 71.0808 92.0714 72.1099 95.2869C73.5986 99.9374 76.8791 106.052 79.9865 109.783C84.2954 114.938 89.4004 119.372 95.1084 122.921C97.1012 124.182 101.169 126.588 103.382 127.392C116.08 132.003 130.562 148.765 115.05 160.09C112.583 158.46 107.659 156.854 104.858 155.344C85.4192 144.863 68.3788 128.929 65.4947 105.943C64.6361 99.1018 65.3858 92.6956 65.9204 85.7895Z" fill="#025492"/>
|
||||
<path d="M85.297 98.7725C84.8478 97.9461 84.6992 95.9267 84.9463 94.9635C85.4136 93.1407 89.7686 93.3906 91.6139 93.1666C100.675 92.0655 108.311 86.1518 114.524 79.821C117.314 76.9778 120.415 72.8952 122.452 69.4583C127.934 60.1292 131.229 48.8067 141.888 44.049C147.306 41.6313 157.62 44.3302 159.721 50.0298C171.559 70.1696 160.842 104.496 140.975 116.247C122.453 127.202 97.5734 119.444 86.6423 101.446C86.1574 100.649 85.6295 99.6262 85.297 98.7725Z" fill="#4AB1D5"/>
|
||||
<path d="M85.297 98.7725C84.8478 97.9461 84.6992 95.9267 84.9463 94.9635C85.4136 93.1407 89.7686 93.3906 91.6139 93.1666C100.675 92.0655 108.311 86.1518 114.524 79.821C117.314 76.9778 120.415 72.8952 122.452 69.4583C127.934 60.1292 131.229 48.8067 141.888 44.049C147.306 41.6313 157.62 44.3302 159.721 50.0298C157.87 53.3284 156.064 56.8406 154.336 60.2178C143.327 81.7362 123.356 100.3 97.6749 99.1307C95.7764 99.0443 86.2785 98.5703 85.297 98.7725Z" fill="#025492"/>
|
||||
<path d="M79.8223 72.5317C84.7401 71.3991 89.7444 74.0438 91.5712 78.7402C92.3399 80.7166 92.4684 82.8844 91.9386 84.9376C91.0221 88.4887 88.2561 91.2692 84.7052 92.2079C81.1605 93.1451 77.3855 92.1018 74.828 89.4786C72.2708 86.8554 71.329 83.0595 72.3637 79.548C73.3987 76.0365 76.2498 73.3547 79.8223 72.5317Z" fill="#025492"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
549
auth/src/static/styles/style.css
Normal file
549
auth/src/static/styles/style.css
Normal file
@@ -0,0 +1,549 @@
|
||||
@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: black;
|
||||
--foreground: white;
|
||||
--foreground-secondary: #999999;
|
||||
--foreground-tertiary: #404040;
|
||||
--foreground-quaternary: #262626;
|
||||
--primary: #51d5ff;
|
||||
--secondary: rgb(81, 213, 255, 0.3);
|
||||
--tertiary: rgb(81, 213, 255, 0.18);
|
||||
--hover: #3b7de8;
|
||||
--active: #2f6dd4;
|
||||
--danger: #de090d;
|
||||
--warning: #ff8306;
|
||||
--success: #06ff13;
|
||||
--box-shadow:
|
||||
0 8px 32px var(--tertiary), 0 0 0 1px var(--foreground-quaternary);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--bg: white;
|
||||
--foreground: black;
|
||||
--foreground-secondary: rgb(60, 60, 67, 0.6);
|
||||
--foreground-tertiary: rgb(60, 60, 67, 0.3);
|
||||
--foreground-quaternary: rgb(60, 60, 67, 0.18);
|
||||
|
||||
--primary: #00559d;
|
||||
--secondary: rgb(0, 85, 157, 0.3);
|
||||
--tertiary: rgb(0, 85, 157, 0.18);
|
||||
--hover: #056ac0;
|
||||
--active: #1355c1;
|
||||
--danger: #de090d;
|
||||
--warning: #ff8306;
|
||||
--success: #06ff13;
|
||||
--box-shadow:
|
||||
0 8px 32px rgba(0, 0, 0, 0.08),
|
||||
0 0 0 1px var(--foreground-quaternary);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 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: var(--bg);
|
||||
color: var(--foreground);
|
||||
background-size: 400% 400%;
|
||||
animation: gradientShift 12s ease infinite;
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════
|
||||
COMPONENTI CONDIVISI
|
||||
════════════════════════════════════════════════════════════ */
|
||||
|
||||
.card {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
background: var(--bg);
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
padding: 28px;
|
||||
box-shadow: var(--box-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(--foreground);
|
||||
}
|
||||
|
||||
.muted {
|
||||
font-size: 13px;
|
||||
color: var(--foreground-secondary);
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 10px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
background: var(--primary);
|
||||
color: var(--bg);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* ── Input condiviso ── */
|
||||
.input-group {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.input-group label {
|
||||
font-size: 13px;
|
||||
color: var(--foreground-tertiary);
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.input-group input {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
background: var(--bg);
|
||||
border: 0.4px solid var(--foreground-tertiary);
|
||||
border-radius: 10px;
|
||||
color: var(--foreground);
|
||||
font-family: "Elms", sans-serif;
|
||||
font-size: 15px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.input-group input::placeholder {
|
||||
color: var(--foreground-tertiary);
|
||||
}
|
||||
|
||||
.input-group input:focus {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 13px;
|
||||
color: var(--danger);
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
/* ── Animazioni bottoni ── */
|
||||
@keyframes btnPulse {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0 var(--secondary);
|
||||
}
|
||||
70% {
|
||||
box-shadow: 0 0 0 10px transparent;
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes btnSpin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Bottone ghost (es. logout, revoca) ── */
|
||||
.btn-ghost {
|
||||
padding: 8px 16px;
|
||||
background: transparent;
|
||||
color: var(--foreground);
|
||||
border: 0.4px solid var(--foreground-tertiary);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-family: "Elms", sans-serif;
|
||||
font-size: 13px;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
color 0.15s;
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
border-color: var(--danger);
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════
|
||||
PAGINA LOGIN
|
||||
════════════════════════════════════════════════════════════ */
|
||||
|
||||
.login-container {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 48px 40px;
|
||||
background: var(--bg);
|
||||
border: none;
|
||||
border-radius: 16px;
|
||||
box-shadow: var(--box-shadow);
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 4px;
|
||||
color: var(--primary);
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.login-container h1 {
|
||||
font-size: 26px;
|
||||
color: var(--foreground);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.login-container > p {
|
||||
font-size: 14px;
|
||||
color: var(--foreground-secondary);
|
||||
margin-bottom: 32px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.btn-login {
|
||||
width: 100%;
|
||||
padding: 13px;
|
||||
background: var(--primary);
|
||||
color: var(--bg);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-family: "Elms", sans-serif;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
transform 0.15s;
|
||||
}
|
||||
|
||||
.btn-login:hover {
|
||||
opacity: 0.85;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-login:active {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
animation: btnPulse 0.4s ease-out;
|
||||
}
|
||||
|
||||
.btn-login:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.btn-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid rgba(0, 0, 0, 0.25);
|
||||
border-top-color: var(--bg);
|
||||
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.8;
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════
|
||||
PAGINA PROFILO
|
||||
════════════════════════════════════════════════════════════ */
|
||||
|
||||
body.page-profile {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.profile-shell {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 20px 80px;
|
||||
}
|
||||
|
||||
/* ── Topbar ── */
|
||||
.profile-topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
padding-bottom: 24px;
|
||||
border-bottom: 0.4px solid var(--foreground-tertiary);
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.profile-brand {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 4px;
|
||||
color: var(--primary);
|
||||
text-transform: uppercase;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.profile-nav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
padding: 6px 14px;
|
||||
border-radius: 8px;
|
||||
border: 0.4px solid var(--foreground-tertiary);
|
||||
color: var(--foreground);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
transition:
|
||||
border-color 0.15s,
|
||||
transform 0.1s;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
border-color: var(--primary);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* ── Main grid ── */
|
||||
.profile-main {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* ── Hero ── */
|
||||
.profile-hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
color: var(--bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hero-info h1 {
|
||||
font-size: 22px;
|
||||
color: var(--foreground);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* ── Sessioni ── */
|
||||
.sessions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.session-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 14px 16px;
|
||||
border: 0.4px solid var(--foreground-tertiary);
|
||||
border-radius: 10px;
|
||||
background: var(--bg);
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.session-row.is-current {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.session-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.session-title {
|
||||
font-size: 14px;
|
||||
color: var(--foreground);
|
||||
margin-bottom: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.session-meta {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: var(--foreground-secondary);
|
||||
}
|
||||
|
||||
.btn-revoke {
|
||||
padding: 6px 14px;
|
||||
background: transparent;
|
||||
color: var(--danger);
|
||||
border: 0.4px solid var(--foreground-tertiary);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-family: "Elms", sans-serif;
|
||||
font-size: 12px;
|
||||
flex-shrink: 0;
|
||||
transition:
|
||||
background 0.15s,
|
||||
border-color 0.15s;
|
||||
}
|
||||
|
||||
.btn-revoke:hover:not(:disabled) {
|
||||
background: rgba(222, 9, 13, 0.08);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
.btn-revoke:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
/* ── Griglia chiave/valore ── */
|
||||
.kv-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 180px 1fr;
|
||||
gap: 12px 20px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.kv-grid dt {
|
||||
color: var(--foreground-tertiary);
|
||||
}
|
||||
|
||||
.kv-grid dd {
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
/* ── Toast ── */
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(20px);
|
||||
padding: 12px 20px;
|
||||
border-radius: 10px;
|
||||
background: var(--foreground-quaternary);
|
||||
border: 0.4px solid var(--foreground-tertiary);
|
||||
color: var(--foreground);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
transform 0.2s;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
|
||||
.toast.ok {
|
||||
border-color: var(--success);
|
||||
}
|
||||
.toast.err {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
|
||||
/* ── 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;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.session-row {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.btn-revoke {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
12
console/package.json
Normal file
12
console/package.json
Normal file
@@ -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"
|
||||
}
|
||||
1
console/src/index.js
Normal file
1
console/src/index.js
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
8
stream/Dockerfile
Normal file
8
stream/Dockerfile
Normal file
@@ -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"]
|
||||
24
stream/package.json
Normal file
24
stream/package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
28
stream/src/core/securitycore.js
Normal file
28
stream/src/core/securitycore.js
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
26
stream/src/core/sessioncore.js
Normal file
26
stream/src/core/sessioncore.js
Normal file
@@ -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');
|
||||
}
|
||||
32
stream/src/data/db.js
Normal file
32
stream/src/data/db.js
Normal file
@@ -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);
|
||||
}
|
||||
32
stream/src/data/influx.js
Normal file
32
stream/src/data/influx.js
Normal file
@@ -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 };
|
||||
18
stream/src/data/redis.js
Normal file
18
stream/src/data/redis.js
Normal file
@@ -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 };
|
||||
44
stream/src/routes/connect.js
Normal file
44
stream/src/routes/connect.js
Normal file
@@ -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 }
|
||||
3
stream/src/ws/connection.js
Normal file
3
stream/src/ws/connection.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import { encode, decode } from '@msgpack/msgpack';
|
||||
import { queryData as datas } from '../data/db.js';
|
||||
import { write, point } from '';
|
||||
44
stream/src/ws/upgrade.js
Normal file
44
stream/src/ws/upgrade.js
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user