commit c01b9887143a7e620c596668aed7c43c55097679 Author: jhartworks Date: Tue Aug 11 23:37:44 2026 +0200 first commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1c6b5ed --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +node_modules +npm-debug.log +.git +.gitignore +.env diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..27a2084 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.env +coverage/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..df57215 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM node:22-alpine + +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev + +COPY src ./src +COPY README.md ./ + +EXPOSE 3923 + +CMD ["npm", "start"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..cbafbad --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +# Bambu Web Helper + +Multi-user web application for Bambu Cloud monitoring with Docker, Postgres, +user management, and a separate private MQTT broker configuration for every user. + +## Features + +- Web interface on port `3923` +- Postgres database through `docker compose` +- Login, self-service password changes, and admin password resets +- Admin-managed per-user printer limits, initially `1` +- Per-printer Bambu Cloud token storage +- Live printer status through Bambu Cloud MQTT +- Per-user MQTT broker with optional authentication, TLS, and certificate-error override +- Per-user Bambu push and MQTT sync intervals + +## Start with Docker + +1. Ensure Docker Desktop is running. +2. Run `docker compose up --build -d`. +3. Open `http://localhost:3923`. + +The initial admin is set in `.env`: + +- Username: `bambumin` +- Password: `ChangeMe123!` + +## Defaults + +The `.env` file supplies defaults for newly created users: + +- `DEFAULT_USER_PRINTER_LIMIT=1` +- `DEFAULT_USER_PUSH_INTERVAL_MS=600000` +- `DEFAULT_USER_SYNC_INTERVAL_MS=30000` +- `BRIDGE_REFRESH_INTERVAL_MS=30000` + +## Private MQTT + +After login, every user configures their own MQTT broker in the web interface. +Enter only the hostname or IP address, such as `mqtt.example.net`; enter the port +separately. Enabling TLS uses `mqtts`. Enabling the certificate-error option sets +the MQTT client's `rejectUnauthorized` option to `false`. + +Each printer publishes retained payloads to the configured user broker: + +- `{baseTopic}/{serial}/availability` +- `{baseTopic}/{serial}/state` +- `{baseTopic}/{serial}/raw` +- `{baseTopic}/{serial}/{custom-suffix}` for configured individual scalar values + +In the printer tab, users can map selected fields from `raw` or the normalized +`state` to their own topic suffix. Examples include `print.nozzle_temper` and +`nozzleTemp`. Scalar values are published directly as text, number, or boolean, +so subscribers do not need to parse the JSON status payload. + +The push interval sends Bambu's `pushall` request. The MQTT sync interval republishes +the last known state; new live updates are always published immediately. + +## Bambu Cloud + +The integration follows the connection pattern used by +[Keralots/BambuHelper](https://github.com/Keralots/BambuHelper): + +- `us.mqtt.bambulab.com` for `US` and `EU`, `cn.mqtt.bambulab.com` for `CN` +- TLS on port `8883` +- Username `u_{uid}` and the access token as password +- Status topic `device/{SERIAL}/report` +- Request topic `device/{SERIAL}/request` + +The application requests complete status snapshots with `pushall`. A printer owner +can additionally switch the chamber light and pause, resume, or stop an active print +from the printer page. Stopping always requires a confirmation in the web interface. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..458d7d3 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,30 @@ +services: + app: + build: . + restart: unless-stopped + env_file: + - .env + depends_on: + db: + condition: service_healthy + ports: + - "3923:3923" + db: + image: postgres:16-alpine + restart: unless-stopped + env_file: + - .env + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] + interval: 10s + timeout: 5s + retries: 10 + volumes: + - postgres_data:/var/lib/postgresql/data + +volumes: + postgres_data: diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..7f159d2 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,675 @@ +{ + "name": "bambu-web-helper", + "version": "0.2.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "bambu-web-helper", + "version": "0.2.0", + "dependencies": { + "mqtt": "^5.13.3", + "pg": "^8.16.3" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@types/node": { + "version": "26.1.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.2.tgz", + "integrity": "sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/readable-stream": { + "version": "4.0.24", + "resolved": "https://registry.npmjs.org/@types/readable-stream/-/readable-stream-4.0.24.tgz", + "integrity": "sha512-NRvUNC/JFGPJvqdAfEve8oginbM6V08u5NzLWpG8MwA2kTPOLnqk+wpwuPT+mp3aUsxyuT6m2gnrPuHYCruzEg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "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/bl": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz", + "integrity": "sha512-jLsPgN/YSvPUg9UX0Kd73CXpm2Psg9FxMeCSXnk3WBO3CMT10JMwijubhGfHCnFu6TPn1ei3b975dxv7K2pWVg==", + "license": "MIT", + "dependencies": { + "@types/readable-stream": "^4.0.0", + "buffer": "^6.0.3", + "inherits": "^2.0.4", + "readable-stream": "^4.2.0" + } + }, + "node_modules/broker-factory": { + "version": "3.1.15", + "resolved": "https://registry.npmjs.org/broker-factory/-/broker-factory-3.1.15.tgz", + "integrity": "sha512-ko+aWvgNuP49meGrdjUu7rC+Y+Wai3cCPxP3xWwHsHfehFjOh5ZQM2yC4gEB2UddeZ/YXhm0K1eG/L6fxym2Og==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7", + "fast-unique-numbers": "^9.0.27", + "tslib": "^2.8.1", + "worker-factory": "^7.0.50" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "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", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/commist": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/commist/-/commist-3.2.0.tgz", + "integrity": "sha512-4PIMoPniho+LqXmpS5d3NuGYncG6XWlkBSVGiWycL22dd42OYdUGil2CWuzklaJoNxyxUSpO4MKIBU94viWNAw==", + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/concat-stream/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/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/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/fast-unique-numbers": { + "version": "9.0.27", + "resolved": "https://registry.npmjs.org/fast-unique-numbers/-/fast-unique-numbers-9.0.27.tgz", + "integrity": "sha512-nDA9ADeINN8SA2u2wCtU+siWFTTDqQR37XvgPIDDmboWQeExz7X0mImxuaN+kJddliIqy2FpVRmnvRZ+j8i1/A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=18.2.0" + } + }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", + "license": "MIT" + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "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": "BSD-3-Clause" + }, + "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/ip-address": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", + "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/js-sdsl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/js-sdsl/-/js-sdsl-4.3.0.tgz", + "integrity": "sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mqtt": { + "version": "5.15.2", + "resolved": "https://registry.npmjs.org/mqtt/-/mqtt-5.15.2.tgz", + "integrity": "sha512-VWZU2CSUY3U3oN0PSBRDE5SNsFi4zqqNeQ/uv3pZWqY3CrBXD/dhd0ZLjlsk5YnebGlrapi4lRVNJPUNQ5aZ3w==", + "license": "MIT", + "dependencies": { + "@types/readable-stream": "^4.0.21", + "@types/ws": "^8.18.1", + "commist": "^3.2.0", + "concat-stream": "^2.0.0", + "debug": "^4.4.1", + "help-me": "^5.0.0", + "lru-cache": "^10.4.3", + "minimist": "^1.2.8", + "mqtt-packet": "^9.0.2", + "number-allocator": "^1.0.14", + "readable-stream": "^4.7.0", + "rfdc": "^1.4.1", + "socks": "^2.8.6", + "split2": "^4.2.0", + "worker-timers": "^8.0.23", + "ws": "^8.18.3" + }, + "bin": { + "mqtt": "build/bin/mqtt.js", + "mqtt_pub": "build/bin/pub.js", + "mqtt_sub": "build/bin/sub.js" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/mqtt-packet": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/mqtt-packet/-/mqtt-packet-9.0.2.tgz", + "integrity": "sha512-MvIY0B8/qjq7bKxdN1eD+nrljoeaai+qjLJgfRn3TiMuz0pamsIWY2bFODPZMSNmabsLANXsLl4EMoWvlaTZWA==", + "license": "MIT", + "dependencies": { + "bl": "^6.0.8", + "debug": "^4.3.4", + "process-nextick-args": "^2.0.1" + } + }, + "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/number-allocator": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/number-allocator/-/number-allocator-1.0.14.tgz", + "integrity": "sha512-OrL44UTVAvkKdOdRQZIJpLkAdjXGTRda052sN4sO77bKEzYYqWKMBjQvrJFzqygI99gL6Z4u2xctPW1tB8ErvA==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.1", + "js-sdsl": "4.3.0" + } + }, + "node_modules/pg": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", + "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", + "license": "MIT", + "peer": true, + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.15.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz", + "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", + "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.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", + "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", + "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/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "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/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/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/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT" + }, + "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/worker-factory": { + "version": "7.0.50", + "resolved": "https://registry.npmjs.org/worker-factory/-/worker-factory-7.0.50.tgz", + "integrity": "sha512-hhwc0G+sFwM4qBuhJIUBn2p1Jf8v/FwmLUANBf/Q+Lt2uI8mfIZQhXaZQACodQD4R7Zp6cn/6702bIvNn2puJQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7", + "fast-unique-numbers": "^9.0.27", + "tslib": "^2.8.1" + } + }, + "node_modules/worker-timers": { + "version": "8.0.34", + "resolved": "https://registry.npmjs.org/worker-timers/-/worker-timers-8.0.34.tgz", + "integrity": "sha512-WXL+Dqsm0G6dnC66rQsvM3tPT2adhbqSirWUCZGglkALOr8ocS0KlpU0iuKbjflJOlu3ydZikMEVrYnHOM9suw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7", + "tslib": "^2.8.1", + "worker-timers-broker": "^8.0.18", + "worker-timers-worker": "^9.0.15" + } + }, + "node_modules/worker-timers-broker": { + "version": "8.0.18", + "resolved": "https://registry.npmjs.org/worker-timers-broker/-/worker-timers-broker-8.0.18.tgz", + "integrity": "sha512-FrjzDVX1wKfZN0gRbCFqv8VHuTncG4sbI/WGEg4tSSQeIsnwqg4YBYWMAHYLJtUDEYYmiK65UKFrVMVk2irDSg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7", + "broker-factory": "^3.1.15", + "fast-unique-numbers": "^9.0.27", + "tslib": "^2.8.1", + "worker-timers-worker": "^9.0.15" + } + }, + "node_modules/worker-timers-worker": { + "version": "9.0.15", + "resolved": "https://registry.npmjs.org/worker-timers-worker/-/worker-timers-worker-9.0.15.tgz", + "integrity": "sha512-KKUe7lZ/Aignr51H6hOUik8LwTnIgojH/1lwhli8A8qIEIyewogZTpNpMW5B6BF7nmwBOkUoTYgf1H3QShcjSA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7", + "tslib": "^2.8.1", + "worker-factory": "^7.0.50" + } + }, + "node_modules/ws": { + "version": "8.21.2", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.2.tgz", + "integrity": "sha512-54dMVAo4WIe6SKy3vBgN+9bJZqqQ8IMRevAkOLQALhi49qkkQDQfWdAZ8KQlXiEabw88ARXXdUrlvtbKQX+aKw==", + "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/package.json b/package.json new file mode 100644 index 0000000..99cde1f --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "bambu-web-helper", + "version": "0.2.0", + "description": "Multi-user Bambu Cloud bridge with private MQTT publishing, Postgres storage, and a web admin UI.", + "type": "module", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "dev": "node --watch src/index.js" + }, + "dependencies": { + "mqtt": "^5.13.3", + "pg": "^8.16.3" + } +} diff --git a/src/auth.js b/src/auth.js new file mode 100644 index 0000000..2df2bbd --- /dev/null +++ b/src/auth.js @@ -0,0 +1,127 @@ +import crypto from "node:crypto"; + +const SCRYPT_KEYLEN = 64; + +function base64UrlEncode(value) { + return Buffer.from(value).toString("base64url"); +} + +function base64UrlDecode(value) { + return Buffer.from(value, "base64url").toString("utf8"); +} + +function sign(value, secret) { + return crypto.createHmac("sha256", secret).update(value).digest("base64url"); +} + +function timingSafeEqualText(left, right) { + const leftBuffer = Buffer.from(left); + const rightBuffer = Buffer.from(right); + if (leftBuffer.length !== rightBuffer.length) { + return false; + } + return crypto.timingSafeEqual(leftBuffer, rightBuffer); +} + +function passwordTag(passwordHash) { + return crypto.createHash("sha256").update(passwordHash).digest("hex").slice(0, 16); +} + +export async function hashPassword(password) { + const salt = crypto.randomBytes(16).toString("hex"); + const derived = await new Promise((resolve, reject) => { + crypto.scrypt(password, salt, SCRYPT_KEYLEN, { maxmem: 64 * 1024 * 1024 }, (error, result) => { + if (error) { + reject(error); + return; + } + resolve(result); + }); + }); + return `scrypt:${salt}:${Buffer.from(derived).toString("hex")}`; +} + +export async function verifyPassword(password, passwordHash) { + const parts = passwordHash.split(":"); + if (parts.length !== 3 || parts[0] !== "scrypt") { + return false; + } + + const [, salt, expectedHex] = parts; + const derived = await new Promise((resolve, reject) => { + crypto.scrypt(password, salt, SCRYPT_KEYLEN, { maxmem: 64 * 1024 * 1024 }, (error, result) => { + if (error) { + reject(error); + return; + } + resolve(result); + }); + }); + + return timingSafeEqualText(Buffer.from(derived).toString("hex"), expectedHex); +} + +export function createSessionToken(user, secret, ttlHours) { + const payload = JSON.stringify({ + uid: user.id, + exp: Date.now() + ttlHours * 60 * 60 * 1000, + tag: passwordTag(user.password_hash), + }); + const encodedPayload = base64UrlEncode(payload); + return `${encodedPayload}.${sign(encodedPayload, secret)}`; +} + +export function verifySessionToken(token, secret) { + if (typeof token !== "string" || !token.includes(".")) { + return null; + } + + const [encodedPayload, signature] = token.split("."); + const expectedSignature = sign(encodedPayload, secret); + if (!timingSafeEqualText(signature, expectedSignature)) { + return null; + } + + try { + const payload = JSON.parse(base64UrlDecode(encodedPayload)); + if (!payload?.uid || !payload?.exp || !payload?.tag) { + return null; + } + if (Date.now() > payload.exp) { + return null; + } + return payload; + } catch { + return null; + } +} + +export function isSessionValidForUser(sessionPayload, user) { + return sessionPayload?.tag === passwordTag(user.password_hash); +} + +export function parseCookies(cookieHeader) { + const cookies = {}; + if (!cookieHeader) { + return cookies; + } + + for (const chunk of cookieHeader.split(";")) { + const index = chunk.indexOf("="); + if (index === -1) { + continue; + } + const key = chunk.slice(0, index).trim(); + const value = chunk.slice(index + 1).trim(); + cookies[key] = value; + } + return cookies; +} + +export function makeSessionCookie(token, maxAgeSeconds) { + return `session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAgeSeconds}`; +} + +export function clearSessionCookie() { + return "session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"; +} diff --git a/src/bambu-api.js b/src/bambu-api.js new file mode 100644 index 0000000..28cbea4 --- /dev/null +++ b/src/bambu-api.js @@ -0,0 +1,74 @@ +const BBL_HEADERS = { + "Content-Type": "application/json", + "User-Agent": "bambu_network_agent/01.09.05.01", + "X-BBL-Client-Name": "OrcaSlicer", + "X-BBL-Client-Type": "slicer", + "X-BBL-Client-Version": "01.09.05.51", + "X-BBL-Language": "en-US", + "X-BBL-OS-Type": "linux", + "X-BBL-OS-Version": "6.2.0", + "X-BBL-Agent-Version": "01.09.05.01", + Accept: "application/json", +}; + +export function getBrokerHost(region) { + return region === "CN" ? "cn.mqtt.bambulab.com" : "us.mqtt.bambulab.com"; +} + +export function getApiBase(region) { + return region === "CN" ? "https://api.bambulab.cn" : "https://api.bambulab.com"; +} + +function decodeJwtPayload(token) { + const segments = token.split("."); + if (segments.length < 2) { + return null; + } + + const base64 = segments[1].replace(/-/g, "+").replace(/_/g, "/"); + const padded = base64.padEnd(Math.ceil(base64.length / 4) * 4, "="); + try { + return JSON.parse(Buffer.from(padded, "base64").toString("utf8")); + } catch { + return null; + } +} + +export function extractCloudUserIdFromToken(token) { + const payload = decodeJwtPayload(token); + const uid = payload?.uid ?? payload?.sub ?? payload?.user_id; + if (!uid) { + return null; + } + return `u_${uid}`; +} + +export async function fetchCloudUserId(token, region) { + const response = await fetch(`${getApiBase(region)}/v1/user-service/my/profile`, { + method: "GET", + headers: { + ...BBL_HEADERS, + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + throw new Error(`Profile request failed with HTTP ${response.status}`); + } + + const data = await response.json(); + const uid = data?.uidStr ?? data?.uid; + if (!uid) { + throw new Error("Profile response did not contain uid"); + } + + return `u_${uid}`; +} + +export async function resolveCloudUserId(token, region) { + const fromToken = extractCloudUserIdFromToken(token); + if (fromToken) { + return fromToken; + } + return fetchCloudUserId(token, region); +} diff --git a/src/bridge-manager.js b/src/bridge-manager.js new file mode 100644 index 0000000..81b349d --- /dev/null +++ b/src/bridge-manager.js @@ -0,0 +1,408 @@ +import crypto from "node:crypto"; +import mqtt from "mqtt"; +import { getBrokerHost, resolveCloudUserId } from "./bambu-api.js"; +import { listBridgePrinters, updatePrinterCloudUserId } from "./db.js"; +import { getPrivateMqttSettings } from "./private-broker.js"; +import { createPrinterState, markPrinterDisconnected, markPrinterError, mergeReport } from "./state.js"; + +function makeSignature(printer) { + return JSON.stringify({ + serial: printer.serial, + name: printer.name, + region: printer.region, + token: printer.access_token, + owner: printer.username, + pushIntervalMs: printer.push_interval_ms, + syncIntervalMs: printer.sync_interval_ms, + privateMqtt: getPrivateMqttSettings(printer), + }); +} + +function getMqttFields(printer) { + return Array.isArray(printer.mqtt_fields) ? printer.mqtt_fields.map((field) => ({ + id: Number(field.id), + source: field.source, + jsonPath: field.jsonPath, + topicSuffix: field.topicSuffix, + })) : []; +} + +export class BridgeManager { + constructor({ pool, privateBroker, refreshIntervalMs }) { + this.pool = pool; + this.privateBroker = privateBroker; + this.refreshIntervalMs = refreshIntervalMs; + this.connections = new Map(); + this.states = new Map(); + this.listeners = new Set(); + this.syncTimer = null; + } + + onChange(listener) { + this.listeners.add(listener); + } + + emitChange() { + for (const listener of this.listeners) { + listener(); + } + } + + getSnapshotForUser(user) { + return Array.from(this.states.values()) + .filter((state) => user.is_admin || state.ownerUserId === Number(user.id)) + .sort((left, right) => left.name.localeCompare(right.name)) + .map((state) => ({ + printerId: state.printerId, + ownerUserId: state.ownerUserId, + ownerUsername: state.ownerUsername, + serial: state.serial, + name: state.name, + region: state.region, + connected: state.connected, + errorMessage: state.errorMessage, + lastSeenAt: state.lastSeenAt, + lastMessageAt: state.lastMessageAt, + mqttFields: state.mqttFields, + normalized: state.normalized, + raw: state.raw, + })); + } + + async start() { + await this.refresh(); + this.syncTimer = setInterval(() => { + this.refresh().catch((error) => { + console.error("[bridge] refresh failed:", error.message); + }); + }, this.refreshIntervalMs); + } + + async refresh() { + const printers = await listBridgePrinters(this.pool); + const desiredIds = new Set(printers.map((printer) => Number(printer.id))); + + for (const [printerId, connection] of this.connections.entries()) { + if (!desiredIds.has(printerId)) { + this.stopConnection(printerId, connection); + this.connections.delete(printerId); + this.states.delete(printerId); + this.emitChange(); + } + } + this.privateBroker.reconcile(printers); + + for (const printer of printers) { + const printerId = Number(printer.id); + const signature = makeSignature(printer); + const current = this.connections.get(printerId); + + if (!this.states.has(printerId)) { + this.states.set(printerId, createPrinterState(printer)); + } + + if (current && current.signature === signature) { + const currentState = this.states.get(printerId); + const mqttFields = getMqttFields(printer); + if (currentState && JSON.stringify(currentState.mqttFields) !== JSON.stringify(mqttFields)) { + this.updateState(printerId, { + ...currentState, + mqttFields, + }, current.privateMqttSettings); + } + continue; + } + + if (current) { + this.stopConnection(printerId, current, false); + } + + await this.startConnection(printer, signature); + } + } + + async startConnection(printer, signature) { + const printerId = Number(printer.id); + const privateMqttSettings = getPrivateMqttSettings(printer); + const state = createPrinterState(printer); + this.updateState(printerId, state, privateMqttSettings); + + try { + const cloudUserId = printer.cloud_user_id || await resolveCloudUserId(printer.access_token, printer.region); + if (!printer.cloud_user_id) { + await updatePrinterCloudUserId(this.pool, printerId, cloudUserId); + } + + const clientId = `bambu-web-helper-${printer.serial}-${crypto.randomUUID().slice(0, 8)}`; + const brokerHost = getBrokerHost(printer.region); + const client = mqtt.connect(`mqtts://${brokerHost}:8883`, { + username: cloudUserId, + password: printer.access_token, + clientId, + protocolVersion: 4, + reconnectPeriod: 5000, + rejectUnauthorized: true, + keepalive: 30, + connectTimeout: 15000, + }); + + const publishPushall = () => { + if (!client.connected) { + return; + } + const payload = { + pushing: { + sequence_id: String(Date.now()), + command: "pushall", + version: 1, + push_target: 1, + }, + }; + client.publish(`device/${printer.serial}/request`, JSON.stringify(payload)); + }; + + const pushallTimer = setInterval(publishPushall, Number(printer.push_interval_ms)); + const syncTimer = setInterval(() => { + const currentState = this.states.get(printerId); + if (currentState) { + this.privateBroker.publishPrinterState(currentState, privateMqttSettings); + } + }, Number(printer.sync_interval_ms)); + + client.on("connect", () => { + console.log(`[bridge] connected ${printer.username}/${printer.serial}`); + client.subscribe(`device/${printer.serial}/report`, { qos: 0 }, (error) => { + if (error) { + console.error(`[bridge] subscribe failed ${printer.serial}:`, error.message); + return; + } + publishPushall(); + }); + }); + + client.on("message", (_topic, payloadBuffer) => { + try { + const payload = JSON.parse(payloadBuffer.toString("utf8")); + const currentState = this.states.get(printerId) || createPrinterState(printer); + const nextState = mergeReport(currentState, payload); + this.updateState(printerId, nextState, privateMqttSettings); + } catch (error) { + console.error(`[bridge] invalid payload ${printer.serial}:`, error.message); + } + }); + + client.on("close", () => { + const currentState = this.states.get(printerId); + if (currentState) { + this.updateState(printerId, markPrinterDisconnected(currentState), privateMqttSettings); + } + }); + + client.on("offline", () => { + const currentState = this.states.get(printerId); + if (currentState) { + this.updateState(printerId, markPrinterDisconnected(currentState), privateMqttSettings); + } + }); + + client.on("error", (error) => { + const currentState = this.states.get(printerId); + if (currentState) { + this.updateState(printerId, markPrinterError(currentState, error.message), privateMqttSettings); + } + console.error(`[bridge] error ${printer.serial}:`, error.message); + }); + + this.connections.set(printerId, { + signature, + client, + pushallTimer, + syncTimer, + privateMqttSettings, + ownerUserId: Number(printer.user_id), + }); + } catch (error) { + const currentState = this.states.get(printerId) || state; + this.updateState(printerId, markPrinterError(currentState, error.message), privateMqttSettings); + console.error(`[bridge] failed to start ${printer.username}/${printer.serial}:`, error.message); + } + } + + stopConnection(printerId, connection, publishOfflineState = true) { + clearInterval(connection.pushallTimer); + clearInterval(connection.syncTimer); + connection.client.end(true); + const currentState = this.states.get(printerId); + if (currentState) { + const disconnected = markPrinterDisconnected(currentState); + if (publishOfflineState) { + this.updateState(printerId, disconnected, connection.privateMqttSettings); + } else { + this.states.set(printerId, disconnected); + this.emitChange(); + } + } + } + + updateState(printerId, nextState, privateMqttSettings = this.connections.get(printerId)?.privateMqttSettings) { + this.states.set(printerId, nextState); + this.privateBroker.publishPrinterState(nextState, privateMqttSettings); + this.emitChange(); + } + + clearPrivateMqttField(printerId, topicSuffix) { + const state = this.states.get(Number(printerId)); + const settings = this.connections.get(Number(printerId))?.privateMqttSettings; + if (state && settings) { + this.privateBroker.clearPrinterField(state, settings, topicSuffix); + } + } + + async setChamberLight(printerId, actor, enabled) { + const connection = this.connections.get(Number(printerId)); + if (!connection) { + const error = new Error("Printer is not connected."); + error.status = 409; + throw error; + } + if (!actor.is_admin && connection.ownerUserId !== Number(actor.id)) { + const error = new Error("Forbidden."); + error.status = 403; + throw error; + } + if (!connection.client.connected) { + const error = new Error("Printer is currently offline."); + error.status = 409; + throw error; + } + + const currentState = this.states.get(Number(printerId)); + if (!currentState) { + const error = new Error("Printer state is unavailable."); + error.status = 409; + throw error; + } + const payload = { + system: { + sequence_id: String(Date.now()), + command: "ledctrl", + led_node: "chamber_light", + led_mode: enabled ? "on" : "off", + led_on_time: 500, + led_off_time: 500, + loop_times: 0, + interval_time: 0, + }, + }; + + await new Promise((resolve, reject) => { + connection.client.publish(`device/${currentState.serial}/request`, JSON.stringify(payload), { qos: 0 }, (error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + + const existingLights = Array.isArray(currentState.raw?.print?.lights_report) + ? currentState.raw.print.lights_report + : []; + const hasChamberLight = existingLights.some((light) => light?.node === "chamber_light"); + const lightsReport = existingLights.map((light) => ( + light?.node === "chamber_light" ? { ...light, mode: enabled ? "on" : "off" } : light + )); + if (!hasChamberLight) { + lightsReport.push({ node: "chamber_light", mode: enabled ? "on" : "off" }); + } + + this.updateState(Number(printerId), { + ...currentState, + raw: { + ...currentState.raw, + print: { + ...(currentState.raw?.print || {}), + lights_report: lightsReport, + }, + }, + normalized: { + ...currentState.normalized, + lightsReport, + chamberLight: enabled, + }, + }, connection.privateMqttSettings); + } + + async sendPrintCommand(printerId, actor, command) { + if (!["pause", "resume", "stop"].includes(command)) { + const error = new Error("Unsupported print command."); + error.status = 400; + throw error; + } + + const connection = this.connections.get(Number(printerId)); + if (!connection) { + const error = new Error("Printer is not connected."); + error.status = 409; + throw error; + } + if (!actor.is_admin && connection.ownerUserId !== Number(actor.id)) { + const error = new Error("Forbidden."); + error.status = 403; + throw error; + } + if (!connection.client.connected) { + const error = new Error("Printer is currently offline."); + error.status = 409; + throw error; + } + + const currentState = this.states.get(Number(printerId)); + if (!currentState) { + const error = new Error("Printer state is unavailable."); + error.status = 409; + throw error; + } + const printState = String(currentState.normalized?.gcodeState || "").toUpperCase(); + if (command === "pause" && printState !== "RUNNING") { + const error = new Error("Only a running print can be paused."); + error.status = 409; + throw error; + } + if (command === "resume" && printState !== "PAUSE") { + const error = new Error("Only a paused print can be resumed."); + error.status = 409; + throw error; + } + if (command === "stop" && !["RUNNING", "PAUSE", "PREPARE"].includes(printState)) { + const error = new Error("There is no active print to stop."); + error.status = 409; + throw error; + } + + const payload = { + print: { + sequence_id: String(Date.now()), + command, + param: "", + }, + }; + await new Promise((resolve, reject) => { + connection.client.publish(`device/${currentState.serial}/request`, JSON.stringify(payload), { qos: 1 }, (error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + } + + close() { + clearInterval(this.syncTimer); + for (const [printerId, connection] of this.connections.entries()) { + this.stopConnection(printerId, connection); + } + this.connections.clear(); + } +} diff --git a/src/db.js b/src/db.js new file mode 100644 index 0000000..ad9016f --- /dev/null +++ b/src/db.js @@ -0,0 +1,655 @@ +import { Pool } from "pg"; +import { hashPassword, verifyPassword } from "./auth.js"; + +function fail(message, status = 400) { + const error = new Error(message); + error.status = status; + throw error; +} + +function sanitizeUsername(username) { + const value = String(username || "").trim(); + if (!/^[A-Za-z0-9_.-]{3,32}$/u.test(value)) { + fail("Username must be 3-32 chars and only contain letters, numbers, dot, underscore or dash."); + } + return value; +} + +function sanitizePassword(password) { + const value = String(password || ""); + if (value.length < 8) { + fail("Password must be at least 8 characters long."); + } + return value; +} + +function sanitizeRegion(region) { + const value = String(region || "").trim().toUpperCase(); + if (!["US", "EU", "CN"].includes(value)) { + fail("Region must be US, EU or CN."); + } + return value; +} + +function sanitizeSerial(serial) { + const value = String(serial || "").trim(); + if (value.length < 6 || value.length > 32) { + fail("Serial looks invalid."); + } + return value; +} + +function sanitizePrinterName(name, serial) { + const value = String(name || "").trim(); + return value || serial; +} + +function sanitizePrinterLimit(limit) { + const value = Number(limit); + if (!Number.isInteger(value) || value < 1 || value > 500) { + fail("Printer limit must be an integer between 1 and 500."); + } + return value; +} + +function sanitizeInterval(value, label) { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 5000 || parsed > 86_400_000) { + fail(`${label} must be an integer between 5000 and 86400000 milliseconds.`); + } + return parsed; +} + +function sanitizeMqttHost(host, required) { + const value = String(host || "").trim(); + if (!value) { + if (required) { + fail("MQTT host is required when MQTT output is enabled."); + } + return ""; + } + + if (value.includes("://")) { + fail("MQTT host must not include a protocol. Configure TLS with the checkbox."); + } + + let parsed; + try { + parsed = new URL(`mqtt://${value}`); + } catch { + fail("MQTT host is invalid."); + } + + if (!parsed.hostname || parsed.port || parsed.username || parsed.password || (parsed.pathname && parsed.pathname !== "/") || parsed.search || parsed.hash) { + fail("MQTT host must only contain a hostname or IP address. Configure port separately."); + } + return parsed.hostname; +} + +function sanitizeMqttPort(port) { + const parsed = Number(port); + if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) { + fail("MQTT port must be an integer between 1 and 65535."); + } + return parsed; +} + +function sanitizeOptionalText(value, label, maxLength = 1024) { + const text = String(value || "").trim(); + if (text.length > maxLength) { + fail(`${label} is too long.`); + } + return text; +} + +function sanitizeMqttTopic(topic) { + const value = String(topic || "").trim().replace(/^\/+|\/+$/gu, ""); + if (!value || value.length > 512 || value.includes("+") || value.includes("#")) { + fail("MQTT base topic is invalid."); + } + return value; +} + +function sanitizeMqttFieldSource(source) { + const value = String(source || "raw").trim().toLowerCase(); + if (!['raw', 'state'].includes(value)) { + fail("MQTT field source must be raw or state."); + } + return value; +} + +function sanitizeMqttJsonPath(path) { + const value = String(path || "") + .trim() + .replace(/\[(\d+)\]/gu, ".$1"); + const segment = "(?:[A-Za-z_$][A-Za-z0-9_$-]*|\\d+)"; + if (!value || value.length > 512 || !new RegExp(`^${segment}(?:\\.${segment})*$`, "u").test(value)) { + fail("JSON path is invalid. Use dot notation such as print.nozzle_temper or ams.ams[0].tray[0].remain."); + } + return value; +} + +function sanitizeMqttTopicSuffix(topic) { + const value = String(topic || "").trim().replace(/^\/+|\/+$/gu, ""); + if (!value || value.length > 256 || value.includes("+") || value.includes("#") || !/^[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)*$/u.test(value)) { + fail("MQTT topic suffix is invalid."); + } + if (["availability", "state", "raw"].includes(value)) { + fail("This MQTT topic suffix is reserved."); + } + return value; +} + +function parseBoolean(value, fallback = false) { + if (typeof value === "boolean") { + return value; + } + if (value === undefined || value === null) { + return fallback; + } + if (value === "true") { + return true; + } + if (value === "false") { + return false; + } + fail("Boolean setting is invalid."); +} + +export function createPool(connectionString) { + return new Pool({ + connectionString, + }); +} + +export async function initDatabase(pool) { + await pool.query(` + CREATE TABLE IF NOT EXISTS users ( + id BIGSERIAL PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + is_admin BOOLEAN NOT NULL DEFAULT FALSE, + printer_limit INTEGER NOT NULL DEFAULT 1 CHECK (printer_limit >= 1), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ); + `); + + await pool.query(` + CREATE TABLE IF NOT EXISTS printers ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + serial TEXT NOT NULL, + region TEXT NOT NULL CHECK (region IN ('US', 'EU', 'CN')), + access_token TEXT NOT NULL, + cloud_user_id TEXT, + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(user_id, serial) + ); + `); + + await pool.query(` + CREATE TABLE IF NOT EXISTS printer_mqtt_fields ( + id BIGSERIAL PRIMARY KEY, + printer_id BIGINT NOT NULL REFERENCES printers(id) ON DELETE CASCADE, + source TEXT NOT NULL CHECK (source IN ('raw', 'state')), + json_path TEXT NOT NULL, + topic_suffix TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(printer_id, topic_suffix) + ); + `); + + // ALTER statements make this safe for installations created before per-user MQTT settings existed. + await pool.query(` + ALTER TABLE users + ADD COLUMN IF NOT EXISTS private_mqtt_enabled BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS private_mqtt_host TEXT, + ADD COLUMN IF NOT EXISTS private_mqtt_port INTEGER NOT NULL DEFAULT 1883, + ADD COLUMN IF NOT EXISTS private_mqtt_tls BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS private_mqtt_ignore_certificate_errors BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN IF NOT EXISTS private_mqtt_username TEXT, + ADD COLUMN IF NOT EXISTS private_mqtt_password TEXT, + ADD COLUMN IF NOT EXISTS private_mqtt_base_topic TEXT NOT NULL DEFAULT 'bambu', + ADD COLUMN IF NOT EXISTS push_interval_ms INTEGER NOT NULL DEFAULT 600000, + ADD COLUMN IF NOT EXISTS sync_interval_ms INTEGER NOT NULL DEFAULT 30000; + `); +} + +export async function ensureAdminUser(pool, { username, password, printerLimit, pushIntervalMs = 600000, syncIntervalMs = 30000 }) { + const normalizedUsername = sanitizeUsername(username); + const existing = await pool.query("SELECT id FROM users WHERE username = $1", [normalizedUsername]); + if (existing.rowCount > 0) { + await pool.query( + "UPDATE users SET is_admin = TRUE, printer_limit = GREATEST(printer_limit, $2), updated_at = NOW() WHERE username = $1", + [normalizedUsername, sanitizePrinterLimit(printerLimit)], + ); + return; + } + + const passwordHash = await hashPassword(sanitizePassword(password)); + await pool.query( + `INSERT INTO users (username, password_hash, is_admin, printer_limit, push_interval_ms, sync_interval_ms) + VALUES ($1, $2, TRUE, $3, $4, $5)`, + [ + normalizedUsername, + passwordHash, + sanitizePrinterLimit(printerLimit), + sanitizeInterval(pushIntervalMs, "Push interval"), + sanitizeInterval(syncIntervalMs, "Sync interval"), + ], + ); +} + +export async function getUserById(pool, userId) { + const result = await pool.query("SELECT * FROM users WHERE id = $1", [userId]); + return result.rows[0] || null; +} + +export async function getUserByUsername(pool, username) { + const result = await pool.query("SELECT * FROM users WHERE username = $1", [sanitizeUsername(username)]); + return result.rows[0] || null; +} + +export function serializeUser(user) { + return { + id: Number(user.id), + username: user.username, + isAdmin: user.is_admin, + printerLimit: user.printer_limit, + mqttEnabled: user.private_mqtt_enabled, + mqttHost: user.private_mqtt_host || "", + mqttPort: user.private_mqtt_port, + mqttTls: user.private_mqtt_tls, + mqttIgnoreCertificateErrors: user.private_mqtt_ignore_certificate_errors, + mqttUsername: user.private_mqtt_username || "", + mqttPasswordConfigured: Boolean(user.private_mqtt_password), + mqttBaseTopic: user.private_mqtt_base_topic, + pushIntervalMs: user.push_interval_ms, + syncIntervalMs: user.sync_interval_ms, + createdAt: user.created_at, + updatedAt: user.updated_at, + }; +} + +export async function authenticateUser(pool, username, password) { + const user = await getUserByUsername(pool, username); + if (!user) { + fail("Invalid username or password.", 401); + } + + const valid = await verifyPassword(password, user.password_hash); + if (!valid) { + fail("Invalid username or password.", 401); + } + + return user; +} + +export async function listUsers(pool) { + const result = await pool.query(` + SELECT + u.id, + u.username, + u.is_admin, + u.printer_limit, + u.created_at, + u.updated_at, + COUNT(p.id) AS printer_count + FROM users u + LEFT JOIN printers p ON p.user_id = u.id + GROUP BY u.id + ORDER BY u.username ASC + `); + + return result.rows.map((row) => ({ + id: Number(row.id), + username: row.username, + isAdmin: row.is_admin, + printerLimit: row.printer_limit, + printerCount: Number(row.printer_count), + createdAt: row.created_at, + updatedAt: row.updated_at, + })); +} + +export async function createUser(pool, { + username, + password, + printerLimit, + isAdmin = false, + pushIntervalMs = 600000, + syncIntervalMs = 30000, +}) { + const normalizedUsername = sanitizeUsername(username); + const normalizedPassword = sanitizePassword(password); + const normalizedLimit = sanitizePrinterLimit(printerLimit); + const passwordHash = await hashPassword(normalizedPassword); + + try { + const result = await pool.query( + `INSERT INTO users (username, password_hash, is_admin, printer_limit, push_interval_ms, sync_interval_ms) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING *`, + [ + normalizedUsername, + passwordHash, + Boolean(isAdmin), + normalizedLimit, + sanitizeInterval(pushIntervalMs, "Push interval"), + sanitizeInterval(syncIntervalMs, "Sync interval"), + ], + ); + return serializeUser({ ...result.rows[0], password_hash: passwordHash }); + } catch (error) { + if (error.code === "23505") { + fail("Username already exists."); + } + throw error; + } +} + +export async function updateUser(pool, userId, { printerLimit }) { + const normalizedLimit = sanitizePrinterLimit(printerLimit); + const countResult = await pool.query("SELECT COUNT(*) AS count FROM printers WHERE user_id = $1", [userId]); + const printerCount = Number(countResult.rows[0]?.count || 0); + if (normalizedLimit < printerCount) { + fail(`User already has ${printerCount} printers. Lower limit not allowed.`); + } + + const result = await pool.query( + `UPDATE users + SET printer_limit = $2, updated_at = NOW() + WHERE id = $1 + RETURNING *`, + [userId, normalizedLimit], + ); + if (result.rowCount === 0) { + fail("User not found.", 404); + } + return serializeUser(result.rows[0]); +} + +export async function resetUserPassword(pool, userId, newPassword) { + const passwordHash = await hashPassword(sanitizePassword(newPassword)); + const result = await pool.query( + `UPDATE users + SET password_hash = $2, updated_at = NOW() + WHERE id = $1 + RETURNING *`, + [userId, passwordHash], + ); + if (result.rowCount === 0) { + fail("User not found.", 404); + } + return result.rows[0]; +} + +export async function updateOwnSettings(pool, userId, data) { + const existing = await getUserById(pool, userId); + if (!existing) { + fail("User not found.", 404); + } + + const mqttEnabled = parseBoolean(data.mqttEnabled, existing.private_mqtt_enabled); + const mqttHost = sanitizeMqttHost(data.mqttHost ?? existing.private_mqtt_host, mqttEnabled); + const mqttPort = sanitizeMqttPort(data.mqttPort ?? existing.private_mqtt_port); + const mqttTls = parseBoolean(data.mqttTls, existing.private_mqtt_tls); + const mqttIgnoreCertificateErrors = parseBoolean( + data.mqttIgnoreCertificateErrors, + existing.private_mqtt_ignore_certificate_errors, + ); + const mqttUsername = sanitizeOptionalText(data.mqttUsername ?? existing.private_mqtt_username, "MQTT username", 255); + const mqttBaseTopic = sanitizeMqttTopic(data.mqttBaseTopic ?? existing.private_mqtt_base_topic); + const pushIntervalMs = sanitizeInterval(data.pushIntervalMs ?? existing.push_interval_ms, "Push interval"); + const syncIntervalMs = sanitizeInterval(data.syncIntervalMs ?? existing.sync_interval_ms, "Sync interval"); + + let mqttPassword = existing.private_mqtt_password; + if (parseBoolean(data.mqttClearPassword, false)) { + mqttPassword = null; + } else if (data.mqttPassword !== undefined && String(data.mqttPassword) !== "") { + mqttPassword = sanitizeOptionalText(data.mqttPassword, "MQTT password", 2048); + } + + const result = await pool.query( + `UPDATE users + SET private_mqtt_enabled = $2, + private_mqtt_host = $3, + private_mqtt_port = $4, + private_mqtt_tls = $5, + private_mqtt_ignore_certificate_errors = $6, + private_mqtt_username = $7, + private_mqtt_password = $8, + private_mqtt_base_topic = $9, + push_interval_ms = $10, + sync_interval_ms = $11, + updated_at = NOW() + WHERE id = $1 + RETURNING *`, + [ + userId, + mqttEnabled, + mqttHost || null, + mqttPort, + mqttTls, + mqttIgnoreCertificateErrors, + mqttUsername || null, + mqttPassword, + mqttBaseTopic, + pushIntervalMs, + syncIntervalMs, + ], + ); + return result.rows[0]; +} + +export async function changeOwnPassword(pool, userId, currentPassword, newPassword) { + const user = await getUserById(pool, userId); + if (!user) { + fail("User not found.", 404); + } + + const valid = await verifyPassword(currentPassword, user.password_hash); + if (!valid) { + fail("Current password is incorrect.", 401); + } + + return resetUserPassword(pool, userId, newPassword); +} + +export async function listBridgePrinters(pool) { + const result = await pool.query(` + SELECT + p.id, + p.user_id, + p.name, + p.serial, + p.region, + p.access_token, + p.cloud_user_id, + p.enabled, + p.created_at, + p.updated_at, + u.username, + u.private_mqtt_enabled, + u.private_mqtt_host, + u.private_mqtt_port, + u.private_mqtt_tls, + u.private_mqtt_ignore_certificate_errors, + u.private_mqtt_username, + u.private_mqtt_password, + u.private_mqtt_base_topic, + u.push_interval_ms, + u.sync_interval_ms, + mqtt_fields.mqtt_fields + FROM printers p + INNER JOIN users u ON u.id = p.user_id + LEFT JOIN LATERAL ( + SELECT COALESCE( + json_agg( + json_build_object( + 'id', fields.id, + 'source', fields.source, + 'jsonPath', fields.json_path, + 'topicSuffix', fields.topic_suffix + ) + ORDER BY fields.id ASC + ), + '[]'::json + ) AS mqtt_fields + FROM printer_mqtt_fields fields + WHERE fields.printer_id = p.id + ) mqtt_fields ON TRUE + WHERE p.enabled = TRUE + ORDER BY p.id ASC + `); + return result.rows; +} + +export async function updatePrinterCloudUserId(pool, printerId, cloudUserId) { + await pool.query( + "UPDATE printers SET cloud_user_id = $2, updated_at = NOW() WHERE id = $1", + [printerId, cloudUserId], + ); +} + +export async function countPrintersForUser(pool, userId) { + const result = await pool.query("SELECT COUNT(*) AS count FROM printers WHERE user_id = $1", [userId]); + return Number(result.rows[0]?.count || 0); +} + +export async function createPrinter(pool, actor, data) { + const serial = sanitizeSerial(data.serial); + const region = sanitizeRegion(data.region); + const accessToken = String(data.accessToken || "").trim(); + if (!accessToken) { + fail("Access token is required."); + } + + const ownerId = Number(data.userId || actor.id); + const owner = actor.is_admin && ownerId !== Number(actor.id) ? await getUserById(pool, ownerId) : actor; + if (!owner) { + fail("Owner user not found.", 404); + } + + const printerCount = await countPrintersForUser(pool, owner.id); + if (printerCount >= owner.printer_limit) { + fail(`Printer limit reached (${owner.printer_limit}).`); + } + + try { + await pool.query( + `INSERT INTO printers (user_id, name, serial, region, access_token, cloud_user_id, enabled, updated_at) + VALUES ($1, $2, $3, $4, $5, NULL, TRUE, NOW())`, + [owner.id, sanitizePrinterName(data.name, serial), serial, region, accessToken], + ); + } catch (error) { + if (error.code === "23505") { + fail("This serial already exists for that user."); + } + throw error; + } +} + +export async function updatePrinter(pool, actor, printerId, data) { + const printer = await getPrinterById(pool, printerId); + if (!printer) { + fail("Printer not found.", 404); + } + if (!actor.is_admin && Number(printer.user_id) !== Number(actor.id)) { + fail("Forbidden.", 403); + } + + const serial = sanitizeSerial(data.serial ?? printer.serial); + const region = sanitizeRegion(data.region ?? printer.region); + const accessToken = String(data.accessToken ?? printer.access_token).trim(); + if (!accessToken) { + fail("Access token is required."); + } + + try { + await pool.query( + `UPDATE printers + SET name = $2, + serial = $3, + region = $4, + access_token = $5, + cloud_user_id = NULL, + updated_at = NOW() + WHERE id = $1`, + [printerId, sanitizePrinterName(data.name ?? printer.name, serial), serial, region, accessToken], + ); + } catch (error) { + if (error.code === "23505") { + fail("This serial already exists for that user."); + } + throw error; + } +} + +export async function deletePrinter(pool, actor, printerId) { + const printer = await getPrinterById(pool, printerId); + if (!printer) { + fail("Printer not found.", 404); + } + if (!actor.is_admin && Number(printer.user_id) !== Number(actor.id)) { + fail("Forbidden.", 403); + } + + await pool.query("DELETE FROM printers WHERE id = $1", [printerId]); +} + +async function getOwnedPrinter(pool, actor, printerId) { + const printer = await getPrinterById(pool, printerId); + if (!printer) { + fail("Printer not found.", 404); + } + if (!actor.is_admin && Number(printer.user_id) !== Number(actor.id)) { + fail("Forbidden.", 403); + } + return printer; +} + +export async function createPrinterMqttField(pool, actor, printerId, data) { + await getOwnedPrinter(pool, actor, printerId); + const source = sanitizeMqttFieldSource(data.source); + const jsonPath = sanitizeMqttJsonPath(data.jsonPath); + const topicSuffix = sanitizeMqttTopicSuffix(data.topicSuffix); + + try { + const result = await pool.query( + `INSERT INTO printer_mqtt_fields (printer_id, source, json_path, topic_suffix) + VALUES ($1, $2, $3, $4) + RETURNING id, source, json_path, topic_suffix`, + [printerId, source, jsonPath, topicSuffix], + ); + return result.rows[0]; + } catch (error) { + if (error.code === "23505") { + fail("This MQTT topic suffix is already configured for the printer."); + } + throw error; + } +} + +export async function deletePrinterMqttField(pool, actor, printerId, fieldId) { + await getOwnedPrinter(pool, actor, printerId); + const result = await pool.query( + `DELETE FROM printer_mqtt_fields + WHERE id = $1 AND printer_id = $2 + RETURNING topic_suffix AS "topicSuffix"`, + [fieldId, printerId], + ); + if (result.rowCount === 0) { + fail("MQTT field mapping not found.", 404); + } + return result.rows[0]; +} + +export async function getPrinterById(pool, printerId) { + const result = await pool.query("SELECT * FROM printers WHERE id = $1", [printerId]); + return result.rows[0] || null; +} diff --git a/src/env.js b/src/env.js new file mode 100644 index 0000000..45691cb --- /dev/null +++ b/src/env.js @@ -0,0 +1,78 @@ +import fs from "node:fs"; +import path from "node:path"; + +function loadDotEnvFile() { + const envPath = path.resolve(process.cwd(), ".env"); + if (!fs.existsSync(envPath)) { + return; + } + + const content = fs.readFileSync(envPath, "utf8"); + for (const rawLine of content.split(/\r?\n/u)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) { + continue; + } + + const separatorIndex = line.indexOf("="); + if (separatorIndex === -1) { + continue; + } + + const key = line.slice(0, separatorIndex).trim(); + const value = line.slice(separatorIndex + 1).trim(); + if (!(key in process.env)) { + process.env[key] = value; + } + } +} + +function readString(name, fallback = undefined) { + const value = process.env[name]; + if (typeof value === "string" && value.trim() !== "") { + return value.trim(); + } + if (fallback !== undefined) { + return fallback; + } + throw new Error(`Missing environment variable ${name}`); +} + +function readNumber(name, fallback) { + const value = process.env[name]; + if (value === undefined || value === "") { + return fallback; + } + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + throw new Error(`Environment variable ${name} must be a number`); + } + return parsed; +} + +loadDotEnvFile(); + +export function loadEnvConfig() { + return { + app: { + host: readString("APP_HOST", "0.0.0.0"), + port: readNumber("APP_PORT", 3923), + sessionSecret: readString("SESSION_SECRET"), + sessionTtlHours: readNumber("SESSION_TTL_HOURS", 168), + }, + database: { + connectionString: readString("DATABASE_URL"), + }, + admin: { + username: readString("ADMIN_USERNAME", "bambumin"), + password: readString("ADMIN_PASSWORD", "ChangeMe123!"), + printerLimit: readNumber("ADMIN_PRINTER_LIMIT", 25), + }, + defaults: { + printerLimit: readNumber("DEFAULT_USER_PRINTER_LIMIT", 1), + pushIntervalMs: readNumber("DEFAULT_USER_PUSH_INTERVAL_MS", readNumber("PUSHALL_INTERVAL_MS", 600000)), + syncIntervalMs: readNumber("DEFAULT_USER_SYNC_INTERVAL_MS", readNumber("SYNC_INTERVAL_MS", 30000)), + bridgeRefreshIntervalMs: readNumber("BRIDGE_REFRESH_INTERVAL_MS", 30000), + }, + }; +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..b7e61f6 --- /dev/null +++ b/src/index.js @@ -0,0 +1,40 @@ +import { loadEnvConfig } from "./env.js"; +import { createPool, ensureAdminUser, initDatabase } from "./db.js"; +import { PrivateBrokerManager } from "./private-broker.js"; +import { BridgeManager } from "./bridge-manager.js"; +import { createWebServer } from "./web.js"; +const config = loadEnvConfig(); +const pool = createPool(config.database.connectionString); + +await initDatabase(pool); +await ensureAdminUser(pool, { ...config.admin, ...config.defaults }); + +const privateBroker = new PrivateBrokerManager(); +const bridgeManager = new BridgeManager({ + pool, + privateBroker, + refreshIntervalMs: config.defaults.bridgeRefreshIntervalMs, +}); + +await bridgeManager.start(); + +const web = createWebServer({ + app: config.app, + pool, + bridgeManager, + defaults: config.defaults, +}); +await web.start(); + +console.log(`[http] listening on http://${config.app.host}:${config.app.port}`); +console.log(`[admin] seeded admin user ${config.admin.username}`); + +function shutdown() { + bridgeManager.close(); + privateBroker.close(); + web.close(); + pool.end(); +} + +process.on("SIGINT", shutdown); +process.on("SIGTERM", shutdown); diff --git a/src/private-broker.js b/src/private-broker.js new file mode 100644 index 0000000..2ca80a9 --- /dev/null +++ b/src/private-broker.js @@ -0,0 +1,181 @@ +import crypto from "node:crypto"; +import mqtt from "mqtt"; + +export function getPrivateMqttSettings(printer) { + return { + userId: Number(printer.user_id), + username: printer.username, + enabled: printer.private_mqtt_enabled, + host: printer.private_mqtt_host, + port: Number(printer.private_mqtt_port), + tls: printer.private_mqtt_tls, + ignoreCertificateErrors: printer.private_mqtt_ignore_certificate_errors, + usernameForBroker: printer.private_mqtt_username, + password: printer.private_mqtt_password, + baseTopic: printer.private_mqtt_base_topic, + }; +} + +function getSignature(settings) { + return JSON.stringify(settings); +} + +function getBrokerUrl(settings) { + const protocol = settings.tls ? "mqtts" : "mqtt"; + return `${protocol}://${settings.host}:${settings.port}`; +} + +function getJsonPathValue(source, jsonPath) { + let value = source; + for (const segment of String(jsonPath || "").split(".")) { + if (!value || typeof value !== "object" || !Object.hasOwn(value, segment)) { + return { found: false }; + } + value = value[segment]; + } + return { found: true, value }; +} + +function scalarPayload(value) { + if (value === null) { + return ""; + } + if (typeof value === "string" || typeof value === "boolean") { + return String(value); + } + if (typeof value === "number" && Number.isFinite(value)) { + return String(value); + } + return null; +} + +export class PrivateBrokerManager { + constructor() { + this.clients = new Map(); + } + + reconcile(printers) { + const desired = new Map(); + for (const printer of printers) { + const settings = getPrivateMqttSettings(printer); + desired.set(settings.userId, settings); + } + + for (const [userId, entry] of this.clients.entries()) { + const settings = desired.get(userId); + if (!settings || !settings.enabled || entry.signature !== getSignature(settings)) { + this.stopClient(userId, entry); + } + } + } + + getClient(settings) { + if (!settings?.enabled || !settings.host) { + return null; + } + + const signature = getSignature(settings); + const current = this.clients.get(settings.userId); + if (current && current.signature === signature) { + return current; + } + if (current) { + this.stopClient(settings.userId, current); + } + + const url = getBrokerUrl(settings); + const client = mqtt.connect(url, { + clientId: `bambu-web-helper-${settings.userId}-${crypto.randomUUID().slice(0, 8)}`, + username: settings.usernameForBroker || undefined, + password: settings.password || undefined, + reconnectPeriod: 5000, + connectTimeout: 15000, + protocolVersion: 4, + rejectUnauthorized: !settings.ignoreCertificateErrors, + }); + const entry = { client, settings, signature, states: new Map() }; + + client.on("connect", () => { + console.log(`[private-mqtt] connected ${settings.username} -> ${url}`); + for (const state of entry.states.values()) { + this.publishState(entry, state); + } + }); + client.on("error", (error) => { + console.error(`[private-mqtt] error for ${settings.username}:`, error.message); + }); + this.clients.set(settings.userId, entry); + return entry; + } + + publishPrinterState(printerState, settings) { + const entry = this.getClient(settings); + if (!entry) { + return; + } + entry.states.set(printerState.printerId, printerState); + this.publishState(entry, printerState); + } + + clearPrinterField(printerState, settings, topicSuffix) { + const entry = this.getClient(settings); + if (!entry) { + return; + } + const topic = `${entry.settings.baseTopic}/${printerState.serial}/${topicSuffix}`; + entry.client.publish(topic, "", { qos: 0, retain: true }); + } + + publishState(entry, printerState) { + if (!entry.client.connected) { + return; + } + const prefix = `${entry.settings.baseTopic}/${printerState.serial}`; + this.publish(entry, `${prefix}/availability`, { + printerId: printerState.printerId, + ownerUsername: printerState.ownerUsername, + connected: printerState.connected, + lastSeenAt: printerState.lastSeenAt, + errorMessage: printerState.errorMessage, + }); + this.publish(entry, `${prefix}/state`, printerState.normalized); + this.publish(entry, `${prefix}/raw`, printerState.raw); + + for (const field of printerState.mqttFields || []) { + const source = field.source === "state" ? printerState.normalized : printerState.raw; + const result = getJsonPathValue(source, field.jsonPath); + if (!result.found) { + continue; + } + const payload = scalarPayload(result.value); + if (payload === null) { + continue; + } + this.publishScalar(entry, `${prefix}/${field.topicSuffix}`, payload); + } + } + + publish(entry, topic, payload) { + entry.client.publish(topic, JSON.stringify(payload), { qos: 0, retain: true }); + } + + publishScalar(entry, topic, payload) { + entry.client.publish(topic, payload, { qos: 0, retain: true }); + } + + stopClient(userId, entry = this.clients.get(userId)) { + if (!entry) { + return; + } + entry.client.end(true); + if (this.clients.get(userId) === entry) { + this.clients.delete(userId); + } + } + + close() { + for (const [userId, entry] of this.clients.entries()) { + this.stopClient(userId, entry); + } + } +} diff --git a/src/state.js b/src/state.js new file mode 100644 index 0000000..5f8c6a5 --- /dev/null +++ b/src/state.js @@ -0,0 +1,232 @@ +function toNumber(value) { + if (value === null || value === undefined || value === "") { + return null; + } + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +const BAMBU_FAN_SPEED_PERCENT = { + 0: 0, + 1: 10, + 2: 20, + 4: 30, + 5: 40, + 7: 50, + 9: 60, + 10: 70, + 12: 80, + 13: 90, + 15: 100, +}; + +function toFanSpeedPercent(value) { + const speed = toNumber(value); + if (speed === null || speed < 0) { + return null; + } + if (speed > 15) { + return Math.min(100, Math.round(speed)); + } + return BAMBU_FAN_SPEED_PERCENT[speed] ?? Math.round((speed / 15) * 10) * 10; +} + +function fanGearPercent(fanGear, byteOffset) { + const gear = toNumber(fanGear); + if (gear === null || gear < 0 || !Number.isSafeInteger(gear)) { + return null; + } + const pwm = Math.floor(gear / (256 ** byteOffset)) % 256; + return Math.round((pwm * 10) / 255) * 10; +} + +function toBoolean(value) { + if (typeof value === "boolean") { + return value; + } + if (value === "true") { + return true; + } + if (value === "false") { + return false; + } + return null; +} + +function deepMerge(target, source) { + if (Array.isArray(source)) { + return source.map((item) => deepMerge(undefined, item)); + } + + if (source && typeof source === "object") { + const base = target && typeof target === "object" && !Array.isArray(target) ? { ...target } : {}; + for (const [key, value] of Object.entries(source)) { + base[key] = deepMerge(base[key], value); + } + return base; + } + + return source; +} + +function normalizeAms(print) { + const ams = print?.ams; + if (!ams || typeof ams !== "object") { + return null; + } + + const units = Array.isArray(ams.ams) ? ams.ams : []; + return { + trayNow: toNumber(print?.tray_now), + version: ams?.version ?? null, + units: units.map((unit) => ({ + id: toNumber(unit?.id), + humidity: toNumber(unit?.humidity), + temperature: toNumber(unit?.temp), + trays: Array.isArray(unit?.tray) + ? unit.tray.map((tray) => ({ + id: toNumber(tray?.id), + type: tray?.tray_type ?? null, + subtype: tray?.tray_sub_brands ?? null, + color: tray?.tray_color ?? null, + k: toNumber(tray?.k), + remain: toNumber(tray?.remain), + })) + : [], + })), + }; +} + +function normalizeChamberLight(lightsReport) { + const chamberLight = lightsReport.find((light) => light?.node === "chamber_light"); + const mode = String(chamberLight?.mode ?? chamberLight?.led_mode ?? "").toLowerCase(); + if (mode === "on") { + return true; + } + if (mode === "off") { + return false; + } + return null; +} + +export function createPrinterState(printer) { + return { + printerId: Number(printer.id), + ownerUserId: Number(printer.user_id), + ownerUsername: printer.username, + serial: printer.serial, + name: printer.name, + region: printer.region, + connected: false, + errorMessage: null, + lastSeenAt: null, + lastMessageAt: null, + mqttFields: Array.isArray(printer.mqtt_fields) ? printer.mqtt_fields.map((field) => ({ + id: Number(field.id), + source: field.source, + jsonPath: field.jsonPath, + topicSuffix: field.topicSuffix, + })) : [], + raw: {}, + normalized: { + printerId: Number(printer.id), + ownerUserId: Number(printer.user_id), + ownerUsername: printer.username, + serial: printer.serial, + name: printer.name, + region: printer.region, + connected: false, + chamberLight: null, + }, + }; +} + +export function mergeReport(existingState, report) { + const mergedRaw = deepMerge(existingState.raw, report); + const print = mergedRaw?.print ?? {}; + + const gcodeState = print?.gcode_state ?? null; + const stage = print?.stg ?? null; + const fanGear = print?.fan_gear; + + const lightsReport = Array.isArray(print?.lights_report) ? print.lights_report : []; + const normalized = { + printerId: existingState.printerId, + ownerUserId: existingState.ownerUserId, + ownerUsername: existingState.ownerUsername, + serial: existingState.serial, + name: existingState.name, + region: existingState.region, + connected: true, + errorMessage: null, + lastSeenAt: new Date().toISOString(), + gcodeState, + stage, + printing: typeof gcodeState === "string" ? ["RUNNING", "PREPARE", "PAUSE"].includes(gcodeState) : false, + progress: toNumber(print?.mc_percent), + remainingMinutes: toNumber(print?.mc_remaining_time), + nozzleTemp: toNumber(print?.nozzle_temper), + nozzleTarget: toNumber(print?.nozzle_target_temper), + bedTemp: toNumber(print?.bed_temper), + bedTarget: toNumber(print?.bed_target_temper), + chamberTemp: toNumber(print?.chamber_temper), + layer: toNumber(print?.layer_num), + totalLayers: toNumber(print?.total_layer_num), + wifiSignal: toNumber(print?.wifi_signal), + speedLevel: toNumber(print?.spd_lvl), + partFanSpeed: fanGearPercent(fanGear, 0) ?? toFanSpeedPercent(print?.cooling_fan_speed), + auxFanSpeed: fanGearPercent(fanGear, 1) ?? toFanSpeedPercent(print?.big_fan1_speed), + chamberFanSpeed: fanGearPercent(fanGear, 2) ?? toFanSpeedPercent(print?.big_fan2_speed), + heatbreakFanSpeed: toFanSpeedPercent(print?.heatbreak_fan_speed), + subtaskName: print?.subtask_name ?? null, + projectId: print?.project_id ?? null, + taskId: print?.task_id ?? null, + profileId: print?.profile_id ?? null, + message: print?.msg ?? null, + homeFlag: print?.home_flag ?? null, + stat: print?.stat ?? null, + ams: normalizeAms(print), + lightsReport, + chamberLight: normalizeChamberLight(lightsReport), + rawFlags: { + online: toBoolean(print?.online), + upgradeState: print?.upgrade_state ?? null, + }, + }; + + return { + ...existingState, + connected: true, + errorMessage: null, + lastSeenAt: normalized.lastSeenAt, + lastMessageAt: normalized.lastSeenAt, + raw: mergedRaw, + normalized, + }; +} + +export function markPrinterDisconnected(existingState, errorMessage = null) { + return { + ...existingState, + connected: false, + errorMessage, + normalized: { + ...existingState.normalized, + connected: false, + errorMessage, + }, + }; +} + +export function markPrinterError(existingState, errorMessage) { + return { + ...existingState, + connected: false, + errorMessage, + normalized: { + ...existingState.normalized, + connected: false, + errorMessage, + }, + }; +} diff --git a/src/web.js b/src/web.js new file mode 100644 index 0000000..1de1ed5 --- /dev/null +++ b/src/web.js @@ -0,0 +1,357 @@ +import fs from "node:fs"; +import path from "node:path"; +import http from "node:http"; +import { + authenticateUser, + changeOwnPassword, + createPrinter, + createPrinterMqttField, + createUser, + deletePrinter, + deletePrinterMqttField, + getUserById, + listUsers, + resetUserPassword, + serializeUser, + updatePrinter, + updateOwnSettings, + updateUser, +} from "./db.js"; +import { + clearSessionCookie, + createSessionToken, + isSessionValidForUser, + makeSessionCookie, + parseCookies, + verifySessionToken, +} from "./auth.js"; + +const INDEX_HTML = fs.readFileSync(path.resolve(process.cwd(), "src", "www", "index.html"), "utf8"); + +function sendJson(response, status, payload, headers = {}) { + response.writeHead(status, { + "Content-Type": "application/json; charset=utf-8", + ...headers, + }); + response.end(JSON.stringify(payload)); +} + +function sendError(response, error) { + const status = error?.status || 500; + sendJson(response, status, { + error: error?.message || "Internal server error", + }); +} + +async function readJson(request) { + const chunks = []; + for await (const chunk of request) { + chunks.push(chunk); + } + + const raw = Buffer.concat(chunks).toString("utf8").trim(); + if (!raw) { + return {}; + } + try { + return JSON.parse(raw); + } catch { + const error = new Error("Invalid JSON body."); + error.status = 400; + throw error; + } +} + +function parsePrinterId(pathname) { + const match = pathname.match(/^\/api\/printers\/(\d+)$/u); + return match ? Number(match[1]) : null; +} + +function parsePrinterActionId(pathname, action) { + const escapedAction = action.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + const match = pathname.match(new RegExp(`^/api/printers/(\\d+)/${escapedAction}$`, "u")); + return match ? Number(match[1]) : null; +} + +function parsePrinterMqttFieldIds(pathname) { + const match = pathname.match(/^\/api\/printers\/(\d+)\/mqtt-fields\/(\d+)$/u); + return match ? { printerId: Number(match[1]), fieldId: Number(match[2]) } : null; +} + +function parseUserId(pathname, suffix) { + const escapedSuffix = suffix.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); + const match = pathname.match(new RegExp(`^/api/admin/users/(\\d+)${escapedSuffix}$`, "u")); + return match ? Number(match[1]) : null; +} + +export function createWebServer({ app, pool, bridgeManager, defaults }) { + const clients = new Set(); + + async function resolveUser(request) { + const cookies = parseCookies(request.headers.cookie || ""); + const token = cookies.session; + const session = verifySessionToken(token, app.sessionSecret); + if (!session) { + return null; + } + + const user = await getUserById(pool, session.uid); + if (!user || !isSessionValidForUser(session, user)) { + return null; + } + return user; + } + + function buildBootstrap(user) { + const serializedUser = serializeUser(user); + const printers = bridgeManager.getSnapshotForUser(user); + return { + user: serializedUser, + printers, + printerCount: printers.length, + adminUsers: user.is_admin ? [] : undefined, + }; + } + + async function buildAdminBootstrap(user) { + const base = buildBootstrap(user); + if (user.is_admin) { + base.adminUsers = await listUsers(pool); + } + return base; + } + + function broadcast() { + for (const client of clients) { + const payload = { + generatedAt: new Date().toISOString(), + printers: bridgeManager.getSnapshotForUser(client.user), + }; + client.response.write(`data: ${JSON.stringify(payload)}\n\n`); + } + } + + const server = http.createServer(async (request, response) => { + try { + const url = new URL(request.url, `http://${request.headers.host}`); + + if (request.method === "GET" && url.pathname === "/") { + response.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + response.end(INDEX_HTML); + return; + } + + if (request.method === "GET" && url.pathname === "/api/health") { + sendJson(response, 200, { ok: true, at: new Date().toISOString() }); + return; + } + + if (request.method === "POST" && url.pathname === "/api/login") { + const body = await readJson(request); + const user = await authenticateUser(pool, body.username, body.password); + const token = createSessionToken(user, app.sessionSecret, app.sessionTtlHours); + sendJson( + response, + 200, + await buildAdminBootstrap(user), + { "Set-Cookie": makeSessionCookie(token, app.sessionTtlHours * 60 * 60) }, + ); + return; + } + + if (request.method === "POST" && url.pathname === "/api/logout") { + sendJson(response, 200, { ok: true }, { "Set-Cookie": clearSessionCookie() }); + return; + } + + const user = await resolveUser(request); + + if (!user) { + if (url.pathname.startsWith("/api/")) { + sendJson(response, 401, { error: "Authentication required." }, { "Set-Cookie": clearSessionCookie() }); + return; + } + } + + if (request.method === "GET" && url.pathname === "/api/bootstrap") { + sendJson(response, 200, await buildAdminBootstrap(user)); + return; + } + + if (request.method === "GET" && url.pathname === "/api/events") { + response.writeHead(200, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }); + response.write(`data: ${JSON.stringify({ + generatedAt: new Date().toISOString(), + printers: bridgeManager.getSnapshotForUser(user), + })}\n\n`); + const entry = { response, user }; + clients.add(entry); + request.on("close", () => clients.delete(entry)); + return; + } + + if (request.method === "POST" && url.pathname === "/api/account/password") { + const body = await readJson(request); + const updatedUser = await changeOwnPassword(pool, user.id, body.currentPassword, body.newPassword); + const token = createSessionToken(updatedUser, app.sessionSecret, app.sessionTtlHours); + sendJson( + response, + 200, + { ok: true }, + { "Set-Cookie": makeSessionCookie(token, app.sessionTtlHours * 60 * 60) }, + ); + return; + } + + if (request.method === "POST" && url.pathname === "/api/account/settings") { + const body = await readJson(request); + const updatedUser = await updateOwnSettings(pool, user.id, body); + await bridgeManager.refresh(); + sendJson(response, 200, { + user: serializeUser(updatedUser), + printers: bridgeManager.getSnapshotForUser(updatedUser), + }); + return; + } + + if (request.method === "POST" && url.pathname === "/api/printers") { + const body = await readJson(request); + await createPrinter(pool, user, body); + await bridgeManager.refresh(); + sendJson(response, 200, { printers: bridgeManager.getSnapshotForUser(user) }); + return; + } + + const mqttFieldsPrinterId = parsePrinterActionId(url.pathname, "mqtt-fields"); + if (mqttFieldsPrinterId && request.method === "POST") { + const body = await readJson(request); + await createPrinterMqttField(pool, user, mqttFieldsPrinterId, body); + await bridgeManager.refresh(); + sendJson(response, 200, { printers: bridgeManager.getSnapshotForUser(user) }); + return; + } + + const mqttFieldIds = parsePrinterMqttFieldIds(url.pathname); + if (mqttFieldIds && request.method === "DELETE") { + const removedField = await deletePrinterMqttField(pool, user, mqttFieldIds.printerId, mqttFieldIds.fieldId); + bridgeManager.clearPrivateMqttField(mqttFieldIds.printerId, removedField.topicSuffix); + await bridgeManager.refresh(); + sendJson(response, 200, { printers: bridgeManager.getSnapshotForUser(user) }); + return; + } + + const chamberLightPrinterId = parsePrinterActionId(url.pathname, "chamber-light"); + if (chamberLightPrinterId && request.method === "POST") { + const body = await readJson(request); + if (typeof body.on !== "boolean") { + const error = new Error("Light state must be a boolean."); + error.status = 400; + throw error; + } + await bridgeManager.setChamberLight(chamberLightPrinterId, user, body.on); + sendJson(response, 200, { printers: bridgeManager.getSnapshotForUser(user) }); + return; + } + + const commandPrinterId = parsePrinterActionId(url.pathname, "commands"); + if (commandPrinterId && request.method === "POST") { + const body = await readJson(request); + if (!["pause", "resume", "stop"].includes(body.command)) { + const error = new Error("Unsupported print command."); + error.status = 400; + throw error; + } + if (body.command === "stop" && body.confirm !== true) { + const error = new Error("Stopping a print requires confirmation."); + error.status = 400; + throw error; + } + await bridgeManager.sendPrintCommand(commandPrinterId, user, body.command); + sendJson(response, 200, { printers: bridgeManager.getSnapshotForUser(user) }); + return; + } + + const printerId = parsePrinterId(url.pathname); + if (printerId && request.method === "PUT") { + const body = await readJson(request); + await updatePrinter(pool, user, printerId, body); + await bridgeManager.refresh(); + sendJson(response, 200, { printers: bridgeManager.getSnapshotForUser(user) }); + return; + } + + if (printerId && request.method === "DELETE") { + await deletePrinter(pool, user, printerId); + await bridgeManager.refresh(); + sendJson(response, 200, { printers: bridgeManager.getSnapshotForUser(user) }); + return; + } + + if (url.pathname.startsWith("/api/admin/")) { + if (!user.is_admin) { + sendJson(response, 403, { error: "Admin only." }); + return; + } + + if (request.method === "GET" && url.pathname === "/api/admin/users") { + sendJson(response, 200, { users: await listUsers(pool) }); + return; + } + + if (request.method === "POST" && url.pathname === "/api/admin/users") { + const body = await readJson(request); + await createUser(pool, { + username: body.username, + password: body.password, + printerLimit: body.printerLimit, + isAdmin: false, + pushIntervalMs: defaults.pushIntervalMs, + syncIntervalMs: defaults.syncIntervalMs, + }); + sendJson(response, 200, { users: await listUsers(pool) }); + return; + } + + const limitUserId = parseUserId(url.pathname, ""); + if (limitUserId && request.method === "PATCH") { + const body = await readJson(request); + await updateUser(pool, limitUserId, { printerLimit: body.printerLimit }); + sendJson(response, 200, { users: await listUsers(pool) }); + return; + } + + const resetUserId = parseUserId(url.pathname, "/reset-password"); + if (resetUserId && request.method === "POST") { + const body = await readJson(request); + await resetUserPassword(pool, resetUserId, body.newPassword); + sendJson(response, 200, { users: await listUsers(pool) }); + return; + } + } + + sendJson(response, 404, { error: "Not found." }); + } catch (error) { + sendError(response, error); + } + }); + + bridgeManager.onChange(() => broadcast()); + + return { + start() { + return new Promise((resolve) => { + server.listen(app.port, app.host, () => resolve()); + }); + }, + close() { + for (const client of clients) { + client.response.end(); + } + server.close(); + }, + }; +} diff --git a/src/www/index.html b/src/www/index.html new file mode 100644 index 0000000..231cdfe --- /dev/null +++ b/src/www/index.html @@ -0,0 +1,1740 @@ + + + + + + Bambu Web Helper + + + +
+ +
+ + + + + + + + + + + + +
+
+ + + + + + + +