first commit
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.env
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
node_modules/
|
||||||
|
.env
|
||||||
|
coverage/
|
||||||
+13
@@ -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"]
|
||||||
@@ -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.
|
||||||
@@ -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:
|
||||||
Generated
+675
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
+127
@@ -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";
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
+78
@@ -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),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+232
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+357
@@ -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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+1740
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user