diff --git a/README.md b/README.md index bb64fd4..ca924a8 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,12 @@ It runs as two connected services: - Create and organize playlists and slides - Design reusable templates and canvas sizes - Register screens and assign playlists to them +- Manage roles and permissions for the admin web UI - Upload images and other media for use in slides and templates - View live screen connections and send player commands +Admin permissions are split into CRUD actions per section, so you can grant read-only, editor, creator, or delete access separately. + ## Documentation Player-facing API details live in [docs/api.md](docs/api.md). It covers the player HTTP endpoints for screen playback, playlist data, connections, and commands. @@ -34,6 +37,8 @@ When the app starts for the first time, it creates the database tables it needs - Username: `admin` - Password: `admin` +The first admin account is placed into the built-in `Administrators` role, which has full web-admin access through the CRUD permissions. + You can change the initial admin credentials with these optional environment variables: - `DEFAULT_ADMIN_USERNAME` diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index fbe2bd9..0000000 --- a/package-lock.json +++ /dev/null @@ -1,1927 +0,0 @@ -{ - "name": "pulse-signage", - "version": "1.3.3", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "pulse-signage", - "version": "1.3.3", - "dependencies": { - "bootstrap-icons": "1.11.3", - "dotenv": "^17.4.2", - "express": "^4.21.2", - "handlebars": "^4.7.8", - "multer": "^1.4.5-lts.1", - "mysql2": "^3.14.3", - "qrcode": "^1.5.4", - "ws": "^8.21.0" - }, - "devDependencies": { - "nodemon": "^3.1.10" - } - }, - "node_modules/@types/node": { - "version": "26.1.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", - "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", - "license": "MIT", - "peer": true, - "dependencies": { - "undici-types": "~8.3.0" - } - }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", - "license": "MIT", - "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/append-field": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", - "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", - "license": "MIT" - }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" - }, - "node_modules/aws-ssl-profiles": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", - "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", - "license": "MIT", - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/body-parser": { - "version": "1.20.6", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", - "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.15.1", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/bootstrap-icons": { - "version": "1.11.3", - "resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.11.3.tgz", - "integrity": "sha512-+3lpHrCw/it2/7lBL15VR0HEumaBss0+f/Lb6ZvHISn1mlK83jjFpooTLsMWbIjJMDjDjOExMsTxnXSIT4k4ww==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/twbs" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/bootstrap" - } - ], - "license": "MIT" - }, - "node_modules/brace-expansion": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", - "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "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/busboy": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", - "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", - "dependencies": { - "streamsearch": "^1.1.0" - }, - "engines": { - "node": ">=10.16.0" - } - }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/camelcase": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", - "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/cliui": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", - "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^6.2.0" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/concat-stream": { - "version": "1.6.2", - "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", - "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", - "engines": [ - "node >= 0.8" - ], - "license": "MIT", - "dependencies": { - "buffer-from": "^1.0.0", - "inherits": "^2.0.3", - "readable-stream": "^2.2.2", - "typedarray": "^0.0.6" - } - }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", - "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "license": "MIT" - }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "license": "MIT" - }, - "node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/decamelize": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", - "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/denque": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", - "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", - "license": "Apache-2.0", - "engines": { - "node": ">=0.10" - } - }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/dijkstrajs": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", - "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", - "license": "MIT" - }, - "node_modules/dotenv": { - "version": "17.4.2", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", - "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT" - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT" - }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/express": { - "version": "4.22.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", - "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", - "license": "MIT", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "~1.20.5", - "content-disposition": "~0.5.4", - "content-type": "~1.0.4", - "cookie": "~0.7.1", - "cookie-signature": "~1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "~1.3.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", - "proxy-addr": "~2.0.7", - "qs": "~6.15.1", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "~0.19.0", - "serve-static": "~1.16.2", - "setprototypeof": "1.2.0", - "statuses": "~2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" - }, - "engines": { - "node": ">= 0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/finalhandler": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", - "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "statuses": "~2.0.2", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/generate-function": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", - "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", - "license": "MIT", - "dependencies": { - "is-property": "^1.0.2" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/handlebars": { - "version": "4.7.9", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", - "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", - "license": "MIT", - "dependencies": { - "minimist": "^1.2.5", - "neo-async": "^2.6.2", - "source-map": "^0.6.1", - "wordwrap": "^1.0.0" - }, - "bin": { - "handlebars": "bin/handlebars" - }, - "engines": { - "node": ">=0.4.7" - }, - "optionalDependencies": { - "uglify-js": "^3.1.4" - } - }, - "node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ignore-by-default": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", - "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", - "dev": true, - "license": "ISC" - }, - "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/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-extglob": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", - "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-glob": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", - "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-extglob": "^2.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, - "node_modules/is-property": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", - "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", - "license": "MIT" - }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "license": "MIT" - }, - "node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/long": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "license": "Apache-2.0" - }, - "node_modules/lru.min": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", - "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", - "license": "MIT", - "engines": { - "bun": ">=1.0.0", - "deno": ">=1.30.0", - "node": ">=8.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wellwelwel" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/minimatch": { - "version": "10.2.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", - "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.5" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "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/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "license": "MIT", - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, - "node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/multer": { - "version": "1.4.5-lts.2", - "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz", - "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==", - "deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.", - "license": "MIT", - "dependencies": { - "append-field": "^1.0.0", - "busboy": "^1.0.0", - "concat-stream": "^1.5.2", - "mkdirp": "^0.5.4", - "object-assign": "^4.1.1", - "type-is": "^1.6.4", - "xtend": "^4.0.0" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/mysql2": { - "version": "3.22.6", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.22.6.tgz", - "integrity": "sha512-fPKmeDGUzvFP7bMD5SASlJ5zIgvCC4hbanTmhbUlEmhyrY1hR4Hi3xLOWgTd3luYjLVifx6uGvMNJ2m/LlqEpg==", - "license": "MIT", - "dependencies": { - "aws-ssl-profiles": "^1.1.2", - "denque": "^2.1.0", - "generate-function": "^2.3.1", - "iconv-lite": "^0.7.2", - "long": "^5.3.2", - "lru.min": "^1.1.4", - "named-placeholders": "^1.1.6", - "sql-escaper": "^1.4.0" - }, - "engines": { - "node": ">= 8.0" - }, - "peerDependencies": { - "@types/node": ">= 8" - } - }, - "node_modules/mysql2/node_modules/iconv-lite": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", - "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/named-placeholders": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", - "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", - "license": "MIT", - "dependencies": { - "lru.min": "^1.1.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/neo-async": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "license": "MIT" - }, - "node_modules/nodemon": { - "version": "3.1.14", - "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", - "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", - "dev": true, - "license": "MIT", - "dependencies": { - "chokidar": "^3.5.2", - "debug": "^4", - "ignore-by-default": "^1.0.1", - "minimatch": "^10.2.1", - "pstree.remy": "^1.1.8", - "semver": "^7.5.3", - "simple-update-notifier": "^2.0.0", - "supports-color": "^5.5.0", - "touch": "^3.1.0", - "undefsafe": "^2.0.5" - }, - "bin": { - "nodemon": "bin/nodemon.js" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/nodemon" - } - }, - "node_modules/nodemon/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/nodemon/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==", - "dev": true, - "license": "MIT" - }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/p-try": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", - "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-to-regexp": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", - "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", - "license": "MIT" - }, - "node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pngjs": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", - "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", - "license": "MIT", - "engines": { - "node": ">=10.13.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/proxy-addr": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", - "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", - "license": "MIT", - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - } - }, - "node_modules/pstree.remy": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", - "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", - "dev": true, - "license": "MIT" - }, - "node_modules/qrcode": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", - "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", - "license": "MIT", - "dependencies": { - "dijkstrajs": "^1.0.1", - "pngjs": "^5.0.0", - "yargs": "^15.3.1" - }, - "bin": { - "qrcode": "bin/qrcode" - }, - "engines": { - "node": ">=10.13.0" - } - }, - "node_modules/qs": { - "version": "6.15.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", - "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", - "license": "BSD-3-Clause", - "dependencies": { - "es-define-property": "^1.0.1", - "side-channel": "^1.1.1" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", - "license": "MIT", - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/readable-stream/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/require-main-filename": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", - "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", - "license": "ISC" - }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/send": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", - "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", - "license": "MIT", - "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.4.1", - "range-parser": "~1.2.1", - "statuses": "~2.0.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/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/serve-static": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", - "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", - "license": "MIT", - "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "~0.19.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/set-blocking": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "license": "ISC" - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC" - }, - "node_modules/side-channel": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", - "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4", - "side-channel-list": "^1.0.1", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", - "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/simple-update-notifier": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", - "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/sql-escaper": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.0.tgz", - "integrity": "sha512-JCY91/D1lVq7p6RzuHg9XFQH9vT9L22bljsk+uTdMWke4ZqPCCE+iIMNPmJPUUTywn6eW2nncaWc+MizKZgF4A==", - "license": "MIT", - "engines": { - "bun": ">=1.0.0", - "deno": ">=2.0.0", - "node": ">=12.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" - } - }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/streamsearch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", - "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/string_decoder": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", - "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", - "license": "MIT", - "dependencies": { - "safe-buffer": "~5.1.0" - } - }, - "node_modules/string_decoder/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, - "node_modules/touch": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", - "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", - "dev": true, - "license": "ISC", - "bin": { - "nodetouch": "bin/nodetouch.js" - } - }, - "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", - "license": "MIT", - "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" - }, - "engines": { - "node": ">= 0.6" - } - }, - "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/uglify-js": { - "version": "3.19.3", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", - "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", - "license": "BSD-2-Clause", - "optional": true, - "bin": { - "uglifyjs": "bin/uglifyjs" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/undefsafe": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", - "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", - "dev": true, - "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", - "peer": true - }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/which-module": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", - "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", - "license": "ISC" - }, - "node_modules/wordwrap": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", - "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", - "license": "MIT" - }, - "node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", - "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" - } - }, - "node_modules/y18n": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", - "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", - "license": "ISC" - }, - "node_modules/yargs": { - "version": "15.4.1", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", - "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", - "license": "MIT", - "dependencies": { - "cliui": "^6.0.0", - "decamelize": "^1.2.0", - "find-up": "^4.1.0", - "get-caller-file": "^2.0.1", - "require-directory": "^2.1.1", - "require-main-filename": "^2.0.0", - "set-blocking": "^2.0.0", - "string-width": "^4.2.0", - "which-module": "^2.0.0", - "y18n": "^4.0.0", - "yargs-parser": "^18.1.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/yargs-parser": { - "version": "18.1.3", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", - "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", - "license": "ISC", - "dependencies": { - "camelcase": "^5.0.0", - "decamelize": "^1.2.0" - }, - "engines": { - "node": ">=6" - } - } - } -} diff --git a/src/db.js b/src/db.js index bd34b5e..550ea21 100644 --- a/src/db.js +++ b/src/db.js @@ -1,5 +1,6 @@ const mysql = require('mysql2/promise'); const { hashPassword } = require('./auth'); +const { PERMISSIONS, DEFAULT_ROLE, normalizePermissionKeys } = require('./rbac'); function createPool() { return mysql.createPool({ @@ -38,6 +39,229 @@ async function pruneStaleOnboardingDevices(pool) { ); } +async function getTableColumnNames(pool, tableName) { + const [rows] = await pool.query( + `SELECT column_name + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = ?`, + [tableName] + ); + + return new Set((rows || []).map(function (row) { + return String(row.COLUMN_NAME || row.column_name || '').trim().toLowerCase(); + }).filter(Boolean)); +} + +function getPermissionKey(row) { + return String((row && (row.permission_key || row.perm_key)) || '').trim().toLowerCase(); +} + +function getLegacyPermissionTargets(permissionKey) { + const normalizedKey = String(permissionKey || '').trim().toLowerCase(); + const parts = normalizedKey.split('.'); + if (parts.length !== 2) { + return [normalizedKey].filter(Boolean); + } + + const sectionKey = parts[0]; + const actionKey = parts[1]; + if (actionKey === 'view') { + return [`${sectionKey}.read`]; + } + if (actionKey === 'manage') { + return [`${sectionKey}.read`, `${sectionKey}.create`, `${sectionKey}.edit`, `${sectionKey}.delete`]; + } + + return [normalizedKey].filter(Boolean); +} + +function buildPermissionSeedColumns(columnNames) { + const columns = []; + if (columnNames.has('permission_key')) { + columns.push('permission_key'); + } + if (columnNames.has('perm_key')) { + columns.push('perm_key'); + } + if (columnNames.has('name')) { + columns.push('name'); + } + if (columnNames.has('section_name')) { + columns.push('section_name'); + } + if (columnNames.has('description')) { + columns.push('description'); + } + if (columnNames.has('created_by')) { + columns.push('created_by'); + } + if (columnNames.has('modified_by')) { + columns.push('modified_by'); + } + return columns; +} + +async function backfillLegacyRbacSchema(pool) { + const permissionColumnNames = await getTableColumnNames(pool, 'permissions'); + const hasPermissionKeyColumn = permissionColumnNames.has('permission_key'); + const hasLegacyPermissionKeyColumn = permissionColumnNames.has('perm_key'); + + const [permissionRows] = await pool.query('SELECT id, permission_key, perm_key, name, section_name FROM permissions ORDER BY id ASC'); + const [rolePermissionRows] = await pool.query( + `SELECT rp.role_id, p.permission_key, p.perm_key + FROM role_permissions rp + JOIN permissions p ON p.id = rp.permission_id` + ); + + const rolePermissionTargets = new Map(); + const desiredPermissionKeys = new Set(PERMISSIONS.map(function (permission) { + return permission.key; + })); + const legacyPermissionRowIds = []; + + function addRoleTarget(roleId, permissionKey) { + const normalizedPermissionKey = String(permissionKey || '').trim().toLowerCase(); + if (!normalizedPermissionKey) { + return; + } + if (!rolePermissionTargets.has(roleId)) { + rolePermissionTargets.set(roleId, new Set()); + } + rolePermissionTargets.get(roleId).add(normalizedPermissionKey); + } + + for (const row of rolePermissionRows || []) { + const currentKey = getPermissionKey(row); + const targetKeys = getLegacyPermissionTargets(currentKey); + if (currentKey.endsWith('.view') || currentKey.endsWith('.manage')) { + for (const targetKey of targetKeys) { + addRoleTarget(Number(row.role_id), targetKey); + } + } else { + addRoleTarget(Number(row.role_id), currentKey); + } + } + + for (const row of permissionRows || []) { + const currentKey = getPermissionKey(row); + if (currentKey.endsWith('.view') || currentKey.endsWith('.manage')) { + legacyPermissionRowIds.push(Number(row.id)); + } + } + + const roleRows = await pool.query('SELECT id, role_key, name FROM roles ORDER BY id ASC').then(function (result) { + return result[0] || []; + }); + const defaultRoleRow = roleRows.find(function (row) { + return String(row.role_key || '').trim().toLowerCase() === DEFAULT_ROLE.key; + }) || null; + if (defaultRoleRow) { + if (!rolePermissionTargets.has(Number(defaultRoleRow.id))) { + rolePermissionTargets.set(Number(defaultRoleRow.id), new Set()); + } + const defaultPermissions = rolePermissionTargets.get(Number(defaultRoleRow.id)); + for (const permission of PERMISSIONS) { + defaultPermissions.add(permission.key); + } + } + + const seedColumns = buildPermissionSeedColumns(permissionColumnNames); + if (!seedColumns.length) { + throw new Error('permissions table is missing required columns.'); + } + + for (const permission of PERMISSIONS) { + const seedValues = []; + if (permissionColumnNames.has('permission_key')) { + seedValues.push(permission.key); + } + if (permissionColumnNames.has('perm_key')) { + seedValues.push(permission.key); + } + if (permissionColumnNames.has('name')) { + seedValues.push(permission.name); + } + if (permissionColumnNames.has('section_name')) { + seedValues.push(permission.sectionName); + } + if (permissionColumnNames.has('description')) { + seedValues.push(permission.description || null); + } + if (permissionColumnNames.has('created_by')) { + seedValues.push(null); + } + if (permissionColumnNames.has('modified_by')) { + seedValues.push(null); + } + + const updateAssignments = []; + if (permissionColumnNames.has('name')) { + updateAssignments.push('name = VALUES(name)'); + } + if (permissionColumnNames.has('section_name')) { + updateAssignments.push('section_name = VALUES(section_name)'); + } + if (permissionColumnNames.has('description')) { + updateAssignments.push('description = VALUES(description)'); + } + if (permissionColumnNames.has('perm_key')) { + updateAssignments.push('perm_key = VALUES(perm_key)'); + } + if (permissionColumnNames.has('permission_key')) { + updateAssignments.push('permission_key = VALUES(permission_key)'); + } + + await pool.query( + `INSERT INTO permissions (${seedColumns.join(', ')}) + VALUES (${seedColumns.map(function () { return '?'; }).join(', ')}) + ON DUPLICATE KEY UPDATE ${updateAssignments.join(', ')}`, + seedValues + ); + } + + if (legacyPermissionRowIds.length) { + await pool.query('DELETE FROM permissions WHERE id IN (?)', [legacyPermissionRowIds]); + } + + const [currentPermissionRows] = await pool.query('SELECT id, permission_key, perm_key FROM permissions'); + const permissionIdByKey = new Map(); + for (const row of currentPermissionRows || []) { + const currentKey = getPermissionKey(row); + if (currentKey) { + permissionIdByKey.set(currentKey, Number(row.id)); + } + } + + await pool.query('DELETE FROM role_permissions'); + for (const [roleId, permissionKeys] of rolePermissionTargets.entries()) { + const expandedPermissionKeys = normalizePermissionKeys(Array.from(permissionKeys.values())); + for (const permissionKey of expandedPermissionKeys) { + const permissionId = permissionIdByKey.get(permissionKey); + if (!permissionId) { + continue; + } + await pool.query( + 'INSERT IGNORE INTO role_permissions (role_id, permission_id, created_by, modified_by) VALUES (?, ?, ?, ?)', + [Number(roleId), permissionId, null, null] + ); + } + } + + const [roleRowsAfter] = await pool.query('SELECT id, role_key, name FROM roles ORDER BY id ASC'); + for (const row of roleRowsAfter || []) { + const currentKey = String(row.role_key || '').trim(); + const isAdministratorsRole = String(row.name || '').trim().toLowerCase() === DEFAULT_ROLE.name.toLowerCase(); + const expectedKey = isAdministratorsRole ? DEFAULT_ROLE.key : `role-${row.id}`; + if (!currentKey || currentKey !== expectedKey) { + await pool.query( + 'UPDATE roles SET role_key = ?, name = ?, description = COALESCE(description, ?) WHERE id = ?', + [expectedKey, String(row.name || '').trim() || DEFAULT_ROLE.name, DEFAULT_ROLE.description || null, row.id] + ); + } + } +} + async function ensureSchema(pool) { await pool.query(` CREATE TABLE IF NOT EXISTS canvas_sizes ( @@ -239,6 +463,73 @@ async function ensureSchema(pool) { await addColumnIfMissing(pool, 'users', 'created_by', 'INT NULL'); await addColumnIfMissing(pool, 'users', 'modified_by', 'INT NULL'); + await pool.query(` + CREATE TABLE IF NOT EXISTS roles ( + id INT AUTO_INCREMENT PRIMARY KEY, + role_key VARCHAR(100) NOT NULL UNIQUE, + name VARCHAR(255) NOT NULL, + description TEXT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + await addColumnIfMissing(pool, 'roles', 'role_key', 'VARCHAR(100) NULL'); + await addColumnIfMissing(pool, 'roles', 'description', 'TEXT NULL'); + await addColumnIfMissing(pool, 'roles', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'); + await addColumnIfMissing(pool, 'roles', 'created_by', 'INT NULL'); + await addColumnIfMissing(pool, 'roles', 'modified_by', 'INT NULL'); + + await pool.query(` + CREATE TABLE IF NOT EXISTS permissions ( + id INT AUTO_INCREMENT PRIMARY KEY, + permission_key VARCHAR(100) NOT NULL UNIQUE, + perm_key VARCHAR(100) NULL, + name VARCHAR(255) NOT NULL, + section_name VARCHAR(255) NOT NULL, + description TEXT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + await addColumnIfMissing(pool, 'permissions', 'perm_key', 'VARCHAR(100) NULL'); + await addColumnIfMissing(pool, 'permissions', 'permission_key', 'VARCHAR(100) NULL'); + await addColumnIfMissing(pool, 'permissions', 'name', 'VARCHAR(255) NULL'); + await addColumnIfMissing(pool, 'permissions', 'section_name', 'VARCHAR(255) NULL'); + await addColumnIfMissing(pool, 'permissions', 'description', 'TEXT NULL'); + await addColumnIfMissing(pool, 'permissions', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'); + await addColumnIfMissing(pool, 'permissions', 'created_by', 'INT NULL'); + await addColumnIfMissing(pool, 'permissions', 'modified_by', 'INT NULL'); + + await pool.query(` + CREATE TABLE IF NOT EXISTS role_permissions ( + role_id INT NOT NULL, + permission_id INT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (role_id, permission_id), + CONSTRAINT fk_role_permissions_role FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE, + CONSTRAINT fk_role_permissions_permission FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + await addColumnIfMissing(pool, 'role_permissions', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'); + await addColumnIfMissing(pool, 'role_permissions', 'created_by', 'INT NULL'); + await addColumnIfMissing(pool, 'role_permissions', 'modified_by', 'INT NULL'); + + await pool.query(` + CREATE TABLE IF NOT EXISTS user_roles ( + user_id INT NOT NULL, + role_id INT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, role_id), + CONSTRAINT fk_user_roles_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_user_roles_role FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + await addColumnIfMissing(pool, 'user_roles', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP'); + await addColumnIfMissing(pool, 'user_roles', 'created_by', 'INT NULL'); + await addColumnIfMissing(pool, 'user_roles', 'modified_by', 'INT NULL'); + await pool.query(` CREATE TABLE IF NOT EXISTS auth_sessions ( session_hash CHAR(64) PRIMARY KEY, @@ -265,6 +556,27 @@ async function ensureSchema(pool) { } await pool.query('UPDATE users SET name = username WHERE name IS NULL OR name = ""'); + + await pool.query( + `INSERT INTO roles (role_key, name, description, created_by, modified_by) + VALUES (?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE name = VALUES(name), description = VALUES(description), modified_by = VALUES(modified_by)`, + [DEFAULT_ROLE.key, DEFAULT_ROLE.name, DEFAULT_ROLE.description || null, null, null] + ); + + await backfillLegacyRbacSchema(pool); + + if (!userCountRows.length || Number(userCountRows[0].user_count) === 0) { + const [roleRows] = await pool.query('SELECT id FROM roles WHERE role_key = ? LIMIT 1', [DEFAULT_ROLE.key]); + const defaultRoleId = roleRows.length ? Number(roleRows[0].id) : null; + if (defaultRoleId) { + await pool.query( + `INSERT IGNORE INTO user_roles (user_id, role_id, created_by, modified_by) + SELECT id, ?, NULL, NULL FROM users`, + [defaultRoleId] + ); + } + } } module.exports = { diff --git a/src/rbac.js b/src/rbac.js new file mode 100644 index 0000000..b824391 --- /dev/null +++ b/src/rbac.js @@ -0,0 +1,224 @@ +const PERMISSION_SECTIONS = [ + { + key: 'dashboard', + order: 10, + name: 'Dashboard', + sectionName: 'Main navigation', + actions: [ + { key: 'read', name: 'Read', description: 'Access the dashboard overview.' }, + { key: 'allow', name: 'Allow', description: 'Send global player commands.' } + ] + }, + { + key: 'clients', + order: 20, + name: 'Connected clients', + sectionName: 'Main navigation', + actions: [ + { key: 'read', name: 'Read', description: 'View connected player clients and live status.' }, + { key: 'allow', name: 'Allow', description: 'Use the connected client command buttons.' } + ] + }, + { + key: 'screens', + order: 30, + name: 'Screens', + sectionName: 'Content', + actions: [ + { key: 'read', name: 'Read', description: 'View the screen list and open screen details.' }, + { key: 'create', name: 'Create', description: 'Create new screens.' }, + { key: 'edit', name: 'Update', description: 'Edit screens and send screen commands.' }, + { key: 'delete', name: 'Delete', description: 'Delete screens.' } + ] + }, + { + key: 'playlists', + order: 40, + name: 'Playlists', + sectionName: 'Content', + actions: [ + { key: 'read', name: 'Read', description: 'View playlists and playlist contents.' }, + { key: 'create', name: 'Create', description: 'Create new playlists.' }, + { key: 'edit', name: 'Update', description: 'Edit playlists and playlist slides.' }, + { key: 'delete', name: 'Delete', description: 'Delete playlists.' } + ] + }, + { + key: 'slides', + order: 50, + name: 'Slides', + sectionName: 'Content', + actions: [ + { key: 'read', name: 'Read', description: 'View slides.' }, + { key: 'create', name: 'Create', description: 'Create new slides.' }, + { key: 'edit', name: 'Update', description: 'Edit slide content.' }, + { key: 'delete', name: 'Delete', description: 'Delete slides.' } + ] + }, + { + key: 'templates', + order: 60, + name: 'Slide templates', + sectionName: 'Content', + actions: [ + { key: 'read', name: 'Read', description: 'View slide templates.' }, + { key: 'create', name: 'Create', description: 'Create new slide templates.' }, + { key: 'edit', name: 'Update', description: 'Edit slide templates.' }, + { key: 'delete', name: 'Delete', description: 'Delete slide templates.' } + ] + }, + { + key: 'canvas-sizes', + order: 70, + name: 'Canvas sizes', + sectionName: 'Content', + actions: [ + { key: 'read', name: 'Read', description: 'View canvas sizes.' }, + { key: 'create', name: 'Create', description: 'Create new canvas sizes.' }, + { key: 'edit', name: 'Update', description: 'Edit canvas sizes.' }, + { key: 'delete', name: 'Delete', description: 'Delete canvas sizes.' } + ] + }, + { + key: 'users', + order: 80, + name: 'Users', + sectionName: 'Settings', + actions: [ + { key: 'read', name: 'Read', description: 'View users and role assignments.' }, + { key: 'create', name: 'Create', description: 'Create new users.' }, + { key: 'edit', name: 'Update', description: 'Edit users, passwords, and role assignments.' }, + { key: 'delete', name: 'Delete', description: 'Delete users.' } + ] + }, + { + key: 'rbac', + order: 90, + name: 'Roles and permissions', + sectionName: 'Settings', + actions: [ + { key: 'read', name: 'Read', description: 'View roles and permissions.' }, + { key: 'create', name: 'Create', description: 'Create new roles.' }, + { key: 'edit', name: 'Update', description: 'Edit role details and permissions.' }, + { key: 'delete', name: 'Delete', description: 'Delete roles.' } + ] + } +]; + +const PERMISSIONS = PERMISSION_SECTIONS.flatMap(function (section) { + return section.actions.map(function (action) { + return { + key: `${section.key}.${action.key}`, + name: section.name, + sectionOrder: section.order, + actionName: action.name, + sectionName: section.sectionName, + sectionKey: section.key, + actionKey: action.key, + description: action.description + }; + }); +}); + +const DEFAULT_ROLE = { + key: 'administrators', + name: 'Administrators', + description: 'Full access to the admin interface.' +}; + +function normalizePermissionKey(permissionKey) { + return String(permissionKey || '').trim(); +} + +function normalizePermissionKeys(permissionKeys) { + const normalized = []; + + (Array.isArray(permissionKeys) ? permissionKeys : []).forEach(function (permissionKey) { + const normalizedPermissionKey = normalizePermissionKey(permissionKey); + if (!normalizedPermissionKey) { + return; + } + + normalized.push(normalizedPermissionKey); + + const parts = normalizedPermissionKey.split('.'); + if (parts.length !== 2) { + return; + } + + const sectionKey = parts[0]; + const actionKey = parts[1]; + if (actionKey === 'create' || actionKey === 'edit' || actionKey === 'delete') { + normalized.push(`${sectionKey}.read`); + } + if (actionKey === 'manage') { + normalized.push(`${sectionKey}.read`); + normalized.push(`${sectionKey}.create`); + normalized.push(`${sectionKey}.edit`); + normalized.push(`${sectionKey}.delete`); + } + if (actionKey === 'view') { + normalized.push(`${sectionKey}.read`); + } + }); + + return Array.from(new Set(normalized)); +} + +function hasPermission(currentUser, permissionKey) { + const normalizedPermissionKey = normalizePermissionKey(permissionKey); + if (!normalizedPermissionKey || !currentUser) { + return false; + } + + const permissionKeys = Array.isArray(currentUser.permissionKeys) + ? currentUser.permissionKeys + : Array.isArray(currentUser.permissions) + ? currentUser.permissions + : []; + + return permissionKeys.map(normalizePermissionKey).includes(normalizedPermissionKey); +} + +function hasAnyPermission(currentUser, permissionKeys) { + const normalizedPermissionKeys = normalizePermissionKeys(permissionKeys); + if (!normalizedPermissionKeys.length || !currentUser) { + return false; + } + + return normalizedPermissionKeys.some(function (permissionKey) { + return hasPermission(currentUser, permissionKey); + }); +} + +function requirePermission(permissionKey) { + const normalizedPermissionKey = normalizePermissionKey(permissionKey); + if (!normalizedPermissionKey) { + throw new Error('requirePermission requires a permission key.'); + } + + return function (req, res, next) { + if (!req.currentUser) { + return res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.')); + } + + if (hasPermission(req.currentUser, normalizedPermissionKey)) { + return next(); + } + + const error = new Error('You do not have permission to access this area.'); + error.statusCode = 403; + error.expose = true; + next(error); + }; +} + +module.exports = { + PERMISSIONS, + PERMISSION_SECTIONS, + DEFAULT_ROLE, + hasPermission, + hasAnyPermission, + requirePermission, + normalizePermissionKeys +}; \ No newline at end of file diff --git a/src/web.js b/src/web.js index af81c02..6a383df 100644 --- a/src/web.js +++ b/src/web.js @@ -15,6 +15,8 @@ const registerAdminManageRoutes = require('./web/routes/admin-manage'); const registerAdminScreenCommandRoutes = require('./web/routes/admin-screen-commands'); const registerAdminContentRoutes = require('./web/routes/admin-content'); const { createWebBootstrap } = require('./web/bootstrap'); +const { requirePermission } = require('./rbac'); +const rbacData = require('./web/rbac-data'); const { createPlayerActionService } = require('./web/player-actions'); const { isClientNameAvailable, withClientNameReservation } = require('./client-name-check'); const { createSessionService } = require('./web/session'); @@ -130,6 +132,7 @@ async function start() { pool: pool, common: common, pages: pages, + requirePermission: requirePermission, buildDashboardState: webBootstrap.buildDashboardState }); @@ -149,7 +152,10 @@ async function start() { pages: pages, formatDashboardDate: formatDashboardDate, getAuditUserId: getAuditUserId, - hashPassword: hashPassword + hashPassword: hashPassword, + readArrayField: readArrayField, + rbacData: rbacData, + requirePermission: requirePermission }); registerAdminManageRoutes(app, { @@ -172,7 +178,8 @@ async function start() { getScreenConnections: playerActionService.getScreenConnections, getPlaylistDeleteBlockMessage: playerActionService.getPlaylistDeleteBlockMessage, forwardPlayerCommand: playerActionService.forwardPlayerCommand, - playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL + playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL, + requirePermission: requirePermission }); registerAdminScreenCommandRoutes(app, { @@ -180,7 +187,19 @@ async function start() { forwardPlayerCommand: playerActionService.forwardPlayerCommand, getScreenConnections: playerActionService.getScreenConnections, isClientNameAvailable: isClientNameAvailable, - withClientNameReservation: withClientNameReservation + withClientNameReservation: withClientNameReservation, + requirePermission: requirePermission + }); + + const registerAdminRbacRoutes = require('./web/routes/admin-rbac'); + registerAdminRbacRoutes(app, { + pool: pool, + pages: pages, + getAuditUserId: getAuditUserId, + rbacData: rbacData, + permissions: require('./rbac').PERMISSIONS, + normalizePermissionKeys: require('./rbac').normalizePermissionKeys, + requirePermission: requirePermission }); registerAdminContentRoutes(app, { @@ -201,13 +220,39 @@ async function start() { broadcastDashboardState: broadcastDashboardState, getSlideDeleteBlockMessage: playerActionService.getSlideDeleteBlockMessage, getTemplateDeleteBlockMessage: playerActionService.getTemplateDeleteBlockMessage, - getCanvasSizeDeleteBlockMessage: playerActionService.getCanvasSizeDeleteBlockMessage + getCanvasSizeDeleteBlockMessage: playerActionService.getCanvasSizeDeleteBlockMessage, + requirePermission: requirePermission }); - app.use(function (error, _req, res, _next) { + app.use(function (error, req, res, _next) { console.error(error); - res.status(error.statusCode || 500).send(error.statusCode ? error.message : 'Internal server error'); + const statusCode = Number(error && (error.statusCode || error.status)) || 500; + const wantsHtml = !String(req.originalUrl || '').startsWith('/api/') && (!req.accepts || req.accepts('html')); + + if (wantsHtml && pages.renderErrorPage) { + const isPermissionError = statusCode === 403; + const message = isPermissionError + ? String(error && error.message ? error.message : 'You do not have permission to access this area.') + : String(error && error.message ? error.message : 'An unexpected error occurred.'); + const title = isPermissionError + ? 'Access denied' + : statusCode === 404 + ? 'Not found' + : 'Something went wrong'; + + return res.status(statusCode).send(pages.renderErrorPage({ + statusCode: statusCode, + title: title, + errorTitle: title, + message: message, + detail: statusCode >= 500 ? 'The server could not complete the request.' : '', + backUrl: req.currentUser ? '/admin' : '/login', + backLabel: req.currentUser ? 'Back to dashboard' : 'Sign in' + }, req.currentUser)); + } + + res.status(statusCode).send(statusCode >= 500 ? 'Internal server error' : String(error && error.message ? error.message : 'Error')); }); // Ensure schema and mirror uploads before the web service starts handling traffic. diff --git a/src/web/public/css/theme-custom.css b/src/web/public/css/theme-custom.css index f5a097a..1fc76b5 100644 --- a/src/web/public/css/theme-custom.css +++ b/src/web/public/css/theme-custom.css @@ -195,7 +195,6 @@ margin: 0.75rem 0 0; font-size: clamp(1.4rem, 1.1rem + 1vw, 2.2rem); line-height: 1.08; - max-width: 28rem; } .dashboard-hero-copy { diff --git a/src/web/public/js/dashboard/dashboard-page.js b/src/web/public/js/dashboard/dashboard-page.js index 1bfebca..9ee8148 100644 --- a/src/web/public/js/dashboard/dashboard-page.js +++ b/src/web/public/js/dashboard/dashboard-page.js @@ -206,7 +206,7 @@ } } - function renderClientRow(client) { + function renderClientRow(client, hasActionsColumn) { var connectedAt = client.connectedAt ? '
' + escapeHtml(client.connectedAtLabel || formatDashboardDate(client.connectedAt) || client.connectedAt) + '
' + (client.lastSeenAt ? '
' + escapeHtml(client.lastSeenAtLabel || formatDashboardDate(client.lastSeenAt) || client.lastSeenAt) + '
' : '') : 'Unknown'; var clientIpValue = normalizeDisplayIp(client.clientIp); var clientIp = clientIpValue ? escapeHtml(clientIpValue) : 'Unknown'; @@ -215,6 +215,7 @@ var clientName = clientNameValue ? escapeHtml(clientNameValue) : 'Unknown'; var screenName = client.screen_name ? escapeHtml(client.screen_name) : 'Unknown'; var currentSlide = client.currentSlideTitle ? escapeHtml(client.currentSlideTitle) : 'No slide currently showing'; + var actionCell = hasActionsColumn ? '' + renderClientActionCell(client) + '' : ''; return [ '', @@ -224,7 +225,7 @@ '' + clientIp + '', '' + viewport + '', '' + connectedAt + '', - '' + renderClientActionCell(client) + '', + actionCell, '' ].join(''); } @@ -266,8 +267,10 @@ if (!tbody || !Array.isArray(state.clients)) { return; } + var table = document.getElementById('dashboard-clients-table'); + var hasActionsColumn = Boolean(table && String(table.getAttribute('data-has-actions-column') || '').toLowerCase() === 'true'); if (!state.clients.length) { - tbody.innerHTML = 'No connected clients yet.'; + tbody.innerHTML = 'No connected clients yet.'; return; } var existingRows = {}; @@ -286,7 +289,7 @@ var row = existingRows[rowKey]; if (!row) { var tempBody = document.createElement('tbody'); - tempBody.innerHTML = renderClientRow(client); + tempBody.innerHTML = renderClientRow(client, hasActionsColumn); row = tempBody.firstElementChild; } @@ -298,7 +301,7 @@ row.setAttribute('data-client-id', client.clientId || ''); row.setAttribute('data-client-device-id', client.deviceId || ''); row.setAttribute('data-client-screen-slug', client.screen_slug || ''); - if (row.cells && row.cells.length >= 7) { + if (row.cells && row.cells.length >= 6) { var connectedAt = client.connectedAt ? '
' + escapeHtml(client.connectedAtLabel || formatDashboardDate(client.connectedAt) || client.connectedAt) + '
' + (client.lastSeenAt ? '
' + escapeHtml(client.lastSeenAtLabel || formatDashboardDate(client.lastSeenAt) || client.lastSeenAt) + '
' : '') : 'Unknown'; var clientIpValue = normalizeDisplayIp(client.clientIp); var clientIp = clientIpValue ? escapeHtml(clientIpValue) : 'Unknown'; @@ -314,7 +317,9 @@ row.cells[3].innerHTML = clientIp; row.cells[4].innerHTML = viewport; row.cells[5].innerHTML = connectedAt; - updateClientActionCell(row.cells[6], client); + if (hasActionsColumn && row.cells.length >= 7) { + updateClientActionCell(row.cells[6], client); + } } var referenceNode = tbody.children[index] || null; diff --git a/src/web/public/js/rbac-permissions.js b/src/web/public/js/rbac-permissions.js new file mode 100644 index 0000000..10da083 --- /dev/null +++ b/src/web/public/js/rbac-permissions.js @@ -0,0 +1,111 @@ +(function () { + function getGroupCheckboxes(group) { + return Array.prototype.slice.call(group.querySelectorAll('input[name="permission_keys[]"]')); + } + + function getPermissionKey(checkbox) { + return String((checkbox && (checkbox.getAttribute('data-permission-key') || checkbox.value)) || '').trim().toLowerCase(); + } + + function getActionKey(checkbox) { + var permissionKey = getPermissionKey(checkbox); + var parts = permissionKey.split('.'); + if (parts.length !== 2) { + return ''; + } + + return String(parts[1] || '').trim().toLowerCase(); + } + + function syncPermissionGroup(group) { + var checkboxes = getGroupCheckboxes(group); + if (!checkboxes.length) { + return; + } + + var readCheckbox = null; + var nonReadChecked = false; + + checkboxes.forEach(function (checkbox) { + if (getActionKey(checkbox) === 'read') { + readCheckbox = checkbox; + return; + } + if (checkbox.checked) { + nonReadChecked = true; + } + }); + + if (!readCheckbox) { + return; + } + + if (!readCheckbox.checked && nonReadChecked) { + readCheckbox.checked = true; + } + + if (!readCheckbox.checked) { + checkboxes.forEach(function (checkbox) { + if (getActionKey(checkbox) !== 'read') { + checkbox.checked = false; + } + }); + } + } + + function handleGroupChange(event) { + var checkbox = event.target && event.target.matches ? event.target : null; + if (!checkbox || checkbox.tagName !== 'INPUT' || checkbox.type !== 'checkbox') { + return; + } + + var actionKey = getActionKey(checkbox); + var group = checkbox.closest('.accordion-item'); + if (!group) { + return; + } + + var checkboxes = getGroupCheckboxes(group); + var readCheckbox = checkboxes.find(function (candidate) { + return getActionKey(candidate) === 'read'; + }) || null; + + if (!readCheckbox) { + return; + } + + if (actionKey === 'read' && !checkbox.checked) { + checkboxes.forEach(function (candidate) { + if (candidate !== checkbox) { + candidate.checked = false; + } + }); + return; + } + + if (actionKey !== 'read' && checkbox.checked) { + readCheckbox.checked = true; + } + } + + function initPermissionGroups() { + document.querySelectorAll('.accordion-item').forEach(function (group) { + syncPermissionGroup(group); + }); + + document.addEventListener('change', function (event) { + var checkbox = event.target; + if (!checkbox || checkbox.tagName !== 'INPUT' || checkbox.type !== 'checkbox') { + return; + } + if (String(checkbox.getAttribute('name') || '') !== 'permission_keys[]') { + return; + } + + handleGroupChange(event); + syncPermissionGroup(checkbox.closest('.accordion-item')); + }); + } + + initPermissionGroups(); +}()); \ No newline at end of file diff --git a/src/web/public/js/toast.js b/src/web/public/js/toast.js index f96d2b4..9689209 100644 --- a/src/web/public/js/toast.js +++ b/src/web/public/js/toast.js @@ -40,7 +40,7 @@ function getMessageVariant(message, fallbackVariant) { var text = String(message || '').trim(); - if (/^(unable to delete|cannot delete|can't delete)|\bstill (?:in use|linked|assigned|used)\b/i.test(text)) { + if (/\b(?:unable to|cannot|can't|could not|failed to)\s+delete\b/i.test(text) || /\bdelete\b.*\b(?:before|first)\b/i.test(text) || /\bstill (?:in use|linked|assigned|used)\b/i.test(text)) { return 'danger'; } return String(fallbackVariant || 'success').trim().toLowerCase() || 'success'; @@ -63,7 +63,9 @@ if (existingBody) { existingBody.textContent = text; } - setToastVariant(existingToast, getMessageVariant(text, variant)); + var nextVariant = getMessageVariant(text, variant); + existingToast.setAttribute('data-toast-variant', nextVariant); + setToastVariant(existingToast, nextVariant); var existingInstance = getBootstrapToast(existingToast); if (existingInstance) { existingInstance.show(); @@ -80,7 +82,9 @@ toast.setAttribute('data-bs-autohide', 'true'); toast.setAttribute('data-bs-delay', '4000'); toast.innerHTML = '
'; - setToastVariant(toast, getMessageVariant(text, variant)); + var toastVariant = getMessageVariant(text, variant); + toast.setAttribute('data-toast-variant', toastVariant); + setToastVariant(toast, toastVariant); toast.querySelector('.toast-body').textContent = text; toast.addEventListener('hidden.bs.toast', function () { @@ -110,7 +114,8 @@ // ignore URL cleanup failures } - setToastVariant(toast, getMessageVariant((toast.querySelector('.toast-body') && toast.querySelector('.toast-body').textContent) || '', 'success')); + var existingVariant = String(toast.getAttribute('data-toast-variant') || '').trim().toLowerCase() || getMessageVariant((toast.querySelector('.toast-body') && toast.querySelector('.toast-body').textContent) || '', 'success'); + setToastVariant(toast, existingVariant); var instance = getBootstrapToast(toast); if (instance) { diff --git a/src/web/rbac-data.js b/src/web/rbac-data.js new file mode 100644 index 0000000..8ef392d --- /dev/null +++ b/src/web/rbac-data.js @@ -0,0 +1,196 @@ +const { PERMISSIONS, normalizePermissionKeys } = require('../rbac'); + +function parseCsvIds(value) { + return String(value || '') + .split(',') + .map(function (item) { + return Number(item); + }) + .filter(function (item) { + return Number.isInteger(item) && item > 0; + }); +} + +async function fetchPermissions(pool) { + const [rows] = await pool.query('SELECT id, permission_key, name, section_name, description FROM permissions ORDER BY section_name ASC, name ASC'); + return rows || []; +} + +async function fetchRoles(pool) { + const [rows] = await pool.query( + `SELECT r.id, r.role_key, r.name, r.description, r.created_at, r.modified_at, + (SELECT COUNT(*) FROM user_roles ur WHERE ur.role_id = r.id) AS user_count, + (SELECT COUNT(*) FROM role_permissions rp WHERE rp.role_id = r.id) AS permission_count + FROM roles r + ORDER BY r.name ASC` + ); + return rows || []; +} + +async function fetchRoleById(pool, roleId) { + const [rows] = await pool.query( + `SELECT r.id, r.role_key, r.name, r.description, r.created_at, r.modified_at, + (SELECT COUNT(*) FROM user_roles ur WHERE ur.role_id = r.id) AS user_count, + (SELECT COUNT(*) FROM role_permissions rp WHERE rp.role_id = r.id) AS permission_count + FROM roles r + WHERE r.id = ? + LIMIT 1`, + [roleId] + ); + return rows[0] || null; +} + +async function fetchRolePermissionKeys(pool, roleId) { + const [rows] = await pool.query( + `SELECT p.permission_key + FROM role_permissions rp + JOIN permissions p ON p.id = rp.permission_id + WHERE rp.role_id = ? + ORDER BY p.section_name ASC, p.name ASC`, + [roleId] + ); + return (rows || []).map(function (row) { + return String(row.permission_key || '').trim(); + }).filter(Boolean); +} + +async function fetchRoleUserIds(pool, roleId) { + const [rows] = await pool.query( + `SELECT ur.user_id + FROM user_roles ur + WHERE ur.role_id = ? + ORDER BY ur.user_id ASC`, + [roleId] + ); + return (rows || []).map(function (row) { + return Number(row.user_id); + }).filter(function (userId) { + return Number.isInteger(userId) && userId > 0; + }); +} + +async function fetchRolesForUser(pool, userId) { + const [rows] = await pool.query( + `SELECT r.id, r.role_key, r.name, r.description + FROM user_roles ur + JOIN roles r ON r.id = ur.role_id + WHERE ur.user_id = ? + ORDER BY r.name ASC`, + [userId] + ); + return rows || []; +} + +async function fetchUsersWithRoles(pool) { + const [rows] = await pool.query( + `SELECT u.id, u.name, u.username, u.created_at, u.modified_at, + COALESCE(role_data.role_names, '') AS role_names, + COALESCE(role_data.role_ids_csv, '') AS role_ids_csv + FROM users u + LEFT JOIN ( + SELECT ur.user_id, + GROUP_CONCAT(DISTINCT r.name ORDER BY r.name SEPARATOR ', ') AS role_names, + GROUP_CONCAT(DISTINCT r.id ORDER BY r.name SEPARATOR ',') AS role_ids_csv + FROM user_roles ur + JOIN roles r ON r.id = ur.role_id + GROUP BY ur.user_id + ) role_data ON role_data.user_id = u.id + ORDER BY u.id ASC` + ); + + return (rows || []).map(function (row) { + return Object.assign({}, row, { + roleIds: parseCsvIds(row.role_ids_csv), + roleNames: String(row.role_names || '').trim() + }); + }); +} + +async function fetchUserWithRoles(pool, userId) { + const [rows] = await pool.query( + `SELECT u.id, u.name, u.username, u.created_at, u.modified_at, + COALESCE(role_data.role_names, '') AS role_names, + COALESCE(role_data.role_ids_csv, '') AS role_ids_csv + FROM users u + LEFT JOIN ( + SELECT ur.user_id, + GROUP_CONCAT(DISTINCT r.name ORDER BY r.name SEPARATOR ', ') AS role_names, + GROUP_CONCAT(DISTINCT r.id ORDER BY r.name SEPARATOR ',') AS role_ids_csv + FROM user_roles ur + JOIN roles r ON r.id = ur.role_id + GROUP BY ur.user_id + ) role_data ON role_data.user_id = u.id + WHERE u.id = ? + LIMIT 1`, + [userId] + ); + + if (!rows.length) { + return null; + } + + return Object.assign({}, rows[0], { + roleIds: parseCsvIds(rows[0].role_ids_csv), + roleNames: String(rows[0].role_names || '').trim() + }); +} + +async function syncUserRoles(pool, userId, roleIds) { + const uniqueRoleIds = Array.from(new Set((Array.isArray(roleIds) ? roleIds : []).map(function (roleId) { + return Number(roleId); + }).filter(function (roleId) { + return Number.isInteger(roleId) && roleId > 0; + }))); + + await pool.query('DELETE FROM user_roles WHERE user_id = ?', [userId]); + for (const roleId of uniqueRoleIds) { + await pool.query('INSERT IGNORE INTO user_roles (user_id, role_id, created_by, modified_by) VALUES (?, ?, ?, ?)', [userId, roleId, null, null]); + } +} + +async function syncRoleUsers(pool, roleId, userIds) { + const uniqueUserIds = Array.from(new Set((Array.isArray(userIds) ? userIds : []).map(function (userId) { + return Number(userId); + }).filter(function (userId) { + return Number.isInteger(userId) && userId > 0; + }))); + + await pool.query('DELETE FROM user_roles WHERE role_id = ?', [roleId]); + for (const userId of uniqueUserIds) { + await pool.query('INSERT IGNORE INTO user_roles (user_id, role_id, created_by, modified_by) VALUES (?, ?, ?, ?)', [userId, roleId, null, null]); + } +} + +async function syncRolePermissions(pool, roleId, permissionKeys) { + const uniquePermissionKeys = normalizePermissionKeys(permissionKeys); + + if (!uniquePermissionKeys.length) { + await pool.query('DELETE FROM role_permissions WHERE role_id = ?', [roleId]); + return; + } + + const [permissionRows] = await pool.query('SELECT id, permission_key FROM permissions WHERE permission_key IN (?)', [uniquePermissionKeys]); + if (permissionRows.length !== uniquePermissionKeys.length) { + throw new Error('One or more selected permissions are invalid.'); + } + + await pool.query('DELETE FROM role_permissions WHERE role_id = ?', [roleId]); + for (const permissionRow of permissionRows) { + await pool.query('INSERT IGNORE INTO role_permissions (role_id, permission_id, created_by, modified_by) VALUES (?, ?, ?, ?)', [roleId, Number(permissionRow.id), null, null]); + } +} + +module.exports = { + PERMISSIONS, + fetchPermissions, + fetchRoles, + fetchRoleById, + fetchRolePermissionKeys, + fetchRoleUserIds, + fetchRolesForUser, + fetchUsersWithRoles, + fetchUserWithRoles, + syncUserRoles, + syncRoleUsers, + syncRolePermissions +}; \ No newline at end of file diff --git a/src/web/routes/admin-content.js b/src/web/routes/admin-content.js index 1530bea..a476675 100644 --- a/src/web/routes/admin-content.js +++ b/src/web/routes/admin-content.js @@ -16,6 +16,7 @@ module.exports = function registerAdminContentRoutes(app, deps) { const getSlideDeleteBlockMessage = deps.getSlideDeleteBlockMessage; const getTemplateDeleteBlockMessage = deps.getTemplateDeleteBlockMessage; const getCanvasSizeDeleteBlockMessage = deps.getCanvasSizeDeleteBlockMessage; + const requirePermission = deps.requirePermission; if (!pool || !common || !pages || !upload || typeof fetchScreensBySlideId !== 'function' || typeof fetchScreensByTemplateId !== 'function' || typeof collectUploadReferencesFromSlide !== 'function' || typeof collectUploadReferencesFromTemplate !== 'function' || typeof collectUploadReferencesFromPayload !== 'function' || typeof syncPlaylistUploadsOnChange !== 'function' || typeof getAuditUserId !== 'function' || typeof redirectAfterSave !== 'function' || typeof notifyPlayerScreens !== 'function' || typeof broadcastDashboardState !== 'function' || typeof getSlideDeleteBlockMessage !== 'function' || typeof getTemplateDeleteBlockMessage !== 'function' || typeof getCanvasSizeDeleteBlockMessage !== 'function') { throw new Error('registerAdminContentRoutes requires the content route dependencies.'); @@ -55,7 +56,7 @@ module.exports = function registerAdminContentRoutes(app, deps) { }, {}); } - app.get('/admin/slides', async function (req, res, next) { + app.get('/admin/slides', requirePermission('slides.read'), async function (req, res, next) { try { const data = await common.fetchAdminData(pool); res.send(pages.renderSlidesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser)); @@ -64,7 +65,7 @@ module.exports = function registerAdminContentRoutes(app, deps) { } }); - app.get('/admin/slides/new', async function (req, res, next) { + app.get('/admin/slides/new', requirePermission('slides.create'), async function (req, res, next) { try { const data = await common.fetchTemplatesData(pool); res.send(pages.renderSlideFormPage(data, 'create', null, req.query.message ? String(req.query.message) : '', req.currentUser)); @@ -73,7 +74,7 @@ module.exports = function registerAdminContentRoutes(app, deps) { } }); - app.get('/admin/slides/:id/edit', async function (req, res, next) { + app.get('/admin/slides/:id/edit', requirePermission('slides.edit'), async function (req, res, next) { try { const slide = await common.fetchSlideById(pool, Number(req.params.id)); if (!slide) { @@ -86,7 +87,7 @@ module.exports = function registerAdminContentRoutes(app, deps) { } }); - app.post('/admin/slides', upload.any(), async function (req, res, next) { + app.post('/admin/slides', requirePermission('slides.create'), upload.any(), async function (req, res, next) { try { const payload = await common.buildSlidePayload(pool, req, null); const actorId = getAuditUserId(req); @@ -110,7 +111,7 @@ module.exports = function registerAdminContentRoutes(app, deps) { } }); - app.post('/admin/slides/:id', upload.any(), async function (req, res, next) { + app.post('/admin/slides/:id', requirePermission('slides.edit'), upload.any(), async function (req, res, next) { try { const slide = await common.fetchSlideById(pool, Number(req.params.id)); if (!slide) { @@ -147,7 +148,7 @@ module.exports = function registerAdminContentRoutes(app, deps) { } }); - app.post('/admin/slides/:id/delete', async function (req, res, next) { + app.post('/admin/slides/:id/delete', requirePermission('slides.delete'), async function (req, res, next) { try { const slide = await common.fetchSlideById(pool, Number(req.params.id)); if (!slide) { @@ -177,7 +178,7 @@ module.exports = function registerAdminContentRoutes(app, deps) { } }); - app.get('/admin/templates', async function (req, res, next) { + app.get('/admin/templates', requirePermission('templates.read'), async function (req, res, next) { try { const data = await common.fetchTemplatesData(pool); res.send(pages.renderTemplatesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser)); @@ -186,7 +187,7 @@ module.exports = function registerAdminContentRoutes(app, deps) { } }); - app.get('/admin/templates/new', async function (req, res, next) { + app.get('/admin/templates/new', requirePermission('templates.create'), async function (req, res, next) { try { const data = await common.fetchCanvasSizesData(pool); res.send(pages.renderTemplateFormPage(null, 'create', req.query.message ? String(req.query.message) : '', data.canvasSizes, req.currentUser)); @@ -195,7 +196,7 @@ module.exports = function registerAdminContentRoutes(app, deps) { } }); - app.post('/admin/templates', upload.any(), async function (req, res, next) { + app.post('/admin/templates', requirePermission('templates.create'), upload.any(), async function (req, res, next) { try { const payload = await common.buildTemplatePayload(pool, req, null); const actorId = getAuditUserId(req); @@ -226,7 +227,7 @@ module.exports = function registerAdminContentRoutes(app, deps) { } }); - app.get('/admin/templates/:id/edit', async function (req, res, next) { + app.get('/admin/templates/:id/edit', requirePermission('templates.edit'), async function (req, res, next) { try { const template = await common.fetchTemplateById(pool, Number(req.params.id)); if (!template) { @@ -239,7 +240,7 @@ module.exports = function registerAdminContentRoutes(app, deps) { } }); - app.post('/admin/templates/:id', upload.any(), async function (req, res, next) { + app.post('/admin/templates/:id', requirePermission('templates.edit'), upload.any(), async function (req, res, next) { try { const template = await common.fetchTemplateById(pool, Number(req.params.id)); if (!template) { @@ -280,7 +281,7 @@ module.exports = function registerAdminContentRoutes(app, deps) { } }); - app.post('/admin/templates/:id/delete', async function (req, res, next) { + app.post('/admin/templates/:id/delete', requirePermission('templates.delete'), async function (req, res, next) { try { const template = await common.fetchTemplateById(pool, Number(req.params.id)); if (!template) { @@ -308,7 +309,7 @@ module.exports = function registerAdminContentRoutes(app, deps) { } }); - app.get('/admin/canvas-sizes', async function (req, res, next) { + app.get('/admin/canvas-sizes', requirePermission('canvas-sizes.read'), async function (req, res, next) { try { const data = await common.fetchAdminData(pool); res.send(pages.renderCanvasSizesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser)); @@ -317,11 +318,11 @@ module.exports = function registerAdminContentRoutes(app, deps) { } }); - app.get('/admin/canvas-sizes/new', function (req, res) { + app.get('/admin/canvas-sizes/new', requirePermission('canvas-sizes.create'), function (req, res) { res.send(pages.renderCanvasSizeFormPage(null, 'create', req.query.message ? String(req.query.message) : '', req.currentUser)); }); - app.post('/admin/canvas-sizes', async function (req, res, next) { + app.post('/admin/canvas-sizes', requirePermission('canvas-sizes.create'), async function (req, res, next) { try { const payload = common.buildCanvasSizePayload(req, null); if (await canvasSizeExists(payload.width, payload.height)) { @@ -339,7 +340,7 @@ module.exports = function registerAdminContentRoutes(app, deps) { } }); - app.get('/admin/canvas-sizes/:id/edit', async function (req, res, next) { + app.get('/admin/canvas-sizes/:id/edit', requirePermission('canvas-sizes.edit'), async function (req, res, next) { try { const canvasSize = await common.fetchCanvasSizeById(pool, Number(req.params.id)); if (!canvasSize) { @@ -351,7 +352,7 @@ module.exports = function registerAdminContentRoutes(app, deps) { } }); - app.post('/admin/canvas-sizes/:id', async function (req, res, next) { + app.post('/admin/canvas-sizes/:id', requirePermission('canvas-sizes.edit'), async function (req, res, next) { try { const canvasSize = await common.fetchCanvasSizeById(pool, Number(req.params.id)); if (!canvasSize) { @@ -372,7 +373,7 @@ module.exports = function registerAdminContentRoutes(app, deps) { } }); - app.post('/admin/canvas-sizes/:id/delete', async function (req, res, next) { + app.post('/admin/canvas-sizes/:id/delete', requirePermission('canvas-sizes.delete'), async function (req, res, next) { try { const canvasSize = await common.fetchCanvasSizeById(pool, Number(req.params.id)); if (!canvasSize) { diff --git a/src/web/routes/admin-manage.js b/src/web/routes/admin-manage.js index 2117ce7..8c54c7f 100644 --- a/src/web/routes/admin-manage.js +++ b/src/web/routes/admin-manage.js @@ -19,6 +19,7 @@ module.exports = function registerAdminManageRoutes(app, deps) { const getPlaylistDeleteBlockMessage = deps.getPlaylistDeleteBlockMessage; const forwardPlayerCommand = deps.forwardPlayerCommand; const PLAYER_PUBLIC_BASE_URL = deps.playerPublicBaseUrl; + const requirePermission = deps.requirePermission; async function fetchAllScreenSlugs() { const [rows] = await pool.query('SELECT slug FROM screens ORDER BY slug ASC'); @@ -29,7 +30,7 @@ module.exports = function registerAdminManageRoutes(app, deps) { .filter(Boolean); } - app.post('/admin/commands', async function (req, res, next) { + app.post('/admin/commands', requirePermission('dashboard.allow'), async function (req, res, next) { try { const command = String((req.body && req.body.command) || req.query.command || '').trim().toLowerCase(); const blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout') @@ -71,7 +72,7 @@ module.exports = function registerAdminManageRoutes(app, deps) { } }); - app.post('/admin/playlists', async function (req, res, next) { + app.post('/admin/playlists', requirePermission('playlists.create'), async function (req, res, next) { try { const name = String(req.body.name || '').trim(); const fadeBetweenSlides = req.body.fade_between_slides ? 1 : 0; @@ -90,7 +91,7 @@ module.exports = function registerAdminManageRoutes(app, deps) { } }); - app.post('/admin/playlists/:id', async function (req, res, next) { + app.post('/admin/playlists/:id', requirePermission('playlists.edit'), async function (req, res, next) { const connection = await pool.getConnection(); try { const name = String(req.body.name || '').trim(); @@ -255,7 +256,7 @@ module.exports = function registerAdminManageRoutes(app, deps) { } }); - app.post('/admin/playlists/:id/delete', async function (req, res, next) { + app.post('/admin/playlists/:id/delete', requirePermission('playlists.delete'), async function (req, res, next) { try { const playlist = await common.fetchPlaylistById(pool, Number(req.params.id)); if (!playlist) { @@ -272,7 +273,7 @@ module.exports = function registerAdminManageRoutes(app, deps) { } }); - app.post('/admin/playlists/:id/slides', async function (req, res, next) { + app.post('/admin/playlists/:id/slides', requirePermission('playlists.edit'), async function (req, res, next) { try { const playlist = await common.fetchPlaylistById(pool, Number(req.params.id)); if (!playlist) { @@ -307,7 +308,7 @@ module.exports = function registerAdminManageRoutes(app, deps) { } }); - app.post('/admin/playlists/:id/slides/:playlistSlideId', async function (req, res, next) { + app.post('/admin/playlists/:id/slides/:playlistSlideId', requirePermission('playlists.edit'), async function (req, res, next) { try { const playlist = await common.fetchPlaylistById(pool, Number(req.params.id)); if (!playlist) { @@ -331,7 +332,7 @@ module.exports = function registerAdminManageRoutes(app, deps) { } }); - app.post('/admin/playlists/:id/slides/:playlistSlideId/move', async function (req, res, next) { + app.post('/admin/playlists/:id/slides/:playlistSlideId/move', requirePermission('playlists.edit'), async function (req, res, next) { const connection = await pool.getConnection(); try { const playlist = await common.fetchPlaylistById(connection, Number(req.params.id)); @@ -378,7 +379,7 @@ module.exports = function registerAdminManageRoutes(app, deps) { } }); - app.get('/admin/playlists/:id/slides/:playlistSlideId/config', async function (req, res, next) { + app.get('/admin/playlists/:id/slides/:playlistSlideId/config', requirePermission('playlists.edit'), async function (req, res, next) { try { const playlist = await common.fetchPlaylistById(pool, Number(req.params.id)); if (!playlist) { @@ -415,7 +416,7 @@ module.exports = function registerAdminManageRoutes(app, deps) { } }); - app.post('/admin/playlists/:id/slides/:playlistSlideId/config', async function (req, res, next) { + app.post('/admin/playlists/:id/slides/:playlistSlideId/config', requirePermission('playlists.edit'), async function (req, res, next) { try { const playlist = await common.fetchPlaylistById(pool, Number(req.params.id)); if (!playlist) { @@ -495,7 +496,7 @@ module.exports = function registerAdminManageRoutes(app, deps) { } }); - app.post('/admin/playlists/:id/slides/:playlistSlideId/delete', async function (req, res, next) { + app.post('/admin/playlists/:id/slides/:playlistSlideId/delete', requirePermission('playlists.edit'), async function (req, res, next) { try { const playlist = await common.fetchPlaylistById(pool, Number(req.params.id)); if (!playlist) { @@ -510,7 +511,7 @@ module.exports = function registerAdminManageRoutes(app, deps) { } }); - app.get('/admin/screens/new', async function (req, res, next) { + app.get('/admin/screens/new', requirePermission('screens.create'), async function (req, res, next) { try { const data = await common.fetchScreenEditData(pool); res.send(pages.renderScreenFormPage(data, req.query.message ? String(req.query.message) : '', req.currentUser)); @@ -519,7 +520,7 @@ module.exports = function registerAdminManageRoutes(app, deps) { } }); - app.post('/admin/screens', async function (req, res, next) { + app.post('/admin/screens', requirePermission('screens.create'), async function (req, res, next) { try { const name = String(req.body.name || '').trim(); if (!name) { @@ -540,7 +541,7 @@ module.exports = function registerAdminManageRoutes(app, deps) { } }); - app.post('/admin/screens/:id', async function (req, res, next) { + app.post('/admin/screens/:id', requirePermission('screens.edit'), async function (req, res, next) { try { const name = String(req.body.name || '').trim(); if (!name) { @@ -575,7 +576,7 @@ module.exports = function registerAdminManageRoutes(app, deps) { } }); - app.post('/admin/screens/:id/delete', async function (req, res, next) { + app.post('/admin/screens/:id/delete', requirePermission('screens.delete'), async function (req, res, next) { try { const screen = await common.fetchScreenById(pool, Number(req.params.id)); if (!screen) { diff --git a/src/web/routes/admin-pages.js b/src/web/routes/admin-pages.js index d734a86..d11b4cd 100644 --- a/src/web/routes/admin-pages.js +++ b/src/web/routes/admin-pages.js @@ -3,8 +3,16 @@ module.exports = function registerAdminPagesRoutes(app, deps) { const common = deps.common; const pages = deps.pages; const buildDashboardState = deps.buildDashboardState; + const requirePermission = deps.requirePermission; - app.get('/admin', async function (req, res, next) { + function requireQueryPermission(readPermissionKey, editPermissionKey) { + return function (req, res, next) { + const permissionKey = req.query && req.query.edit ? editPermissionKey : readPermissionKey; + return requirePermission(permissionKey)(req, res, next); + }; + } + + app.get('/admin', requirePermission('dashboard.read'), async function (req, res, next) { try { const data = await buildDashboardState(pool); res.send(pages.renderDashboardPage(data, req.query.message ? String(req.query.message) : '', req.currentUser)); @@ -13,7 +21,7 @@ module.exports = function registerAdminPagesRoutes(app, deps) { } }); - app.get('/admin/clients', async function (req, res, next) { + app.get('/admin/clients', requirePermission('clients.read'), async function (req, res, next) { try { const data = await buildDashboardState(pool); res.send(pages.renderConnectedClientsPage(data, req.query.message ? String(req.query.message) : '', req.currentUser)); @@ -22,7 +30,7 @@ module.exports = function registerAdminPagesRoutes(app, deps) { } }); - app.get('/admin/screens', async function (req, res, next) { + app.get('/admin/screens', requireQueryPermission('screens.read', 'screens.edit'), async function (req, res, next) { try { const data = await buildDashboardState(pool); if (req.query.edit) { @@ -39,7 +47,7 @@ module.exports = function registerAdminPagesRoutes(app, deps) { } }); - app.get('/admin/screens/:id/edit', async function (req, res, next) { + app.get('/admin/screens/:id/edit', requirePermission('screens.edit'), async function (req, res, next) { try { const screen = await common.fetchScreenById(pool, Number(req.params.id)); if (!screen) { @@ -52,7 +60,7 @@ module.exports = function registerAdminPagesRoutes(app, deps) { } }); - app.get('/admin/playlists', async function (req, res, next) { + app.get('/admin/playlists', requireQueryPermission('playlists.read', 'playlists.edit'), async function (req, res, next) { try { const data = await common.fetchAdminData(pool); if (req.query.edit) { @@ -68,11 +76,11 @@ module.exports = function registerAdminPagesRoutes(app, deps) { } }); - app.get('/admin/playlists/new', function (req, res) { + app.get('/admin/playlists/new', requirePermission('playlists.create'), function (req, res) { res.send(pages.renderPlaylistFormPage(req.query.message ? String(req.query.message) : '', req.currentUser)); }); - app.get('/admin/playlists/:id/edit', async function (req, res, next) { + app.get('/admin/playlists/:id/edit', requirePermission('playlists.edit'), async function (req, res, next) { try { const playlist = await common.fetchPlaylistById(pool, Number(req.params.id)); if (!playlist) { diff --git a/src/web/routes/admin-rbac.js b/src/web/routes/admin-rbac.js new file mode 100644 index 0000000..5588ac0 --- /dev/null +++ b/src/web/routes/admin-rbac.js @@ -0,0 +1,365 @@ +module.exports = function registerAdminRbacRoutes(app, deps) { + const pool = deps.pool; + const pages = deps.pages; + const getAuditUserId = deps.getAuditUserId; + const rbacData = deps.rbacData; + const permissions = Array.isArray(deps.permissions) ? deps.permissions : []; + const normalizePermissionKeys = deps.normalizePermissionKeys; + const requirePermission = deps.requirePermission; + + function slugifyRoleKey(name) { + const value = String(name || '').trim().toLowerCase(); + const slug = value.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); + return slug || 'role'; + } + + function normalizeSelectedIds(values) { + return Array.from(new Set((Array.isArray(values) ? values : []).map(function (value) { + return Number(value); + }).filter(function (value) { + return Number.isInteger(value) && value > 0; + }))); + } + + function getActionLabel(actionKey) { + const normalizedActionKey = String(actionKey || '').trim().toLowerCase(); + if (normalizedActionKey === 'edit') { + return 'Update'; + } + if (normalizedActionKey === 'allow') { + return 'Allow'; + } + if (!normalizedActionKey) { + return 'Read'; + } + return normalizedActionKey.charAt(0).toUpperCase() + normalizedActionKey.slice(1); + } + + async function createUniqueRoleKey(baseName) { + const baseKey = slugifyRoleKey(baseName); + let candidate = baseKey; + let suffix = 2; + + while (true) { + const [rows] = await pool.query('SELECT id FROM roles WHERE role_key = ? LIMIT 1', [candidate]); + if (!rows.length) { + return candidate; + } + candidate = `${baseKey}-${suffix}`; + suffix += 1; + } + } + + function mapPermissionsForView(permissionRows, selectedPermissionKeys) { + const selectedKeys = new Set(normalizePermissionKeys(selectedPermissionKeys)); + const permissionDefinitions = new Map(permissions.map(function (permission) { + return [String(permission.key || '').trim(), permission]; + })); + return (Array.isArray(permissionRows) ? permissionRows : []).map(function (permission) { + const definition = permissionDefinitions.get(String(permission.permission_key || '').trim()) || null; + return Object.assign({}, permission, { + resourceKey: definition ? definition.sectionKey : String(permission.section_name || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-'), + resourceName: definition ? definition.name : permission.section_name, + categoryName: definition ? definition.sectionName : permission.section_name, + sectionOrder: definition ? definition.sectionOrder : 999, + actionKey: definition ? definition.actionKey : 'read', + actionLabel: definition ? definition.actionName : getActionLabel(permission.actionKey), + isSelected: selectedKeys.has(String(permission.permission_key || '').trim()) + }); + }); + } + + function mapUsersForView(userRows, selectedUserIds) { + const selectedIds = new Set(normalizeSelectedIds(selectedUserIds)); + return (Array.isArray(userRows) ? userRows : []).map(function (user) { + return Object.assign({}, user, { + isSelected: selectedIds.has(Number(user.id)) + }); + }); + } + + function buildPermissionGroups(permissionRows) { + const groups = []; + const groupIndex = new Map(); + + (Array.isArray(permissionRows) ? permissionRows : []).forEach(function (permission) { + const sectionKey = String(permission.resourceKey || permission.sectionKey || permission.sectionName || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-'); + if (!groupIndex.has(sectionKey)) { + const group = { + id: sectionKey || 'permissions', + title: String(permission.resourceName || permission.categoryName || 'Permissions').trim(), + categoryName: String(permission.categoryName || '').trim(), + sectionOrder: Number(permission.sectionOrder) || 999, + permissions: [] + }; + groupIndex.set(sectionKey, group); + groups.push(group); + } + groupIndex.get(sectionKey).permissions.push(permission); + }); + + groups.forEach(function (group) { + group.permissions.sort(function (left, right) { + const actionOrder = { create: 1, read: 2, update: 3, edit: 3, delete: 4, allow: 5 }; + const leftOrder = actionOrder[String(left.actionKey || '').trim()] || 99; + const rightOrder = actionOrder[String(right.actionKey || '').trim()] || 99; + if (leftOrder !== rightOrder) { + return leftOrder - rightOrder; + } + return String(left.name || '').localeCompare(String(right.name || '')); + }); + }); + + groups.sort(function (left, right) { + const leftOrder = Number(left.sectionOrder) || 999; + const rightOrder = Number(right.sectionOrder) || 999; + if (leftOrder !== rightOrder) { + return leftOrder - rightOrder; + } + return String(left.title || '').localeCompare(String(right.title || '')); + }); + + return groups; + } + + async function buildRoleCreateViewModel(formValues, selectedPermissionKeys) { + const permissionRows = await rbacData.fetchPermissions(pool); + return { + formValues: { + name: String(formValues && formValues.name || '').trim(), + description: String(formValues && formValues.description || '').trim() + }, + permissionGroups: buildPermissionGroups(mapPermissionsForView(permissionRows, selectedPermissionKeys)) + }; + } + + async function loadRoleViewModel(roleId) { + const role = await rbacData.fetchRoleById(pool, roleId); + if (!role) { + return null; + } + + const permissionRows = await rbacData.fetchPermissions(pool); + const selectedPermissionKeys = await rbacData.fetchRolePermissionKeys(pool, role.id); + const selectedUserIds = await rbacData.fetchRoleUserIds(pool, role.id); + const users = await rbacData.fetchUsersWithRoles(pool); + return { + role: Object.assign({}, role, { + permissionKeys: selectedPermissionKeys, + permissionCount: Number(role.permission_count) || 0, + userCount: Number(role.user_count) || 0 + }), + permissionGroups: buildPermissionGroups(mapPermissionsForView(permissionRows, selectedPermissionKeys)), + users: mapUsersForView(users, selectedUserIds) + }; + } + + app.get('/admin/rbac', requirePermission('rbac.read'), async function (req, res, next) { + try { + const roles = await rbacData.fetchRoles(pool); + res.send(pages.renderRbacPage({ roles: roles }, req.query.message ? String(req.query.message) : '', req.currentUser)); + } catch (error) { + next(error); + } + }); + + app.get('/admin/rbac/new', requirePermission('rbac.create'), function (req, res, next) { + buildRoleCreateViewModel({ + name: String(req.query.name || '').trim(), + description: String(req.query.description || '').trim() + }, []).then(function (viewModel) { + res.send(pages.renderRbacAddPage(req.query.message ? String(req.query.message) : '', req.currentUser, viewModel.formValues, viewModel.permissionGroups, 'primary')); + }).catch(function (error) { + next(error); + }); + }); + + app.post('/admin/rbac', requirePermission('rbac.create'), async function (req, res, next) { + try { + const name = String(req.body.name || '').trim(); + const description = String(req.body.description || '').trim(); + const selectedPermissionKeys = normalizePermissionKeys(Array.isArray(req.body['permission_keys[]']) + ? req.body['permission_keys[]'] + : req.body.permission_keys + ? [].concat(req.body.permission_keys) + : []); + const validPermissionKeys = new Set(permissions.map(function (permission) { + return String(permission.key || '').trim(); + })); + const createViewModel = await buildRoleCreateViewModel({ name: name, description: description }, selectedPermissionKeys); + if (!name) { + return res.status(400).send(pages.renderRbacAddPage('Role name is required.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, 'warning')); + } + + if (selectedPermissionKeys.some(function (permissionKey) { + return !validPermissionKeys.has(permissionKey); + })) { + return res.status(400).send(pages.renderRbacAddPage('One or more selected permissions are invalid.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, 'warning')); + } + + const roleKey = await createUniqueRoleKey(name); + const actorId = getAuditUserId(req); + const connection = await pool.getConnection(); + try { + await connection.beginTransaction(); + const [result] = await connection.query( + 'INSERT INTO roles (role_key, name, description, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', + [roleKey, name, description || null, actorId, actorId] + ); + if (selectedPermissionKeys.length) { + await rbacData.syncRolePermissions(connection, Number(result.insertId), selectedPermissionKeys); + } + await connection.commit(); + } catch (error) { + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + res.redirect('/admin/rbac?message=' + encodeURIComponent('Role created.')); + } catch (error) { + next(error); + } + }); + + app.get('/admin/rbac/:id/edit', requirePermission('rbac.edit'), async function (req, res, next) { + try { + const roleId = Number(req.params.id); + if (!Number.isInteger(roleId) || roleId <= 0) { + return res.status(400).send('Invalid role.'); + } + + const viewModel = await loadRoleViewModel(roleId); + if (!viewModel) { + return res.status(404).send('Role not found.'); + } + + res.send(pages.renderRbacEditPage(viewModel.role, req.query.message ? String(req.query.message) : '', req.currentUser, viewModel.permissionGroups, viewModel.users)); + } catch (error) { + next(error); + } + }); + + app.post('/admin/rbac/:id', requirePermission('rbac.edit'), async function (req, res, next) { + try { + const roleId = Number(req.params.id); + if (!Number.isInteger(roleId) || roleId <= 0) { + return res.status(400).send('Invalid role.'); + } + + const role = await rbacData.fetchRoleById(pool, roleId); + if (!role) { + return res.status(404).send('Role not found.'); + } + + const name = String(req.body.name || '').trim(); + const description = String(req.body.description || '').trim(); + if (!name) { + return res.redirect('/admin/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Role name is required.')); + } + + await pool.query( + 'UPDATE roles SET name = ?, description = ?, modified_by = ? WHERE id = ?', + [name, description || null, getAuditUserId(req), roleId] + ); + res.redirect('/admin/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Role updated.')); + } catch (error) { + next(error); + } + }); + + app.post('/admin/rbac/:id/permissions', requirePermission('rbac.edit'), async function (req, res, next) { + try { + const roleId = Number(req.params.id); + if (!Number.isInteger(roleId) || roleId <= 0) { + return res.status(400).send('Invalid role.'); + } + + const role = await rbacData.fetchRoleById(pool, roleId); + if (!role) { + return res.status(404).send('Role not found.'); + } + + const selectedPermissionKeys = Array.isArray(req.body['permission_keys[]']) + ? req.body['permission_keys[]'] + : req.body.permission_keys + ? [].concat(req.body.permission_keys) + : []; + const validPermissionKeys = new Set(permissions.map(function (permission) { + return String(permission.key || '').trim(); + })); + const normalizedPermissionKeys = normalizePermissionKeys(selectedPermissionKeys); + if (normalizedPermissionKeys.some(function (permissionKey) { + return !validPermissionKeys.has(permissionKey); + })) { + return res.redirect('/admin/rbac/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected permissions are invalid.')); + } + + await rbacData.syncRolePermissions(pool, roleId, normalizedPermissionKeys); + res.redirect('/admin/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Permissions updated.')); + } catch (error) { + next(error); + } + }); + + app.post('/admin/rbac/:id/users', requirePermission('rbac.edit'), async function (req, res, next) { + try { + const roleId = Number(req.params.id); + if (!Number.isInteger(roleId) || roleId <= 0) { + return res.status(400).send('Invalid role.'); + } + + const role = await rbacData.fetchRoleById(pool, roleId); + if (!role) { + return res.status(404).send('Role not found.'); + } + + const selectedUserIds = Array.isArray(req.body['user_ids[]']) + ? req.body['user_ids[]'] + : req.body.user_ids + ? [].concat(req.body.user_ids) + : []; + const normalizedUserIds = normalizeSelectedIds(selectedUserIds); + const availableUsers = await rbacData.fetchUsersWithRoles(pool); + const validUserIds = new Set(availableUsers.map(function (user) { + return Number(user.id); + })); + + if (normalizedUserIds.some(function (userId) { + return !validUserIds.has(userId); + })) { + return res.redirect('/admin/rbac/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected users are invalid.')); + } + + await rbacData.syncRoleUsers(pool, roleId, normalizedUserIds); + res.redirect('/admin/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Users updated.')); + } catch (error) { + next(error); + } + }); + + app.post('/admin/rbac/:id/delete', requirePermission('rbac.delete'), async function (req, res, next) { + try { + const roleId = Number(req.params.id); + if (!Number.isInteger(roleId) || roleId <= 0) { + return res.status(400).send('Invalid role.'); + } + + const role = await rbacData.fetchRoleById(pool, roleId); + if (!role) { + return res.status(404).send('Role not found.'); + } + if (String(role.role_key || '') === 'administrators') { + return res.redirect('/admin/rbac?message=' + encodeURIComponent('The built-in Administrators role cannot be deleted.')); + } + if (Number(role.user_count) > 0) { + return res.redirect('/admin/rbac?message=' + encodeURIComponent('Remove all users from this role before deleting it.')); + } + + await pool.query('DELETE FROM roles WHERE id = ?', [roleId]); + res.redirect('/admin/rbac?message=' + encodeURIComponent('Role deleted.')); + } catch (error) { + next(error); + } + }); +}; \ No newline at end of file diff --git a/src/web/routes/admin-screen-commands.js b/src/web/routes/admin-screen-commands.js index cac5a74..92fd214 100644 --- a/src/web/routes/admin-screen-commands.js +++ b/src/web/routes/admin-screen-commands.js @@ -4,8 +4,9 @@ module.exports = function registerAdminScreenCommandRoutes(app, deps) { const getScreenConnections = deps.getScreenConnections; const isClientNameAvailable = deps.isClientNameAvailable; const withClientNameReservation = deps.withClientNameReservation; + const requirePermission = deps.requirePermission; - app.post('/admin/screens/:slug/commands', async function (req, res, next) { + app.post('/admin/screens/:slug/commands', requirePermission('clients.allow'), async function (req, res, next) { try { const slug = String(req.params.slug || '').trim(); const command = String((req.body && req.body.command) || req.query.command || '').trim().toLowerCase(); diff --git a/src/web/routes/admin-users.js b/src/web/routes/admin-users.js index 5b74066..1bfc100 100644 --- a/src/web/routes/admin-users.js +++ b/src/web/routes/admin-users.js @@ -4,36 +4,85 @@ module.exports = function registerAdminUsersRoutes(app, deps) { const formatDashboardDate = deps.formatDashboardDate; const getAuditUserId = deps.getAuditUserId; const hashPassword = deps.hashPassword; + const readArrayField = deps.readArrayField; + const rbacData = deps.rbacData; + const requirePermission = deps.requirePermission; - app.get('/admin/users', async function (req, res, next) { + async function fetchRoleOptions() { + return rbacData.fetchRoles(pool); + } + + function mapRolesForForm(roles, selectedRoleIds) { + const selectedIds = new Set((Array.isArray(selectedRoleIds) ? selectedRoleIds : []).map(function (roleId) { + return Number(roleId); + }).filter(function (roleId) { + return Number.isInteger(roleId) && roleId > 0; + })); + + return (Array.isArray(roles) ? roles : []).map(function (role) { + return Object.assign({}, role, { + isSelected: selectedIds.has(Number(role.id)) + }); + }); + } + + async function validateRoleIds(roleIds) { + const availableRoles = await fetchRoleOptions(); + const validRoleIds = new Set(availableRoles.map(function (role) { + return Number(role.id); + })); + const normalizedRoleIds = Array.from(new Set((Array.isArray(roleIds) ? roleIds : []).map(function (roleId) { + return Number(roleId); + }).filter(function (roleId) { + return Number.isInteger(roleId) && roleId > 0; + }))); + + if (!normalizedRoleIds.length) { + return { ok: false, message: 'Select at least one role.' }; + } + + if (normalizedRoleIds.some(function (roleId) { + return !validRoleIds.has(roleId); + })) { + return { ok: false, message: 'One or more selected roles are invalid.' }; + } + + return { ok: true, roleIds: normalizedRoleIds }; + } + + app.get('/admin/users', requirePermission('users.read'), async function (req, res, next) { try { - const [rows] = await pool.query('SELECT id, name, username, created_at, modified_at FROM users ORDER BY id ASC'); - const users = rows.map(function (user) { + const users = await rbacData.fetchUsersWithRoles(pool); + const mappedUsers = users.map(function (user) { return Object.assign({}, user, { isCurrentUser: Number(user.id) === Number(req.currentUser.id), createdAtLabel: formatDashboardDate(user.created_at), - modifiedAtLabel: formatDashboardDate(user.modified_at) + modifiedAtLabel: formatDashboardDate(user.modified_at), + roleNames: String(user.roleNames || '').trim() || 'No roles assigned' }); }); - res.send(pages.renderUsersPage({ users: users }, req.query.message ? String(req.query.message) : '', req.currentUser)); + res.send(pages.renderUsersPage({ users: mappedUsers }, req.query.message ? String(req.query.message) : '', req.currentUser)); } catch (error) { next(error); } }); - app.get('/admin/users/new', function (req, res) { - res.send(pages.renderUsersAddPage(req.query.message ? String(req.query.message) : '', req.currentUser)); + app.get('/admin/users/new', requirePermission('users.create'), function (req, res) { + fetchRoleOptions().then(function (roles) { + res.send(pages.renderUsersAddPage(req.query.message ? String(req.query.message) : '', req.currentUser, mapRolesForForm(roles, []), {}, 'primary')); + }).catch(function (error) { + res.status(500).send(error.message || 'Unable to load roles.'); + }); }); - app.get('/admin/users/:id/edit', async function (req, res, next) { + app.get('/admin/users/:id/edit', requirePermission('users.edit'), async function (req, res, next) { try { const userId = Number(req.params.id); if (!Number.isInteger(userId) || userId <= 0) { return res.status(400).send('Invalid user.'); } - const [rows] = await pool.query('SELECT id, name, username, created_at, modified_at FROM users WHERE id = ? LIMIT 1', [userId]); - const user = rows[0] || null; + const user = await rbacData.fetchUserWithRoles(pool, userId); if (!user) { return res.status(404).send('User not found.'); } @@ -41,54 +90,109 @@ module.exports = function registerAdminUsersRoutes(app, deps) { return res.redirect('/admin/account?message=' + encodeURIComponent('Use My Account to update your own login details.')); } + const roles = await fetchRoleOptions(); res.send(pages.renderUsersEditPage(Object.assign({}, user, { isCurrentUser: false, createdAtLabel: formatDashboardDate(user.created_at), - modifiedAtLabel: formatDashboardDate(user.modified_at) - }), req.query.message ? String(req.query.message) : '', req.currentUser)); + modifiedAtLabel: formatDashboardDate(user.modified_at), + roleNames: String(user.roleNames || '').trim() || 'No roles assigned' + }), req.query.message ? String(req.query.message) : '', req.currentUser, mapRolesForForm(roles, user.roleIds))); } catch (error) { next(error); } }); - app.post('/admin/users', async function (req, res, next) { + app.post('/admin/users', requirePermission('users.create'), async function (req, res, next) { + const connection = await pool.getConnection(); try { const name = String(req.body.name || '').trim(); const username = String(req.body.username || '').trim(); const password = String(req.body.password || ''); const confirmPassword = String(req.body.confirm_password || ''); + const selectedRoleIds = readArrayField(req.body, ['role_ids[]', 'role_ids']); + const roleCheck = await validateRoleIds(selectedRoleIds); + const formValues = { + username: username, + name: name + }; + + async function renderValidationError(message) { + const roles = await fetchRoleOptions(); + return res.status(400).send(pages.renderUsersAddPage(message, req.currentUser, mapRolesForForm(roles, selectedRoleIds), formValues, 'warning')); + } if (!name) { - return res.redirect('/admin/users/new?message=' + encodeURIComponent('Name is required.')); + return renderValidationError('Name is required.'); } if (!username) { - return res.redirect('/admin/users/new?message=' + encodeURIComponent('Username is required.')); + return renderValidationError('Username is required.'); } if (!password || password.length < 8) { - return res.redirect('/admin/users/new?message=' + encodeURIComponent('Password must be at least 8 characters.')); + return renderValidationError('Password must be at least 8 characters.'); } if (password !== confirmPassword) { - return res.redirect('/admin/users/new?message=' + encodeURIComponent('Passwords do not match.')); + return renderValidationError('Passwords do not match.'); + } + if (!roleCheck.ok) { + return renderValidationError(roleCheck.message); } - const [existingRows] = await pool.query('SELECT id FROM users WHERE username = ? LIMIT 1', [username]); + const [existingRows] = await connection.query('SELECT id FROM users WHERE username = ? LIMIT 1', [username]); if (existingRows.length) { - return res.redirect('/admin/users/new?message=' + encodeURIComponent('That username already exists.')); + return renderValidationError('That username already exists.'); } const passwordRecord = hashPassword(password); const actorId = getAuditUserId(req); - await pool.query( + await connection.beginTransaction(); + const [result] = await connection.query( 'INSERT INTO users (name, username, password_hash, password_salt, password_iterations, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)', [name, username, passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, actorId, actorId] ); + await rbacData.syncUserRoles(connection, result.insertId, roleCheck.roleIds); + await connection.commit(); res.redirect('/admin/users?message=' + encodeURIComponent('User created.')); + } catch (error) { + try { + await connection.rollback(); + } catch (_rollbackError) { + // Ignore rollback failures and surface the original error. + } + next(error); + } finally { + connection.release(); + } + }); + + app.post('/admin/users/:id/roles', requirePermission('users.edit'), async function (req, res, next) { + try { + const userId = Number(req.params.id); + if (!Number.isInteger(userId) || userId <= 0) { + return res.status(400).send('Invalid user.'); + } + if (Number(req.currentUser.id) === userId) { + return res.redirect('/admin/account?message=' + encodeURIComponent('Use My Account to update your own login details.')); + } + + const [rows] = await pool.query('SELECT id FROM users WHERE id = ? LIMIT 1', [userId]); + if (!rows.length) { + return res.status(404).send('User not found.'); + } + + const selectedRoleIds = readArrayField(req.body, ['role_ids[]', 'role_ids']); + const roleCheck = await validateRoleIds(selectedRoleIds); + if (!roleCheck.ok) { + return res.redirect('/admin/users/' + userId + '/edit?message=' + encodeURIComponent(roleCheck.message)); + } + + await rbacData.syncUserRoles(pool, userId, roleCheck.roleIds); + res.redirect('/admin/users/' + userId + '/edit?message=' + encodeURIComponent('Roles updated.')); } catch (error) { next(error); } }); - app.post('/admin/users/:id/username', async function (req, res, next) { + app.post('/admin/users/:id/username', requirePermission('users.edit'), async function (req, res, next) { try { const userId = Number(req.params.id); const name = String(req.body.name || '').trim(); @@ -122,7 +226,7 @@ module.exports = function registerAdminUsersRoutes(app, deps) { } }); - app.post('/admin/users/:id/password', async function (req, res, next) { + app.post('/admin/users/:id/password', requirePermission('users.edit'), async function (req, res, next) { try { const userId = Number(req.params.id); const password = String(req.body.password || ''); @@ -158,7 +262,7 @@ module.exports = function registerAdminUsersRoutes(app, deps) { } }); - app.post('/admin/users/:id/delete', async function (req, res, next) { + app.post('/admin/users/:id/delete', requirePermission('users.delete'), async function (req, res, next) { try { const userId = Number(req.params.id); if (!Number.isInteger(userId) || userId <= 0) { diff --git a/src/web/routes/error.js b/src/web/routes/error.js new file mode 100644 index 0000000..a355391 --- /dev/null +++ b/src/web/routes/error.js @@ -0,0 +1,62 @@ +const { renderView } = require('../view'); + +function getErrorCopy(statusCode, message) { + const normalizedStatusCode = Number(statusCode) || 500; + + if (normalizedStatusCode === 403) { + return { + title: 'Access denied', + errorTitle: 'Access denied', + errorMessage: message || 'You do not have permission to access this area.', + backLabel: 'Back to dashboard' + }; + } + + if (normalizedStatusCode === 404) { + return { + title: 'Not found', + errorTitle: 'Oops! Page not found.', + errorMessage: message || 'We could not find the page you were looking for. Meanwhile, you may return to the dashboard or try searching for what you need.', + backLabel: 'Back to dashboard' + }; + } + + if (normalizedStatusCode >= 500) { + return { + title: 'Something went wrong', + errorTitle: 'Something went wrong.', + errorMessage: message || 'An unexpected error occurred. Please try again in a moment.', + backLabel: 'Back to dashboard' + }; + } + + return { + title: 'Error', + errorTitle: 'Error', + errorMessage: message || 'An unexpected error occurred.', + backLabel: 'Back to dashboard' + }; +} + +module.exports = function renderErrorPage(options, currentUser) { + const errorOptions = options || {}; + const statusCode = Number(errorOptions.statusCode || 500); + const copy = getErrorCopy(statusCode, String(errorOptions.message || '').trim()); + return renderView('error', { + title: String(errorOptions.title || copy.title || 'Error').trim(), + active: '', + messageVariant: 'primary', + currentUser: currentUser || null, + statusCode: statusCode, + errorTitle: String(errorOptions.errorTitle || copy.errorTitle || errorOptions.title || 'Error').trim(), + errorMessage: String(errorOptions.message || copy.errorMessage || 'An unexpected error occurred.').trim(), + detail: String(errorOptions.detail || '').trim(), + backUrl: String(errorOptions.backUrl || '/admin').trim() || '/admin', + backLabel: String(errorOptions.backLabel || copy.backLabel || 'Back to dashboard').trim() || 'Back to dashboard', + searchUrl: String(errorOptions.searchUrl || '').trim(), + bodyClass: 'error-page bg-dark text-white', + errorShell: true, + stylesheets: [], + scripts: [] + }); +}; \ No newline at end of file diff --git a/src/web/routes/index.js b/src/web/routes/index.js index 142dbbd..a335bea 100644 --- a/src/web/routes/index.js +++ b/src/web/routes/index.js @@ -20,5 +20,9 @@ module.exports = { renderTemplateEditPage: require('./templates/edit'), renderCanvasSizesPage: require('./canvas-sizes/list'), renderCanvasSizeFormPage: require('./canvas-sizes/add'), - renderCanvasSizeEditPage: require('./canvas-sizes/edit') + renderCanvasSizeEditPage: require('./canvas-sizes/edit'), + renderErrorPage: require('./error'), + renderRbacPage: require('./rbac/list'), + renderRbacAddPage: require('./rbac/add'), + renderRbacEditPage: require('./rbac/edit') }; diff --git a/src/web/routes/rbac/add.js b/src/web/routes/rbac/add.js new file mode 100644 index 0000000..a09348b --- /dev/null +++ b/src/web/routes/rbac/add.js @@ -0,0 +1,14 @@ +const { renderView } = require('../../view'); + +module.exports = function renderRbacAddPage(message, currentUser, formValues, permissionGroups, messageVariant) { + return renderView('rbac/add', { + title: 'Create role', + active: 'rbac', + message: message, + messageVariant: messageVariant || 'primary', + currentUser: currentUser || null, + formValues: formValues || {}, + permissionGroups: permissionGroups || [], + scripts: ['js/rbac-permissions.js'] + }); +}; \ No newline at end of file diff --git a/src/web/routes/rbac/edit.js b/src/web/routes/rbac/edit.js new file mode 100644 index 0000000..c4af71b --- /dev/null +++ b/src/web/routes/rbac/edit.js @@ -0,0 +1,14 @@ +const { renderView } = require('../../view'); + +module.exports = function renderRbacEditPage(role, message, currentUser, permissionGroups, users) { + return renderView('rbac/edit', { + title: 'Edit role', + active: 'rbac', + message: message, + currentUser: currentUser || null, + role: role, + permissionGroups: permissionGroups || [], + users: users || [], + scripts: ['js/rbac-permissions.js'] + }); +}; \ No newline at end of file diff --git a/src/web/routes/rbac/list.js b/src/web/routes/rbac/list.js new file mode 100644 index 0000000..f342090 --- /dev/null +++ b/src/web/routes/rbac/list.js @@ -0,0 +1,11 @@ +const { renderView } = require('../../view'); + +module.exports = function renderRbacPage(data, message, currentUser) { + return renderView('rbac/list', { + title: 'Roles and permissions', + active: 'rbac', + message: message, + currentUser: currentUser || null, + roles: data.roles || [] + }); +}; \ No newline at end of file diff --git a/src/web/routes/users/add.js b/src/web/routes/users/add.js index 5a9290b..001c9ad 100644 --- a/src/web/routes/users/add.js +++ b/src/web/routes/users/add.js @@ -1,10 +1,13 @@ const { renderView } = require('../../view'); -module.exports = function renderUsersAddPage(message, currentUser) { +module.exports = function renderUsersAddPage(message, currentUser, roles, formValues, messageVariant) { return renderView('users/add', { title: 'Add user', active: 'users', message: message, - currentUser: currentUser || null + messageVariant: messageVariant || 'primary', + currentUser: currentUser || null, + roles: roles || [], + formValues: formValues || {} }); }; \ No newline at end of file diff --git a/src/web/routes/users/edit.js b/src/web/routes/users/edit.js index df95a8c..346cdca 100644 --- a/src/web/routes/users/edit.js +++ b/src/web/routes/users/edit.js @@ -1,11 +1,12 @@ const { renderView } = require('../../view'); -module.exports = function renderUsersEditPage(user, message, currentUser) { +module.exports = function renderUsersEditPage(user, message, currentUser, roles) { return renderView('users/edit', { title: 'Edit user', active: 'users', message: message, currentUser: currentUser || null, - user: user + user: user, + roles: roles || [] }); }; \ No newline at end of file diff --git a/src/web/session.js b/src/web/session.js index 631b945..5200f65 100644 --- a/src/web/session.js +++ b/src/web/session.js @@ -1,3 +1,5 @@ +const { normalizePermissionKeys } = require('../rbac'); + function createSessionService(options) { const sessionCookieName = String(options && options.sessionCookieName || '').trim(); const sessionMaxAgeMs = Number(options && options.sessionMaxAgeMs); @@ -66,8 +68,34 @@ function createSessionService(options) { return null; } + const userId = Number(rows[0].id); + const [roleRows] = await pool.query( + `SELECT r.role_key + FROM user_roles ur + JOIN roles r ON r.id = ur.role_id + WHERE ur.user_id = ? + ORDER BY r.name ASC`, + [userId] + ); + const [permissionRows] = await pool.query( + `SELECT p.permission_key + FROM user_roles ur + JOIN role_permissions rp ON rp.role_id = ur.role_id + JOIN permissions p ON p.id = rp.permission_id + WHERE ur.user_id = ? + ORDER BY p.section_name ASC, p.name ASC`, + [userId] + ); + await pool.query('UPDATE auth_sessions SET last_used_at = CURRENT_TIMESTAMP, modified_by = ? WHERE session_hash = ?', [rows[0].user_id, tokenHash]); - return rows[0]; + return Object.assign({}, rows[0], { + roleKeys: roleRows.map(function (row) { + return String(row.role_key || '').trim(); + }).filter(Boolean), + permissionKeys: normalizePermissionKeys(permissionRows.map(function (row) { + return String(row.permission_key || '').trim(); + })) + }); } async function createUserSession(pool, userId) { diff --git a/src/web/view.js b/src/web/view.js index 41f83b4..8b22b16 100644 --- a/src/web/view.js +++ b/src/web/view.js @@ -2,10 +2,20 @@ const fs = require('fs'); const path = require('path'); const Handlebars = require('handlebars'); const { version: appVersion } = require('../../package.json'); +const { hasPermission, hasAnyPermission } = require('../rbac'); const VIEWS_ROOT = path.join(__dirname, 'views'); const cache = new Map(); +function inferMessageVariant(message, fallbackVariant) { + const text = String(message || '').trim(); + if (/\b(?:unable to|cannot|can't|could not|failed to)\s+delete\b/i.test(text) || /\bdelete\b.*\b(?:before|first)\b/i.test(text) || /\bstill (?:in use|linked|assigned|used)\b/i.test(text)) { + return 'danger'; + } + + return String(fallbackVariant || '').trim().toLowerCase() || 'primary'; +} + Handlebars.registerHelper('eq', function (left, right) { return left === right; }); @@ -23,6 +33,16 @@ Handlebars.registerHelper('json', function (value) { return new Handlebars.SafeString(JSON.stringify(value).replace(/', ``, - `', - '', + hasSecondaryActions ? `` : '', + hasSecondaryActions ? '' : '', '', ].join('')); }); @@ -89,6 +118,9 @@ function loadTemplate(relativePath) { function renderView(viewName, context) { const viewContext = Object.assign({ stylesheets: [], scripts: [], appVersion: appVersion }, context || {}); + if (!viewContext.messageVariant) { + viewContext.messageVariant = inferMessageVariant(viewContext.message, 'primary'); + } const page = loadTemplate(`${viewName}.hbs`); const layout = loadTemplate(path.join('layout.hbs')); const body = page(viewContext); @@ -97,6 +129,9 @@ function renderView(viewName, context) { function renderFragment(viewName, context) { const viewContext = Object.assign({ stylesheets: [], scripts: [], appVersion: appVersion }, context || {}); + if (!viewContext.messageVariant) { + viewContext.messageVariant = inferMessageVariant(viewContext.message, 'primary'); + } const page = loadTemplate(`${viewName}.hbs`); const layout = loadTemplate(path.join('frame-layout.hbs')); const body = page(viewContext); diff --git a/src/web/views/clients/list.hbs b/src/web/views/clients/list.hbs index 4663ff1..e9dceae 100644 --- a/src/web/views/clients/list.hbs +++ b/src/web/views/clients/list.hbs @@ -10,82 +10,94 @@

Active connections

- - - - {{#if clients.length}} - {{#each clients}} - - - - - - - + {{/each}} + {{else}} + + {{/if}} + +
ClientCurrent ScreenCurrent SlideIPViewportConnected/UpdatedActions
-
{{#if client_name}}{{client_name}}{{else}}Unknown{{/if}}
-
-
{{#if screen_name}}{{screen_name}}{{else}}Unknown{{/if}}
-
- {{#if currentSlideTitle}} -
{{currentSlideTitle}}
- {{else}} - No slide currently showing - {{/if}} -
{{#if clientIp}}{{clientIp}}{{else}}Unknown{{/if}} - {{#if viewport}} - {{viewport.width}}x{{viewport.height}} - {{else}} - Unknown - {{/if}} - - {{#if connectedAt}} -
{{connectedAtLabel}}
- {{#if lastSeenAt}} -
{{lastSeenAtLabel}}
+ + + + + + + + + + {{#if (hasPermission currentUser 'clients.allow')}}{{/if}} + + + + {{#if clients.length}} + {{#each clients}} + + + + + + + + {{#if (hasPermission currentUser 'clients.allow')}} + {{/if}} - - - - {{/each}} - {{else}} - - {{/if}} - -
ClientCurrent ScreenCurrent SlideIPViewportConnected/UpdatedActions
+
{{#if client_name}}{{client_name}}{{else}}Unknown{{/if}}
+
+
{{#if screen_name}}{{screen_name}}{{else}}Unknown{{/if}}
+
+ {{#if currentSlideTitle}} +
{{currentSlideTitle}}
+ {{else}} + No slide currently showing {{/if}} - {{else}} - Unknown +
{{#if clientIp}}{{clientIp}}{{else}}Unknown{{/if}} + {{#if viewport}} + {{viewport.width}}x{{viewport.height}} + {{else}} + Unknown + {{/if}} + + {{#if connectedAt}} +
{{connectedAtLabel}}
+ {{#if lastSeenAt}} +
{{lastSeenAtLabel}}
+ {{/if}} + {{else}} + Unknown + {{/if}} +
+
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+
-
-
- - - -
-
- - - -
-
- - - -
-
- - - -
-
- - - -
-
-
No connected clients yet.
+
No connected clients yet.
\ No newline at end of file diff --git a/src/web/views/dashboard/index.hbs b/src/web/views/dashboard/index.hbs index 16dfc84..b281c80 100644 --- a/src/web/views/dashboard/index.hbs +++ b/src/web/views/dashboard/index.hbs @@ -13,104 +13,126 @@

Use the cards below for the current totals, then jump to the screens table when you need a deeper look at playback and connection state.

+ {{#if (hasPermission currentUser "playlists.read")}}
{{playlists.length}} playlists
-
- {{screens.length}} - screens -
+ {{/if}} + {{#if (hasPermission currentUser "screens.read")}} +
+ {{screens.length}} + screens +
+ {{/if}} + {{#if (hasPermission currentUser "clients.read")}}
{{connectedClientsCount}} clients
+ {{/if}}
-
-
-
-
-
-

Quick actions

-

Send global commands without leaving the overview.

+{{#if (hasPermission currentUser "dashboard.allow")}} +
+
+
+
+
+

Quick actions

+

Send global commands without + leaving the overview.

+
-
-
-
-
- - - Restart every connected player page. -
-
- - - - Blank active players immediately. -
+
+
+
+ + + Restart every connected player page. +
+
+ + + + Blank active players + immediately. +
+
-
+{{/if}} -
-
-
-
-
-

Current screens

-

Player URL, playlist assignment, and live connection count.

+{{#if (hasPermission currentUser "screens.read")}} +
+
+
+
+
+

Current screens

+

Player URL, playlist assignment, + and live connection count.

+
+
+
+ + + + {{#if screens.length}} + {{#each screens}} + + + + + + + {{/each}} + {{else}} + + {{/if}} + +
NamePlayer URLPlaylistConnected clients
{{name}}{{playerUrl slug}}{{playlist_name}} + {{#if player_connection_count}} +
{{player_connection_count}} + connected
+ {{else}} + No clients connected. + {{/if}} +
No screens yet.
-
-
- - - - {{#if screens.length}} - {{#each screens}} - - - - - - - {{/each}} - {{else}} - - {{/if}} - -
NamePlayer URLPlaylistConnected clients
{{name}}{{playerUrl slug}}{{playlist_name}} - {{#if player_connection_count}} -
{{player_connection_count}} connected
- {{else}} - No clients connected. - {{/if}} -
No screens yet.
-
+{{/if}} \ No newline at end of file diff --git a/src/web/views/error.hbs b/src/web/views/error.hbs new file mode 100644 index 0000000..c4fcb5f --- /dev/null +++ b/src/web/views/error.hbs @@ -0,0 +1,15 @@ +
+
+
+
{{statusCode}}
+

{{errorTitle}}

+

+ {{errorMessage}} +

+ + + {{backLabel}} + +
+
+
diff --git a/src/web/views/layout.hbs b/src/web/views/layout.hbs index 1553190..ce58306 100644 --- a/src/web/views/layout.hbs +++ b/src/web/views/layout.hbs @@ -18,7 +18,13 @@ {{/if}} - {{#if authShell}} + {{#if errorShell}} +
+ {{{body}}} +
+ + + {{else if authShell}}