From bcfce32adbb8515bf311eed45aa65ef21032b44e Mon Sep 17 00:00:00 2001 From: Giuseppe Raffa <77052701+sesee3@users.noreply.github.com> Date: Sat, 28 Mar 2026 15:29:34 +0100 Subject: [PATCH] feat: initialize microservice architecture with auth, api, realtime, copernicus, ml, and console modules --- .env.example | 0 .gitignore | 2 + api/.dockerignore | 2 + api/.env.example | 14 + api/Dockerfile | 13 + api/package-lock.json | 1541 +++++++++++++++++++++++ api/package.json | 22 + api/src/index.js | 73 ++ api/src/routes/data.js | 27 + api/src/routes/params.js | 216 ++++ api/src/routes/params.sensor.js | 51 + api/src/routes/storage.js | 59 + api/src/storage/influx.js | 67 + api/src/storage/minio.js | 136 ++ api/src/storage/postgres.js | 79 ++ auth/Dockerfile | 13 + auth/package-lock.json | 1402 +++++++++++++++++++++ auth/package.json | 22 + auth/src/core/auth.core.js | 120 ++ auth/src/core/session.core.js | 56 + auth/src/index.js | 49 + auth/src/routes/auth.js | 97 ++ auth/src/routes/sessions.js | 0 auth/src/routes/users.js | 2 + auth/src/static/icon.png | Bin 0 -> 32888 bytes auth/src/static/style/login.css | 99 ++ auth/src/static/style/style.css | 201 +++ auth/src/storage/database.js | 105 ++ auth/src/storage/redis.js | 36 + auth/src/templates/loginpage.html | 39 + auth/src/templates/sessions.html | 1 + auth/src/templates/user.html | 1 + auth/src/tools/jwt.js | 70 + auth/src/tools/security.js | 54 + auth/src/tools/tracking.js | 24 + console/.dockerignore | 2 + console/Dockerfile | 13 + console/package-lock.json | 1127 +++++++++++++++++ console/package.json | 19 + console/src/index.js | 92 ++ console/src/pages/dashboard.html | 55 + console/src/pages/live.html | 707 +++++++++++ console/src/static/styles/dashboard.css | 26 + console/src/static/styles/live.css | 669 ++++++++++ console/src/static/styles/style.css | 201 +++ copernicus/Dockerfile | 0 copernicus/core/cache.py | 125 ++ copernicus/core/copernicus.py | 310 +++++ copernicus/core/storage.py | 112 ++ copernicus/main.py | 53 + copernicus/requirements.txt | 13 + copernicus/routers/catalog.py | 46 + copernicus/routers/datasets.py | 57 + copernicus/routers/jobs.py | 145 +++ copernicus/schemas.py | 77 ++ copernicus/static/script.js | 581 +++++++++ copernicus/static/style.css | 0 copernicus/templates/coprncs.html | 163 +++ docker-compose.yml | 164 +++ ml/.dockerignore | 1 + ml/.env.example | 0 ml/Dockerfile | 13 + ml/main.py | 19 + ml/requirements.txt | 2 + ml/static/font/quicksand.ttf | Bin 0 -> 126624 bytes ml/static/styles/style.css | 201 +++ ml/templates/console.html | 28 + ml/templates/datasets.html | 0 ml/templates/results.html | 89 ++ ml/templates/test.html | 0 ml/templates/train.html | 0 package-lock.json | 179 +++ package.json | 6 + realtime/.dockerignore | 1 + realtime/.env.example | 9 + realtime/Dockerfile | 12 + realtime/package-lock.json | 1110 ++++++++++++++++ realtime/package.json | 19 + realtime/src/helper/authdb.js | 84 ++ realtime/src/helper/cryptoUtils.js | 35 + realtime/src/helper/influxReader.js | 75 ++ realtime/src/helper/influxWriter.js | 156 +++ realtime/src/helper/redis.js | 79 ++ realtime/src/helper/tokenStore.js | 52 + realtime/src/index.js | 149 +++ realtime/src/routes/connect.js | 74 ++ realtime/src/routes/sensors.js | 36 + realtime/src/routes/sessions.js | 36 + realtime/src/socket.js | 110 ++ 89 files changed, 12025 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 api/.dockerignore create mode 100644 api/.env.example create mode 100644 api/Dockerfile create mode 100644 api/package-lock.json create mode 100644 api/package.json create mode 100644 api/src/index.js create mode 100644 api/src/routes/data.js create mode 100644 api/src/routes/params.js create mode 100644 api/src/routes/params.sensor.js create mode 100644 api/src/routes/storage.js create mode 100644 api/src/storage/influx.js create mode 100644 api/src/storage/minio.js create mode 100644 api/src/storage/postgres.js create mode 100644 auth/Dockerfile create mode 100644 auth/package-lock.json create mode 100644 auth/package.json create mode 100644 auth/src/core/auth.core.js create mode 100644 auth/src/core/session.core.js create mode 100644 auth/src/index.js create mode 100644 auth/src/routes/auth.js create mode 100644 auth/src/routes/sessions.js create mode 100644 auth/src/routes/users.js create mode 100644 auth/src/static/icon.png create mode 100644 auth/src/static/style/login.css create mode 100644 auth/src/static/style/style.css create mode 100644 auth/src/storage/database.js create mode 100644 auth/src/storage/redis.js create mode 100644 auth/src/templates/loginpage.html create mode 100644 auth/src/templates/sessions.html create mode 100644 auth/src/templates/user.html create mode 100644 auth/src/tools/jwt.js create mode 100644 auth/src/tools/security.js create mode 100644 auth/src/tools/tracking.js create mode 100644 console/.dockerignore create mode 100644 console/Dockerfile create mode 100644 console/package-lock.json create mode 100644 console/package.json create mode 100644 console/src/index.js create mode 100644 console/src/pages/dashboard.html create mode 100644 console/src/pages/live.html create mode 100644 console/src/static/styles/dashboard.css create mode 100644 console/src/static/styles/live.css create mode 100644 console/src/static/styles/style.css create mode 100644 copernicus/Dockerfile create mode 100644 copernicus/core/cache.py create mode 100644 copernicus/core/copernicus.py create mode 100644 copernicus/core/storage.py create mode 100644 copernicus/main.py create mode 100644 copernicus/requirements.txt create mode 100644 copernicus/routers/catalog.py create mode 100644 copernicus/routers/datasets.py create mode 100644 copernicus/routers/jobs.py create mode 100644 copernicus/schemas.py create mode 100644 copernicus/static/script.js create mode 100644 copernicus/static/style.css create mode 100644 copernicus/templates/coprncs.html create mode 100644 docker-compose.yml create mode 100644 ml/.dockerignore create mode 100644 ml/.env.example create mode 100644 ml/Dockerfile create mode 100644 ml/main.py create mode 100644 ml/requirements.txt create mode 100644 ml/static/font/quicksand.ttf create mode 100644 ml/static/styles/style.css create mode 100644 ml/templates/console.html create mode 100644 ml/templates/datasets.html create mode 100644 ml/templates/results.html create mode 100644 ml/templates/test.html create mode 100644 ml/templates/train.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 realtime/.dockerignore create mode 100644 realtime/.env.example create mode 100644 realtime/Dockerfile create mode 100644 realtime/package-lock.json create mode 100644 realtime/package.json create mode 100644 realtime/src/helper/authdb.js create mode 100644 realtime/src/helper/cryptoUtils.js create mode 100644 realtime/src/helper/influxReader.js create mode 100644 realtime/src/helper/influxWriter.js create mode 100644 realtime/src/helper/redis.js create mode 100644 realtime/src/helper/tokenStore.js create mode 100644 realtime/src/index.js create mode 100644 realtime/src/routes/connect.js create mode 100644 realtime/src/routes/sensors.js create mode 100644 realtime/src/routes/sessions.js create mode 100644 realtime/src/socket.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e69de29 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6ed48a9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +node_modules diff --git a/api/.dockerignore b/api/.dockerignore new file mode 100644 index 0000000..5171c54 --- /dev/null +++ b/api/.dockerignore @@ -0,0 +1,2 @@ +node_modules +npm-debug.log \ No newline at end of file diff --git a/api/.env.example b/api/.env.example new file mode 100644 index 0000000..765950e --- /dev/null +++ b/api/.env.example @@ -0,0 +1,14 @@ +PORT=3003 + +VERSION=2.0.0 +VERSION_BUILD=1.0 +VERSION_STATE=pre-release + +INFLX_URL= +INFLX_TOKEN= +INFLX_ORG= + +MINIO_ENDPOINT= +MINIO_PORT= +MINIO_ACCESS_KEY= +MINIO_SECRET_KEY= \ No newline at end of file diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..a4a8991 --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,13 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm install + + +COPY src ./src + +EXPOSE 3003 + +CMD ["node", "src/index.js"] \ No newline at end of file diff --git a/api/package-lock.json b/api/package-lock.json new file mode 100644 index 0000000..6bc1a97 --- /dev/null +++ b/api/package-lock.json @@ -0,0 +1,1541 @@ +{ + "name": "meb-api-service", + "version": "2.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "meb-api-service", + "version": "2.0.0", + "dependencies": { + "@influxdata/influxdb-client": "^1.35.0", + "@influxdata/influxdb-client-apis": "^1.35.0", + "cookie-parser": "^1.4.7", + "dotenv": "^17.3.1", + "express": "^5.2.1", + "ioredis": "^5.10.0", + "jsonwebtoken": "^9.0.3", + "minio": "^8.0.7", + "pg": "^8.20.0" + } + }, + "node_modules/@influxdata/influxdb-client": { + "version": "1.35.0", + "resolved": "https://registry.npmjs.org/@influxdata/influxdb-client/-/influxdb-client-1.35.0.tgz", + "integrity": "sha512-woWMi8PDpPQpvTsRaUw4Ig+nOGS/CWwAwS66Fa1Vr/EkW+NEwxI8YfPBsdBMn33jK2Y86/qMiiuX/ROHIkJLTw==", + "license": "MIT" + }, + "node_modules/@influxdata/influxdb-client-apis": { + "version": "1.35.0", + "resolved": "https://registry.npmjs.org/@influxdata/influxdb-client-apis/-/influxdb-client-apis-1.35.0.tgz", + "integrity": "sha512-+7h6smVPHYBge2rNKgYh/5k+SriYvPMsoJDfbUiQt1vJtpWnElwgBDLDl7Cr6d9XPC+FCI9GP4GQEMv7y8WxdA==", + "license": "MIT", + "peerDependencies": { + "@influxdata/influxdb-client": "*" + } + }, + "node_modules/@ioredis/commands": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", + "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", + "license": "MIT" + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, + "node_modules/block-stream2": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/block-stream2/-/block-stream2-2.1.0.tgz", + "integrity": "sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg==", + "license": "MIT", + "dependencies": { + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/browser-or-node": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/browser-or-node/-/browser-or-node-2.1.1.tgz", + "integrity": "sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==", + "license": "MIT" + }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-uri-component": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", + "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dotenv": { + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", + "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fast-xml-builder": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.2.tgz", + "integrity": "sha512-NJAmiuVaJEjVa7TjLZKlYd7RqmzOC91EtPFXHvlTcqBVo50Qh7XV5IwvXi1c7NRz2Q/majGX9YLcwJtWgHjtkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.1.3" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.3.tgz", + "integrity": "sha512-Ymnuefk6VzAhT3SxLzVUw+nMio/wB1NGypHkgetwtXcK1JfryaHk4DWQFGVwQ9XgzyS5iRZ7C2ZGI4AMsdMZ6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "fast-xml-builder": "^1.1.2", + "path-expression-matcher": "^1.1.3", + "strnum": "^2.1.2" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/filter-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz", + "integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ioredis": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.0.tgz", + "integrity": "sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/minio": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/minio/-/minio-8.0.7.tgz", + "integrity": "sha512-E737MgufW8CeQAsTAtnEMrxZ9scMSf29kkhZoXzDTKj/Jszzo2SfeZUH9wbDQH2Rsq6TCtl/yQL0+XdVKZansQ==", + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.4", + "block-stream2": "^2.1.0", + "browser-or-node": "^2.1.1", + "buffer-crc32": "^1.0.0", + "eventemitter3": "^5.0.1", + "fast-xml-parser": "^5.3.4", + "ipaddr.js": "^2.0.1", + "lodash": "^4.17.21", + "mime-types": "^2.1.35", + "query-string": "^7.1.3", + "stream-json": "^1.8.0", + "through2": "^4.0.2", + "xml2js": "^0.5.0 || ^0.6.2" + }, + "engines": { + "node": "^16 || ^18 || >=20" + } + }, + "node_modules/minio/node_modules/ipaddr.js": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz", + "integrity": "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/minio/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minio/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-expression-matcher": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.3.tgz", + "integrity": "sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/query-string": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz", + "integrity": "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==", + "license": "MIT", + "dependencies": { + "decode-uri-component": "^0.2.2", + "filter-obj": "^1.1.0", + "split-on-first": "^1.0.0", + "strict-uri-encode": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.5.0.tgz", + "integrity": "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/split-on-first": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz", + "integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stream-chain": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/stream-chain/-/stream-chain-2.2.5.tgz", + "integrity": "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==", + "license": "BSD-3-Clause" + }, + "node_modules/stream-json": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/stream-json/-/stream-json-1.9.1.tgz", + "integrity": "sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==", + "license": "BSD-3-Clause", + "dependencies": { + "stream-chain": "^2.2.5" + } + }, + "node_modules/strict-uri-encode": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", + "integrity": "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strnum": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz", + "integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/through2": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", + "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", + "license": "MIT", + "dependencies": { + "readable-stream": "3" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/api/package.json b/api/package.json new file mode 100644 index 0000000..465261d --- /dev/null +++ b/api/package.json @@ -0,0 +1,22 @@ +{ + "name": "meb-api-service", + "version": "2.0.0", + "description": "API microservice for MEB console - Node.js", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "dev": "node --watch src/index.js", + "generate-docs": "node -e \"console.log(JSON.stringify(require('./swagger-specs-logic')))\" > openapi.json" + }, + "dependencies": { + "@influxdata/influxdb-client": "^1.35.0", + "@influxdata/influxdb-client-apis": "^1.35.0", + "cookie-parser": "^1.4.7", + "dotenv": "^17.3.1", + "express": "^5.2.1", + "ioredis": "^5.10.0", + "jsonwebtoken": "^9.0.3", + "minio": "^8.0.7", + "pg": "^8.20.0" + } +} diff --git a/api/src/index.js b/api/src/index.js new file mode 100644 index 0000000..20604ff --- /dev/null +++ b/api/src/index.js @@ -0,0 +1,73 @@ +const express = require('express'); +const parser = require('cookie-parser'); +const jwt = require('jsonwebtoken'); + +const app = express(); +const PORT = process.env.PORT; + +const version = process.env.VERSION; +const vBuild = process.env.VERSION_BUILD; +const vState = process.env.VERSION_STATE; + +app.use(express.json()); +app.use(parser()); + +app.get('/', (req, res) => { + res.redirect('/health'); +}); + +app.get('/health', (req, res) => { + res.json({ + status: "ok", + service: "api", + version: version, + build_number: vBuild, + version_state: vState + }); +}); + +// Route pubblica: autenticazione tramite SENSOR_CODE (per il plugin) +const paramsSensorRoutes = require('./routes/params.sensor'); +app.use('/params/sensor', paramsSensorRoutes); + +// Middleware di autenticazione per le API +app.use((req, res, next) => { + if (req.path === '/health' || req.path === '/') return next(); + + // 1. Service-to-service: x-api-key header + const apiKey = req.headers['x-api-key']; + if (apiKey && apiKey === process.env.INTERNAL_API_KEY) { + req.internal = true; + return next(); + } + + // 2. User auth: cookie o Authorization header + const token = req.cookies?.auth_token + || (req.headers.authorization?.startsWith('Bearer ') && req.headers.authorization.slice(7)); + + if (!token) { + return res.status(401).json({ error: 'Unauthorized: Nessun token di autenticazione fornito' }); + } + + try { + const payload = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] }); + req.user = payload; + next(); + } catch (err) { + return res.status(401).json({ error: 'Unauthorized: Token non valido o scaduto' }); + } +}); + +const dataRoutes = require('./routes/data'); +app.use('/data', dataRoutes); + +const storageRoutes = require('./routes/storage') +app.use('/storage', storageRoutes) + +const paramsRoutes = require('./routes/params') +app.use('/params', paramsRoutes) + +// Avvio del server +app.listen(PORT, () => { + console.log(`Started on port ${PORT}`); +}); diff --git a/api/src/routes/data.js b/api/src/routes/data.js new file mode 100644 index 0000000..669784d --- /dev/null +++ b/api/src/routes/data.js @@ -0,0 +1,27 @@ +// Collezione di tutte le api che usando influxdb e la telemetria della barca + +//api.mebboat.it/data + +const router = require('express').Router(); + +const { write, query, writeBatch } = require('../storage/influx'); + +router.post('/write', async (req, res) => { + const { measurement, sensor, data } = req.body; + await write(measurement, sensor, data); + res.json({ status: "ok" }); +}); + +router.post('/write/batch', async (req, res) => { + const { points } = req.body; + await writeBatch(points); + res.json({ result: "added" }); +}); + +router.get('/query', async (req, res) => { + const { collection, timeFromNow, measurement, sensor, field } = req.query; + const result = await query(collection, timeFromNow, measurement, sensor, field); + res.json(result); +}); + +module.exports = router; \ No newline at end of file diff --git a/api/src/routes/params.js b/api/src/routes/params.js new file mode 100644 index 0000000..b489bb7 --- /dev/null +++ b/api/src/routes/params.js @@ -0,0 +1,216 @@ +const router = require('express').Router(); +const { query } = require('../storage/postgres'); + +const sets = ['forecasts', 'marine', 'sensors', 'telemetry']; + +/* ─── READS ─── */ + +/** + * GET /params/active?set=sensors + * Restituisce il set attivo per il tipo richiesto + */ +router.get('/active', async (req, res) => { + const { set } = req.query; + if (!set || !sets.includes(set)) + return res.status(400).json({ error: 'SET parameter invalid' }); + + try { + const result = await query(`SELECT * FROM ${set} WHERE active = true LIMIT 1`, [], 'references'); + res.json(result.rows[0] || null); + } catch (err) { + console.error('[PARAMS] Error reading active set:', err.message); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * GET /params/sets?type=sensors + * Lista tutti i set di un tipo (con paginazione) + */ +router.get('/sets', async (req, res) => { + const { type } = req.query; + if (!type || !sets.includes(type)) + return res.status(400).json({ error: 'type parameter invalid' }); + + try { + const result = await query( + `SELECT id, name, description, mayor, minor, patch, state, active, tags, created_at, updated_at + FROM ${type} ORDER BY updated_at DESC`, + [], 'references' + ); + res.json(result.rows); + } catch (err) { + console.error('[PARAMS] Error listing sets:', err.message); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * GET /params/sets/:id?type=sensors + * Restituisce un singolo set completo (con content) + */ +router.get('/sets/:id', async (req, res) => { + const { type } = req.query; + const { id } = req.params; + if (!type || !sets.includes(type)) + return res.status(400).json({ error: 'type parameter invalid' }); + + try { + const result = await query(`SELECT * FROM ${type} WHERE id = $1`, [id], 'references'); + if (!result.rows[0]) return res.status(404).json({ error: 'Set not found' }); + res.json(result.rows[0]); + } catch (err) { + console.error('[PARAMS] Error reading set:', err.message); + res.status(500).json({ error: 'Internal server error' }); + } +}); + + +/* ─── WRITES ─── */ + +/** + * POST /params/sets?type=sensors + * Crea un nuovo set di regole + */ +router.post('/sets', async (req, res) => { + const { type } = req.query; + if (!type || !sets.includes(type)) + return res.status(400).json({ error: 'type parameter invalid' }); + + const { name, description, content, tags } = req.body; + if (!name || !content) return res.status(400).json({ error: 'name and content required' }); + + try { + const result = await query( + `INSERT INTO ${type} (name, description, mayor, minor, patch, state, active, tags, content) + VALUES ($1, $2, 1, 0, 0, 'draft', false, $3, $4) RETURNING *`, + [name, description || '', tags || [], JSON.stringify(content)], + 'references' + ); + res.status(201).json(result.rows[0]); + } catch (err) { + console.error('[PARAMS] Error creating set:', err.message); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * PUT /params/sets/:id?type=sensors + * Aggiorna un set esistente + */ +router.put('/sets/:id', async (req, res) => { + const { type } = req.query; + const { id } = req.params; + if (!type || !sets.includes(type)) + return res.status(400).json({ error: 'type parameter invalid' }); + + const { name, description, content, tags, state } = req.body; + + try { + const fields = []; + const values = []; + let idx = 1; + + if (name !== undefined) { fields.push(`name = $${idx++}`); values.push(name); } + if (description !== undefined) { fields.push(`description = $${idx++}`); values.push(description); } + if (content !== undefined) { fields.push(`content = $${idx++}`); values.push(JSON.stringify(content)); } + if (tags !== undefined) { fields.push(`tags = $${idx++}`); values.push(tags); } + if (state !== undefined) { fields.push(`state = $${idx++}`); values.push(state); } + + if (fields.length === 0) return res.status(400).json({ error: 'No fields to update' }); + + fields.push(`updated_at = NOW()`); + values.push(id); + + const result = await query( + `UPDATE ${type} SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`, + values, 'references' + ); + + if (!result.rows[0]) return res.status(404).json({ error: 'Set not found' }); + res.json(result.rows[0]); + } catch (err) { + console.error('[PARAMS] Error updating set:', err.message); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * POST /params/sets/:id/activate?type=sensors + * Attiva un set (disattiva tutti gli altri dello stesso tipo) + */ +router.post('/sets/:id/activate', async (req, res) => { + const { type } = req.query; + const { id } = req.params; + if (!type || !sets.includes(type)) + return res.status(400).json({ error: 'type parameter invalid' }); + + try { + // Disattiva tutti + await query(`UPDATE ${type} SET active = false WHERE active = true`, [], 'references'); + // Attiva quello richiesto + const result = await query( + `UPDATE ${type} SET active = true, state = 'active', updated_at = NOW() WHERE id = $1 RETURNING *`, + [id], 'references' + ); + + if (!result.rows[0]) return res.status(404).json({ error: 'Set not found' }); + res.json(result.rows[0]); + } catch (err) { + console.error('[PARAMS] Error activating set:', err.message); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * DELETE /params/sets/:id?type=sensors + * Elimina un set (solo se non attivo) + */ +router.delete('/sets/:id', async (req, res) => { + const { type } = req.query; + const { id } = req.params; + if (!type || !sets.includes(type)) + return res.status(400).json({ error: 'type parameter invalid' }); + + try { + // Controlla che non sia attivo + const check = await query(`SELECT active FROM ${type} WHERE id = $1`, [id], 'references'); + if (!check.rows[0]) return res.status(404).json({ error: 'Set not found' }); + if (check.rows[0].active) return res.status(409).json({ error: 'Cannot delete an active set' }); + + await query(`DELETE FROM ${type} WHERE id = $1`, [id], 'references'); + res.json({ deleted: true }); + } catch (err) { + console.error('[PARAMS] Error deleting set:', err.message); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +/** + * PUT /params/sets/:id/version?type=sensors + * Incrementa la versione (bump) + */ +router.put('/sets/:id/version', async (req, res) => { + const { type } = req.query; + const { id } = req.params; + const { bump } = req.body; // 'major', 'minor', 'patch' + if (!type || !sets.includes(type)) + return res.status(400).json({ error: 'type parameter invalid' }); + + const field = bump === 'major' ? 'mayor' : bump === 'minor' ? 'minor' : 'patch'; + const resets = bump === 'major' ? ', minor = 0, patch = 0' : bump === 'minor' ? ', patch = 0' : ''; + + try { + const result = await query( + `UPDATE ${type} SET ${field} = ${field} + 1 ${resets}, updated_at = NOW() WHERE id = $1 RETURNING *`, + [id], 'references' + ); + if (!result.rows[0]) return res.status(404).json({ error: 'Set not found' }); + res.json(result.rows[0]); + } catch (err) { + console.error('[PARAMS] Error bumping version:', err.message); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +module.exports = router; diff --git a/api/src/routes/params.sensor.js b/api/src/routes/params.sensor.js new file mode 100644 index 0000000..17337fe --- /dev/null +++ b/api/src/routes/params.sensor.js @@ -0,0 +1,51 @@ +const router = require('express').Router(); +const crypto = require('crypto'); +const { query } = require('../storage/postgres'); + +const sets = ['forecasts', 'sensors']; + +function hashSensorCode(code) { + return crypto.createHash('sha256').update(code).digest('hex'); +} + +/** + * GET /params/sensor/:sensorCode/active?set=sensors + * Autenticazione tramite SENSOR_CODE (stesso meccanismo di realtime) + */ +router.get('/:sensorCode/active', async (req, res) => { + const { sensorCode } = req.params; + const { set } = req.query; + + if (!set || !sets.includes(set)) + return res.status(400).json({ error: 'SET parameter invalid' }); + + try { + const hashed = hashSensorCode(sensorCode); + const sensor = await query( + 'SELECT id, is_active FROM sensors WHERE code_hash = $1', + [hashed], + 'sensors' + ); + + if (!sensor.rows[0]) { + return res.status(401).json({ error: 'Sensor code not valid' }); + } + + if (!sensor.rows[0].is_active) { + return res.status(403).json({ error: 'Sensor is not active' }); + } + + const result = await query( + `SELECT * FROM ${set} WHERE active = true LIMIT 1`, + [], + 'references' + ); + + res.json(result.rows[0] || null); + } catch (err) { + console.error('[PARAMS/SENSOR] Error:', err.message); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +module.exports = router; diff --git a/api/src/routes/storage.js b/api/src/routes/storage.js new file mode 100644 index 0000000..3831ed4 --- /dev/null +++ b/api/src/routes/storage.js @@ -0,0 +1,59 @@ +// Collezzione di tutte le api che prendono i dati da minio +//api.mebboat.it/storage + +const express = require('express'); +const { getBuckets, getBucket, getObject, getObjects, getFileStream } = require('../storage/minio'); +const router = express.Router(); + +/** + * Restituisce una lista con tutti i bucket del database, + * altrimenti restituisce i file del bucket passato come parametro + * + * @param {string} bucket - il bucket + */ +router.get('/', async (req, res) => { + const { bucket } = req.query; + + if (bucket == undefined) { + const buckets = await getBuckets(); + res.status(200).json(buckets); + } else { + const returnBucket = await getBucket(bucket); + res.status(200).json(returnBucket); + } +}) + +router.get('/files', async (req, res) => { + const { bucket, fileID } = req.query; + + if (bucket == undefined) { + res.status(400).json({ error: "No bucket name in the request" }); + } else { + if (fileID == undefined) { + const files = await getObjects(bucket); + res.status(200).json(files); + } else { + const file = await getObject(bucket, fileID); + res.status(200).json(file); + } + } +}) + +router.get('/file', async (req, res) => { + const { bucket, fileID } = req.query; + const stream = await getFileStream(bucket, fileID); + res.setHeader('Content-Type', 'application/octet-stream'); + stream.pipe(res); +}) + +router.post('/upload', async (req, res) => { + const { bucket } = req.query; + const files = await getObjects(bucket); + res.status(200).json(files); +}) + +router.get('/download', async (req, res) => { + +}) + +module.exports = router; \ No newline at end of file diff --git a/api/src/storage/influx.js b/api/src/storage/influx.js new file mode 100644 index 0000000..eca99d8 --- /dev/null +++ b/api/src/storage/influx.js @@ -0,0 +1,67 @@ +const { InfluxDB, Point } = require('@influxdata/influxdb-client'); + +const url = process.env.INFLX_URL; +const token = process.env.INFLX_TOKEN; +const org = process.env.INFLX_ORG; + +const boatTelemetry = "boat" + +const client = new InfluxDB({ url, token }) +const write = client.getWriteApi(org, boatTelemetry); +const querying = client.getQueryApi(org); + + +async function append(measurement, sensor, data) { + const point = new Point(measurement) + .tag("sensor", sensor) + .floatField('temperature', data.temperature) + .floatField('humidity', data.humidity) + + write.writePoint(point); + await write.flush(); + +} + +async function writeBatch(datas) { + datas.forEach(data => { + append(data.measurement, data.sensor, data.data); + }) +} + +async function query(bucket, relativeTime, measurement, sensor, field) { + + const fluxTimeRange = relativeTime || "-1h"; + let fluxQuery = ` + from(bucket: "${bucket}") + |> range(start: ${fluxTimeRange}) + |> filter(fn: (r) => r._measurement == "${measurement}")`; + + if (sensor) { + fluxQuery += `\n |> filter(fn: (r) => r.sensor == "${sensor}")`; + } + + if (field) { + fluxQuery += `\n |> filter(fn: (r) => r._field == "${field}")`; + } + + fluxQuery += `\n |> yield(name: "data")`; + + try { + const data = []; + for await (const { values, tableMeta } of querying.iterateRows(fluxQuery)) { + data.push(tableMeta.toObject(values)); + } + return data; + } catch (error) { + console.error("Error in query:", error); + return []; + } + +} + +module.exports = { + write:append, + writeBatch, + query +} + diff --git a/api/src/storage/minio.js b/api/src/storage/minio.js new file mode 100644 index 0000000..75c012b --- /dev/null +++ b/api/src/storage/minio.js @@ -0,0 +1,136 @@ +const Minio = require('minio'); + +const client = new Minio.Client({ + endPoint: process.env.MINIO_ENDPOINT || 'localhost', + port: parseInt(process.env.MINIO_PORT) || 9000, + useSSL: process.env.MINIO_USE_SSL === 'true', + accessKey: process.env.MINIO_ACCESS_KEY, + secretKey: process.env.MINIO_SECRET_KEY +}) + + +// Buckets + +/** + * + * @param {String} bucket - Il nome del bucket + * @returns {String} - restituisce il nome del bucket creato + */ +async function bucketExists(bucket) { + const exists = await client.bucketExists(bucket); + if(!exists) { + await client.makeBucket(bucket); + } + return bucket +} + +/** + * Restituisce un array con tutti i bucket sul server + * @returns {Array} - i diversi bucket presenti sul server + */ +async function getBuckets() { + const buckets = await client.listBuckets(); + return buckets; +} + +/** + * Restituisce i metadata del bucket passato come parametro + * @param {String} bucket - il nome del bucket + * @returns {Object} - i metadata del bucket + */ +async function getBucket(bucket) { + const buckets = await client.listBuckets(); + return buckets.filter(b => b.name === bucket); +} + + +/** + * Restituisce un array con tutti gli oggetti presenti nel bucket passato come parametro + * @param {String} bucket - il nome del bucket + * @returns {Promise} - i file del bucket + */ +async function getObjects(bucket) { + return new Promise((resolve, reject) => { + const objects = []; + const stream = client.listObjects(bucket, '', true); + stream.on('data', obj => objects.push(obj)); + stream.on('error', err => reject(err)); + stream.on('end', () => resolve(objects)); + }); +} + +/** + * Restituisce i metadata del file con id passato come parametro presente nel bucket + * @param {String} bucket - il nome del bucket + * @param {String} objectName - il nome dell'oggetto + * @returns {Object} - i metadata del file + */ +async function getObject(bucket, objectName) { + const item = await client.statObject(bucket, objectName); + return item; +} + +/** + * Elimina il file con l'id passato a parametro dal bucket + * @param {String} bucket - il nome del bucket + * @param {String} objectName - il nome dell'oggetto + */ +async function removeObject(bucket, objectName) { + await client.removeObject(bucket, objectName); +} + + +//Upload - Download +/** + * Carica un file nel bucket come buffer + * @param {String} bucket - il nome del bucket + * @param {String} objectName - il nome che avrà il file in Minio + * @param {Buffer} fileBuffer - il file caricato + * @param {Number} size - dimensione del file + * @param {String} contentType - mimetype (es. 'image/png') + */ +async function upload(bucket, objectName, fileBuffer, size, contentType) { + await bucketExists(bucket); + + const metaData = { + 'Content-Type': contentType || 'application/octet-stream', + } + + const result = await client.putObject(bucket, objectName, fileBuffer, size, metaData); + return result; +} + +/** + * Genera un URL temporaneo di download + * @param {String} bucket - il nome del bucket + * @param {String} objectName - il nome del file + * @param {Number} expiry - quanto dura il link in secondi (default: 24h = 86400) + * + * @returns {String} url - url di download del file + */ +async function download(bucket, objectName, expiry = 86400) { + const url = await client.presignedGetObject(bucket, objectName, expiry); + return url; +} + +/** + * Recupera e ritorna lo stream dei dati dal server Minio (per leggere il contenuto via API) + * @param {String} bucket - il nome del bucket + * @param {String} objectName - il nome dell'oggetto + */ +async function getFileStream(bucket, objectName) { + const dataStream = await client.getObject(bucket, objectName); + return dataStream; +} + +module.exports = { + bucketExists, + getBuckets, + getBucket, + getObjects, + getObject, + removeObject, + upload, + download, + getFileStream +} \ No newline at end of file diff --git a/api/src/storage/postgres.js b/api/src/storage/postgres.js new file mode 100644 index 0000000..04cc596 --- /dev/null +++ b/api/src/storage/postgres.js @@ -0,0 +1,79 @@ +const { Pool } = require('pg'); + +const config = { + user: process.env.PG_USER, + password: process.env.PG_PASSWORD, + host: process.env.PG_HOST, + port: process.env.PG_PORT, + max: 10, + idleTimeoutMillis: 30000, + connectionTimeoutMillis: 5000 +} + +const pools = { + users: new Pool({ ...config, database: process.env.DATA_DB }), + references: new Pool({ ...config, database: process.env.REFERENCES_DB }), + sensors: new Pool({ ...config, database: process.env.SENSOR_DB || 'users' }) +} + +Object.entries(pools).forEach(([name, pool]) => { + pool.on('error', (err) => { + console.error(`Error in ${name} pool`, err); + }) +}); + +/** + * + * @param {'users' | 'references'} db - the name of the database + * @returns {Promise} + */ +async function getClient(db) { + const pool = pools[db]; + if (!pool) throw new Error(`Database pool type ${db} does not exist`); + return await pool.connect(); +} + +/** + * Esegue una query sul database specificato + * @param {string} text - Query SQL + * @param {any[]} params - Parametri + * @param {'users' | 'references'} name - Quale DB usare + */ +async function query(text, params, name = 'users') { + const client = await getClient(name); + try { + return await client.query(text, params); + } catch (error) { + console.error(`[DB Query Error on ${name}]`, error.message); + throw error; + } finally { + client.release(); + } +} + +/** + * Inserisce una riga in una tabella + */ +async function append(table, data, type = 'users') { + const keys = Object.keys(data); + const values = Object.values(data); + const placeholders = keys.map((_, i) => `$${i + 1}`).join(', '); + const columns = keys.join(', '); + const sql = `INSERT INTO ${table} (${columns}) VALUES (${placeholders}) RETURNING *`; + return await query(sql, values, type); +} + +/** + * Rimuove una riga + */ +async function remove(table, condition, params, type = 'users') { + const sql = `DELETE FROM ${table} WHERE ${condition}`; + return await query(sql, params, type); +} +module.exports = { + query, + append, + remove, + getClient, + pools +}; \ No newline at end of file diff --git a/auth/Dockerfile b/auth/Dockerfile new file mode 100644 index 0000000..ad9d768 --- /dev/null +++ b/auth/Dockerfile @@ -0,0 +1,13 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm install + + +COPY src ./src + +EXPOSE 3006 + +CMD ["node", "src/index.js"] \ No newline at end of file diff --git a/auth/package-lock.json b/auth/package-lock.json new file mode 100644 index 0000000..d4d2b06 --- /dev/null +++ b/auth/package-lock.json @@ -0,0 +1,1402 @@ +{ + "name": "meb-auth-service", + "version": "1.2.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "meb-auth-service", + "version": "1.2.0", + "dependencies": { + "bcrypt": "^6.0.0", + "cookie-parser": "^1.4.7", + "express": "^5.2.1", + "ioredis": "^5.10.0", + "jsonwebtoken": "^9.0.3", + "nunjucks": "^3.2.4", + "pg": "^8.20.0", + "ua-parser-js": "^2.0.9", + "uuid": "^13.0.0" + } + }, + "node_modules/@ioredis/commands": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", + "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", + "license": "MIT" + }, + "node_modules/a-sync-waterfall": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/a-sync-waterfall/-/a-sync-waterfall-1.0.1.tgz", + "integrity": "sha512-RYTOHHdWipFUliRFMCS4X2Yn2X8M87V/OpSqWzKKOGhzqyUxzyVmhHDH9sAvG+ZuQf/TAOFsLCpMw09I1ufUnA==", + "license": "MIT" + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "license": "MIT" + }, + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-europe-js": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/detect-europe-js/-/detect-europe-js-0.1.2.tgz", + "integrity": "sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + } + ], + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ioredis": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz", + "integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-standalone-pwa": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-standalone-pwa/-/is-standalone-pwa-0.1.1.tgz", + "integrity": "sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + } + ], + "license": "MIT" + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-addon-api": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.6.0.tgz", + "integrity": "sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/nunjucks": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/nunjucks/-/nunjucks-3.2.4.tgz", + "integrity": "sha512-26XRV6BhkgK0VOxfbU5cQI+ICFUtMLixv1noZn1tGU38kQH5A5nmmbk/O45xdyBhD1esk47nKrY0mvQpZIhRjQ==", + "license": "BSD-2-Clause", + "dependencies": { + "a-sync-waterfall": "^1.0.0", + "asap": "^2.0.3", + "commander": "^5.1.0" + }, + "bin": { + "nunjucks-precompile": "bin/precompile" + }, + "engines": { + "node": ">= 6.9.0" + }, + "peerDependencies": { + "chokidar": "^3.3.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ua-is-frozen": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ua-is-frozen/-/ua-is-frozen-0.1.2.tgz", + "integrity": "sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + } + ], + "license": "MIT" + }, + "node_modules/ua-parser-js": { + "version": "2.0.9", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-2.0.9.tgz", + "integrity": "sha512-OsqGhxyo/wGdLSXMSJxuMGN6H4gDnKz6Fb3IBm4bxZFMnyy0sdf6MN96Ie8tC6z/btdO+Bsy8guxlvLdwT076w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "license": "AGPL-3.0-or-later", + "dependencies": { + "detect-europe-js": "^0.1.2", + "is-standalone-pwa": "^0.1.1", + "ua-is-frozen": "^0.1.2" + }, + "bin": { + "ua-parser-js": "script/cli.js" + }, + "engines": { + "node": "*" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/auth/package.json b/auth/package.json new file mode 100644 index 0000000..37c6692 --- /dev/null +++ b/auth/package.json @@ -0,0 +1,22 @@ +{ + "name": "meb-auth-service", + "version": "1.2.0", + "description": "Servizio di Sicurezza e Autenticazione per il server MEB", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "dev": "node --watch src/index.js", + "generate-docs": "node -e \"console.log(JSON.stringify(require('./swagger-specs-logic')))\" > openapi.json" + }, + "dependencies": { + "bcrypt": "^6.0.0", + "cookie-parser": "^1.4.7", + "express": "^5.2.1", + "ioredis": "^5.10.0", + "jsonwebtoken": "^9.0.3", + "nunjucks": "^3.2.4", + "pg": "^8.20.0", + "ua-parser-js": "^2.0.9", + "uuid": "^13.0.0" + } +} diff --git a/auth/src/core/auth.core.js b/auth/src/core/auth.core.js new file mode 100644 index 0000000..e746f23 --- /dev/null +++ b/auth/src/core/auth.core.js @@ -0,0 +1,120 @@ +const query = require('../storage/database').query; +const track = require('../tools/tracking') +const { v4: uuid } = require('uuid'); +const security = require('../tools/security') + + +/** + * Registra un nuovo utente + */ +async function register(username, password) { + const userExists = await query('SELECT id FROM users WHERE username = $1', [username]); + + if (userExists.rows.length > 0) { + throw new Error('User already exists'); + } + + const hashedPassword = security.hashPassword(password); + const id = uuid(); + + await query('INSERT INTO users (id, username, password_hash) VALUES ($1, $2, $3)', [id, username, hashedPassword]); + + return { + success: true, + user: { + id, + username + } + }; +} + + +/** + * Esegue il login di un utente + */ +async function login(username, password) { + const result = await query('SELECT id, username, password_hash, is_active, created_at FROM users WHERE username = $1', [username]); + if (result.rows.length === 0) { + throw new Error('No user matched') + } + + const user = result.rows[0]; + const isValid = await security.verifyPassword(password, user.password_hash); + + if (!isValid) { + throw new Error('Password mismatch') + } + + return { + id: user.id, + username: user.username, + created: user.created_at + } +} + +/** + * Esegue il logout di un utente + * + */ +async function logout(sessionID) { + if (!sessionID) { + throw new Error('no sessio id passed'); + } + + const result = await query('UPDATE sessions SET is_revoked = TRUE WHERE id = $1', [sessionID]); + return result.rowCount > 0; +} + +/** + * Crea una nuova sessione per un utente che ha appaena eseguito il login + */ +async function newSession(userId, userAgent, ip) { + const id = uuid(); + const sessionCode = security.generateSessionCode(); + const metadata = track.getBasicMetadata(userAgent); + + await query( + `INSERT INTO sessions (id, user_id, session_code, encoded_username, ip_address, user_agent, browser, os, device_type) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + [id, userId, sessionCode, '', ip, userAgent, metadata.browser, metadata.os, metadata.device_type] + ); + + return { id, sessionCode }; +} + +/** + * Valida una sessione + */ +async function validateSession(token) { + const parsed = security.parseSessionToken(token); + + if (!parsed) { + throw new Error('Invalid token format'); + } + + const { code, username } = parsed; + + const result = await query('SELECT s.id as session_id, s.user_id, u.username, u.is_active, u.created_at FROM sessions s JOIN users u ON s.user_id = u.id WHERE s.session_code = $1 AND s.is_revoked = FALSE', [code]); + if (result.rows.length === 0) { + throw new Error('Session not found or revoked') + } + + const session = result.rows[0]; + + if (session.username !== username) { + throw new Error('Session user mismatch'); + } + + if (!session.is_active) { + throw new Error('Session is not active'); + } +} + + +module.exports = { + register, + login, + logout, + newSession, + validateSession +} \ No newline at end of file diff --git a/auth/src/core/session.core.js b/auth/src/core/session.core.js new file mode 100644 index 0000000..88a6b9b --- /dev/null +++ b/auth/src/core/session.core.js @@ -0,0 +1,56 @@ +const query = require('../storage/database').query; +const { parseSessionToken } = require('../tools/security'); + +async function getSessions(username) { + const result = await query('SELECT s.id, s.session_code, s.browser, s.os, s.device_type, s.created_at, s.last_active, s.is_revoked FROM sessions s JOIN users u ON s.user_id = u.id WHERE u.username = $1 AND s.is_revoked = FALSE ORDER BY s.last_active DESC', [username]); + + return result.rows.map(s => ({ + id: s.id, + code: s.session_code, + browser: s.browser, + os: s.os, + deviceType: s.device_type, + createdAt: s.created_at?.toLocaleDateString('it-IT', { + month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' + }), + lastActive: s.last_active?.toLocaleDateString('it-IT', { + month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' + }), + isRevoked: s.is_revoked, + isCurrent: false + })); +}; + +async function getCurrentSessionID(token) { + const parsed = parseSessionToken(token); + if (!parsed) { + throw new Error('Invalid token'); + } + + const result = await query('SELECT id FROM sessions WHERE session_code = $1', [parsed.code]); + return result.rows[0]?.id || null; +} + +async function revoke(id, username) { + const result = await query('UPDATE sessions s SET is_revoked = TRUE FROM users u WHERE s.id = $1 AND s.user_id = u.id AND u.username = $2', [id, username]); + return result.rowCount > 0; +} + +async function revokeOthers(username, current) { + const result = await query('UPDATE sessions s SET is_revoked = TRUE FROM users u WHERE s.user_id = u.id AND u.username = $1 AND s.id != $2 AND s.is_revoked = FALSE', [username, current]); + return result.rowCount; +} + +async function getCount(username) { + const result = await query('SELECT COUNT(*) as count FROM sessions s JOIN users u ON s.user_id = u.id WHERE u.username = $1 AND s.is_revoked = FALSE', [username]); + return parseInt(result.rows[0].count, 10); +} + +module.exports = { + getSessions, + getCurrentSessionID, + revoke, + revokeOthers, + getCount +}; + diff --git a/auth/src/index.js b/auth/src/index.js new file mode 100644 index 0000000..91c4bc6 --- /dev/null +++ b/auth/src/index.js @@ -0,0 +1,49 @@ +const express = require('express'); +const nunjucks = require('nunjucks'); +const path = require('path'); +const parser = require('cookie-parser'); + +const database = require('./storage/database'); + +const app = express(); +const PORT = process.env.PORT || 3006; + +const version = process.env.VERSION; +const vBuild = process.env.VERSION_BUILD; +const vState = process.env.VERSION_STATE; + +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); +app.use(parser()); + +// Static files +const staticFolder = path.join(__dirname, 'static'); +app.use('/static', express.static(staticFolder)); + +// Nunjucks templates +const templatesFolder = path.join(__dirname, 'templates'); +nunjucks.configure(templatesFolder, { + autoescape: true, + express: app, + noCache: true, + watch: false +}); +app.set('views', templatesFolder); +app.set('view engine', 'html'); + +// Routes +const authRoutes = require('./routes/auth'); +app.use('/', authRoutes); + +// Startup +async function start() { + await database.initDb(); + app.listen(PORT, () => { + console.log(`[AUTH] Started on port ${PORT}`); + }); +} + +start().catch(err => { + console.error('[AUTH] Failed to start:', err); + process.exit(1); +}); diff --git a/auth/src/routes/auth.js b/auth/src/routes/auth.js new file mode 100644 index 0000000..11662ae --- /dev/null +++ b/auth/src/routes/auth.js @@ -0,0 +1,97 @@ +const router = require('express').Router(); +const auth = require('../core/auth.core'); +const jwt = require('../tools/jwt'); + +const version = process.env.VERSION; +const vBuild = process.env.VERSION_BUILD; +const vState = process.env.VERSION_STATE; + +const CONSOLE_URL = process.env.CONSOLE_URL || 'http://localhost:3004'; +const COOKIE_DOMAIN = process.env.COOKIE_DOMAIN || undefined; + +router.get('/health', (req, res) => { + res.json({ + status: 'ok', + service: 'auth', + version: version, + build_number: vBuild, + version_state: vState + }); +}); + +router.post('/register', async (req, res) => { + const { username, password } = req.body; + + if (!username || !password) { + return res.status(400).json({ error: 'Username e password richiesti' }); + } + + try { + await auth.register(username, password); + res.status(201).end(); + } catch (err) { + console.error('[AUTH] Register failed:', err.message); + const status = err.message === 'User already exists' ? 409 : 500; + res.status(status).json({ error: err.message }); + } +}); + +router.get('/login', (req, res) => { + const redirect = req.query.redirect || ''; + res.render('loginpage', { error: null, redirect }); +}); + +router.post('/login', async (req, res) => { + const { username, password, redirect } = req.body; + + try { + const user = await auth.login(username, password); + const session = await auth.newSession(user.id, req.headers['user-agent'], req.ip); + const token = jwt.generateToken(user, session.id); + + const cookieOptions = { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + maxAge: 7 * 24 * 60 * 60 * 1000 // 7 giorni + }; + + if (COOKIE_DOMAIN) { + cookieOptions.domain = COOKIE_DOMAIN; + } + + res.cookie('auth_token', token, cookieOptions); + + // Redirect alla pagina da cui l'utente e' arrivato, o alla console + const destination = redirect || CONSOLE_URL; + res.redirect(destination); + } catch (err) { + console.error('[AUTH] Login failed:', err.message, err.stack); + res.render('loginpage', { error: 'Credenziali non valide', redirect: redirect || '' }); + } +}); + +router.post('/logout', async (req, res) => { + const token = req.cookies && req.cookies.auth_token; + + if (token) { + try { + const verified = jwt.verifyToken(token); + if (verified.valid) { + await auth.logout(verified.payload.session_id); + } + } catch (err) { + console.error('[AUTH] Logout error:', err.message); + } + } + + const clearOptions = { httpOnly: true, sameSite: 'lax' }; + if (COOKIE_DOMAIN) { + clearOptions.domain = COOKIE_DOMAIN; + } + + res.clearCookie('auth_token', clearOptions); + res.redirect('/login'); +}); + +module.exports = router; diff --git a/auth/src/routes/sessions.js b/auth/src/routes/sessions.js new file mode 100644 index 0000000..e69de29 diff --git a/auth/src/routes/users.js b/auth/src/routes/users.js new file mode 100644 index 0000000..0651f20 --- /dev/null +++ b/auth/src/routes/users.js @@ -0,0 +1,2 @@ +const router = require('express').Router(); + diff --git a/auth/src/static/icon.png b/auth/src/static/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..652cfe37bf0b273d53b63ea0aa2bc9e1071d164e GIT binary patch literal 32888 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 nRh3);(Jdd8s28AV { + console.error('Error in database', err); +}); + +/** + * Execute a query with parameters + * @param {string} text - SQL query + * @param {Array} params - Query parameters + * @returns {Promise} Query result + */ +async function query(text, params) { + const start = Date.now(); + const result = await pool.query(text, params); + const duration = Date.now() - start; + + if (duration > 100) { + console.warn(`[DB] Slow query (${duration}ms):`, text.substring(0, 80)); + } + + return result; +} + +/** + * Get a client from pool for transactions + * @returns {Promise} Pool client + */ +async function getClient() { + return await pool.connect(); +} + +/** + * Initialize database and ensure tables exist + */ +async function initDb() { + // Test connection + await pool.query('SELECT NOW()'); + // Ensure pgcrypto extension (provides gen_random_uuid) + // Note: creating extensions requires proper DB permissions (usually superuser in PG) + try { + await pool.query(`CREATE EXTENSION IF NOT EXISTS pgcrypto;`); + } catch (err) { + console.warn('[DB] Could not create pgcrypto extension (may require superuser):', err.message); + } + + // Ensure tables exist (UUID default generated by DB) + await pool.query(` + CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + username VARCHAR(50) UNIQUE NOT NULL, + password_hash VARCHAR(255) NOT NULL, + is_active BOOLEAN DEFAULT TRUE, + created_at TIMESTAMP DEFAULT NOW(), + telegram_id VARCHAR(50) UNIQUE + ); + + CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); + CREATE INDEX IF NOT EXISTS idx_users_telegram_id ON users(telegram_id); + `); + + await pool.query(` + CREATE TABLE IF NOT EXISTS sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + session_code VARCHAR(64) NOT NULL, + encoded_username TEXT NOT NULL, + ip_address INET, + user_agent TEXT, + browser VARCHAR(100), + os VARCHAR(100), + device_type VARCHAR(50), + location_country VARCHAR(100), + location_city VARCHAR(100), + created_at TIMESTAMP DEFAULT NOW(), + last_active TIMESTAMP DEFAULT NOW(), + is_revoked BOOLEAN DEFAULT FALSE + ); + + -- Altera colonna in base al nuovo standard token 32 byte - 64 url chars + ALTER TABLE sessions ALTER COLUMN session_code TYPE VARCHAR(64); + + CREATE INDEX IF NOT EXISTS idx_sessions_code ON sessions(session_code); + CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id); + `); +} + +module.exports = { + pool, + query, + getClient, + initDb +}; diff --git a/auth/src/storage/redis.js b/auth/src/storage/redis.js new file mode 100644 index 0000000..b0680b9 --- /dev/null +++ b/auth/src/storage/redis.js @@ -0,0 +1,36 @@ +const Redis = require('ioredis'); + +const redis = new Redis({ + host: process.env.REDIS_HOST, + port: parseInt(process.env.REDIS_PORT), + maxRetriesPerRequest: 3, + lazyConnect: true, + retryStrategy(times) { + const delay = Math.min(times * 50, 2000); + return delay; + } +}) + +redis.on('error', (error) => { + console.error('redis error', error); +}) + +redis.on('connect', () => {}) +redis.on('reconnecting', () => { + console.log('reconnecting to redis') +}) + +async function configure() { + try { + await redis.connect(); + await redis.ping(); + } catch (err) { + console.error('Redis error', err); + } +} + +function connected() { + return redis.status === 'ready'; +} + +module.exports = { redis, configure, connected }; \ No newline at end of file diff --git a/auth/src/templates/loginpage.html b/auth/src/templates/loginpage.html new file mode 100644 index 0000000..87ad4fe --- /dev/null +++ b/auth/src/templates/loginpage.html @@ -0,0 +1,39 @@ + + + + + + + + + + +
+ +
+ + + \ No newline at end of file diff --git a/auth/src/templates/sessions.html b/auth/src/templates/sessions.html new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/auth/src/templates/sessions.html @@ -0,0 +1 @@ + diff --git a/auth/src/templates/user.html b/auth/src/templates/user.html new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/auth/src/templates/user.html @@ -0,0 +1 @@ + diff --git a/auth/src/tools/jwt.js b/auth/src/tools/jwt.js new file mode 100644 index 0000000..a87fadb --- /dev/null +++ b/auth/src/tools/jwt.js @@ -0,0 +1,70 @@ +const jwt = require('jsonwebtoken'); + +const secret = process.env.JWT_SECRET; +const expires_in = process.env.JWT_EXPIRES_IN; + +/** + * Genera un JWT Token a partire dall'utente e crea una nuova sessione + * + * Uso dell'algoritmo HS256 per firmare il token con JWT_SECRET + * + * @param {Object} user - Utente + * @param {string} sessionID - ID della sessione + * @returns {string} - JWT Token + */ +function generateToken(user, sessionID) { + const payload = { + sub: user.id, + username: user.username, + session_id: sessionID, + iat: Math.floor(Date.now() / 1000) + }; + + return jwt.sign(payload, secret, { expiresIn: expires_in, algorithm: 'HS256' }); +} + +/** + * Verifica e decodifica il token + * @param {string} token - JWT Token + * @returns {{valid: boolean, payload?: Object, error?: string, reason?: string}} - Il risultato della verifica. Se fallisce restituisce errore e motivo, altrimenti restituisce una conferma e il payload completo + */ +function verifyToken(token) { + try { + const payload = jwt.verify(token, secret, { + algorithms: ['HS256'] + }); + return { + valid: true, + payload: { + user_id: payload.sub, + username: payload.username, + session_id: payload.session_id, + iat: payload.iat, + exp: payload.exp + } + }; + } catch (err) { + + const reason = err.name === 'TokenExpiredError' ? 'expired' : 'invalid'; + + return { + valid: false, + error: err.message, + reason: `token ${reason}` + }; + } +} + +function getToken(header) { + if (!header) return null; + + const parts = header.split(' '); + if (parts.length === 2 && parts[0].toLowerCase() === 'bearer') { + return parts[1]; + } + + //TODO: valutare se modificare in return null per accettare solo metodo Bearer Authorization Token, + return header; +} + +module.exports = { generateToken, verifyToken, getToken }; \ No newline at end of file diff --git a/auth/src/tools/security.js b/auth/src/tools/security.js new file mode 100644 index 0000000..13d4c3b --- /dev/null +++ b/auth/src/tools/security.js @@ -0,0 +1,54 @@ +const bcrypt = require('bcrypt'); +const crypto = require('crypto'); + +const saltRounds = 12; + +/** + * Genera un hash di una password + * @param {string} password - Password da hashare + * @returns {string} - Hash della password + */ +function hashPassword(password) { + return bcrypt.hashSync(password, saltRounds); +} + +/** + * Verifica una password + * @param {string} password - Password da verificare + * @param {string} hash - Hash della password + * @returns {boolean} - True se la password è corretta, false altrimenti + */ +function verifyPassword(password, hash) { + return bcrypt.compareSync(password, hash); +} + +/** + * Create a session token from code and username + * Format: XXXXXXXX-base64_username + * @param {string} sessionCode + * @param {string} username + * @returns {string} Session token + */ +function generateSessionCode() { + return crypto.randomBytes(32).toString('base64url'); +} + +/** + * Parse a session token + * @param {string} token + * @returns {string|null} The session token itself if valid + */ +function parseSessionToken(token) { + if (!token || typeof token !== 'string' || token.length < 32 || token.length > 64) { + return null; + } + + return token; +} + +module.exports = { + hashPassword, + verifyPassword, + generateSessionCode, + parseSessionToken +}; diff --git a/auth/src/tools/tracking.js b/auth/src/tools/tracking.js new file mode 100644 index 0000000..62287b9 --- /dev/null +++ b/auth/src/tools/tracking.js @@ -0,0 +1,24 @@ +//TODO: Verfica se serve davvero prendere le info come ip e browser + +const { UAParser: parser } = require('ua-parser-js'); + +/** + * Estrae le informazioni base su browser, sistema operativo e dispositivo per identificare meglio la sessione dell'utente + * @param {*} userAgent + * @returns + */ +function getBasicMetadata(userAgent) { + const parsed = parser(userAgent); + + return { + browser: parsed.browser.name, + os: parsed.os.name, + device_type: parsed.device.type, + + } +} + +module.exports = { + getBasicMetadata +} + diff --git a/console/.dockerignore b/console/.dockerignore new file mode 100644 index 0000000..5171c54 --- /dev/null +++ b/console/.dockerignore @@ -0,0 +1,2 @@ +node_modules +npm-debug.log \ No newline at end of file diff --git a/console/Dockerfile b/console/Dockerfile new file mode 100644 index 0000000..c9b6242 --- /dev/null +++ b/console/Dockerfile @@ -0,0 +1,13 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm install + + +COPY src ./src + +EXPOSE 3004 + +CMD ["node", "src/index.js"] \ No newline at end of file diff --git a/console/package-lock.json b/console/package-lock.json new file mode 100644 index 0000000..955ec20 --- /dev/null +++ b/console/package-lock.json @@ -0,0 +1,1127 @@ +{ + "name": "meb-console-service", + "version": "1.4.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "meb-console-service", + "version": "1.4.0", + "dependencies": { + "cookie-parser": "^1.4.7", + "dotenv": "^17.3.1", + "express": "^5.2.1", + "ioredis": "^5.10.0", + "jsonwebtoken": "^9.0.3", + "nunjucks": "^3.2.4" + } + }, + "node_modules/@ioredis/commands": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", + "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", + "license": "MIT" + }, + "node_modules/a-sync-waterfall": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/a-sync-waterfall/-/a-sync-waterfall-1.0.1.tgz", + "integrity": "sha512-RYTOHHdWipFUliRFMCS4X2Yn2X8M87V/OpSqWzKKOGhzqyUxzyVmhHDH9sAvG+ZuQf/TAOFsLCpMw09I1ufUnA==", + "license": "MIT" + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-parser/node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dotenv": { + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz", + "integrity": "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ioredis": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.0.tgz", + "integrity": "sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nunjucks": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/nunjucks/-/nunjucks-3.2.4.tgz", + "integrity": "sha512-26XRV6BhkgK0VOxfbU5cQI+ICFUtMLixv1noZn1tGU38kQH5A5nmmbk/O45xdyBhD1esk47nKrY0mvQpZIhRjQ==", + "license": "BSD-2-Clause", + "dependencies": { + "a-sync-waterfall": "^1.0.0", + "asap": "^2.0.3", + "commander": "^5.1.0" + }, + "bin": { + "nunjucks-precompile": "bin/precompile" + }, + "engines": { + "node": ">= 6.9.0" + }, + "peerDependencies": { + "chokidar": "^3.3.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/console/package.json b/console/package.json new file mode 100644 index 0000000..d9a5312 --- /dev/null +++ b/console/package.json @@ -0,0 +1,19 @@ +{ + "name": "meb-console-service", + "version": "1.4.0", + "description": "Console service for meb", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "dev": "node --watch src/index.js", + "generate-docs": "node -e \"console.log(JSON.stringify(require('./swagger-specs-logic')))\" > openapi.json" + }, + "dependencies": { + "cookie-parser": "^1.4.7", + "dotenv": "^17.3.1", + "express": "^5.2.1", + "ioredis": "^5.10.0", + "jsonwebtoken": "^9.0.3", + "nunjucks": "^3.2.4" + } +} diff --git a/console/src/index.js b/console/src/index.js new file mode 100644 index 0000000..10b2382 --- /dev/null +++ b/console/src/index.js @@ -0,0 +1,92 @@ +const express = require('express'); +const nunjucks = require('nunjucks'); +const path = require('path'); +const jwt = require('jsonwebtoken'); + +const parser = require('cookie-parser'); + +const app = express(); +const PORT = process.env.PORT; + +const version = process.env.VERSION; +const vBuild = process.env.VERSION_BUILD; +const vState = process.env.VERSION_STATE; + +app.use(express.json()); +app.use(parser()); + +// Set up static files serving +const staticFolder = path.join(__dirname, 'static'); +app.use('/static', express.static(staticFolder)); + +app.get('/', (req, res) => { + res.redirect(301, "/dashboard") +}); + +app.get('/health', (req, res) => { + res.json({ + status: "ok", + service: "console", + version: version, + build_number: vBuild, + version_state: vState + }); +}); + +const pagesFolder = path.join(__dirname, 'pages'); +nunjucks.configure(pagesFolder, { + autoescape: true, + express: app, + noCache: true, + watch: false +}) +app.set('views', pagesFolder); +app.set('view engine', 'html'); + +const renderPage = (page, extra = {}) => (req, res) => { + res.render(page, {current_path: req.path, ...extra}) +} + +// Middleware di autenticazione per le pagine +app.use((req, res, next) => { + if (req.path === '/health' || req.path.startsWith('/static')) { + return next(); + } + + const authBase = process.env.AUTH_LOGIN_URL || 'http://localhost:3006/login'; + + // Costruisci l'URL di redirect-back: protocollo + host + path originale + const proto = req.protocol; + const host = req.get('host'); + const redirectBack = `${proto}://${host}${req.originalUrl}`; + const loginUrl = `${authBase}?redirect=${encodeURIComponent(redirectBack)}`; + + const token = req.cookies && req.cookies.auth_token; + + if (!token) { + return res.redirect(loginUrl); + } + + try { + const payload = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] }); + req.user = payload; + next(); + } catch (err) { + const clearOptions = { httpOnly: true, sameSite: 'lax' }; + if (process.env.COOKIE_DOMAIN) { + clearOptions.domain = process.env.COOKIE_DOMAIN; + } + res.clearCookie('auth_token', clearOptions); + return res.redirect(loginUrl); + } +}); + +app.get('/dashboard', renderPage('dashboard')); +app.get('/live', renderPage('live', { + realtimeUrl: process.env.REALTIME_URL || 'http://localhost:3002', + realtimeWsUrl: process.env.REALTIME_WS_URL || 'ws://localhost:3002' +})); + +app.listen(PORT, () => { + console.log(`Started on port ${PORT}`); +}); diff --git a/console/src/pages/dashboard.html b/console/src/pages/dashboard.html new file mode 100644 index 0000000..249530b --- /dev/null +++ b/console/src/pages/dashboard.html @@ -0,0 +1,55 @@ + + + + + + + + +
+
+

+ +
+

username

+ +
+
+ +
+
🚤
+

Benvenuto nella MEB Console

+

Usa uno dei tool a disposizione per visualizzare i dati dalla barca in tempo reale, creare nuovi dataset condivisi e

+
+ +
+ + +
+

Live

+

Visualizza i dati dei sensori sulla barca in tempo reale.

+
+
+ + +
+

Previsioni

+

Visualizza le condizioni meteo attuali e le previsioni future.

+
+
+ + +
+

Previsioni

+

Visualizza le condizioni meteo attuali e le previsioni future.

+
+
+ +
+ +
+ + + \ No newline at end of file diff --git a/console/src/pages/live.html b/console/src/pages/live.html new file mode 100644 index 0000000..e4eac83 --- /dev/null +++ b/console/src/pages/live.html @@ -0,0 +1,707 @@ + + + + + + + + + + + + +
+ + +
+
+

Sessioni Attive

+ +
+
Caricamento sessioni...
+
+ +
+
+ + + + + + + +
+
+ + + + +
+ +
+ +
+
+
+ HDG +
+
+
+ Vento +
+
+
+ Onde +
+
+
+
+ + + + diff --git a/console/src/static/styles/dashboard.css b/console/src/static/styles/dashboard.css new file mode 100644 index 0000000..4e0230a --- /dev/null +++ b/console/src/static/styles/dashboard.css @@ -0,0 +1,26 @@ +.content { + width: 100%; + height: 100%; +} + + +.card[title="Live"] { + position: relative; + z-index: 1; +} + +.card[title="Live"]::before { + content: ''; + position: absolute; + inset: 0; + z-index: -1; + background: linear-gradient(135deg, #ff00d0, #0026ff); + filter: blur(40px); + opacity: 0; + transition: opacity 0.7s ease; + border-radius: inherit; +} + +.card[title="Live"]:hover::before { + opacity: 0.2; +} \ No newline at end of file diff --git a/console/src/static/styles/live.css b/console/src/static/styles/live.css new file mode 100644 index 0000000..5ab0e46 --- /dev/null +++ b/console/src/static/styles/live.css @@ -0,0 +1,669 @@ +/* === Session Overlay Popup === */ +.session-overlay { + position: fixed; + inset: 0; + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); +} + +.session-popup { + background: var(--header-bg, #fff); + border: 1px solid var(--header-border); + border-radius: 24px; + padding: 32px; + width: 420px; + max-width: 90vw; + max-height: 70vh; + display: flex; + flex-direction: column; + box-shadow: var(--shadow-lg, 0 20px 60px rgba(0,0,0,0.3)); + user-select: none; +} + +.session-popup h2 { + font-size: 1.25rem; + font-weight: 600; + color: var(--text-primary); + margin: 0 0 4px; +} + +.popup-subtitle { + font-size: 0.85rem; + color: var(--text-secondary); + margin: 0 0 20px; +} + +.session-list { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 16px; +} + +.session-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 16px; + border-radius: 16px; + border: 1px solid var(--header-border); + background: var(--surface, #f8fafc); + cursor: pointer; + transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease; +} + +.session-item:hover { + border-color: var(--accent-light, #bfdbfe); + box-shadow: 0 0 0 2px var(--accent-light, #bfdbfe); + transform: translateY(-1px); +} + +.session-item-info { + display: flex; + flex-direction: column; + gap: 2px; +} + +.session-item-info strong { + font-size: 0.95rem; + color: var(--text-primary); +} + +.session-item-id { + font-size: 0.75rem; + color: var(--text-tertiary); + font-family: monospace; +} + +.session-item-meta { + display: flex; + align-items: center; + gap: 8px; +} + +.session-item-time { + font-size: 0.8rem; + color: var(--text-secondary); +} + +.session-item-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #10b981; + box-shadow: 0 0 8px #10b981; + flex-shrink: 0; +} + +.session-loading, +.session-empty, +.session-error { + text-align: center; + padding: 32px 16px; + color: var(--text-secondary); + font-size: 0.9rem; +} + +.session-error { + color: #ef4444; +} + +/* === Top Info (Last Update) === */ +.last-update { + font-size: 0.8rem; + color: var(--text-secondary); + margin-top: 4px; +} + +/* === Layout Wrappers === */ +.dashboard-layout { + display: flex; + align-items: flex-start; + gap: 24px; + margin: 0 30px 20px; + padding-bottom: 140px; /* Evita che la bottom bar copra i dati */ +} + +.main-column { + flex: 1; + min-width: 0; +} + +/* === Sticky Map & Expanded Chart === */ +.sticky-area { + position: sticky; + top: 20px; + z-index: 100; + display: flex; + flex-direction: column; + gap: 16px; + margin-bottom: 20px; +} + +.map-container { + border-radius: 24px; + overflow: hidden; + border: 1px solid rgba(226, 232, 240, 0.6); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.04); + background: var(--surface); +} + +.map-container #liveMap { + width: 100%; + height: 300px; /* Ridotto un po' per far spazio */ +} + +.expanded-chart-container { + background: var(--surface, #f8fafc); + border: 1px solid rgba(226, 232, 240, 0.6); + border-radius: 24px; + padding: 20px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.04); + position: relative; + display: flex; + flex-direction: column; + height: 300px; +} + +.expanded-chart-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.expanded-chart-header h3 { + margin: 0; + font-size: 1rem; + color: var(--text-primary); +} + +.close-expanded-btn { + background: transparent; + border: none; + color: var(--text-secondary); + cursor: pointer; + font-size: 1.2rem; + line-height: 1; +} + +.close-expanded-btn:hover { + color: var(--text-primary); +} + +.expanded-chart-body { + flex: 1; + min-height: 0; + position: relative; +} + +.expanded-chart-body canvas { + position: absolute; + inset: 0; + width: 100% !important; + height: 100% !important; +} + +/* === Card ibrida (Dati + Minigrafico) === */ +.grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 20px; +} + +.data-card { + background: var(--surface, #ffffff); + border: 1px solid rgba(226, 232, 240, 0.6); + border-radius: 24px; + padding: 20px; + display: flex; + flex-direction: column; + box-shadow: 0 4px 20px rgba(0,0,0,0.03); + position: relative; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.data-card:hover { + box-shadow: 0 12px 32px rgba(0,0,0,0.06); + border-color: var(--accent-light); + transform: translateY(-2px); +} + +.card-top { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 12px; +} + +.card-title-group { + display: flex; + align-items: center; + gap: 10px; +} + +.card-icon { + width: 32px; + height: 32px; + border-radius: 8px; + background: #f1f5f9; + color: #64748b; + display: flex; + align-items: center; + justify-content: center; +} + +.card-info { + margin-left: 6px; +} + +.card-info h4 { + margin: 0; + font-size: 0.9rem; + color: var(--text-primary); + font-weight: 600; +} + +.card-info span { + font-size: 0.75rem; + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.card-actions { + display: flex; + gap: 6px; + opacity: 0; + transition: opacity 0.2s; +} + +.data-card:hover .card-actions { + opacity: 1; +} + +.card-action-btn { + background: transparent; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 4px; + border-radius: 4px; +} + +.card-action-btn:hover { + background: #f1f5f9; + color: var(--text-primary); +} + +.card-body { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 16px; + padding: 0 6px 6px; +} + +.card-values { + display: flex; + flex-direction: row; + align-items: flex-end; + gap: 4px; + min-width: 80px; +} + +.card-main-val { + font-size: 1.75rem; + font-weight: 700; + color: var(--text-primary); + line-height: 1; +} + +.card-unit { + font-size: 0.85rem; + color: var(--text-secondary); + font-weight: 500; + margin-bottom: 2px; +} + +.card-mini-chart { + flex: 1; + height: 50px; + position: relative; + min-width: 0; +} + +.card-mini-chart canvas { + position: absolute; + inset: 0; + width: 100% !important; + height: 100% !important; +} + +/* Modalità Confronto disabilita il minigrafico se selezionato, ma lascio a JS questo controllo visivo */ + +/* === Sidebar Confronto === */ +.comparison-sidebar { + width: 400px; + background: var(--surface); + border: 1px solid rgba(226, 232, 240, 0.6); + border-radius: 24px; + padding: 24px; + display: flex; + flex-direction: column; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.04); + position: sticky; + top: 20px; + height: calc(100vh - 120px); + overflow: hidden; +} + +.comp-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + border-bottom: 1px solid var(--header-border); + padding-bottom: 12px; +} + +.comp-header h3 { + margin: 0; + font-size: 1.1rem; +} + +.comp-close { + background: transparent; + border: none; + cursor: pointer; + font-size: 1.2rem; + color: var(--text-secondary); +} + +.comp-list { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 16px; +} + +.comp-pill { + padding: 4px 10px; + border-radius: 12px; + font-size: 0.8rem; + display: flex; + align-items: center; + gap: 6px; + background: #f1f5f9; + border: 1px solid var(--header-border); +} + +.comp-pill .color-dot { + width: 8px; + height: 8px; + border-radius: 50%; +} + +.comp-pill button { + background: none; + border: none; + cursor: pointer; + line-height: 1; + font-size: 1rem; + color: var(--text-secondary); +} + +.comp-chart-area { + flex: 1; + min-height: 0; + position: relative; +} + +.comp-chart-area canvas { + position: absolute; + inset: 0; + width: 100% !important; + height: 100% !important; +} + +/* Quando la modalita confronto è attiva le card cambiano aspetto al click? Lo gestisce JS (es. classe .selected-for-comp) */ +.data-card.selected-for-comp { + border-color: var(--accent-color); + box-shadow: 0 0 0 1px var(--accent-color); +} +.data-card.selected-for-comp .card-mini-chart { + opacity: 0.2; /* Nasconde o sbiadisce il mini chart per far capire che è in confronto */ +} + +/* === Bottom Bar & secondary ... same as before mostly === */ +.bottom-bar { + position: fixed; + bottom: 32px; + left: 50%; + transform: translateX(-50%); + display: flex; + width: max-content; + max-width: 95vw; + height: 56px; + justify-content: center; + align-items: center; + gap: 12px; + padding: 0 16px; + font-size: 15px; + user-select: none; + background-color: rgba(255, 255, 255, 0.75); + backdrop-filter: blur(24px) saturate(1.5); + -webkit-backdrop-filter: blur(24px) saturate(1.5); + border: 1px solid rgba(255, 255, 255, 0.4); + border-radius: 28px; + z-index: 1000; + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.08), 0 4px 12px rgba(0, 0, 0, 0.02); + overflow-x: auto; + scrollbar-width: none; + color: var(--text-primary); +} + +.bottom-bar::-webkit-scrollbar { + display: none; +} + +.bottom-bar .search-field { + height: 38px; + width: 200px; + padding-right: 10px; + border-radius: 14px; + border: 1px solid rgba(226, 232, 240, 0.8); + background: rgba(255, 255, 255, 0.6); + display: flex; + align-items: center; + transition: background 0.3s ease, border-color 0.3s ease, box-shadow 0.3s ease; + flex-shrink: 0; +} + +.bottom-bar .search-field:focus-within { + background: rgba(255, 255, 255, 0.9); + border-color: var(--accent-light, #bfdbfe); + box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.1); +} + +.bottom-bar .search-field input { + border: none; + outline: none; + background: transparent; + color: var(--text-primary); + font-size: 14px; + padding: 5px; + width: 100%; +} + +.bottom-bar .filter { + display: flex; + height: 38px; + padding: 0 4px; + align-items: center; + gap: 4px; + flex-shrink: 0; + border-radius: 14px; + border: 1px solid rgba(226, 232, 240, 0.8); + background: rgba(255, 255, 255, 0.6); +} + +.bottom-bar .filter.boxed button { + display: flex; + width: 30px; + height: 30px; + padding: 0; + justify-content: center; + align-items: center; +} + +.bottom-bar .filter button { + display: flex; + padding: 6px 12px; + justify-content: center; + align-items: center; + border-radius: 8px; + border: none; + background: transparent; + color: var(--text-secondary); + transition: color 0.15s ease, background 0.15s ease; + cursor: pointer; + white-space: nowrap; + font-size: 13px; + font-weight: 500; +} + +.bottom-bar .filter button.active { + background: var(--accent-color); + color: #ffffff; +} + +.bottom-bar > button#downloadBtn { + display: flex; + padding: 6px 16px; + justify-content: center; + align-items: center; + border-radius: 12px; + background: var(--surface, #f8fafc); + border: 1px solid var(--header-border); + color: var(--text-primary); + font-size: 13px; + font-weight: 600; + cursor: pointer; +} + +.bar-sep { + width: 1px; + height: 24px; + background-color: var(--header-border); + margin: 0 4px; + flex-shrink: 0; +} + +.map-bar { + position: fixed; + bottom: 6rem; + left: 50%; + transform: translateX(-50%) translateY(20px); + display: flex; + width: max-content; + height: 48px; + justify-content: center; + align-items: center; + gap: 12px; + padding: 0 16px; + background-color: rgba(255, 255, 255, 0.75); + backdrop-filter: blur(24px) saturate(1.5); + -webkit-backdrop-filter: blur(24px) saturate(1.5); + border: 1px solid rgba(255, 255, 255, 0.4); + border-radius: 24px; + z-index: 1000; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.06); + opacity: 0; + pointer-events: none; + transition: opacity 0.3s ease, transform 0.3s ease; +} + +.map-bar.visible { + opacity: 1; + pointer-events: all; + transform: translateX(-50%) translateY(0); +} + +.map-bar .filter { + display: flex; + height: 36px; + padding: 0 4px; + align-items: center; + gap: 4px; + flex-shrink: 0; + border-radius: 12px; + border: 1px solid var(--header-border); + background: var(--surface, #f8fafc); +} + +.map-bar .filter button { + display: flex; + padding: 6px 12px; + justify-content: center; + align-items: center; + border-radius: 8px; + border: none; + background: transparent; + color: var(--text-secondary); +} + +.map-bar .filter button.active { + background: var(--accent-color); + color: #ffffff; +} + +.map-toggles-group { + display: flex; + gap: 8px; + align-items: center; +} + +.bb-toggle { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + background: var(--surface, #f8fafc); + border: 1px solid var(--header-border); + border-radius: 12px; + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); + cursor: pointer; +} + +.bb-toggle.on { + border-color: var(--accent-border, #bfdbfe); + background: var(--accent-light, #eff6ff); + color: var(--accent-color, #2563eb); +} + +@media (max-width: 1100px) { + .dashboard-layout { + flex-direction: column; + } + .comparison-sidebar { + width: 100%; + height: 400px; + position: static; + } +} + +@media (max-width: 900px) { + .grid { + grid-template-columns: 1fr; + } +} + diff --git a/console/src/static/styles/style.css b/console/src/static/styles/style.css new file mode 100644 index 0000000..8b7defd --- /dev/null +++ b/console/src/static/styles/style.css @@ -0,0 +1,201 @@ +:root { + --accent-color: #2563eb; + --accent-hover: #1d4ed8; + --accent-light: #eff6ff; + --accent-border: #bfdbfe; + + --text-primary: #0f172a; + --text-secondary: #4755698f; + --text-tertiary: #94a3b8c0; + + --surface: #f8fafc; + + --header-bg: rgba(255, 255, 255, 0.85); + /* For Glassmorphism */ + --header-border: #e2e8f0; + + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + --radius-md: 8px; + --radius-lg: 12px; +} + +* { + margin: 0; + padding: 0; +} + +@font-face { + font-family: 'Normal'; + src: url('../font/Quicksand-VariableFont_wght.ttf'); + font-weight: 400; + font-style: normal; +} + +@font-face { + font-family: 'Bold'; + src: url('../font/Quicksand-VariableFont_wght.ttf'); + font-weight: 700; + font-style: normal; +} + +body { + font-family: 'Normal', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + color: var(--text-primary); + -webkit-font-smoothing: antialiased; +} + +button { + padding: 10px 24px; + border-radius: var(--radius-lg); + border: 1px solid var(--header-border); + background-color: var(--bg-surface); + color: var(--text-primary); + font-size: 14px; + font-weight: 600; + font-family: 'Bold', inherit; + cursor: pointer; + transition: all 0.5s cubic-bezier(0.8, 0, 0.2, 1); + white-space: nowrap; + flex-shrink: 0; +} + +button:hover { + background-color: var(--accent-light); + color: var(--accent-color); + border-color: var(--accent-border); +} + +button.prominent { + background-color: var(--accent-color); + color: #ffffff; + border: 1px solid transparent; + box-shadow: var(--shadow-sm); +} + +button.prominent:hover { + background-color: var(--accent-hover); + transform: translateY(-1px); + box-shadow: var(--shadow-md); +} + + +button.prominent:active { + transform: translateY(1px); + box-shadow: var(--shadow-sm); +} + + +/* INFO PANEL */ + +.info-panel { + display: block; + text-align: center; + align-content: center; + padding: 60px 20px; + user-select: none; +} + +.info-panel h3 { + margin-bottom: 10px; +} + +.info-panel p { + margin-bottom: 25px; +} + +.info-panel .icon { + font-size: 48px; + margin-bottom: 20px; + align-self: center; + transition: transform 0.12s ease; +} + + + + +/* GRID & CARD ITEMS */ + +.grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1.5rem; + margin-inline: 30px; +} + +.card { + display: flex; + gap: 0.75rem; + align-items: center; + padding: 1rem; + border: 1px solid var(--header-border); + border-radius: 20px; + text-decoration: none; + color: var(--text-primary); + transition: transform 0.12s ease, box-shadow 0.12s ease; +} + +.card h3 { + margin: 0; + font-size: 1rem; + text-align: left; +} + +.card p { + margin: 0.25rem 0 0; + color: var(--text-secondary); + opacity: 0.4; + font-size: 0.8rem; + text-align: left; +} + +.card:hover { + transform: translateY(-4px); + box-shadow: 0 8px 30px #bfdbfe30; +} + +.card.standalone { + grid-column: 1 / -1; +} + + + + + +/* HEADER */ + +.header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 24px; + background-color: var(--header-bg); + border-bottom: 1px solid var(--header-border); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + position: sticky; + top: 0; + z-index: 10; + user-select: none; +} + + +.header h1 { + color: var(--text-primary); + font-size: 1.25rem; + font-weight: 700; + letter-spacing: -0.025em; +} + +.header .profile { + display: flex; + align-items: center; + gap: 16px; +} + +.header .profile p { + font-size: 0.875rem; + font-weight: 500; + color: var(--text-secondary); + padding-inline: 5px; +} \ No newline at end of file diff --git a/copernicus/Dockerfile b/copernicus/Dockerfile new file mode 100644 index 0000000..e69de29 diff --git a/copernicus/core/cache.py b/copernicus/core/cache.py new file mode 100644 index 0000000..09881f9 --- /dev/null +++ b/copernicus/core/cache.py @@ -0,0 +1,125 @@ +""" +Redis Keys: +- marine:catalog:full → lista dei dataset completo (TTL 1h) +- marine:catalog:search:{hash} → risultati ricerca (TTL 30min) +- marine:job:{session_id} → stato job download (TTL 48h) +""" + +import json +import os +import logging +from typing import Any, Optional + +import redis + +logger = logging.getLogger(__name__) + +# Configurazione Redis da variabili ambiente +REDIS_HOST = os.getenv("REDIS_HOST", "meb-redis") +REDIS_PORT = int(os.getenv("REDIS_PORT", "6379")) + +# Pool di connessioni condiviso (thread-safe, riutilizzabile) +_pool: Optional[redis.ConnectionPool] = None +_client: Optional[redis.Redis] = None + + +def _get_client() -> Optional[redis.Redis]: + """Restituisce il client Redis singleton con connection pool. + Ritorna None se Redis non è raggiungibile.""" + global _pool, _client + + if _client is not None: + return _client + + try: + _pool = redis.ConnectionPool( + host=REDIS_HOST, + port=REDIS_PORT, + # Decodifica automatica delle risposte in stringhe UTF-8 + decode_responses=True, + # Massimo 5 connessioni nel pool (VPS 1-core, non serve di più) + max_connections=5, + # Timeout connessione e socket per evitare blocchi + socket_connect_timeout=3, + socket_timeout=3, + # Riprova automaticamente se la connessione viene interrotta + retry_on_timeout=True, + ) + _client = redis.Redis(connection_pool=_pool) + # Test connessione + _client.ping() + logger.info("[Redis] Connessione stabilita per il servizio Marine") + return _client + except Exception as e: + logger.warning(f"[Redis] Non disponibile, la cache è disabilitata: {e}") + _client = None + return None + + +def cache_get(key: str) -> Optional[Any]: + """Legge un valore dalla cache Redis. + + Args: + key: Chiave Redis (es. 'marine:catalog:full') + + Returns: + Il valore deserializzato da JSON, oppure None se non trovato o errore + """ + try: + client = _get_client() + if client is None: + return None + + data = client.get(key) + if data is None: + return None + + return json.loads(data) + except Exception as e: + logger.warning(f"[Redis] Errore lettura chiave '{key}': {e}") + return None + + +def cache_set(key: str, value: Any, ttl: int = 3600) -> bool: + """Scrive un valore nella cache Redis con TTL. + + Args: + key: Chiave Redis + value: Valore da serializzare in JSON + ttl: Tempo di vita in secondi (default: 1 ora) + + Returns: + True se scritto con successo, False altrimenti + """ + try: + client = _get_client() + if client is None: + return False + + serialized = json.dumps(value) + client.setex(key, ttl, serialized) + return True + except Exception as e: + logger.warning(f"[Redis] Errore scrittura chiave '{key}': {e}") + return False + + +def cache_delete(key: str) -> bool: + """Elimina una chiave dalla cache Redis. + + Args: + key: Chiave Redis da eliminare + + Returns: + True se eliminata, False altrimenti + """ + try: + client = _get_client() + if client is None: + return False + + client.delete(key) + return True + except Exception as e: + logger.warning(f"[Redis] Errore eliminazione chiave '{key}': {e}") + return False diff --git a/copernicus/core/copernicus.py b/copernicus/core/copernicus.py new file mode 100644 index 0000000..1ab20d6 --- /dev/null +++ b/copernicus/core/copernicus.py @@ -0,0 +1,310 @@ +import hashlib +import io +import logging +import os +from datetime import datetime, timezone +from typing import Callable, List, Optional + +import pandas as pd + +from core.cache import cache_get, cache_set + +logger = logging.getLogger(__name__) + +# ── Chiavi Redis e TTL ──────────────────────────────────────────────── +# Chiave per il catalogo completo Copernicus +_CATALOG_KEY = "marine:catalog:full" +# TTL del catalogo: 1 ora (il catalogo Copernicus cambia raramente) +_CATALOG_TTL = 3600 +# TTL per i risultati di ricerca: 30 minuti +_SEARCH_TTL = 1800 + + +def _fmt_description(name: Optional[str]) -> Optional[str]: + """Formatta meglio il titolo del dataset""" + if not name: + return None + return name.replace("_", " ").title() + + +def _get_raw_catalog() -> dict: + """Interroga le API di Copernicus per ottenere la lista completa dei dataset. + + Strategia cache Redis: + 1. Cerca in Redis (chiave marine:catalog:full) + 2. Se non trovato → chiama Copernicus SDK → salva in Redis con TTL 1h + 3. Se Redis non disponibile → chiama sempre l'SDK (nessuna cache) + + Il catalogo in Redis sopravvive al restart del servizio grazie + alla persistenza RDB+AOF configurata in redis.conf. + """ + # Cerca in Redis prima di chiamare l'SDK Copernicus + cached = cache_get(_CATALOG_KEY) + if cached is not None: + logger.debug("[Catalogo] Servito da cache Redis") + return cached + + # Cache miss: interroga Copernicus SDK (operazione lenta, ~10-30s) + logger.info("[Catalogo] Cache miss, scaricamento da Copernicus SDK...") + import copernicusmarine + catalog = copernicusmarine.describe(disable_progress_bar=True) + + # Serializza la risposta SDK in un dizionario standard + if hasattr(catalog, "model_dump"): + result = catalog.model_dump() + elif hasattr(catalog, "__dict__"): + result = catalog.__dict__ + else: + result = catalog + + # Salva in Redis per le prossime richieste (TTL 1 ora) + cache_set(_CATALOG_KEY, result, _CATALOG_TTL) + logger.info("[Catalogo] Salvato in cache Redis") + + return result + + +def _get_dataset_reqs(ds: dict) -> tuple: + """ + Ottieni dalla risposta del dataset le variabili disponibili e le coordinate dell'area disponibile. + + Attualmente è implementato Copernicus SDK v2, le variabili sono in:: + dataset -> versions[-1] -> parts[] -> services[] -> variables[] + + Le coordinate sono disponibili in variable.bbox = [min_lon, min_lat, max_lon, max_lat]. + La finestra temporale disponibile è nel servizio "arco-time-series" + dove coordinate_id == 'time' (i valori sono in millisecondi, usando Unix epoch). + """ + variables = [] + seen: set = set() + bounds = { + "min_longitude": None, "max_longitude": None, + "min_latitude": None, "max_latitude": None, + "start_datetime": None, "end_datetime": None, + } + + versions = ds.get("versions", []) + if not versions: + return variables, bounds + + for part in versions[-1].get("parts", []): + for service in part.get("services", []): + service_name = service.get("service_name", "") + for var in service.get("variables", []): + short_name = var.get("short_name", "") + if not short_name or short_name in seen: + continue + seen.add(short_name) + std = var.get("standard_name") + variables.append({ + "short_name": short_name, + "standard_name": std, + "units": var.get("units"), + "description": _fmt_description(std), + }) + + # Ottieni la box delle coordinate + if bounds["min_longitude"] is None: + bbox = var.get("bbox") + if bbox and len(bbox) >= 4: + # [min_lon, min_lat, max_lon, max_lat] + bounds["min_longitude"] = bbox[0] + bounds["min_latitude"] = bbox[1] + bounds["max_longitude"] = bbox[2] + bounds["max_latitude"] = bbox[3] + + # Ottieni la finestra temporale del dataset dal servizio "arco-time-series" + if bounds["start_datetime"] is None and "arco-time" in service_name: + for coord in var.get("coordinates", []): + if coord.get("coordinate_id") == "time": + min_ms = coord.get("minimum_value") + max_ms = coord.get("maximum_value") + if min_ms is not None: + bounds["start_datetime"] = datetime.fromtimestamp( + min_ms / 1000, tz=timezone.utc + ).strftime("%Y-%m-%d") + if max_ms is not None: + bounds["end_datetime"] = datetime.fromtimestamp( + max_ms / 1000, tz=timezone.utc + ).strftime("%Y-%m-%d") + break + + return variables, bounds + + +def get_catalog(search: Optional[str] = None, limit: int = 50, offset: int = 0) -> dict: + """Ottieni dataset dal catalogo Copernicus Marine, filtrabili per nome o ID. + + Cache Redis per le ricerche: + - Chiave: marine:catalog:search:{md5(search|limit|offset)} + - TTL: 30 minuti + - La cache ricerca viene invalidata quando il catalogo scade (1h) + """ + # Genera chiave cache unica per questa combinazione di parametri + cache_key = None + if search: + query_hash = hashlib.md5(f"{search}|{limit}|{offset}".encode()).hexdigest()[:12] + cache_key = f"marine:catalog:search:{query_hash}" + + # Cerca risultato in cache Redis + cached_result = cache_get(cache_key) + if cached_result is not None: + logger.debug(f"[Catalogo] Ricerca '{search}' servita da cache Redis") + return cached_result + + raw = _get_raw_catalog() + # Gestisce formati diversi della risposta SDK (lista o dizionario) + if isinstance(raw, list): + products = raw + else: + products = raw.get("products", []) + + results = [] + for product in products: + title = product.get("title", "") + description = product.get("description", "") + + for ds in product.get("datasets", []): + dataset_id = ds.get("dataset_id", "") + + if search: + needle = search.lower() + if needle not in dataset_id.lower() and needle not in title.lower(): + continue + + variables, bounds = _get_dataset_reqs(ds) + results.append({ + "dataset_id": dataset_id, + "title": title, + "description": description[:200] if description else "", + "variables": variables, + **bounds, + }) + + total = len(results) + page = results[offset: offset + limit] + response = {"total": total, "offset": offset, "limit": limit, "datasets": page} + + # Salva risultato ricerca in cache Redis (solo se c'è un filtro di ricerca) + if cache_key: + cache_set(cache_key, response, _SEARCH_TTL) + + return response + + +def get_dataset_info(dataset_id: str) -> Optional[dict]: + """Return detailed info for a single dataset (variables, bounds, time range).""" + raw = _get_raw_catalog() + if isinstance(raw, list): + products = raw + else: + products = raw.get("products", []) + + for product in products: + for ds in product.get("datasets", []): + if ds.get("dataset_id") == dataset_id: + variables, bounds = _get_dataset_reqs(ds) + return { + "dataset_id": dataset_id, + "title": product.get("title", ""), + "description": product.get("description", ""), + "variables": variables, + **bounds, + } + return None + + +def download_dataset( + dataset_id: str, + variables: List[str], + min_longitude: float, + max_longitude: float, + min_latitude: float, + max_latitude: float, + start_datetime: str, + end_datetime: str, + progress_callback: Optional[Callable[[int, str], None]] = None +) -> pd.DataFrame: + """ + Scarica i dati di un dataset da Copernicus Marine. L'SDK ufficiale di Copernicus, + restituisce i dati del download sotto forma di pandas Dataframe. + """ + import tempfile + + import copernicusmarine + + if progress_callback: + progress_callback(5, "Avvio dowload...") + + # l'SDK di copernicus richiede l'autenticazione di un utente + if not os.getenv("COPERNICUS_USERNAME") or not os.getenv("COPERNICUS_PASSWORD"): + raise ValueError("non sono presenti username e password per copernicus.") + + with tempfile.TemporaryDirectory() as tmpdir: + try: + copernicusmarine.subset( + dataset_id=dataset_id, + variables=variables, + minimum_longitude=min_longitude, + maximum_longitude=max_longitude, + minimum_latitude=min_latitude, + maximum_latitude=max_latitude, + start_datetime=start_datetime, + end_datetime=end_datetime, + username=os.getenv("COPERNICUS_USERNAME"), + password=os.getenv("COPERNICUS_PASSWORD"), + output_directory=tmpdir, + output_filename="data.nc", + force_download=True, + overwrite_output_data=True, + disable_progress_bar=True, + ) + except TypeError: + # Fallback for older versions of copernicusmarine + copernicusmarine.subset( + dataset_id=dataset_id, + variables=variables, + minimum_longitude=min_longitude, + maximum_longitude=max_longitude, + minimum_latitude=min_latitude, + maximum_latitude=max_latitude, + start_datetime=start_datetime, + end_datetime=end_datetime, + username=os.getenv("COPERNICUS_USERNAME"), + password=os.getenv("COPERNICUS_PASSWORD"), + output_directory=tmpdir, + output_filename="data.nc", + overwrite=True, + disable_progress_bar=True, + ) + + if progress_callback: + progress_callback(50, "Download completato, elaboro i dati...") + + import xarray as xr + ds = xr.open_dataset(os.path.join(tmpdir, "data.nc")) + df = ds.to_dataframe().reset_index() + ds.close() + + if df is None or df.empty: + raise ValueError("Nessun dato disponibile. errore nel download") + + if progress_callback: + progress_callback(75, "Elaborazione completata, formatto i dati...") + + return df + + +def dataframe_to_bytes(df: pd.DataFrame, fmt: str, variable_renames: dict = None) -> tuple: + """ + Converte i dati in memorie sottoforma di DataFrame scaircati da Copernicus in byte per migliorarne l'elaborazione e la formattazione in file CSV o JSON.""" + if variable_renames: + df = df.rename(columns=variable_renames) + if fmt == "csv": + buf = io.StringIO() + df.to_csv(buf, index=True) + return buf.getvalue().encode("utf-8"), "text/csv" + else: + buf = io.StringIO() + df.to_json(buf, orient="records", date_format="iso", indent=2) + return buf.getvalue().encode("utf-8"), "application/json" diff --git a/copernicus/core/storage.py b/copernicus/core/storage.py new file mode 100644 index 0000000..0de0ec2 --- /dev/null +++ b/copernicus/core/storage.py @@ -0,0 +1,112 @@ +import io +import json +import os +from typing import Any, Optional + +from minio.error import S3Error + +from minio import Minio + +_minio_host = os.getenv("MINIO_ENDPOINT", "minio") +_minio_port = os.getenv("MINIO_PORT", "9000") +MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", "meb-admin") +MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "meb-cloud") +MINIO_SECURE = os.getenv("MINIO_SECURE", "false").lower() == "true" + +DATASETS_BUCKET = "datasets" +METADATA_FILE = "metadata.json" + +_client: Optional[Minio] = None + + +def get_client() -> Minio: + + global _client + if _client is None: + _client = Minio( + f"{_minio_host}:{_minio_port}", + access_key=MINIO_ACCESS_KEY, + secret_key=MINIO_SECRET_KEY, + secure=MINIO_SECURE + ) + return _client + + +def bucket_exists(bucket: str = DATASETS_BUCKET) -> bool: + try: + client = get_client() + if not client.bucket_exists(bucket): + client.make_bucket(bucket) + return True + except Exception as e: + print(f"[Storage] Error in '{bucket}': {e}") + return False + + +def fetch_metadata() -> dict: + """Il bucket datasets contiene un file JSON di metadata valido per tutti i file dataset salvati, che questi siano JSON, csv o + un altro formato. I metadata per ogni file sono salvati come oggetti nel file metadata.json. """ + try: + client = get_client() + response = client.get_object(DATASETS_BUCKET, METADATA_FILE) + data = json.loads(response.read().decode("utf-8")) + response.close() + return data + except S3Error as e: + if e.code == "NoSuchKey": + return {"datasets": []} + raise + except Exception: + return {"datasets": []} + + +def write_metadata(data: dict) -> None: + """Aggiunge al file metadata.json un nuovo oggetto con l'id del nuovo file caricato dall'utente""" + client = get_client() + raw = json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8") + client.put_object( + DATASETS_BUCKET, + METADATA_FILE, + io.BytesIO(raw), + length=len(raw), + content_type="application/json" + ) + + +def upload_file(data: bytes, filename: str, content_type: str) -> None: + """Carica un nuovo file di qualsiasi formato nel bucket dataset.""" + client = get_client() + client.put_object( + DATASETS_BUCKET, + filename, + io.BytesIO(data), + length=len(data), + content_type=content_type + ) + + +def delete_file(filename: str) -> None: + """Elimina un file dal bucket dataset.""" + client = get_client() + client.remove_object(DATASETS_BUCKET, filename) + + +def get_presigned_url(filename: str, expires_hours: int = 1) -> str: + """Genera un URL temporaneo per scaricare un file dal bucket dataset""" + from datetime import timedelta + client = get_client() + return client.presigned_get_object( + DATASETS_BUCKET, + filename, + expires=timedelta(hours=expires_hours) + ) + + +def file_exists(filename: str) -> bool: + """Verifica se un file esiste.""" + try: + client = get_client() + client.stat_object(DATASETS_BUCKET, filename) + return True + except S3Error: + return False diff --git a/copernicus/main.py b/copernicus/main.py new file mode 100644 index 0000000..3987b31 --- /dev/null +++ b/copernicus/main.py @@ -0,0 +1,53 @@ +""" +Servizio reindirizzato: api.{domain}/marine/* (Traefik strips /marine prefix) +""" + +import os +from contextlib import asynccontextmanager + +from dotenv import load_dotenv +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +load_dotenv() + +from routers import catalog, datasets, jobs + + +@asynccontextmanager +async def lifespan(app: FastAPI): + api_url = os.getenv("API_SERVICE_URL", "http://api:3003") + yield + + +app = FastAPI( + title="MEB Marine Service", + description="Copernicus Marine data download and dataset management for the MEB platform", + version="1.0.0", + docs_url="/docs", + redoc_url="/redoc", + lifespan=lifespan, + root_path="" +) + +app.add_middleware( + CORSMiddleware, + allow_origin_regex=r"https?://.*\.(localhost|mebboat\.it)(:\d+)?$", + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(catalog.router) +app.include_router(jobs.router) +app.include_router(datasets.router) + + +@app.get("/", tags=["health"]) +async def root(): + return {"service": "MEB Marine Service", "version": "1.0.0", "docs": "/docs"} + + +@app.get("/health", tags=["health"]) +async def health(): + return {"status": "healthy"} diff --git a/copernicus/requirements.txt b/copernicus/requirements.txt new file mode 100644 index 0000000..59c411d --- /dev/null +++ b/copernicus/requirements.txt @@ -0,0 +1,13 @@ +fastapi>=0.115.0 +uvicorn[standard]>=0.32.0 +httpx>=0.27.0 +python-dotenv>=1.0.0 +copernicusmarine>=2.0.0 +xarray>=2024.0.0 +pandas>=2.2.0 +numpy>=1.26.0 +pydantic>=2.6.0 +redis>=5.0.0 +python-multipart>=0.0.9 +h5py +h5netcdf diff --git a/copernicus/routers/catalog.py b/copernicus/routers/catalog.py new file mode 100644 index 0000000..e844d59 --- /dev/null +++ b/copernicus/routers/catalog.py @@ -0,0 +1,46 @@ +from fastapi import APIRouter, Depends, Query, HTTPException +from typing import Optional +from middleware.auth import require_auth +from core import copernicus + +""" +api.mebboat.it/marine/... +""" + +router = APIRouter(prefix="/catalog", tags=["Copernicus Marine Database"]) + +@router.get("") +async def list_catalog( + search: Optional[str] = Query(None, description="Cerca per nome o ID"), + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), + user=Depends(require_auth) +): + """ + [API] Ottieni una lista di dataset corrispondeti alla query di ricerca, con paginazione. + Ogni dataset include ID, titolo, descrizione, variabili, posizione e finestra di tempo. + I risultati rimangono salvati nella cache del server per un ora. + """ + try: + return copernicus.get_catalog(search=search, limit=limit, offset=offset) + except Exception as e: + raise HTTPException(status_code=502, detail=f"Catalog unavailable: {str(e)}") + + +@router.get("/{dataset_id}") +async def get_dataset( + dataset_id: str, + user=Depends(require_auth) +): + """ + [API] Ottieni i dati di un dataset dal catalogo di Copernics Marine. + """ + try: + info = copernicus.get_dataset_info(dataset_id) + except Exception as e: + raise HTTPException(status_code=502, detail=f"Catalog unavailable: {str(e)}") + + if info is None: + raise HTTPException(status_code=404, detail=f"Dataset '{dataset_id}' not found in catalog") + + return info diff --git a/copernicus/routers/datasets.py b/copernicus/routers/datasets.py new file mode 100644 index 0000000..6c1f87d --- /dev/null +++ b/copernicus/routers/datasets.py @@ -0,0 +1,57 @@ +""" +api.mebboat.it/marine/datasets/* +""" +import os +from typing import Optional + +import httpx +from fastapi import APIRouter, Depends, HTTPException, Query +from middleware.auth import require_auth + +router = APIRouter(prefix="/datasets", tags=["datasets"]) + +API_URL = os.getenv("API_SERVICE_URL", "http://api-service:3003") + + +def _auth_headers(user: dict) -> dict: + return {"Authorization": f"Bearer {user['token']}"} + + +@router.get("") +async def list_datasets( + tags: Optional[str] = Query(None), + user=Depends(require_auth) +): + async with httpx.AsyncClient(timeout=10.0) as client: + r = await client.get( + f"{API_URL}/marine/datasets", + params={"tags": tags} if tags else {}, + headers=_auth_headers(user), + ) + if not r.is_success: + raise HTTPException(status_code=r.status_code, detail=r.json().get("error", "Upstream error")) + return r.json() + + +@router.get("/{dataset_id}/download") +async def download_dataset(dataset_id: str, user=Depends(require_auth)): + async with httpx.AsyncClient(timeout=10.0) as client: + r = await client.get( + f"{API_URL}/marine/datasets/{dataset_id}/download", + headers=_auth_headers(user), + ) + if not r.is_success: + raise HTTPException(status_code=r.status_code, detail=r.json().get("error", "Upstream error")) + return r.json() + + +@router.delete("/{dataset_id}") +async def delete_dataset(dataset_id: str, user=Depends(require_auth)): + async with httpx.AsyncClient(timeout=10.0) as client: + r = await client.delete( + f"{API_URL}/marine/datasets/{dataset_id}", + headers=_auth_headers(user), + ) + if not r.is_success: + raise HTTPException(status_code=r.status_code, detail=r.json().get("error", "Upstream error")) + return r.json() diff --git a/copernicus/routers/jobs.py b/copernicus/routers/jobs.py new file mode 100644 index 0000000..dbb7d66 --- /dev/null +++ b/copernicus/routers/jobs.py @@ -0,0 +1,145 @@ +""" +Flusso: +1. POST /jobs → crea job in Redis con stato "pending" +2. Background task: scarica dati → aggiorna stato in Redis +3. GET /jobs/{id} → legge stato da Redis +""" + +import json +import os +import uuid +from typing import Any, Dict + +import httpx +from core import copernicus +from core.cache import cache_get, cache_set, cache_delete +from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException +from middleware.auth import require_auth +from schemas import DownloadJobRequest, JobStatus + +router = APIRouter(prefix="/jobs", tags=["sessions"]) + +API_URL = os.getenv("API_SERVICE_URL", "http://api:3003") + +# TTL per lo stato dei job: 48 ore (i job completati vengono puliti automaticamente) +_JOB_TTL = 48 * 3600 + + +def _job_key(session_id: str) -> str: + """Genera la chiave Redis per un job.""" + return f"marine:job:{session_id}" + + +def _get_job(session_id: str) -> Dict[str, Any] | None: + """Legge lo stato di un job da Redis.""" + return cache_get(_job_key(session_id)) + + +def _set_job(session_id: str, **kwargs): + """Aggiorna lo stato di un job in Redis. + Legge lo stato corrente, applica le modifiche, e riscrive.""" + job = cache_get(_job_key(session_id)) + if job is None: + return + job.update(kwargs) + cache_set(_job_key(session_id), job, _JOB_TTL) + + +def _run_download(session_id: str, req: DownloadJobRequest, username: str, user_token: str): + """Download in background: Copernicus → conversione → upload via API service. + + Ad ogni cambio di fase, lo stato viene aggiornato in Redis + così il frontend può fare polling su GET /jobs/{id}. + """ + def progress(pct: int, msg: str): + _set_job(session_id, progress=pct, message=msg) + + try: + _set_job(session_id, status="downloading", progress=5, message="Scarico da Copernicus Marine...") + + # Scarica dati dal catalogo Copernicus + df = copernicus.download_dataset( + dataset_id=req.dataset_id, + variables=req.variables, + min_longitude=req.min_longitude, + max_longitude=req.max_longitude, + min_latitude=req.min_latitude, + max_latitude=req.max_latitude, + start_datetime=req.start_date, + end_datetime=req.end_date, + progress_callback=progress, + ) + + _set_job(session_id, status="converting", progress=80, message="Creo il file...") + + # Converte il DataFrame in bytes (CSV o JSON) + data_bytes, content_type = copernicus.dataframe_to_bytes(df, req.format, req.variable_renames) + filename = f"upload.{req.format}" + + _set_job(session_id, status="saving", progress=90, message="Carico su storage...") + + # Metadati del dataset per l'API service + metadata = { + "nome": req.nome, + "tags": req.tags, + "created_by": username, + "type": req.format, + "notes": req.notes, + "copernicus_dataset_id": req.dataset_id, + "variables": req.variables, + "variable_renames": req.variable_renames, + "bbox": [req.min_longitude, req.min_latitude, req.max_longitude, req.max_latitude], + "start_date": req.start_date, + "end_date": req.end_date, + } + + # Upload al servizio API che gestisce MinIO + with httpx.Client(timeout=None) as client: + r = client.post( + f"{API_URL}/marine/datasets/upload", + headers={"Authorization": f"Bearer {user_token}"}, + files={"file": (filename, data_bytes, content_type)}, + data={"metadata": json.dumps(metadata)}, + ) + + if not r.is_success: + raise RuntimeError(f"API upload failed ({r.status_code}): {r.text}") + + entry = r.json() + _set_job(session_id, status="done", progress=100, message="Dataset salvato.", dataset_id=entry["id"]) + + except Exception as e: + _set_job(session_id, status="error", progress=0, message=str(e)) + + +@router.post("", response_model=JobStatus, status_code=202) +async def new_download_session( + req: DownloadJobRequest, + background_tasks: BackgroundTasks, + user=Depends(require_auth) +): + """Crea un nuovo job di download e lo avvia in background.""" + session_id = str(uuid.uuid4()) + + # Stato iniziale del job salvato in Redis + initial_state = { + "job_id": session_id, + "status": "pending", + "progress": 0, + "message": "In coda", + "dataset_id": None, + } + cache_set(_job_key(session_id), initial_state, _JOB_TTL) + + # Avvia il download in background + background_tasks.add_task(_run_download, session_id, req, user["username"], user["token"]) + return initial_state + + +@router.get("/{session_id}", response_model=JobStatus) +async def get_download_session(session_id: str, user=Depends(require_auth)): + """Legge lo stato di un job di download da Redis.""" + session = _get_job(session_id) + if session is None: + raise HTTPException(status_code=404, detail="Job not found") + return session diff --git a/copernicus/schemas.py b/copernicus/schemas.py new file mode 100644 index 0000000..2a97b0b --- /dev/null +++ b/copernicus/schemas.py @@ -0,0 +1,77 @@ +from pydantic import BaseModel, Field +from typing import Dict, List, Optional, Any +from datetime import datetime + + +# ── Catalog ────────────────────────────────────────────────────────────────── + +class DatasetVariable(BaseModel): + short_name: str + standard_name: Optional[str] = None + units: Optional[str] = None + description: Optional[str] = None # human-readable label derived from standard_name + + +class CatalogDataset(BaseModel): + dataset_id: str + title: Optional[str] = None + description: Optional[str] = None + variables: List[DatasetVariable] = [] + min_longitude: Optional[float] = None + max_longitude: Optional[float] = None + min_latitude: Optional[float] = None + max_latitude: Optional[float] = None + start_datetime: Optional[str] = None + end_datetime: Optional[str] = None + + +# ── Jobs ───────────────────────────────────────────────────────────────────── + +class DownloadJobRequest(BaseModel): + dataset_id: str + variables: List[str] = Field(..., min_length=1) + min_longitude: float + max_longitude: float + min_latitude: float + max_latitude: float + start_date: str # YYYY-MM-DD + end_date: str # YYYY-MM-DD + format: str = Field("json", pattern="^(json|csv)$") + nome: str = Field(..., min_length=1) + tags: List[str] = Field(default_factory=lambda: ["marine"]) + notes: str = "" + variable_renames: Dict[str, str] = Field(default_factory=dict) # {original: custom} + + +class JobStatus(BaseModel): + job_id: str + status: str # pending | downloading | converting | saving | done | error + progress: int = 0 # 0-100 + message: str = "" + dataset_id: Optional[str] = None # filled on done + + +# ── Saved Datasets ──────────────────────────────────────────────────────────── + +class DatasetMeta(BaseModel): + id: str + nome: str + tags: List[str] = [] + created_date: str + created_by: str + used_last_date: Optional[str] = None + type: str # json | csv + size: int + notes: str = "" + version: int = 1 + filename: str + copernicus_dataset_id: str + variables: List[str] = [] + bbox: List[float] = [] # [min_lon, min_lat, max_lon, max_lat] + start_date: str + end_date: str + + +class DatasetListResponse(BaseModel): + datasets: List[DatasetMeta] + count: int diff --git a/copernicus/static/script.js b/copernicus/static/script.js new file mode 100644 index 0000000..01c6657 --- /dev/null +++ b/copernicus/static/script.js @@ -0,0 +1,581 @@ +const MARINE_API = API_URL + '/marine'; +const MAPBOX_TOKEN = 'pk.eyJ1Ijoic2VzZWUzIiwiYSI6ImNtZ2dydndkMDBsNjUya3NjeW91dW41MzcifQ.M2qxj0wL1W7plRzIataojQ'; + +// ── State ────────────────────────────────────────────────────────────────── +let selectedDatasetId = null; +let selectedVariables = new Set(); +let datasetDateRange = { min: null, max: null }; +let tags = ['marine']; +let currentBbox = null; +let currentStep = 0; +let map = null; +let isDrawMode = false; +let isDrawing = false; +let drawStart = null; +let pollInterval = null; +const TOTAL_STEPS = 6; + +// Variable renames: { originalName: customName } +let variableRenames = {}; +let _currentRenaming = null; + +// ── Init ─────────────────────────────────────────────────────────────────── +document.addEventListener('DOMContentLoaded', () => { + initMap(); + renderTags(); + setupTagInput(); + renderDots(); + showStep(0, false); + + document.getElementById('catalogSearch').addEventListener('keydown', e => { + if (e.key === 'Enter') searchCatalog(); + }); + + document.getElementById('renameInput').addEventListener('keydown', e => { + if (e.key === 'Enter') { e.preventDefault(); saveRename(); } + if (e.key === 'Escape') closeRenameModal(); + }); + + ['startDate','endDate','datasetName','outputFormat'].forEach(id => { + const el = document.getElementById(id); + if (el) el.addEventListener('change', () => { markDone(); updateSummary(); refreshNext(); }); + }); +}); + +// ── Stepper ──────────────────────────────────────────────────────────────── +function showStep(i, smooth = true) { + const steps = Array.from(document.querySelectorAll('.step')); + currentStep = Math.max(0, Math.min(i, TOTAL_STEPS - 1)); + + steps.forEach((el, idx) => { + const active = idx === currentStep; + el.classList.toggle('active', active); + if (active) el.removeAttribute('disabled'); + else el.setAttribute('disabled', ''); + }); + + document.getElementById('prevBtn').disabled = currentStep === 0; + document.getElementById('nextBtn').textContent = currentStep === TOTAL_STEPS - 1 ? 'Fine' : 'Prossimo'; + refreshNext(); + updateDots(); + updateSummary(); + markDone(); + + if (smooth) { + const active = steps[currentStep]; + if (active) setTimeout(() => active.scrollIntoView({ behavior: 'smooth', block: 'center' }), 60); + } +} + +function refreshNext() { + document.getElementById('nextBtn').disabled = !canAdvance(currentStep); +} + +function nextStep() { + if (!canAdvance(currentStep)) { showToast('Completa questo passo per continuare', 'error'); return; } + if (currentStep < TOTAL_STEPS - 1) showStep(currentStep + 1); +} + +function prevStep() { + if (currentStep > 0) showStep(currentStep - 1); +} + +function canAdvance(i) { + switch (i) { + case 0: return !!selectedDatasetId; + case 1: return selectedVariables.size > 0; + case 2: return !!currentBbox; + case 3: { + const s = document.getElementById('startDate').value; + const e = document.getElementById('endDate').value; + return !!(s && e && s <= e); + } + case 4: return !!document.getElementById('datasetName').value.trim(); + default: return true; + } +} + +// ── Dots ─────────────────────────────────────────────────────────────────── +function renderDots() { + const c = document.getElementById('progressDots'); + c.innerHTML = ''; + for (let i = 0; i < TOTAL_STEPS; i++) { + const d = document.createElement('button'); + d.className = 'progress-dot'; + d.setAttribute('aria-label', `Passo ${i + 1}`); + d.addEventListener('click', () => { if (i <= currentStep) showStep(i); }); + c.appendChild(d); + } + updateDots(); +} + +function updateDots() { + Array.from(document.getElementById('progressDots').children) + .forEach((d, i) => d.classList.toggle('active', i === currentStep)); +} + +// ── Done badges ──────────────────────────────────────────────────────────── +function markDone() { + const steps = document.querySelectorAll('.step'); + const checks = [ + !!selectedDatasetId, + selectedVariables.size > 0, + !!currentBbox, + canAdvance(3), + !!document.getElementById('datasetName').value.trim(), + false, + ]; + steps.forEach((el, i) => el.classList.toggle('done', checks[i] === true)); +} + +// ── Catalog ──────────────────────────────────────────────────────────────── +async function searchCatalog() { + const q = document.getElementById('catalogSearch').value.trim(); + const btn = document.getElementById('searchBtn'); + const box = document.getElementById('catalogResults'); + + box.innerHTML = '
Ricerca in corso...
'; + btn.disabled = true; + + try { + const params = q ? `?search=${encodeURIComponent(q)}&limit=30` : '?limit=30'; + const res = await MEB_AUTH.fetch(`${MARINE_API}/catalog${params}`); + const data = await res.json(); + + if (!res.ok) throw new Error(data.detail || 'Errore catalogo'); + + if (!data.datasets?.length) { + box.innerHTML = '
Nessun dataset trovato
'; + return; + } + + box.innerHTML = ''; + data.datasets.forEach(ds => { + const item = document.createElement('div'); + item.className = 'catalog-item'; + item.innerHTML = `
${ds.dataset_id}
${ds.title || ''}
`; + item.addEventListener('click', () => selectDataset(ds, item)); + box.appendChild(item); + }); + } catch (e) { + box.innerHTML = `
Errore: ${e.message}
`; + } finally { + btn.disabled = false; + } +} + +async function selectDataset(ds, itemEl) { + document.querySelectorAll('.catalog-item').forEach(i => i.classList.remove('selected')); + itemEl.classList.add('selected'); + selectedDatasetId = ds.dataset_id; + + const badge = document.getElementById('selectedDsBadge'); + badge.textContent = ds.dataset_id; + badge.style.display = 'block'; + + // Reset dependent steps + selectedVariables.clear(); + const vBox = document.getElementById('variablesContainer'); + vBox.innerHTML = 'Caricamento variabili...'; + + try { + const res = await MEB_AUTH.fetch(`${MARINE_API}/catalog/${encodeURIComponent(ds.dataset_id)}`); + const info = await res.json(); + + renderVariables(info.variables || ds.variables || []); + + if (info.min_longitude != null) { + setBboxAndFit(info.min_longitude, info.max_longitude, info.min_latitude, info.max_latitude); + } + + if (info.start_datetime) { + prefillDates(info.start_datetime, info.end_datetime); + } + } catch { + renderVariables(ds.variables || []); + } + + markDone(); + refreshNext(); + // Auto-advance to variables step + setTimeout(() => showStep(1), 700); +} + +// ── Variables ────────────────────────────────────────────────────────────── +function renderVariables(vars) { + const c = document.getElementById('variablesContainer'); + if (!vars?.length) { + c.innerHTML = 'Nessuna variabile disponibile'; + updateVarCount(); + return; + } + + c.innerHTML = ''; + vars.forEach(v => { + const name = typeof v === 'string' ? v : v.short_name; + const desc = typeof v === 'object' ? (v.description || v.standard_name || '') : ''; + const units = typeof v === 'object' && v.units ? v.units : ''; + + const chip = document.createElement('div'); + chip.className = 'var-chip'; + chip.dataset.name = name; + chip.innerHTML = ` + ${esc(name)} + ${desc ? `${esc(desc)}` : ''} + ${units ? `[${esc(units)}]` : ''} + + ${variableRenames[name] ? '→ ' + esc(variableRenames[name]) : ''} + `; + chip.addEventListener('click', () => toggleVar(chip, name)); + c.appendChild(chip); + }); + + updateVarCount(); +} + +function toggleVar(chip, name) { + if (selectedVariables.has(name)) { selectedVariables.delete(name); chip.classList.remove('selected'); } + else { selectedVariables.add(name); chip.classList.add('selected'); } + updateVarCount(); markDone(); refreshNext(); +} + +function updateVarCount() { + const n = selectedVariables.size; + document.getElementById('varCount').textContent = + n === 0 ? 'Nessuna selezionata' : `${n} selezionat${n === 1 ? 'a' : 'e'}`; +} + +function selectAllVars() { + document.querySelectorAll('.var-chip').forEach(chip => { + chip.classList.add('selected'); + selectedVariables.add(chip.dataset.name); + }); + updateVarCount(); markDone(); refreshNext(); +} + +function deselectAllVars() { + document.querySelectorAll('.var-chip').forEach(chip => chip.classList.remove('selected')); + selectedVariables.clear(); + updateVarCount(); markDone(); refreshNext(); +} + +// ── Dates ────────────────────────────────────────────────────────────────── +function prefillDates(minDate, maxDate) { + datasetDateRange = { min: minDate, max: maxDate }; + const s = document.getElementById('startDate'); + const e = document.getElementById('endDate'); + + if (minDate) { s.min = minDate; s.value = minDate; e.min = minDate; } + if (maxDate) { e.max = maxDate; e.value = maxDate; s.max = maxDate; } + + const hint = document.getElementById('dateRangeHint'); + if (minDate && maxDate) hint.textContent = `Dati disponibili: ${minDate} → ${maxDate}`; + + markDone(); updateSummary(); +} + +// ── Map ──────────────────────────────────────────────────────────────────── +function initMap() { + mapboxgl.accessToken = MAPBOX_TOKEN; + map = new mapboxgl.Map({ + container: 'mapContainer', + style: 'mapbox://styles/mapbox/dark-v11', + center: [14, 42], zoom: 3.5, + attributionControl: false, + }); + map.addControl(new mapboxgl.NavigationControl({ showCompass: false }), 'top-right'); + map.addControl(new mapboxgl.AttributionControl({ compact: true }), 'bottom-right'); + + map.on('load', () => { + map.addSource('bbox-rect', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } }); + map.addLayer({ id: 'bbox-fill', type: 'fill', source: 'bbox-rect', paint: { 'fill-color': '#00a7f5', 'fill-opacity': 0.13 } }); + map.addLayer({ id: 'bbox-line', type: 'line', source: 'bbox-rect', paint: { 'line-color': '#00a7f5', 'line-width': 2, 'line-dasharray': [4, 2] } }); + }); + + map.getCanvas().addEventListener('mousedown', onCanvasMouseDown, true); + window.addEventListener('mousemove', onWindowMouseMove); + window.addEventListener('mouseup', onWindowMouseUp); +} + +function startDraw() { + if (!map) return; + isDrawMode = true; + map.dragPan.disable(); map.boxZoom.disable(); + document.getElementById('mapContainer').classList.add('draw-mode'); + const btn = document.getElementById('drawBtn'); + btn.textContent = 'Clicca e trascina…'; + btn.classList.replace('secondary', 'primary'); +} + +function exitDrawMode() { + isDrawMode = isDrawing = false; drawStart = null; + map.dragPan.enable(); map.boxZoom.enable(); + document.getElementById('mapContainer').classList.remove('draw-mode', 'drawing'); + const btn = document.getElementById('drawBtn'); + btn.textContent = 'Disegna area'; + btn.classList.replace('primary', 'secondary'); +} + +function onCanvasMouseDown(e) { + if (!isDrawMode) return; + e.preventDefault(); e.stopPropagation(); + isDrawing = true; + drawStart = map.unproject([e.offsetX, e.offsetY]); + document.getElementById('mapContainer').classList.add('drawing'); +} + +function onWindowMouseMove(e) { + if (!isDrawing || !drawStart) return; + const rc = map.getCanvas().getBoundingClientRect(); + _drawRect(drawStart, map.unproject([e.clientX - rc.left, e.clientY - rc.top])); +} + +function onWindowMouseUp(e) { + if (!isDrawing || !drawStart) return; + const rc = map.getCanvas().getBoundingClientRect(); + const end = map.unproject([e.clientX - rc.left, e.clientY - rc.top]); + setBbox( + Math.min(drawStart.lng, end.lng), Math.max(drawStart.lng, end.lng), + Math.min(drawStart.lat, end.lat), Math.max(drawStart.lat, end.lat) + ); + exitDrawMode(); +} + +function _drawRect(a, b) { + if (!map.getSource('bbox-rect')) return; + map.getSource('bbox-rect').setData({ + type: 'Feature', + geometry: { type: 'Polygon', coordinates: [[[a.lng,a.lat],[b.lng,a.lat],[b.lng,b.lat],[a.lng,b.lat],[a.lng,a.lat]]] }, + }); +} + +function setBbox(minLon, maxLon, minLat, maxLat) { + currentBbox = { minLon, maxLon, minLat, maxLat }; + document.getElementById('minLon').value = minLon.toFixed(4); + document.getElementById('maxLon').value = maxLon.toFixed(4); + document.getElementById('minLat').value = minLat.toFixed(4); + document.getElementById('maxLat').value = maxLat.toFixed(4); + document.getElementById('bboxReadout').textContent = + `${minLon.toFixed(2)}°/${minLat.toFixed(2)}° → ${maxLon.toFixed(2)}°/${maxLat.toFixed(2)}°`; + _drawRect({ lng: minLon, lat: minLat }, { lng: maxLon, lat: maxLat }); + markDone(); refreshNext(); +} + +function setBboxAndFit(minLon, maxLon, minLat, maxLat) { + const doIt = () => { + setBbox(minLon, maxLon, minLat, maxLat); + map.fitBounds([[minLon, minLat], [maxLon, maxLat]], { padding: 40, maxZoom: 10, duration: 600 }); + }; + if (!map) return; + if (map.isStyleLoaded()) doIt(); else map.once('load', doIt); +} + +function clearBbox() { + currentBbox = null; + ['minLon','maxLon','minLat','maxLat'].forEach(id => document.getElementById(id).value = ''); + document.getElementById('bboxReadout').textContent = ''; + if (map?.getSource('bbox-rect')) map.getSource('bbox-rect').setData({ type: 'FeatureCollection', features: [] }); + markDone(); refreshNext(); +} + +// ── Tags ─────────────────────────────────────────────────────────────────── +function setupTagInput() { + const inp = document.getElementById('tagInput'); + inp.addEventListener('keydown', e => { + if ((e.key === 'Enter' || e.key === ',') && inp.value.trim()) { + e.preventDefault(); + const t = inp.value.trim().replace(/,/g,'').toLowerCase(); + if (t && !tags.includes(t)) { tags.push(t); renderTags(); } + inp.value = ''; + } else if (e.key === 'Backspace' && !inp.value && tags.length) { + tags.pop(); renderTags(); + } + }); +} + +function renderTags() { + const wrap = document.getElementById('tagsWrap'); + const inp = document.getElementById('tagInput'); + wrap.innerHTML = ''; + tags.forEach(t => { + const chip = document.createElement('span'); + chip.className = 'tag-chip'; + chip.innerHTML = `${t} ×`; + wrap.appendChild(chip); + }); + wrap.appendChild(inp); +} + +function removeTag(t) { tags = tags.filter(x => x !== t); renderTags(); } + +// ── Download ─────────────────────────────────────────────────────────────── +async function startDownload() { + if (!selectedDatasetId) return showToast('Seleziona un dataset', 'error'); + if (!selectedVariables.size) return showToast('Seleziona almeno una variabile', 'error'); + if (!currentBbox) return showToast("Disegna un'area sulla mappa", 'error'); + if (!document.getElementById('startDate').value || + !document.getElementById('endDate').value) return showToast('Inserisci le date', 'error'); + if (!document.getElementById('datasetName').value.trim()) return showToast('Inserisci un nome', 'error'); + + const body = { + dataset_id: selectedDatasetId, + variables: Array.from(selectedVariables), + min_longitude: parseFloat(document.getElementById('minLon').value), + max_longitude: parseFloat(document.getElementById('maxLon').value), + min_latitude: parseFloat(document.getElementById('minLat').value), + max_latitude: parseFloat(document.getElementById('maxLat').value), + start_date: document.getElementById('startDate').value, + end_date: document.getElementById('endDate').value, + format: document.getElementById('outputFormat').value, + nome: document.getElementById('datasetName').value.trim(), + tags: [...tags], + notes: document.getElementById('datasetNotes').value.trim(), + variable_renames: { ...variableRenames }, + }; + + const btn = document.getElementById('downloadBtn'); + const prog = document.getElementById('downloadProgress'); + btn.disabled = true; + prog.style.display = 'block'; + setProgress(0, 'Avvio download...'); + + try { + const res = await MEB_AUTH.fetch(`${MARINE_API}/jobs`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.detail || 'Errore avvio job'); + pollJob(data.job_id, btn, prog); + } catch (e) { + showToast(`Errore: ${e.message}`, 'error'); + btn.disabled = false; + prog.style.display = 'none'; + } +} + +function pollJob(jobId, btn, prog) { + clearInterval(pollInterval); + pollInterval = setInterval(async () => { + try { + const res = await MEB_AUTH.fetch(`${MARINE_API}/jobs/${jobId}`); + const job = await res.json(); + setProgress(job.progress, job.message); + + if (job.status === 'done') { + clearInterval(pollInterval); + btn.disabled = false; + showToast('Dataset salvato con successo!', 'success'); + setTimeout(resetAll, 1800); + } else if (job.status === 'error') { + clearInterval(pollInterval); + btn.disabled = false; + showToast(`Errore: ${job.message}`, 'error'); + prog.style.display = 'none'; + } + } catch { /* transient */ } + }, 2000); +} + +function setProgress(pct, msg) { + document.getElementById('progressFill').style.width = pct + '%'; + document.getElementById('progressMsg').textContent = msg; +} + +// ── Reset ────────────────────────────────────────────────────────────────── +function resetAll() { + selectedDatasetId = null; + selectedVariables.clear(); + datasetDateRange = { min: null, max: null }; + variableRenames = {}; + tags = ['marine']; + + ['startDate','endDate','datasetName','datasetNotes'].forEach(id => { + const el = document.getElementById(id); + if (el) { el.value = ''; el.min = ''; el.max = ''; } + }); + + document.getElementById('catalogResults').innerHTML = + '
Cerca un dataset Copernicus per iniziare
'; + document.getElementById('selectedDsBadge').style.display = 'none'; + document.getElementById('variablesContainer').innerHTML = + 'Seleziona un dataset per vedere le variabili'; + document.getElementById('dateRangeHint').textContent = ''; + document.getElementById('downloadProgress').style.display = 'none'; + + clearBbox(); + renderTags(); + showStep(0); +} + +// ── Summary ──────────────────────────────────────────────────────────────── +function updateSummary() { + if (currentStep !== 5) return; + const s = document.getElementById('startDate').value || '-'; + const e = document.getElementById('endDate').value || '-'; + const name = document.getElementById('datasetName').value || '-'; + const fmt = document.getElementById('outputFormat').value || '-'; + const vars = Array.from(selectedVariables).join(', ') || '-'; + const bbox = currentBbox + ? `${currentBbox.minLon.toFixed(2)},${currentBbox.minLat.toFixed(2)} → ${currentBbox.maxLon.toFixed(2)},${currentBbox.maxLat.toFixed(2)}` + : '-'; + document.getElementById('summaryContent').innerHTML = ` +
Dataset: ${esc(selectedDatasetId || '-')}
+
Variabili (${selectedVariables.size}): ${esc(vars)}
+
Area: ${esc(bbox)}
+
Periodo: ${esc(s)} → ${esc(e)}
+
Formato: ${esc(fmt)}
+
Nome: ${esc(name)}
+
Tags: ${esc(tags.join(', '))}
+ `; +} + +// ── Rename modal ─────────────────────────────────────────────────────────── +function openRenameModal(varName) { + _currentRenaming = varName; + document.getElementById('renameVarLabel').textContent = varName; + document.getElementById('renameInput').value = variableRenames[varName] || ''; + document.getElementById('renameDeleteBtn').style.display = variableRenames[varName] ? 'inline-flex' : 'none'; + document.getElementById('renameModal').classList.add('visible'); + setTimeout(() => document.getElementById('renameInput').select(), 50); +} + +function saveRename() { + if (!_currentRenaming) return; + const val = document.getElementById('renameInput').value.trim(); + if (!val) { deleteRename(); return; } + variableRenames[_currentRenaming] = val; + _updateRenameBadge(_currentRenaming, val); + closeRenameModal(); +} + +function deleteRename() { + if (!_currentRenaming) return; + delete variableRenames[_currentRenaming]; + _updateRenameBadge(_currentRenaming, ''); + closeRenameModal(); +} + +function closeRenameModal() { + document.getElementById('renameModal').classList.remove('visible'); + _currentRenaming = null; +} + +function _updateRenameBadge(varName, text) { + const chip = [...document.querySelectorAll('.var-chip')].find(c => c.dataset.name === varName); + if (!chip) return; + chip.querySelector('.rename-badge').textContent = text ? '→ ' + text : ''; +} + +// ── Helpers ──────────────────────────────────────────────────────────────── +function esc(s) { + return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); +} + +function showToast(msg, type = 'success') { + const t = document.getElementById('toast'); + t.className = `${type} show`; + t.textContent = msg; + setTimeout(() => t.classList.remove('show'), 3500); +} \ No newline at end of file diff --git a/copernicus/static/style.css b/copernicus/static/style.css new file mode 100644 index 0000000..e69de29 diff --git a/copernicus/templates/coprncs.html b/copernicus/templates/coprncs.html new file mode 100644 index 0000000..decbfa2 --- /dev/null +++ b/copernicus/templates/coprncs.html @@ -0,0 +1,163 @@ + + + + + + Copernicus Marine + + + + + + + + + +
+ + + + +
+
+ 1 +

Cerca un dataset

+ ✓ Selezionato +
+ +
+
+
+
+
+ + +
+
+ 2 +

Variabili

+ ✓ Selezionate +
+
+ Nessuna selezionata + +
+
+ +
+
+ + +
+
+ 3 +

Area

+ ✓ Impostata +
+
+
+ + + +
+ + + + +
+ + +
+
+ 4 +

Finestra temporale

+ +
+
+ + +
+
+
+ + +
+
+ 5 +

Dettagli

+ Completo +
+
+ +
+
+ +
+
+
+ +
+
+
+ +
+
+ + +
+
+ 6 +

Scarica

+
+
+
+ + +
+
+
+
+
+
+
+
+ +
+ + +
+
+
+ + +
+
+ +
+ + +
+
+
+

Rinomina variabile

+
+ +
+ + +
+
+
+ + + \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b19a92c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,164 @@ +services: + auth: + container_name: auth + build: + context: ./auth + dockerfile: Dockerfile + restart: unless-stopped + command: npm run dev + volumes: + - ./auth:/app + - /app/node_modules + env_file: + - ./auth/.env + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://localhost:3006/health').then(r => r.ok ? process.exit(0) : process.exit(1))"] + interval: 30s + timeout: 5s + retries: 3 + networks: + - meb-proxy-net + - meb-internal + ports: + - "3006:3006" + labels: + - "traefik.enable=true" + - "traefik.http.routers.auth.rule=Host(`auth.${URL_DOMAIN}`)" + - "traefik.http.routers.auth.entrypoints=web" + - "traefik.http.services.auth.loadbalancer.server.port=3006" + - "traefik.docker.network=meb-proxy-net" + + api: + container_name: api-services + build: + context: ./api + dockerfile: Dockerfile + restart: unless-stopped + command: npm run dev + volumes: + - ./api/src:/app/src + - /app/node_modules + - ./ml:/ml-source + - /var/run/docker.sock:/var/run/docker.sock + env_file: + - ./api/.env + networks: + - meb-proxy-net + - meb-internal + ports: + - "3003:3003" + + console: + build: + context: ./console + dockerfile: Dockerfile + restart: unless-stopped + command: npm run dev + volumes: + - ./console:/app + - /app/node_modules + env_file: + - ./console/.env + networks: + - meb-proxy-net + - meb-internal + ports: + - "3004:3004" + + realtime: + build: + context: ./realtime + dockerfile: Dockerfile + restart: unless-stopped + command: npm run dev + ports: + - "3002:3002" + - "3102:3102" + volumes: + - ./realtime:/app + - /app/node_modules + env_file: + - ./realtime/.env + networks: + - meb-proxy-net + - meb-internal + + # ml: + # container_name: ml-service + # build: + # context: ./ml + # dockerfile: Dockerfile + # restart: unless-stopped + # volumes: + # - ./ml:/app + # env_file: + # - ./ml/.env + # ports: + # - "3005:3005" + # networks: + # - meb-proxy-net + # - meb-internal + +# marine: +# container_name: marine-service +# build: +# context: ./marine +# dockerfile: Dockerfile +# restart: unless-stopped +# volumes: +# - ./marine:/app +# env_file: +# - ./marine/.env +# environment: +# - REDIS_HOST=meb-redis +# - REDIS_PORT=6379 +# networks: +# - meb-proxy-net +# - meb-internal +# labels: +# - "traefik.enable=true" +# - "traefik.http.routers.marine.rule=Host(`api.${URL_DOMAIN}`) && PathPrefix(`/marine`)" +# - "traefik.http.routers.marine.entrypoints=web" +# - "traefik.http.services.marine.loadbalancer.server.port=8001" +# - "traefik.docker.network=meb-proxy-net" +# - "traefik.http.middlewares.marine-strip.stripprefix.prefixes=/marine" +# - "traefik.http.routers.marine.middlewares=marine-strip" + +# circuits: +# container_name: meb-circuits +# build: +# context: ./circuits +# dockerfile: Dockerfile +# restart: unless-stopped +# environment: +# - DATABASE_URL=postgresql://meb:meb@meb-postgres:5432/circuits +# - AUTH_SERVICE_URL=http://auth:3001 +# - AUTH_URL=http://auth.${URL_DOMAIN:-localhost} +# - API_URL=http://api.${URL_DOMAIN:-localhost} +# - NODE_ENV=${NODE_ENV:-development} +# volumes: +# - ./circuits/src:/app/src +# - /app/node_modules +# healthcheck: +# test: ["CMD", "node", "-e", "fetch('http://localhost:3005/health').then(r => r.ok ? process.exit(0) : process.exit(1))"] +# interval: 30s +# timeout: 5s +# retries: 3 +# depends_on: +# - auth +# networks: +# - meb-proxy-net +# - meb-internal +# labels: +# - "traefik.enable=true" +# - "traefik.http.routers.circuits.rule=Host(`circuits.${URL_DOMAIN}`)" +# - "traefik.http.routers.circuits.entrypoints=web" +# - "traefik.http.services.circuits.loadbalancer.server.port=3005" +# - "traefik.docker.network=meb-proxy-net" +# - "traefik.http.routers.circuits.middlewares=cors-ignore" + +networks: + meb-proxy-net: + external: true + meb-internal: + external: true diff --git a/ml/.dockerignore b/ml/.dockerignore new file mode 100644 index 0000000..bee8a64 --- /dev/null +++ b/ml/.dockerignore @@ -0,0 +1 @@ +__pycache__ diff --git a/ml/.env.example b/ml/.env.example new file mode 100644 index 0000000..e69de29 diff --git a/ml/Dockerfile b/ml/Dockerfile new file mode 100644 index 0000000..cc0f8cd --- /dev/null +++ b/ml/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.11-slim + +WORKDIR /app +ENV PYTHONUNBUFFERED=1 + +COPY ./requirements.txt . + +RUN pip install --no-cache-dir -r requirements.txt + +COPY . /app + +EXPOSE 3007 +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "3007"] diff --git a/ml/main.py b/ml/main.py new file mode 100644 index 0000000..51046b2 --- /dev/null +++ b/ml/main.py @@ -0,0 +1,19 @@ +from fastapi import FastAPI, Request, Response, Header +from fastapi.responses import HTMLResponse, JSONResponse +import time + +app = FastAPI() + +@app.get("/health") +def health(): + return { + "status": "ok", + "service": "ml", + "version": "1.0.0", + "build_number": "1", + "version_state": "dev" + } + +@app.get("/") +def root(): + return {"message": "ML Service"} \ No newline at end of file diff --git a/ml/requirements.txt b/ml/requirements.txt new file mode 100644 index 0000000..97dc7cd --- /dev/null +++ b/ml/requirements.txt @@ -0,0 +1,2 @@ +fastapi +uvicorn diff --git a/ml/static/font/quicksand.ttf b/ml/static/font/quicksand.ttf new file mode 100644 index 0000000000000000000000000000000000000000..44ecad84e4b5ea6aa53525ad6c3fb79301a8fdaa GIT binary patch literal 126624 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 DGa`5I literal 0 HcmV?d00001 diff --git a/ml/static/styles/style.css b/ml/static/styles/style.css new file mode 100644 index 0000000..8b7defd --- /dev/null +++ b/ml/static/styles/style.css @@ -0,0 +1,201 @@ +:root { + --accent-color: #2563eb; + --accent-hover: #1d4ed8; + --accent-light: #eff6ff; + --accent-border: #bfdbfe; + + --text-primary: #0f172a; + --text-secondary: #4755698f; + --text-tertiary: #94a3b8c0; + + --surface: #f8fafc; + + --header-bg: rgba(255, 255, 255, 0.85); + /* For Glassmorphism */ + --header-border: #e2e8f0; + + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + --radius-md: 8px; + --radius-lg: 12px; +} + +* { + margin: 0; + padding: 0; +} + +@font-face { + font-family: 'Normal'; + src: url('../font/Quicksand-VariableFont_wght.ttf'); + font-weight: 400; + font-style: normal; +} + +@font-face { + font-family: 'Bold'; + src: url('../font/Quicksand-VariableFont_wght.ttf'); + font-weight: 700; + font-style: normal; +} + +body { + font-family: 'Normal', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + color: var(--text-primary); + -webkit-font-smoothing: antialiased; +} + +button { + padding: 10px 24px; + border-radius: var(--radius-lg); + border: 1px solid var(--header-border); + background-color: var(--bg-surface); + color: var(--text-primary); + font-size: 14px; + font-weight: 600; + font-family: 'Bold', inherit; + cursor: pointer; + transition: all 0.5s cubic-bezier(0.8, 0, 0.2, 1); + white-space: nowrap; + flex-shrink: 0; +} + +button:hover { + background-color: var(--accent-light); + color: var(--accent-color); + border-color: var(--accent-border); +} + +button.prominent { + background-color: var(--accent-color); + color: #ffffff; + border: 1px solid transparent; + box-shadow: var(--shadow-sm); +} + +button.prominent:hover { + background-color: var(--accent-hover); + transform: translateY(-1px); + box-shadow: var(--shadow-md); +} + + +button.prominent:active { + transform: translateY(1px); + box-shadow: var(--shadow-sm); +} + + +/* INFO PANEL */ + +.info-panel { + display: block; + text-align: center; + align-content: center; + padding: 60px 20px; + user-select: none; +} + +.info-panel h3 { + margin-bottom: 10px; +} + +.info-panel p { + margin-bottom: 25px; +} + +.info-panel .icon { + font-size: 48px; + margin-bottom: 20px; + align-self: center; + transition: transform 0.12s ease; +} + + + + +/* GRID & CARD ITEMS */ + +.grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 1.5rem; + margin-inline: 30px; +} + +.card { + display: flex; + gap: 0.75rem; + align-items: center; + padding: 1rem; + border: 1px solid var(--header-border); + border-radius: 20px; + text-decoration: none; + color: var(--text-primary); + transition: transform 0.12s ease, box-shadow 0.12s ease; +} + +.card h3 { + margin: 0; + font-size: 1rem; + text-align: left; +} + +.card p { + margin: 0.25rem 0 0; + color: var(--text-secondary); + opacity: 0.4; + font-size: 0.8rem; + text-align: left; +} + +.card:hover { + transform: translateY(-4px); + box-shadow: 0 8px 30px #bfdbfe30; +} + +.card.standalone { + grid-column: 1 / -1; +} + + + + + +/* HEADER */ + +.header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 24px; + background-color: var(--header-bg); + border-bottom: 1px solid var(--header-border); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + position: sticky; + top: 0; + z-index: 10; + user-select: none; +} + + +.header h1 { + color: var(--text-primary); + font-size: 1.25rem; + font-weight: 700; + letter-spacing: -0.025em; +} + +.header .profile { + display: flex; + align-items: center; + gap: 16px; +} + +.header .profile p { + font-size: 0.875rem; + font-weight: 500; + color: var(--text-secondary); + padding-inline: 5px; +} \ No newline at end of file diff --git a/ml/templates/console.html b/ml/templates/console.html new file mode 100644 index 0000000..5968994 --- /dev/null +++ b/ml/templates/console.html @@ -0,0 +1,28 @@ + + + + + + ML + + + + +
+

Modelli ML

+
+

Utente

+ +
+
+ +
+ +
+ + + + + \ No newline at end of file diff --git a/ml/templates/datasets.html b/ml/templates/datasets.html new file mode 100644 index 0000000..e69de29 diff --git a/ml/templates/results.html b/ml/templates/results.html new file mode 100644 index 0000000..6c220f0 --- /dev/null +++ b/ml/templates/results.html @@ -0,0 +1,89 @@ + + + + + Risultati + + + + + + + +
+

Risultati

+
+

Utente

+ +
+
+ +
+ +
+ +
+

+ Seleziona +

+ +

+ una sessione di training eseguita per visualizzarne i risultati +

+
+ +
+ +
+

sessione 1

+
+

24/03/2026

+

12:00

+

dataset: d-1

+
+ +
+ +
+

sessione 2

+

24/03/2026

+ +
+ +
+ +
+ +
+ + + + + diff --git a/ml/templates/test.html b/ml/templates/test.html new file mode 100644 index 0000000..e69de29 diff --git a/ml/templates/train.html b/ml/templates/train.html new file mode 100644 index 0000000..e69de29 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1193580 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,179 @@ +{ + "name": "meb-custom-server", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "cookie-parser": "^1.4.7", + "jsonwebtoken": "^9.0.3" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-parser": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", + "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", + "license": "MIT", + "dependencies": { + "cookie": "0.7.2", + "cookie-signature": "1.0.6" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..fa13629 --- /dev/null +++ b/package.json @@ -0,0 +1,6 @@ +{ + "dependencies": { + "cookie-parser": "^1.4.7", + "jsonwebtoken": "^9.0.3" + } +} diff --git a/realtime/.dockerignore b/realtime/.dockerignore new file mode 100644 index 0000000..3c3629e --- /dev/null +++ b/realtime/.dockerignore @@ -0,0 +1 @@ +node_modules diff --git a/realtime/.env.example b/realtime/.env.example new file mode 100644 index 0000000..59fe0c6 --- /dev/null +++ b/realtime/.env.example @@ -0,0 +1,9 @@ +PORT=3004 + +VERSION=1.0.0 +VERSION_BUILD=1.0 +VERSION_STATE=beta + +INFLX_URL= +INFLX_TOKEN= +INFLX_ORG= diff --git a/realtime/Dockerfile b/realtime/Dockerfile new file mode 100644 index 0000000..ed2909e --- /dev/null +++ b/realtime/Dockerfile @@ -0,0 +1,12 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm install + +COPY src ./src + +EXPOSE 3002 + +CMD ["node", "src/index.js"] \ No newline at end of file diff --git a/realtime/package-lock.json b/realtime/package-lock.json new file mode 100644 index 0000000..ef635c9 --- /dev/null +++ b/realtime/package-lock.json @@ -0,0 +1,1110 @@ +{ + "name": "meb-realtime-service", + "version": "1.4.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "meb-realtime-service", + "version": "1.4.0", + "dependencies": { + "@influxdata/influxdb-client": "^1.35.0", + "@influxdata/influxdb-client-apis": "^1.35.0", + "@msgpack/msgpack": "^3.1.3", + "express": "^5.2.1", + "ioredis": "^5.10.0", + "pg": "^8.20.0", + "ws": "^8.19.0" + } + }, + "node_modules/@influxdata/influxdb-client": { + "version": "1.35.0", + "resolved": "https://registry.npmjs.org/@influxdata/influxdb-client/-/influxdb-client-1.35.0.tgz", + "integrity": "sha512-woWMi8PDpPQpvTsRaUw4Ig+nOGS/CWwAwS66Fa1Vr/EkW+NEwxI8YfPBsdBMn33jK2Y86/qMiiuX/ROHIkJLTw==", + "license": "MIT" + }, + "node_modules/@influxdata/influxdb-client-apis": { + "version": "1.35.0", + "resolved": "https://registry.npmjs.org/@influxdata/influxdb-client-apis/-/influxdb-client-apis-1.35.0.tgz", + "integrity": "sha512-+7h6smVPHYBge2rNKgYh/5k+SriYvPMsoJDfbUiQt1vJtpWnElwgBDLDl7Cr6d9XPC+FCI9GP4GQEMv7y8WxdA==", + "license": "MIT", + "peerDependencies": { + "@influxdata/influxdb-client": "*" + } + }, + "node_modules/@ioredis/commands": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", + "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", + "license": "MIT" + }, + "node_modules/@msgpack/msgpack": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.1.3.tgz", + "integrity": "sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==", + "license": "ISC", + "engines": { + "node": ">= 18" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ioredis": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.0.tgz", + "integrity": "sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + } + } +} diff --git a/realtime/package.json b/realtime/package.json new file mode 100644 index 0000000..41b825f --- /dev/null +++ b/realtime/package.json @@ -0,0 +1,19 @@ +{ + "name": "meb-realtime-service", + "version": "1.4.0", + "description": "Realtime service for sensor connections - Node.js", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "dev": "node --watch src/index.js" + }, + "dependencies": { + "@influxdata/influxdb-client": "^1.35.0", + "@influxdata/influxdb-client-apis": "^1.35.0", + "@msgpack/msgpack": "^3.1.3", + "express": "^5.2.1", + "ioredis": "^5.10.0", + "pg": "^8.20.0", + "ws": "^8.19.0" + } +} diff --git a/realtime/src/helper/authdb.js b/realtime/src/helper/authdb.js new file mode 100644 index 0000000..382c505 --- /dev/null +++ b/realtime/src/helper/authdb.js @@ -0,0 +1,84 @@ +const { Pool } = require('pg'); +const { hash, generateShortId } = require('./cryptoUtils'); + +const pool = new Pool({ + user: process.env.DB_USER, + host: process.env.DB_HOST, + database: process.env.DB_NAME, + password: process.env.DB_PASSWORD, + port: process.env.DB_PORT, +}) + +async function checkDB() { + try { + await pool.query('SELECT NOW()'); + } catch (error) { + console.error('Database connection failed:', error); + } +} + +/** + * Restituisce i dati del sensore in base al token ricevuto. + * Il token viene hashato prima della comparazione con il database. + * @param {string} token - il codice segreto del sensore (raw) + */ +async function getSensor(token) { + const hashed = hash(token); + const result = await pool.query('SELECT id, is_active, name, last_seen, created_at FROM sensors WHERE code_hash = $1', [hashed]); + return result.rows[0]; +} + +async function createSensor(name, code) { + const hashedCode = hash(code); + + // Verifica se l'hash esiste già + const result = await pool.query('SELECT id FROM sensors WHERE code_hash = $1', [hashedCode]); + if (result.rows.length > 0) { + throw new Error('Sensor with this code already exists'); + } + + // Genera un ID casuale di 8 caratteri (ottimizzato per spazio, non solo alfanumerico) + const sensorId = generateShortId(8); + + await pool.query('INSERT INTO sensors (id, name, code_hash, is_active, last_seen, created_at) VALUES ($1, $2, $3, $4, $5, $6)', + [sensorId, name, hashedCode, true, new Date(), new Date()]); +} + +/** + * Aggiorna l'ultima attività del sensore. + * @param {*} id - l'id del sensore + * @returns {Promise} + */ +async function updateLastSeen(id) { + await pool.query('UPDATE sensors SET last_seen = NOW() WHERE id = $1', [id]); +} + +/** + * Modifica la disponibilità del sensore. + * @param {*} id - l'id del sensore + * @param {*} is_active - la disponibilità del sensore + * @returns {Promise} + */ +async function setSensorActivity(id, is_active) { + await pool.query('UPDATE sensors SET is_active = $1 WHERE id = $2', [is_active, id]); +} + +async function sensorsExists(id) { + const result = await pool.query('SELECT id FROM sensors WHERE id = $1', [id]); + return result.rows.length > 0; +} + +async function getSensors() { + const resutls = await pool.query('SELECT id, is_active, name, last_seen, created_at FROM sensors'); + return resutls.rows; +} + +module.exports = { + checkDB, + getSensor, + updateLastSeen, + setSensorActivity, + getSensors, + sensorsExists, + createSensor +} \ No newline at end of file diff --git a/realtime/src/helper/cryptoUtils.js b/realtime/src/helper/cryptoUtils.js new file mode 100644 index 0000000..ec2bdab --- /dev/null +++ b/realtime/src/helper/cryptoUtils.js @@ -0,0 +1,35 @@ +const crypto = require('crypto'); + +/** + * Genera un hash SHA256 in formato esadecimale da una stringa. + * Utilizzato per rendere compatibili authdb.js e tokenStore.js. + */ +function hash(text) { + if (!text) return null; + return crypto.createHash('sha256').update(text).digest('hex'); +} + +/** + * Genera una stringa casuale di lunghezza 'length'. + * Ottimizzata per risparmiare spazio (8 caratteri). + * Include lettere, numeri e simboli per massimizzare l'entropia (non solo alfanumerico). + */ +function generateShortId(length = 8) { + const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*'; + let result = ''; + while (result.length < length) { + const bytes = crypto.randomBytes(length); + for (let i = 0; i < bytes.length && result.length < length; i++) { + // Selezioniamo solo i byte che rientrano nel range del charset per evitare bias + if (bytes[i] < 256 - (256 % charset.length)) { + result += charset[bytes[i] % charset.length]; + } + } + } + return result; +} + +module.exports = { + hash, + generateShortId +}; diff --git a/realtime/src/helper/influxReader.js b/realtime/src/helper/influxReader.js new file mode 100644 index 0000000..7e8b3e1 --- /dev/null +++ b/realtime/src/helper/influxReader.js @@ -0,0 +1,75 @@ +const { InfluxDB } = require('@influxdata/influxdb-client'); + +const url = process.env.INFLX_URL; +const token = process.env.INFLX_TOKEN; +const org = process.env.INFLX_ORG; +const bucket = 'boat'; + +const client = new InfluxDB({ url, token }); +const queryApi = client.getQueryApi(org); + +/** + * Query tutti i dati di una sessione sensore da un timestamp di inizio. + * Ritorna array di righe { _time, _measurement, _field, _value, sensor }. + */ +async function querySessionData(sensorId, fromTimestamp) { + const from = new Date(fromTimestamp).toISOString(); + + const query = ` + from(bucket: "${bucket}") + |> range(start: ${from}) + |> filter(fn: (r) => r["sensor"] == "${sensorId}") + |> pivot(rowKey:["_time"], columnKey: ["_field"], valueColumn: "_value") + `; + + const rows = []; + return new Promise((resolve, reject) => { + queryApi.queryRows(query, { + next(row, tableMeta) { + const obj = tableMeta.toObject(row); + rows.push(obj); + }, + error(err) { + console.error(`[INFLUX] Query error:`, err.message); + reject(err); + }, + complete() { + resolve(rows); + } + }); + }); +} + +/** + * Formatta i risultati della query in CSV. + */ +function formatCSV(rows) { + if (rows.length === 0) return ''; + + // Raccogli tutte le colonne uniche escludendo meta-campi InfluxDB + const excludeKeys = new Set(['result', 'table', '_start', '_stop', '']); + const allKeys = new Set(); + for (const row of rows) { + for (const key of Object.keys(row)) { + if (!excludeKeys.has(key)) allKeys.add(key); + } + } + + const columns = ['_time', '_measurement', 'sensor', + ...Array.from(allKeys).filter(k => !['_time', '_measurement', 'sensor'].includes(k)).sort() + ]; + + const header = columns.join(','); + const lines = rows.map(row => + columns.map(col => { + const val = row[col]; + if (val == null) return ''; + if (typeof val === 'string' && val.includes(',')) return `"${val}"`; + return val; + }).join(',') + ); + + return header + '\n' + lines.join('\n'); +} + +module.exports = { querySessionData, formatCSV }; diff --git a/realtime/src/helper/influxWriter.js b/realtime/src/helper/influxWriter.js new file mode 100644 index 0000000..a666ebd --- /dev/null +++ b/realtime/src/helper/influxWriter.js @@ -0,0 +1,156 @@ +const { InfluxDB, Point } = require('@influxdata/influxdb-client'); + +const url = process.env.INFLX_URL; +const token = process.env.INFLX_TOKEN; +const org = process.env.INFLX_ORG; +const boatTelemetry = 'boat'; + +const client = new InfluxDB({ url, token }); +const writeApi = client.getWriteApi(org, boatTelemetry); + +const FIELD_MAP = { + logs: { + lat: 'latitude', lon: 'longitude', hdg: 'heading', + sog: 'speed_over_ground', cog: 'course_over_ground', + depth: 'depth', engTemp: 'engine_temperature', + fTemp: 'forecast_temperature', fHum: 'forecast_humidity', fPres: 'forecast_pressure', + fWSpd: 'forecast_wind_speed', fWDir: 'forecast_wind_direction', + wvH: 'wave_height', wvP: 'wave_period', wvD: 'wave_direction', + curD: 'current_direction', curV: 'current_velocity' + }, + weather: { + temp: 'temperature', hum: 'humidity', pres: 'pressure', + wSpd: 'wind_speed', wDir: 'wind_direction', gust: 'wind_gusts', + rain: 'rain', prec: 'precipitation', + wvH: 'wave_height', wvP: 'wave_period', wvD: 'wave_direction', + wvPkP: 'wave_peak_period', curD: 'current_direction', curV: 'current_velocity' + }, + forecast: { + temp: 'temperature', hum: 'humidity', pres: 'pressure', + wSpd: 'wind_speed', wDir: 'wind_direction', + precProb: 'precipitation_probability', prec: 'precipitation', + rain: 'rain', cloud: 'cloud_cover', + wvH: 'wave_height', wvP: 'wave_period', wvD: 'wave_direction', + curD: 'current_direction', curV: 'current_velocity' + } +}; + +async function writePoint(sensorId, timestamp, measurement, fields) { + try { + const map = FIELD_MAP[measurement] || {}; + const point = new Point(measurement) + .tag('sensor', sensorId) + .timestamp(new Date(timestamp)); + + for (const [key, value] of Object.entries(fields)) { + if (value == null) continue; + const fieldName = map[key] || key; + + if (typeof value === 'number') { + point.floatField(fieldName, value); + } else if (typeof value === 'string') { + point.stringField(fieldName, value); + } else if (typeof value === 'boolean') { + point.booleanField(fieldName, value); + } + } + + writeApi.writePoint(point); + await writeApi.flush(); + } catch (error) { + console.error(`[INFLUX] Errore writePoint (${measurement}):`, error.message); + } +} + +async function writeForecastBatch(sensorId, points) { + try { + const map = FIELD_MAP.forecast || {}; + + for (const [ts, fields] of points) { + const point = new Point('forecast') + .tag('sensor', sensorId) + .timestamp(new Date(ts)); + + for (const [key, value] of Object.entries(fields)) { + if (value == null) continue; + const fieldName = map[key] || key; + + if (typeof value === 'number') { + point.floatField(fieldName, value); + } else if (typeof value === 'string') { + point.stringField(fieldName, value); + } + } + writeApi.writePoint(point); + } + + await writeApi.flush(); + console.log(`[INFLUX] Scritti ${points.length} punti forecast per sensore ${sensorId}`); + } catch (error) { + console.error(`[INFLUX] Errore writeForecastBatch:`, error.message); + } +} + +// --- Batch buffer per watchers --- +const BATCH_SIZE = 10; +const batchBuffers = new Map(); // sensorId → [{timestamp, measurement, fields}, ...] + +function bufferPoint(sensorId, timestamp, measurement, fields) { + if (!batchBuffers.has(sensorId)) { + batchBuffers.set(sensorId, []); + } + const buffer = batchBuffers.get(sensorId); + buffer.push({ timestamp, measurement, fields }); + + if (buffer.length >= BATCH_SIZE) { + const batch = buffer.splice(0, BATCH_SIZE); + writeBatch(sensorId, batch); + } +} + +async function writeBatch(sensorId, batch) { + try { + for (const { timestamp, measurement, fields } of batch) { + const map = FIELD_MAP[measurement] || {}; + const point = new Point(measurement) + .tag('sensor', sensorId) + .timestamp(new Date(timestamp)); + + for (const [key, value] of Object.entries(fields)) { + if (value == null) continue; + const fieldName = map[key] || key; + if (typeof value === 'number') { + point.floatField(fieldName, value); + } else if (typeof value === 'string') { + point.stringField(fieldName, value); + } else if (typeof value === 'boolean') { + point.booleanField(fieldName, value); + } + } + writeApi.writePoint(point); + } + await writeApi.flush(); + console.log(`[INFLUX] Batch scritto: ${batch.length} punti per sensore ${sensorId}`); + } catch (error) { + console.error(`[INFLUX] Errore writeBatch:`, error.message); + } +} + +async function flushBuffer(sensorId) { + const buffer = batchBuffers.get(sensorId); + if (!buffer || buffer.length === 0) return []; + + const remaining = buffer.splice(0); + await writeBatch(sensorId, remaining); + return remaining; +} + +function getBufferedPoints(sensorId) { + return batchBuffers.get(sensorId) || []; +} + +function clearBuffer(sensorId) { + batchBuffers.delete(sensorId); +} + +module.exports = { writePoint, writeForecastBatch, bufferPoint, flushBuffer, getBufferedPoints, clearBuffer, FIELD_MAP }; diff --git a/realtime/src/helper/redis.js b/realtime/src/helper/redis.js new file mode 100644 index 0000000..80fecbe --- /dev/null +++ b/realtime/src/helper/redis.js @@ -0,0 +1,79 @@ +const Redis = require('ioredis'); + +const redis = new Redis({ + host: process.env.REDIS_HOST, + port: process.env.REDIS_PORT +}); + +// Client dedicato per subscribe (ioredis richiede client separato) +const redisSub = new Redis({ + host: process.env.REDIS_HOST, + port: process.env.REDIS_PORT +}); + +redis.on('error', (error) => { + console.error('Redis error:', error); +}); + +redis.on('connect', () => { + console.log('Server connected to Redis DB'); +}); + +redisSub.on('error', (error) => { + console.error('Redis sub error:', error); +}); + +const sensors_hash_map = 'sensors:sessions'; + +async function setSession(sensorID, metadata) { + await redis.hset(sensors_hash_map, sensorID, JSON.stringify(metadata)); +} + +async function getSession(sensorID) { + return await redis.hget(sensors_hash_map, sensorID); +} + +async function deleteSession(sensorID) { + await redis.hdel(sensors_hash_map, sensorID); +} + +async function getSessions() { + return await redis.hgetall(sensors_hash_map); +} + +// --- Pub/Sub per live watchers --- + +async function publishSensorData(sensorId, data) { + await redis.publish(`sensor:data:${sensorId}`, JSON.stringify(data)); +} + +async function addWatcher(sensorId) { + return await redis.incr(`sensor:watchers:${sensorId}`); +} + +async function removeWatcher(sensorId) { + const count = await redis.decr(`sensor:watchers:${sensorId}`); + if (count <= 0) { + await redis.del(`sensor:watchers:${sensorId}`); + return 0; + } + return count; +} + +async function getWatcherCount(sensorId) { + const count = await redis.get(`sensor:watchers:${sensorId}`); + return parseInt(count) || 0; +} + +module.exports = { + setSession, + getSession, + deleteSession, + getSessions, + publishSensorData, + addWatcher, + removeWatcher, + getWatcherCount, + redis, + redisSub +}; \ No newline at end of file diff --git a/realtime/src/helper/tokenStore.js b/realtime/src/helper/tokenStore.js new file mode 100644 index 0000000..c987cc6 --- /dev/null +++ b/realtime/src/helper/tokenStore.js @@ -0,0 +1,52 @@ +const { redis } = require('./redis'); +const { generateShortId } = require('./cryptoUtils'); + +const TOKEN_PREFIX = 'token:pending:'; + +/** + * Genera un nuovo token effimero valido per i prossimi 5 secondi. + */ +async function setToken(sensorId, metadata = {}, duration = 5) { + const token = generateShortId(8); + const key = `${TOKEN_PREFIX}${token}`; + + const payload = JSON.stringify({ + sensorId, + metadata, + createdAt: Date.now() + }); + + await redis.set(key, payload, 'EX', duration); + + return token; +} + +/** + * Consuma (valida e rimuove) un token. + * @returns {Object|null} - I dati della sessione se valida, altrimenti null + */ +async function consumeToken(token) { + const key = `${TOKEN_PREFIX}${token}`; + + // Recupera il token + const rawData = await redis.get(key); + if (!rawData) { + return null; + } + + // Il token è monouso: lo cancelliamo subito dopo la lettura + await redis.del(key); + + try { + const data = JSON.parse(rawData); + return data; // Ritorna l'intero oggetto (sensorId, metadata, ecc.) + } catch (e) { + console.error('Error parsing token data:', e); + return null; + } +} + +module.exports = { + setToken, + consumeToken +}; diff --git a/realtime/src/index.js b/realtime/src/index.js new file mode 100644 index 0000000..f606bd0 --- /dev/null +++ b/realtime/src/index.js @@ -0,0 +1,149 @@ +const express = require('express'); +const WebSocket = require('ws'); +const Redis = require('ioredis'); +const redisHelper = require('./helper/redis'); +const influxWriter = require('./helper/influxWriter'); +const influxReader = require('./helper/influxReader'); + +const app = express(); +app.use(express.json()); +app.use((req, res, next) => { + res.header('Access-Control-Allow-Origin', req.headers.origin || '*'); + res.header('Access-Control-Allow-Credentials', 'true'); + res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + res.header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + if (req.method === 'OPTIONS') return res.sendStatus(204); + next(); +}); + +require('./socket'); + +app.get('/', (req, res) => { + res.redirect('/health'); +}); + +app.get('/health', (req, res) => { + res.status(200).send({ + status: 'OK', + service: 'realtime', + version: process.env.VERSION, + build: process.env.VERSION_BUILD, + state: process.env.VERSION_STATE, + port: process.env.PORT + }); +}); + +app.use('/sensors', require('./routes/sensors')); +app.use('/connect', require('./routes/connect')); +app.use('/sessions', require('./routes/sessions')); + +// --- Flush buffer e CSV export --- + +app.post('/sessions/:sensorId/flush', async (req, res) => { + try { + const { sensorId } = req.params; + const flushed = await influxWriter.flushBuffer(sensorId); + res.status(200).json({ flushed: flushed.length }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +app.get('/sessions/:sensorId/csv', async (req, res) => { + try { + const { sensorId } = req.params; + const { from } = req.query; + if (!from) return res.status(400).json({ error: 'from timestamp required' }); + + // Flusha prima il buffer residuo + await influxWriter.flushBuffer(sensorId); + + const rows = await influxReader.querySessionData(sensorId, parseInt(from)); + const csv = influxReader.formatCSV(rows); + + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', `attachment; filename="session_${sensorId}.csv"`); + res.send(csv); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// --- HTTP server + WebSocket per watchers live --- + +const server = app.listen(process.env.PORT, () => { + console.log(`Realtime on port ${process.env.PORT}`); +}); + +const wss = new WebSocket.Server({ server, path: '/live' }); + +wss.on('connection', (client) => { + let watchedSensor = null; + let subscriber = null; + + client.on('message', async (raw) => { + try { + const msg = JSON.parse(raw); + + if (msg.action === 'watch' && msg.sensorId) { + // Rimuovi watch precedente se esiste + if (watchedSensor) { + await redisHelper.removeWatcher(watchedSensor); + if (subscriber) { + subscriber.unsubscribe(); + subscriber.quit(); + subscriber = null; + } + } + + watchedSensor = msg.sensorId; + await redisHelper.addWatcher(watchedSensor); + + // Subscriber Redis dedicato per questo client + subscriber = new Redis({ + host: process.env.REDIS_HOST, + port: process.env.REDIS_PORT + }); + + subscriber.subscribe(`sensor:data:${watchedSensor}`); + subscriber.on('message', (channel, message) => { + if (client.readyState === WebSocket.OPEN) { + client.send(message); // Dati gia' JSON + } + }); + + client.send(JSON.stringify({ type: 'watching', sensorId: watchedSensor })); + } + + if (msg.action === 'unwatch') { + if (watchedSensor) { + await redisHelper.removeWatcher(watchedSensor); + watchedSensor = null; + } + if (subscriber) { + subscriber.unsubscribe(); + subscriber.quit(); + subscriber = null; + } + client.send(JSON.stringify({ type: 'unwatched' })); + } + + } catch (err) { + console.error('[WS-LIVE] Message error:', err); + } + }); + + client.on('close', async () => { + if (watchedSensor) { + await redisHelper.removeWatcher(watchedSensor); + } + if (subscriber) { + subscriber.unsubscribe(); + subscriber.quit(); + } + }); + + client.on('error', (err) => { + console.error('[WS-LIVE] Client error:', err); + }); +}); \ No newline at end of file diff --git a/realtime/src/routes/connect.js b/realtime/src/routes/connect.js new file mode 100644 index 0000000..497e8b5 --- /dev/null +++ b/realtime/src/routes/connect.js @@ -0,0 +1,74 @@ +const express = require('express'); +const db = require('../helper/authdb'); +const tokenStore = require('../helper/tokenStore'); +const redis = require('../helper/redis'); +const router = express.Router(); + +/** + * POST /connect + * Il sensore invia il suo codice segreto (token) e metadati opzionali. + * Se autentica, riceve un token effimero per la connessione WebSocket. + */ +router.post('/', async (req, res) => { + try { + const { token, metadata } = req.body; + + if (!token) { + return res.status(400).send({ error: 'Token is required' }); + } + + const sensor = await db.getSensor(token); + + if (!sensor) { + return res.status(401).send({ error: 'token not valid' }); + } + if (!sensor.is_active) { + return res.status(403).send({ error: 'token not valid' }); + } + + // Genera il token effimero valido per max 5 secondi + const socketToken = await tokenStore.setToken(sensor.id, metadata, 5); + + return res.status(200).send({ + socketToken, + sensorId: sensor.id, + expiresIn: 5 + }); + } catch (error) { + return res.status(500).send({ error: `${error}` }); + } +}); + +/** + * DELETE /connect/:sensorId + * Disconnette forzatamente un sensore rimuovendo la sua sessione da Redis. + */ +router.delete('/:sensorId', async (req, res) => { + const { sensorId } = req.params; + + try { + await redis.deleteSession(sensorId); + return res.status(200).send({ result: 'disconnected' }); + } catch (error) { + return res.status(500).send({ error: `${error}` }); + } +}); + +/** + * POST /connect/new + * Crea un nuovo sensore nel database. + */ +router.post('/new', async (req, res) => { + const { name, code } = req.body; + if (!name || !code) { + return res.status(400).send({ error: 'Name and code are required' }); + } + try { + await db.createSensor(name, code); + return res.status(200).send({ result: 'created' }); + } catch (error) { + return res.status(500).send({ error: `${error}` }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/realtime/src/routes/sensors.js b/realtime/src/routes/sensors.js new file mode 100644 index 0000000..730cb3d --- /dev/null +++ b/realtime/src/routes/sensors.js @@ -0,0 +1,36 @@ +const express = require('express'); +const db = require('../helper/authdb'); + +router = express.Router(); + +router.get('/', async (req, res) => { + const sensors = await db.getSensors(); + res.status(200).json(sensors); +}); + +router.post('/:id/:activity', async (req, res) => { + const { id, activity } = req.params; + + let isActive; + if (activity === 'active') { + isActive = true; + } else if (activity === 'inactive') { + isActive = false; + } else { + return res.status(400).json({ error: 'Invalid activity' }); + } + + try { + const exists = await db.sensorsExists(id); + if (!exists) { + return res.status(404).json({ error: `Sensor with id ${id} not found` }); + } + await db.setSensorActivity(id, isActive); + res.status(200).json({ status: `Sensor ${activity}` }); + } catch (error) { + console.error('Error updating sensor ID:', id, error); + res.status(500).json({ error: 'Database error' }); + } +}) + +module.exports = router \ No newline at end of file diff --git a/realtime/src/routes/sessions.js b/realtime/src/routes/sessions.js new file mode 100644 index 0000000..9a53626 --- /dev/null +++ b/realtime/src/routes/sessions.js @@ -0,0 +1,36 @@ +const express = require('express'); +const redis = require('../helper/redis'); + +const router = express.Router(); + +/** + * GET /sessions + * Ritorna tutti i sensori attualmente connessi con i loro metadati. + * Se viene passato un parametro ?sensor=ID, restituisce solo quello. + */ +router.get('/', async (req, res) => { + const { sensor } = req.query; + + // Se viene passato un parametro ?sensor=ID, restituiamo solo quello + if (sensor) { + try { + const session = await redis.getSession(sensor); + if (!session) { + return res.status(404).json({ error: 'Session not found' }); + } + return res.status(200).json(JSON.parse(session)); + } catch (error) { + return res.status(500).json({ error: `${error}` }); + } + } + + // Altrimenti restituiamo tutta la lista + try { + const sessions = await redis.getSessions(); + res.status(200).json(sessions); + } catch (error) { + res.status(500).json({ error: `${error}` }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/realtime/src/socket.js b/realtime/src/socket.js new file mode 100644 index 0000000..0c29a19 --- /dev/null +++ b/realtime/src/socket.js @@ -0,0 +1,110 @@ +const WebSocket = require('ws'); +const { encode, decode } = require('@msgpack/msgpack'); +const url = require('url'); +const tokenStore = require('./helper/tokenStore'); +const redisHelper = require('./helper/redis'); +const influxWriter = require('./helper/influxWriter'); + +const ws = new WebSocket.Server({ + port: process.env.SOCKET_PORT, + perMessageDeflate: false, + verifyClient: async (info, callback) => { + const { query } = url.parse(info.req.url, true); + const token = query.token; + + if (!token) { + return callback(false, 401, 'token not passed'); + } + + try { + const sessionData = await tokenStore.consumeToken(token); + if (!sessionData) { + return callback(false, 401, 'token not valid or expired'); + } + + info.req.sensorSession = sessionData; + callback(true); + } catch (error) { + callback(false, 500, `internal server error: ${error}`); + } + } +}); + +ws.on('connection', async (client, req) => { + const session = req.sensorSession; + const sensorId = session.sensorId; + + client.sensorId = sensorId; + + // Registra la sessione su Redis + try { + await redisHelper.setSession(sensorId, { + ...session.metadata, + connectedAt: Math.floor(Date.now() / 1000) + }); + } catch (err) { + console.error(`[WS] Redis setSession error for ${sensorId}:`, err); + } + + /** + * Gestione messaggi in arrivo dal sensore. + * + * Il sensore invia dati codificati in MessagePack (binario). + * Il formato atteso è un array compatto (per risparmiare spazio): + * + * [timestamp, measurement, { field1: value1, field2: value2, ... }] + * + * Esempio pratico (dati meteo da barca): + * [1710681000, "weather", { t: 22.5, h: 65, p: 1013.2, w: 12.3 }] + * + * Dove le chiavi abbreviate sono: + * t = temperature, h = humidity, p = pressure, w = windSpeed ... + * + * Il server decodifica il messaggio e prepara il punto per InfluxDB. + */ + client.on('message', async (raw) => { + try { + const msg = decode(raw); + + // Rispondi con ACK minimale in msgpack: { a: 1 } = acknowledged + client.send(encode({ a: 1 })); + + const [timestamp, measurement, fields] = msg; + + // Pubblica su Redis per i watchers live + redisHelper.publishSensorData(sensorId, { timestamp, measurement, fields }); + + // Controlla se ci sono watchers attivi + const watchers = await redisHelper.getWatcherCount(sensorId); + + if (measurement === 'forecast_batch') { + influxWriter.writeForecastBatch(sensorId, fields); + } else if (watchers > 0) { + // Con watchers: accumula nel buffer, flusha ogni 10 punti + influxWriter.bufferPoint(sensorId, timestamp, measurement, fields); + } else { + // Senza watchers: scrivi immediatamente (comportamento originale) + influxWriter.writePoint(sensorId, timestamp, measurement, fields); + } + + } catch (err) { + console.error(`[WS|${sensorId}] decode error:`, err); + // Rispondi con errore in msgpack: { e: 1 } = error + client.send(encode({ e: 1 })); + } + }); + + client.on('error', (err) => { + console.error(`[WS|${sensorId}] error:`, err); + }); + + client.on('close', async () => { + try { + await redisHelper.deleteSession(sensorId); + } catch (err) { + console.error(`[WS] Redis deleteSession error for ${sensorId}:`, err); + } + }); +}); + +console.log(`[WS] Realtime websocket server on port: ${process.env.SOCKET_PORT}`); \ No newline at end of file