Compare commits

...

3 Commits

Author SHA1 Message Date
Michael Mayer
dbf1650c1c CLI: Add cluster operations and management commands #98
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-16 18:09:09 +02:00
Michael Mayer
5db044284b Frontend: Update Makefile, package.json and package-lock.json
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-16 18:06:11 +02:00
Michael Mayer
3c821a3ea7 Frontend: Add "@ctrl/tinycolor" version override to package.json
Popular Tinycolor npm Package Compromised in Supply Chain Attack:
https://socket.dev/blog/tinycolor-supply-chain-attack-affects-40-packages

Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-16 10:34:51 +02:00
27 changed files with 1995 additions and 176 deletions

View File

@@ -35,11 +35,13 @@ notice:
@echo "Creating license report for frontend dependencies..." @echo "Creating license report for frontend dependencies..."
license-report --only=prod --config=.report.json > NOTICE license-report --only=prod --config=.report.json > NOTICE
install-npm: install-npm:
sudo npm install --unsafe-perm=true --allow-root -g npm@latest npm-check-updates@latest license-report@latest # Keep scripts enabled for npm itself; split other globals and disable scripts for safety
sudo npm install --unsafe-perm=true --allow-root -g npm@latest
sudo npm install --unsafe-perm=true --allow-root -g --ignore-scripts npm-check-updates@latest license-report@latest
install-testcafe: install-testcafe:
npm install -g testcafe@latest npm install -g --ignore-scripts testcafe@latest
install-eslint: install-eslint:
npm install -g eslint globals @eslint/eslintrc @eslint/js eslint-config-prettier eslint-formatter-pretty eslint-plugin-html eslint-plugin-import eslint-plugin-node eslint-plugin-prettier eslint-plugin-promise eslint-plugin-vue eslint-webpack-plugin vue-eslint-parser prettier npm install -g --ignore-scripts eslint globals @eslint/eslintrc @eslint/js eslint-config-prettier eslint-formatter-pretty eslint-plugin-html eslint-plugin-import eslint-plugin-node eslint-plugin-prettier eslint-plugin-promise eslint-plugin-vue eslint-webpack-plugin vue-eslint-parser prettier
upgrade: upgrade:
$(info Securely upgrading NPM dependencies...) $(info Securely upgrading NPM dependencies...)
$(DOCKER_NPM) 'npx -y npm@latest update --save --ignore-scripts --no-update-notifier && npx -y npm@latest install --ignore-scripts --no-audit --no-fund --no-update-notifier' $(DOCKER_NPM) 'npx -y npm@latest update --save --ignore-scripts --no-update-notifier && npx -y npm@latest install --ignore-scripts --no-audit --no-fund --no-update-notifier'
@@ -51,6 +53,8 @@ npm-update:
$(info Updating NPM dependencies in package.lock and package-lock.json...) $(info Updating NPM dependencies in package.lock and package-lock.json...)
npm update --save --package-lock --ignore-scripts --no-update-notifier --no-audit --no-fund npm update --save --package-lock --ignore-scripts --no-update-notifier --no-audit --no-fund
update: npm-update npm-install update: npm-update npm-install
security-check: # Scan for missing --ignore-scripts and unsafe v-html
npm run -s security:scan
watch: watch:
npm run watch npm run watch
build: build:

View File

@@ -35,7 +35,7 @@
"babel-loader": "^10.0.0", "babel-loader": "^10.0.0",
"babel-plugin-istanbul": "^7.0.1", "babel-plugin-istanbul": "^7.0.1",
"babel-plugin-polyfill-corejs3": "^0.13.0", "babel-plugin-polyfill-corejs3": "^0.13.0",
"browserslist": "^4.26.0", "browserslist": "^4.26.2",
"cheerio": "1.0.0-rc.12", "cheerio": "1.0.0-rc.12",
"core-js": "^3.45.1", "core-js": "^3.45.1",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
@@ -100,7 +100,7 @@
"vue-sanitize-directive": "^0.2.1", "vue-sanitize-directive": "^0.2.1",
"vue-style-loader": "^4.1.3", "vue-style-loader": "^4.1.3",
"vue3-gettext": "^2.4.0", "vue3-gettext": "^2.4.0",
"vuetify": "^3.10.0", "vuetify": "^3.10.1",
"webpack": "^5.101.3", "webpack": "^5.101.3",
"webpack-bundle-analyzer": "^4.10.2", "webpack-bundle-analyzer": "^4.10.2",
"webpack-cli": "^6.0.1", "webpack-cli": "^6.0.1",
@@ -3578,9 +3578,9 @@
} }
}, },
"node_modules/@ioredis/commands": { "node_modules/@ioredis/commands": {
"version": "1.3.1", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.3.1.tgz", "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.4.0.tgz",
"integrity": "sha512-bYtU8avhGIcje3IhvF9aSjsa5URMZBHnwKtOvXsT4sfYy9gppW11gLPT/9oNqlJZD47yPKveQFTAFWpHjKvUoQ==", "integrity": "sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@isaacs/cliui": { "node_modules/@isaacs/cliui": {
@@ -4404,9 +4404,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.50.1", "version": "4.50.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.50.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.50.2.tgz",
"integrity": "sha512-HJXwzoZN4eYTdD8bVV22DN8gsPCAj3V20NHKOs8ezfXanGpmVPR7kalUHd+Y31IJp9stdB87VKPFbsGY3H/2ag==", "integrity": "sha512-uLN8NAiFVIRKX9ZQha8wy6UUs06UNSZ32xj6giK/rmMXAgKahwExvK6SsmgU5/brh4w/nSgj8e0k3c1HBQpa0A==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -4417,9 +4417,9 @@
] ]
}, },
"node_modules/@rollup/rollup-android-arm64": { "node_modules/@rollup/rollup-android-arm64": {
"version": "4.50.1", "version": "4.50.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.50.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.50.2.tgz",
"integrity": "sha512-PZlsJVcjHfcH53mOImyt3bc97Ep3FJDXRpk9sMdGX0qgLmY0EIWxCag6EigerGhLVuL8lDVYNnSo8qnTElO4xw==", "integrity": "sha512-oEouqQk2/zxxj22PNcGSskya+3kV0ZKH+nQxuCCOGJ4oTXBdNTbv+f/E3c74cNLeMO1S5wVWacSws10TTSB77g==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -4430,9 +4430,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-arm64": { "node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.50.1", "version": "4.50.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.50.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.50.2.tgz",
"integrity": "sha512-xc6i2AuWh++oGi4ylOFPmzJOEeAa2lJeGUGb4MudOtgfyyjr4UPNK+eEWTPLvmPJIY/pgw6ssFIox23SyrkkJw==", "integrity": "sha512-OZuTVTpj3CDSIxmPgGH8en/XtirV5nfljHZ3wrNwvgkT5DQLhIKAeuFSiwtbMto6oVexV0k1F1zqURPKf5rI1Q==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -4443,9 +4443,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-x64": { "node_modules/@rollup/rollup-darwin-x64": {
"version": "4.50.1", "version": "4.50.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.50.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.50.2.tgz",
"integrity": "sha512-2ofU89lEpDYhdLAbRdeyz/kX3Y2lpYc6ShRnDjY35bZhd2ipuDMDi6ZTQ9NIag94K28nFMofdnKeHR7BT0CATw==", "integrity": "sha512-Wa/Wn8RFkIkr1vy1k1PB//VYhLnlnn5eaJkfTQKivirOvzu5uVd2It01ukeQstMursuz7S1bU+8WW+1UPXpa8A==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -4456,9 +4456,9 @@
] ]
}, },
"node_modules/@rollup/rollup-freebsd-arm64": { "node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.50.1", "version": "4.50.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.50.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.50.2.tgz",
"integrity": "sha512-wOsE6H2u6PxsHY/BeFHA4VGQN3KUJFZp7QJBmDYI983fgxq5Th8FDkVuERb2l9vDMs1D5XhOrhBrnqcEY6l8ZA==", "integrity": "sha512-QkzxvH3kYN9J1w7D1A+yIMdI1pPekD+pWx7G5rXgnIlQ1TVYVC6hLl7SOV9pi5q9uIDF9AuIGkuzcbF7+fAhow==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -4469,9 +4469,9 @@
] ]
}, },
"node_modules/@rollup/rollup-freebsd-x64": { "node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.50.1", "version": "4.50.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.50.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.50.2.tgz",
"integrity": "sha512-A/xeqaHTlKbQggxCqispFAcNjycpUEHP52mwMQZUNqDUJFFYtPHCXS1VAG29uMlDzIVr+i00tSFWFLivMcoIBQ==", "integrity": "sha512-dkYXB0c2XAS3a3jmyDkX4Jk0m7gWLFzq1C3qUnJJ38AyxIF5G/dyS4N9B30nvFseCfgtCEdbYFhk0ChoCGxPog==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -4482,9 +4482,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-gnueabihf": { "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.50.1", "version": "4.50.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.50.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.50.2.tgz",
"integrity": "sha512-54v4okehwl5TaSIkpp97rAHGp7t3ghinRd/vyC1iXqXMfjYUTm7TfYmCzXDoHUPTTf36L8pr0E7YsD3CfB3ZDg==", "integrity": "sha512-9VlPY/BN3AgbukfVHAB8zNFWB/lKEuvzRo1NKev0Po8sYFKx0i+AQlCYftgEjcL43F2h9Ui1ZSdVBc4En/sP2w==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -4495,9 +4495,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-musleabihf": { "node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.50.1", "version": "4.50.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.50.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.50.2.tgz",
"integrity": "sha512-p/LaFyajPN/0PUHjv8TNyxLiA7RwmDoVY3flXHPSzqrGcIp/c2FjwPPP5++u87DGHtw+5kSH5bCJz0mvXngYxw==", "integrity": "sha512-+GdKWOvsifaYNlIVf07QYan1J5F141+vGm5/Y8b9uCZnG/nxoGqgCmR24mv0koIWWuqvFYnbURRqw1lv7IBINw==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -4508,9 +4508,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-gnu": { "node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.50.1", "version": "4.50.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.50.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.50.2.tgz",
"integrity": "sha512-2AbMhFFkTo6Ptna1zO7kAXXDLi7H9fGTbVaIq2AAYO7yzcAsuTNWPHhb2aTA6GPiP+JXh85Y8CiS54iZoj4opw==", "integrity": "sha512-df0Eou14ojtUdLQdPFnymEQteENwSJAdLf5KCDrmZNsy1c3YaCNaJvYsEUHnrg+/DLBH612/R0xd3dD03uz2dg==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -4521,9 +4521,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-musl": { "node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.50.1", "version": "4.50.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.50.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.50.2.tgz",
"integrity": "sha512-Cgef+5aZwuvesQNw9eX7g19FfKX5/pQRIyhoXLCiBOrWopjo7ycfB292TX9MDcDijiuIJlx1IzJz3IoCPfqs9w==", "integrity": "sha512-iPeouV0UIDtz8j1YFR4OJ/zf7evjauqv7jQ/EFs0ClIyL+by++hiaDAfFipjOgyz6y6xbDvJuiU4HwpVMpRFDQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -4533,10 +4533,10 @@
"linux" "linux"
] ]
}, },
"node_modules/@rollup/rollup-linux-loongarch64-gnu": { "node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.50.1", "version": "4.50.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.50.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.50.2.tgz",
"integrity": "sha512-RPhTwWMzpYYrHrJAS7CmpdtHNKtt2Ueo+BlLBjfZEhYBhK00OsEqM08/7f+eohiF6poe0YRDDd8nAvwtE/Y62Q==", "integrity": "sha512-OL6KaNvBopLlj5fTa5D5bau4W82f+1TyTZRr2BdnfsrnQnmdxh4okMxR2DcDkJuh4KeoQZVuvHvzuD/lyLn2Kw==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
@@ -4547,9 +4547,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-ppc64-gnu": { "node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.50.1", "version": "4.50.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.50.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.50.2.tgz",
"integrity": "sha512-eSGMVQw9iekut62O7eBdbiccRguuDgiPMsw++BVUg+1K7WjZXHOg/YOT9SWMzPZA+w98G+Fa1VqJgHZOHHnY0Q==", "integrity": "sha512-I21VJl1w6z/K5OTRl6aS9DDsqezEZ/yKpbqlvfHbW0CEF5IL8ATBMuUx6/mp683rKTK8thjs/0BaNrZLXetLag==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@@ -4560,9 +4560,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-gnu": { "node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.50.1", "version": "4.50.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.50.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.50.2.tgz",
"integrity": "sha512-S208ojx8a4ciIPrLgazF6AgdcNJzQE4+S9rsmOmDJkusvctii+ZvEuIC4v/xFqzbuP8yDjn73oBlNDgF6YGSXQ==", "integrity": "sha512-Hq6aQJT/qFFHrYMjS20nV+9SKrXL2lvFBENZoKfoTH2kKDOJqff5OSJr4x72ZaG/uUn+XmBnGhfr4lwMRrmqCQ==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -4573,9 +4573,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-musl": { "node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.50.1", "version": "4.50.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.50.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.50.2.tgz",
"integrity": "sha512-3Ag8Ls1ggqkGUvSZWYcdgFwriy2lWo+0QlYgEFra/5JGtAd6C5Hw59oojx1DeqcA2Wds2ayRgvJ4qxVTzCHgzg==", "integrity": "sha512-82rBSEXRv5qtKyr0xZ/YMF531oj2AIpLZkeNYxmKNN6I2sVE9PGegN99tYDLK2fYHJITL1P2Lgb4ZXnv0PjQvw==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -4586,9 +4586,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-s390x-gnu": { "node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.50.1", "version": "4.50.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.50.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.50.2.tgz",
"integrity": "sha512-t9YrKfaxCYe7l7ldFERE1BRg/4TATxIg+YieHQ966jwvo7ddHJxPj9cNFWLAzhkVsbBvNA4qTbPVNsZKBO4NSg==", "integrity": "sha512-4Q3S3Hy7pC6uaRo9gtXUTJ+EKo9AKs3BXKc2jYypEcMQ49gDPFU2P1ariX9SEtBzE5egIX6fSUmbmGazwBVF9w==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@@ -4599,9 +4599,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-gnu": { "node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.50.1", "version": "4.50.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.50.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.50.2.tgz",
"integrity": "sha512-MCgtFB2+SVNuQmmjHf+wfI4CMxy3Tk8XjA5Z//A0AKD7QXUYFMQcns91K6dEHBvZPCnhJSyDWLApk40Iq/H3tA==", "integrity": "sha512-9Jie/At6qk70dNIcopcL4p+1UirusEtznpNtcq/u/C5cC4HBX7qSGsYIcG6bdxj15EYWhHiu02YvmdPzylIZlA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -4612,9 +4612,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-musl": { "node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.50.1", "version": "4.50.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.50.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.50.2.tgz",
"integrity": "sha512-nEvqG+0jeRmqaUMuwzlfMKwcIVffy/9KGbAGyoa26iu6eSngAYQ512bMXuqqPrlTyfqdlB9FVINs93j534UJrg==", "integrity": "sha512-HPNJwxPL3EmhzeAnsWQCM3DcoqOz3/IC6de9rWfGR8ZCuEHETi9km66bH/wG3YH0V3nyzyFEGUZeL5PKyy4xvw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -4625,9 +4625,9 @@
] ]
}, },
"node_modules/@rollup/rollup-openharmony-arm64": { "node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.50.1", "version": "4.50.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.50.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.50.2.tgz",
"integrity": "sha512-RDsLm+phmT3MJd9SNxA9MNuEAO/J2fhW8GXk62G/B4G7sLVumNFbRwDL6v5NrESb48k+QMqdGbHgEtfU0LCpbA==", "integrity": "sha512-nMKvq6FRHSzYfKLHZ+cChowlEkR2lj/V0jYj9JnGUVPL2/mIeFGmVM2mLaFeNa5Jev7W7TovXqXIG2d39y1KYA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -4638,9 +4638,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-arm64-msvc": { "node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.50.1", "version": "4.50.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.50.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.50.2.tgz",
"integrity": "sha512-hpZB/TImk2FlAFAIsoElM3tLzq57uxnGYwplg6WDyAxbYczSi8O2eQ+H2Lx74504rwKtZ3N2g4bCUkiamzS6TQ==", "integrity": "sha512-eFUvvnTYEKeTyHEijQKz81bLrUQOXKZqECeiWH6tb8eXXbZk+CXSG2aFrig2BQ/pjiVRj36zysjgILkqarS2YA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -4651,9 +4651,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-ia32-msvc": { "node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.50.1", "version": "4.50.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.50.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.50.2.tgz",
"integrity": "sha512-SXjv8JlbzKM0fTJidX4eVsH+Wmnp0/WcD8gJxIZyR6Gay5Qcsmdbi9zVtnbkGPG8v2vMR1AD06lGWy5FLMcG7A==", "integrity": "sha512-cBaWmXqyfRhH8zmUxK3d3sAhEWLrtMjWBRwdMMHJIXSjvjLKvv49adxiEz+FJ8AP90apSDDBx2Tyd/WylV6ikA==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@@ -4664,9 +4664,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-x64-msvc": { "node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.50.1", "version": "4.50.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.50.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.50.2.tgz",
"integrity": "sha512-StxAO/8ts62KZVRAm4JZYq9+NqNsV7RvimNK+YM7ry//zebEH6meuugqW/P5OFUCjyQgui+9fUxT6d5NShvMvA==", "integrity": "sha512-APwKy6YUhvZaEoHyM+9xqmTpviEI+9eL7LoCH+aLcvWYHJ663qG5zx7WzWZY+a9qkg5JtzcMyJ9z0WtQBMDmgA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -4943,12 +4943,12 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "24.4.0", "version": "24.5.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.4.0.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.5.0.tgz",
"integrity": "sha512-gUuVEAK4/u6F9wRLznPUU4WGUacSEBDPoC2TrBkw3GAnOLHBL45QdfHOXp1kJ4ypBGLxTOB+t7NJLpKoC3gznQ==", "integrity": "sha512-y1dMvuvJspJiPSDZUQ+WMBvF7dpnEqN4x9DDC9ie5Fs/HUZJA3wFp7EhHoVaKX/iI0cRoECV8X2jL8zi0xrHCg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~7.11.0" "undici-types": "~7.12.0"
} }
}, },
"node_modules/@types/parse-json": { "node_modules/@types/parse-json": {
@@ -7439,9 +7439,9 @@
} }
}, },
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.8.3", "version": "2.8.4",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.3.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.4.tgz",
"integrity": "sha512-mcE+Wr2CAhHNWxXN/DdTI+n4gsPc5QpXpWnyCQWiQYIYZX+ZMJ8juXZgjRa/0/YPJo/NSsgW15/YgmI4nbysYw==", "integrity": "sha512-L+YvJwGAgwJBV1p6ffpSTa2KRc69EeeYGYjRVWKs0GKrK+LON0GC0gV+rKSNtALEDvMDqkvCFq9r1r94/Gjwxw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"baseline-browser-mapping": "dist/cli.js" "baseline-browser-mapping": "dist/cli.js"
@@ -7565,9 +7565,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.26.0", "version": "4.26.2",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.0.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.2.tgz",
"integrity": "sha512-P9go2WrP9FiPwLv3zqRD/Uoxo0RSHjzFCiQz7d4vbmwNqQFo9T9WCeP/Qn5EbcKQY6DBbkxEXNcpJOmncNrb7A==", "integrity": "sha512-ECFzp6uFOSB+dcZ5BK/IBaGWssbSYBHvuMeMt3MMFyhI0Z8SqGgEkBLARgpRH3hutIgPVsALcMwbDrJqPxQ65A==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -7584,7 +7584,7 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.8.2", "baseline-browser-mapping": "^2.8.3",
"caniuse-lite": "^1.0.30001741", "caniuse-lite": "^1.0.30001741",
"electron-to-chromium": "^1.5.218", "electron-to-chromium": "^1.5.218",
"node-releases": "^2.0.21", "node-releases": "^2.0.21",
@@ -7733,9 +7733,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001741", "version": "1.0.30001743",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001741.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001743.tgz",
"integrity": "sha512-QGUGitqsc8ARjLdgAfxETDhRbJ0REsP6O3I96TAth/mVjh2cYzN2u+3AzPP3aVSm2FehEItaJw1xd+IGBXWeSw==", "integrity": "sha512-e6Ojr7RV14Un7dz6ASD0aZDmQPT/A+eZU+nuTNfjqmRrmkmQlnTNWH0SKmqagx9PeW87UVqapSurtAXifmtdmw==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -9689,9 +9689,9 @@
} }
}, },
"node_modules/error-ex": { "node_modules/error-ex": {
"version": "1.3.2", "version": "1.3.4",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
"integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"is-arrayish": "^0.2.1" "is-arrayish": "^0.2.1"
@@ -17786,9 +17786,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.50.1", "version": "4.50.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.50.1.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.50.2.tgz",
"integrity": "sha512-78E9voJHwnXQMiQdiqswVLZwJIzdBKJ1GdI5Zx6XwoFKUIk09/sSrr+05QFzvYb8q6Y9pPV45zzDuYa3907TZA==", "integrity": "sha512-BgLRGy7tNS9H66aIMASq1qSYbAAJV6Z6WR4QYTvj5FgF15rZ/ympT1uixHXwzbZUBDbkvqUI1KR0fH1FhMaQ9w==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/estree": "1.0.8" "@types/estree": "1.0.8"
@@ -17801,27 +17801,27 @@
"npm": ">=8.0.0" "npm": ">=8.0.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.50.1", "@rollup/rollup-android-arm-eabi": "4.50.2",
"@rollup/rollup-android-arm64": "4.50.1", "@rollup/rollup-android-arm64": "4.50.2",
"@rollup/rollup-darwin-arm64": "4.50.1", "@rollup/rollup-darwin-arm64": "4.50.2",
"@rollup/rollup-darwin-x64": "4.50.1", "@rollup/rollup-darwin-x64": "4.50.2",
"@rollup/rollup-freebsd-arm64": "4.50.1", "@rollup/rollup-freebsd-arm64": "4.50.2",
"@rollup/rollup-freebsd-x64": "4.50.1", "@rollup/rollup-freebsd-x64": "4.50.2",
"@rollup/rollup-linux-arm-gnueabihf": "4.50.1", "@rollup/rollup-linux-arm-gnueabihf": "4.50.2",
"@rollup/rollup-linux-arm-musleabihf": "4.50.1", "@rollup/rollup-linux-arm-musleabihf": "4.50.2",
"@rollup/rollup-linux-arm64-gnu": "4.50.1", "@rollup/rollup-linux-arm64-gnu": "4.50.2",
"@rollup/rollup-linux-arm64-musl": "4.50.1", "@rollup/rollup-linux-arm64-musl": "4.50.2",
"@rollup/rollup-linux-loongarch64-gnu": "4.50.1", "@rollup/rollup-linux-loong64-gnu": "4.50.2",
"@rollup/rollup-linux-ppc64-gnu": "4.50.1", "@rollup/rollup-linux-ppc64-gnu": "4.50.2",
"@rollup/rollup-linux-riscv64-gnu": "4.50.1", "@rollup/rollup-linux-riscv64-gnu": "4.50.2",
"@rollup/rollup-linux-riscv64-musl": "4.50.1", "@rollup/rollup-linux-riscv64-musl": "4.50.2",
"@rollup/rollup-linux-s390x-gnu": "4.50.1", "@rollup/rollup-linux-s390x-gnu": "4.50.2",
"@rollup/rollup-linux-x64-gnu": "4.50.1", "@rollup/rollup-linux-x64-gnu": "4.50.2",
"@rollup/rollup-linux-x64-musl": "4.50.1", "@rollup/rollup-linux-x64-musl": "4.50.2",
"@rollup/rollup-openharmony-arm64": "4.50.1", "@rollup/rollup-openharmony-arm64": "4.50.2",
"@rollup/rollup-win32-arm64-msvc": "4.50.1", "@rollup/rollup-win32-arm64-msvc": "4.50.2",
"@rollup/rollup-win32-ia32-msvc": "4.50.1", "@rollup/rollup-win32-ia32-msvc": "4.50.2",
"@rollup/rollup-win32-x64-msvc": "4.50.1", "@rollup/rollup-win32-x64-msvc": "4.50.2",
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
@@ -20145,9 +20145,9 @@
} }
}, },
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "7.11.0", "version": "7.12.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.11.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.12.0.tgz",
"integrity": "sha512-kt1ZriHTi7MU+Z/r9DOdAI3ONdaR3M3csEaRc6ewa4f4dTvX4cQCbJ4NkEn0ohE4hHtq85+PhPSTY+pO/1PwgA==", "integrity": "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/unicode-canonical-property-names-ecmascript": { "node_modules/unicode-canonical-property-names-ecmascript": {
@@ -20182,9 +20182,9 @@
} }
}, },
"node_modules/unicode-property-aliases-ecmascript": { "node_modules/unicode-property-aliases-ecmascript": {
"version": "2.1.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz",
"integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==", "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=4" "node": ">=4"
@@ -21280,13 +21280,10 @@
} }
}, },
"node_modules/vuetify": { "node_modules/vuetify": {
"version": "3.10.0", "version": "3.10.1",
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-3.10.0.tgz", "resolved": "https://registry.npmjs.org/vuetify/-/vuetify-3.10.1.tgz",
"integrity": "sha512-cgtssO0yriqwEdOd6jGfUqUUztunxYPDhyY+iog0Q8i5WEa+3+eQ/dGpXeFoU80qMVm0k6uPd8aJpc5wEVXu3g==", "integrity": "sha512-4mQcdANVTgGC9TgsTEzueTe/OXvEvdCLwrJFJDeDYrNlNJmcH6jAeefEl0z1j5z7CH/AQM4NQDoQ+tMqZPOK/g==",
"license": "MIT", "license": "MIT",
"engines": {
"node": "^12.20 || >=14.13"
},
"funding": { "funding": {
"type": "github", "type": "github",
"url": "https://github.com/sponsors/johnleider" "url": "https://github.com/sponsors/johnleider"

View File

@@ -23,7 +23,10 @@
"test-component": "cross-env TZ=UTC BUILD_ENV=development NODE_ENV=development BABEL_ENV=test vitest run tests/vitest/component", "test-component": "cross-env TZ=UTC BUILD_ENV=development NODE_ENV=development BABEL_ENV=test vitest run tests/vitest/component",
"testcafe": "testcafe", "testcafe": "testcafe",
"trace": "webpack --stats-children", "trace": "webpack --stats-children",
"update": "npm update --save --package-lock && npm install --no-update-notifier --no-audit", "update": "npm update --save --package-lock --ignore-scripts && npm install --ignore-scripts --no-update-notifier --no-audit",
"security:scan": "npm run -s security:scan-installs && npm run -s security:scan-xss",
"security:scan-installs": "sh -lc 'set -e; MATCHES=\"$(rg -n --hidden --glob !**/.git/** -S \"npm (ci|install|update)\" ./Makefile ./package.json 2>/dev/null || true)\"; if [ -z \"$MATCHES\" ]; then echo \"No npm install/update/ci commands found in frontend/\"; exit 0; fi; VIOLATIONS=\"$(printf %s \"$MATCHES\" | rg -v -e \"ignore-scripts\" -e \"install .* -g npm\" -e \"update .* -g npm\" -e \":[0-9]+:\\s*#\" -e \"install-npm\" || true)\"; if [ -n \"$VIOLATIONS\" ]; then echo \"ERROR: npm install/update/ci without --ignore-scripts (exceptions excluded)\"; printf %s\\n \"$VIOLATIONS\"; exit 1; fi; echo \"OK: All frontend installs/updates use --ignore-scripts or are allowed exceptions.\"'",
"security:scan-xss": "sh -lc 'set -e; if rg -n --glob \"src/**\" -S \"v-html=\\\"\" src >/dev/null; then echo \"ERROR: v-html usage detected; prefer v-sanitize or $util.sanitizeHtml()\"; rg -n --glob \"src/**\" -S \"v-html=\\\"\" src; exit 1; else echo \"OK: No v-html usage detected.\"; fi'",
"watch": "webpack --watch" "watch": "webpack --watch"
}, },
"browserslist": [ "browserslist": [
@@ -56,7 +59,7 @@
"babel-loader": "^10.0.0", "babel-loader": "^10.0.0",
"babel-plugin-istanbul": "^7.0.1", "babel-plugin-istanbul": "^7.0.1",
"babel-plugin-polyfill-corejs3": "^0.13.0", "babel-plugin-polyfill-corejs3": "^0.13.0",
"browserslist": "^4.26.0", "browserslist": "^4.26.2",
"cheerio": "1.0.0-rc.12", "cheerio": "1.0.0-rc.12",
"core-js": "^3.45.1", "core-js": "^3.45.1",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
@@ -121,7 +124,7 @@
"vue-sanitize-directive": "^0.2.1", "vue-sanitize-directive": "^0.2.1",
"vue-style-loader": "^4.1.3", "vue-style-loader": "^4.1.3",
"vue3-gettext": "^2.4.0", "vue3-gettext": "^2.4.0",
"vuetify": "^3.10.0", "vuetify": "^3.10.1",
"webpack": "^5.101.3", "webpack": "^5.101.3",
"webpack-bundle-analyzer": "^4.10.2", "webpack-bundle-analyzer": "^4.10.2",
"webpack-cli": "^6.0.1", "webpack-cli": "^6.0.1",
@@ -137,6 +140,7 @@
"yarn": "please use npm" "yarn": "please use npm"
}, },
"overrides": { "overrides": {
"@ctrl/tinycolor": "^4.1.3",
"color-convert": "2.0.1", "color-convert": "2.0.1",
"color-name": "1.1.4" "color-name": "1.1.4"
} }

View File

@@ -50,7 +50,7 @@ func ClusterGetTheme(router *gin.RouterGroup) {
} }
clientIp := ClientIP(c) clientIp := ClientIP(c)
themePath := conf.PortalThemePath() themePath := conf.ThemePath()
// Resolve symbolic links. // Resolve symbolic links.
if resolved, err := filepath.EvalSymlinks(themePath); err != nil { if resolved, err := filepath.EvalSymlinks(themePath); err != nil {

View File

@@ -30,12 +30,8 @@ func authRemoveAction(ctx *cli.Context) error {
return cli.ShowSubcommandHelp(ctx) return cli.ShowSubcommandHelp(ctx)
} }
actionPrompt := promptui.Prompt{ if cliMode == NONINTERACTIVE {
Label: fmt.Sprintf("Remove session %s?", clean.LogQuote(id)), // proceed without prompt
IsConfirm: true,
}
if _, err := actionPrompt.Run(); err == nil {
if m, err := query.Session(id); err != nil { if m, err := query.Session(id); err != nil {
return errors.New("session not found") return errors.New("session not found")
} else if err := m.Delete(); err != nil { } else if err := m.Delete(); err != nil {
@@ -44,7 +40,18 @@ func authRemoveAction(ctx *cli.Context) error {
log.Infof("session %s has been removed", clean.LogQuote(id)) log.Infof("session %s has been removed", clean.LogQuote(id))
} }
} else { } else {
log.Infof("session %s was not removed", clean.LogQuote(id)) actionPrompt := promptui.Prompt{Label: fmt.Sprintf("Remove session %s?", clean.LogQuote(id)), IsConfirm: true}
if _, err := actionPrompt.Run(); err == nil {
if m, err := query.Session(id); err != nil {
return errors.New("session not found")
} else if err := m.Delete(); err != nil {
return err
} else {
log.Infof("session %s has been removed", clean.LogQuote(id))
}
} else {
log.Infof("session %s was not removed", clean.LogQuote(id))
}
} }
return nil return nil

View File

@@ -35,7 +35,7 @@ var AuthResetCommand = &cli.Command{
// authResetAction removes all sessions and resets the related database table to a clean state. // authResetAction removes all sessions and resets the related database table to a clean state.
func authResetAction(ctx *cli.Context) error { func authResetAction(ctx *cli.Context) error {
return CallWithDependencies(ctx, func(conf *config.Config) error { return CallWithDependencies(ctx, func(conf *config.Config) error {
confirmed := ctx.Bool("yes") confirmed := RunNonInteractively(ctx.Bool("yes"))
// Show prompt? // Show prompt?
if !confirmed { if !confirmed {

View File

@@ -50,7 +50,7 @@ func clientsRemoveAction(ctx *cli.Context) error {
return fmt.Errorf("client %s has already been deleted", clean.Log(id)) return fmt.Errorf("client %s has already been deleted", clean.Log(id))
} }
if !ctx.Bool("force") { if !ctx.Bool("force") && !RunNonInteractively(false) {
actionPrompt := promptui.Prompt{ actionPrompt := promptui.Prompt{
Label: fmt.Sprintf("Delete client %s?", m.GetUID()), Label: fmt.Sprintf("Delete client %s?", m.GetUID()),
IsConfirm: true, IsConfirm: true,

View File

@@ -31,7 +31,7 @@ var ClientsResetCommand = &cli.Command{
// clientsResetAction removes all registered client applications. // clientsResetAction removes all registered client applications.
func clientsResetAction(ctx *cli.Context) error { func clientsResetAction(ctx *cli.Context) error {
return CallWithDependencies(ctx, func(conf *config.Config) error { return CallWithDependencies(ctx, func(conf *config.Config) error {
confirmed := ctx.Bool("yes") confirmed := RunNonInteractively(ctx.Bool("yes"))
// Show prompt? // Show prompt?
if !confirmed { if !confirmed {

View File

@@ -0,0 +1,31 @@
package commands
import (
"github.com/urfave/cli/v2"
)
// JsonFlag enables machine-readable JSON output for cluster commands.
var JsonFlag = &cli.BoolFlag{
Name: "json",
Usage: "print machine-readable JSON",
}
// OffsetFlag for pagination offset (>= 0).
var OffsetFlag = &cli.IntFlag{
Name: "offset",
Usage: "result `OFFSET` (>= 0)",
Value: 0,
}
// ClusterCommands configures the cluster command group and subcommands.
var ClusterCommands = &cli.Command{
Name: "cluster",
Usage: "Cluster operations and management (portal, nodes)",
Subcommands: []*cli.Command{
ClusterSummaryCommand,
ClusterHealthCommand,
ClusterNodesCommands,
ClusterRegisterCommand,
ClusterThemePullCommand,
},
}

View File

@@ -0,0 +1,47 @@
package commands
import (
"encoding/json"
"fmt"
"time"
"github.com/urfave/cli/v2"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/pkg/txt/report"
)
type healthResponse struct {
Status string `json:"status"`
Time string `json:"time"`
}
// ClusterHealthCommand prints a minimal health response (Portal-only).
var ClusterHealthCommand = &cli.Command{
Name: "health",
Usage: "Shows cluster health (Portal-only)",
Flags: append(report.CliFlags, JsonFlag),
Action: clusterHealthAction,
}
func clusterHealthAction(ctx *cli.Context) error {
return CallWithDependencies(ctx, func(conf *config.Config) error {
if !conf.IsPortal() {
return fmt.Errorf("cluster health is only available on a Portal node")
}
resp := healthResponse{Status: "ok", Time: time.Now().UTC().Format(time.RFC3339)}
if ctx.Bool("json") {
b, _ := json.Marshal(resp)
fmt.Println(string(b))
return nil
}
cols := []string{"Status", "Time"}
rows := [][]string{{resp.Status, resp.Time}}
out, err := report.RenderFormat(rows, cols, report.CliFormat(ctx))
fmt.Printf("\n%s\n", out)
return err
})
}

View File

@@ -0,0 +1,113 @@
package commands
import (
"encoding/json"
"fmt"
"strings"
"github.com/urfave/cli/v2"
"github.com/photoprism/photoprism/internal/config"
reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
"github.com/photoprism/photoprism/pkg/txt/report"
)
// ClusterNodesCommands groups node subcommands.
var ClusterNodesCommands = &cli.Command{
Name: "nodes",
Usage: "Node registry subcommands",
Subcommands: []*cli.Command{
ClusterNodesListCommand,
ClusterNodesShowCommand,
ClusterNodesModCommand,
ClusterNodesRemoveCommand,
ClusterNodesRotateCommand,
},
}
// ClusterNodesListCommand lists registered nodes.
var ClusterNodesListCommand = &cli.Command{
Name: "ls",
Usage: "Lists registered cluster nodes (Portal-only)",
Flags: append(append(report.CliFlags, JsonFlag), CountFlag, OffsetFlag),
ArgsUsage: "",
Action: clusterNodesListAction,
}
func clusterNodesListAction(ctx *cli.Context) error {
return CallWithDependencies(ctx, func(conf *config.Config) error {
if !conf.IsPortal() {
return fmt.Errorf("node listing is only available on a Portal node")
}
r, err := reg.NewFileRegistry(conf)
if err != nil {
return err
}
items, err := r.List()
if err != nil {
return err
}
// Pagination identical to API defaults.
count := int(ctx.Uint("count"))
if count <= 0 || count > 1000 {
count = 100
}
offset := ctx.Int("offset")
if offset < 0 {
offset = 0
}
if offset > len(items) {
offset = len(items)
}
end := offset + count
if end > len(items) {
end = len(items)
}
page := items[offset:end]
// Build admin view (include internal URL and DB meta).
opts := reg.NodeOpts{IncludeInternalURL: true, IncludeDBMeta: true}
out := reg.BuildClusterNodes(page, opts)
if ctx.Bool("json") {
b, _ := json.Marshal(out)
fmt.Println(string(b))
return nil
}
cols := []string{"ID", "Name", "Type", "Labels", "Internal URL", "DB Name", "DB User", "DB Last Rotated", "Created At", "Updated At"}
rows := make([][]string, 0, len(out))
for _, n := range out {
var dbName, dbUser, dbRot string
if n.DB != nil {
dbName, dbUser, dbRot = n.DB.Name, n.DB.User, n.DB.DBLastRotatedAt
}
rows = append(rows, []string{
n.ID, n.Name, n.Type, formatLabels(n.Labels), n.InternalURL, dbName, dbUser, dbRot, n.CreatedAt, n.UpdatedAt,
})
}
if len(rows) == 0 {
log.Warnf("no nodes registered")
return nil
}
result, err := report.RenderFormat(rows, cols, report.CliFormat(ctx))
fmt.Printf("\n%s\n", result)
return err
})
}
func formatLabels(m map[string]string) string {
if len(m) == 0 {
return ""
}
parts := make([]string, 0, len(m))
for k, v := range m {
parts = append(parts, fmt.Sprintf("%s=%s", k, v))
}
return strings.Join(parts, ", ")
}

View File

@@ -0,0 +1,103 @@
package commands
import (
"fmt"
"strings"
"github.com/manifoldco/promptui"
"github.com/urfave/cli/v2"
"github.com/photoprism/photoprism/internal/config"
reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
"github.com/photoprism/photoprism/pkg/clean"
)
// flags for nodes mod
var (
nodesModTypeFlag = &cli.StringFlag{Name: "type", Aliases: []string{"t"}, Usage: "node `TYPE` (portal, instance, service)"}
nodesModInternal = &cli.StringFlag{Name: "internal-url", Aliases: []string{"i"}, Usage: "internal service `URL`"}
nodesModLabel = &cli.StringSliceFlag{Name: "label", Aliases: []string{"l"}, Usage: "`k=v` label (repeatable)"}
)
// ClusterNodesModCommand updates node fields.
var ClusterNodesModCommand = &cli.Command{
Name: "mod",
Usage: "Updates node properties (Portal-only)",
ArgsUsage: "<id|name>",
Flags: []cli.Flag{nodesModTypeFlag, nodesModInternal, nodesModLabel, &cli.BoolFlag{Name: "yes", Aliases: []string{"y"}, Usage: "runs the command non-interactively"}},
Action: clusterNodesModAction,
}
func clusterNodesModAction(ctx *cli.Context) error {
return CallWithDependencies(ctx, func(conf *config.Config) error {
if !conf.IsPortal() {
return fmt.Errorf("node update is only available on a Portal node")
}
key := ctx.Args().First()
if key == "" {
return cli.ShowSubcommandHelp(ctx)
}
r, err := reg.NewFileRegistry(conf)
if err != nil {
return err
}
n, getErr := r.Get(key)
if getErr != nil {
name := clean.TypeLowerDash(key)
if name == "" {
return fmt.Errorf("invalid node identifier")
}
n, getErr = r.FindByName(name)
}
if getErr != nil || n == nil {
return fmt.Errorf("node not found")
}
if v := ctx.String("type"); v != "" {
n.Type = clean.TypeLowerDash(v)
}
if v := ctx.String("internal-url"); v != "" {
n.Internal = v
}
if labels := ctx.StringSlice("label"); len(labels) > 0 {
if n.Labels == nil {
n.Labels = map[string]string{}
}
for _, kv := range labels {
if k, v, ok := splitKV(kv); ok {
n.Labels[k] = v
}
}
}
confirmed := RunNonInteractively(ctx.Bool("yes"))
if !confirmed {
prompt := promptui.Prompt{Label: fmt.Sprintf("Update node %s?", clean.LogQuote(n.Name)), IsConfirm: true}
if _, err := prompt.Run(); err != nil {
log.Infof("update cancelled for %s", clean.LogQuote(n.Name))
return nil
}
}
if err := r.Put(n); err != nil {
return err
}
log.Infof("node %s has been updated", clean.LogQuote(n.Name))
return nil
})
}
func splitKV(s string) (string, string, bool) {
if s == "" {
return "", "", false
}
i := strings.IndexByte(s, '=')
if i <= 0 || i >= len(s)-1 {
return "", "", false
}
return s[:i], s[i+1:], true
}

View File

@@ -0,0 +1,67 @@
package commands
import (
"fmt"
"github.com/manifoldco/promptui"
"github.com/urfave/cli/v2"
"github.com/photoprism/photoprism/internal/config"
reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
"github.com/photoprism/photoprism/pkg/clean"
)
// ClusterNodesRemoveCommand deletes a node from the registry.
var ClusterNodesRemoveCommand = &cli.Command{
Name: "rm",
Usage: "Deletes a node from the registry (Portal-only)",
ArgsUsage: "<id|name>",
Flags: []cli.Flag{
&cli.BoolFlag{Name: "yes", Aliases: []string{"y"}, Usage: "runs the command non-interactively"},
},
Action: clusterNodesRemoveAction,
}
func clusterNodesRemoveAction(ctx *cli.Context) error {
return CallWithDependencies(ctx, func(conf *config.Config) error {
if !conf.IsPortal() {
return fmt.Errorf("node delete is only available on a Portal node")
}
key := ctx.Args().First()
if key == "" {
return cli.ShowSubcommandHelp(ctx)
}
r, err := reg.NewFileRegistry(conf)
if err != nil {
return err
}
// Resolve to id for deletion, but also support name.
id := key
if _, getErr := r.Get(id); getErr != nil {
if n, err2 := r.FindByName(clean.TypeLowerDash(key)); err2 == nil && n != nil {
id = n.ID
} else {
return fmt.Errorf("node not found")
}
}
confirmed := RunNonInteractively(ctx.Bool("yes"))
if !confirmed {
prompt := promptui.Prompt{Label: fmt.Sprintf("Delete node %s?", clean.Log(id)), IsConfirm: true}
if _, err := prompt.Run(); err != nil {
log.Infof("node %s was not deleted", clean.Log(id))
return nil
}
}
if err := r.Delete(id); err != nil {
return err
}
log.Infof("node %s has been deleted", clean.Log(id))
return nil
})
}

View File

@@ -0,0 +1,139 @@
package commands
import (
"encoding/json"
"fmt"
"os"
"github.com/manifoldco/promptui"
"github.com/urfave/cli/v2"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/service/cluster"
reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/txt/report"
)
var (
rotateDBFlag = &cli.BoolFlag{Name: "db", Usage: "rotate DB credentials"}
rotateSecretFlag = &cli.BoolFlag{Name: "secret", Usage: "rotate node secret"}
rotatePortalURL = &cli.StringFlag{Name: "portal-url", Usage: "Portal base `URL` (defaults to config)"}
rotatePortalTok = &cli.StringFlag{Name: "portal-token", Usage: "Portal access `TOKEN` (defaults to config)"}
)
// ClusterNodesRotateCommand triggers rotation via the register endpoint.
var ClusterNodesRotateCommand = &cli.Command{
Name: "rotate",
Usage: "Rotates a node's DB and/or secret via Portal (HTTP)",
ArgsUsage: "<id|name>",
Flags: append([]cli.Flag{rotateDBFlag, rotateSecretFlag, &cli.BoolFlag{Name: "yes", Aliases: []string{"y"}, Usage: "runs the command non-interactively"}, rotatePortalURL, rotatePortalTok, JsonFlag}, report.CliFlags...),
Action: clusterNodesRotateAction,
}
func clusterNodesRotateAction(ctx *cli.Context) error {
return CallWithDependencies(ctx, func(conf *config.Config) error {
key := ctx.Args().First()
if key == "" {
return cli.ShowSubcommandHelp(ctx)
}
// Determine node name. On portal, resolve id->name via registry; otherwise treat key as name.
name := clean.TypeLowerDash(key)
if conf.IsPortal() {
if r, err := reg.NewFileRegistry(conf); err == nil {
if n, err := r.Get(key); err == nil && n != nil {
name = n.Name
} else if n, err := r.FindByName(clean.TypeLowerDash(key)); err == nil && n != nil {
name = n.Name
}
}
}
if name == "" {
return fmt.Errorf("invalid node identifier")
}
// Portal URL and token
portalURL := ctx.String("portal-url")
if portalURL == "" {
portalURL = conf.PortalUrl()
}
if portalURL == "" {
portalURL = os.Getenv(config.EnvVar("portal-url"))
}
if portalURL == "" {
return fmt.Errorf("portal URL is required (use --portal-url or set portal-url)")
}
token := ctx.String("portal-token")
if token == "" {
token = conf.PortalToken()
}
if token == "" {
token = os.Getenv(config.EnvVar("portal-token"))
}
if token == "" {
return fmt.Errorf("portal token is required (use --portal-token or set portal-token)")
}
// Default: rotate DB only if no flag given (safer default)
rotateDB := ctx.Bool("db") || (!ctx.IsSet("db") && !ctx.IsSet("secret"))
rotateSecret := ctx.Bool("secret")
confirmed := RunNonInteractively(ctx.Bool("yes"))
if !confirmed {
var what string
switch {
case rotateDB && rotateSecret:
what = "DB credentials and node secret"
case rotateDB:
what = "DB credentials"
case rotateSecret:
what = "node secret"
}
prompt := promptui.Prompt{Label: fmt.Sprintf("Rotate %s for %s?", what, clean.LogQuote(name)), IsConfirm: true}
if _, err := prompt.Run(); err != nil {
log.Infof("rotation cancelled for %s", clean.LogQuote(name))
return nil
}
}
body := map[string]interface{}{
"nodeName": name,
"rotate": rotateDB,
"rotateSecret": rotateSecret,
}
b, _ := json.Marshal(body)
url := stringsTrimRightSlash(portalURL) + "/api/v1/cluster/nodes/register"
var resp cluster.RegisterResponse
if err := postWithBackoff(url, token, b, &resp); err != nil {
return err
}
if ctx.Bool("json") {
jb, _ := json.Marshal(resp)
fmt.Println(string(jb))
return nil
}
cols := []string{"ID", "Name", "Type", "DB Name", "DB User", "Host", "Port"}
rows := [][]string{{resp.Node.ID, resp.Node.Name, resp.Node.Type, resp.DB.Name, resp.DB.User, resp.DB.Host, fmt.Sprintf("%d", resp.DB.Port)}}
out, _ := report.RenderFormat(rows, cols, report.CliFormat(ctx))
fmt.Printf("\n%s\n", out)
if (resp.Secrets != nil && resp.Secrets.NodeSecret != "") || resp.DB.Password != "" {
fmt.Println("PLEASE WRITE DOWN THE FOLLOWING CREDENTIALS; THEY WILL NOT BE SHOWN AGAIN:")
if resp.Secrets != nil && resp.Secrets.NodeSecret != "" && resp.DB.Password != "" {
fmt.Printf("\n%s\n", report.Credentials("Node Secret", resp.Secrets.NodeSecret, "DB Password", resp.DB.Password))
} else if resp.Secrets != nil && resp.Secrets.NodeSecret != "" {
fmt.Printf("\n%s\n", report.Credentials("Node Secret", resp.Secrets.NodeSecret, "", ""))
} else if resp.DB.Password != "" {
fmt.Printf("\n%s\n", report.Credentials("DB User", resp.DB.User, "DB Password", resp.DB.Password))
}
if resp.DB.DSN != "" {
fmt.Printf("DSN: %s\n", resp.DB.DSN)
}
}
return nil
})
}

View File

@@ -0,0 +1,72 @@
package commands
import (
"encoding/json"
"fmt"
"github.com/urfave/cli/v2"
"github.com/photoprism/photoprism/internal/config"
reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/txt/report"
)
// ClusterNodesShowCommand shows node details.
var ClusterNodesShowCommand = &cli.Command{
Name: "show",
Usage: "Shows node details (Portal-only)",
ArgsUsage: "<id|name>",
Flags: append(report.CliFlags, JsonFlag),
Action: clusterNodesShowAction,
}
func clusterNodesShowAction(ctx *cli.Context) error {
return CallWithDependencies(ctx, func(conf *config.Config) error {
if !conf.IsPortal() {
return fmt.Errorf("node show is only available on a Portal node")
}
key := ctx.Args().First()
if key == "" {
return cli.ShowSubcommandHelp(ctx)
}
r, err := reg.NewFileRegistry(conf)
if err != nil {
return err
}
// Resolve by id first, then by normalized name.
n, getErr := r.Get(key)
if getErr != nil {
name := clean.TypeLowerDash(key)
if name == "" {
return fmt.Errorf("invalid node identifier")
}
n, getErr = r.FindByName(name)
}
if getErr != nil || n == nil {
return fmt.Errorf("node not found")
}
opts := reg.NodeOpts{IncludeInternalURL: true, IncludeDBMeta: true}
dto := reg.BuildClusterNode(*n, opts)
if ctx.Bool("json") {
b, _ := json.Marshal(dto)
fmt.Println(string(b))
return nil
}
cols := []string{"ID", "Name", "Type", "Internal URL", "DB Name", "DB User", "DB Last Rotated", "Created At", "Updated At"}
var dbName, dbUser, dbRot string
if dto.DB != nil {
dbName, dbUser, dbRot = dto.DB.Name, dto.DB.User, dto.DB.DBLastRotatedAt
}
rows := [][]string{{dto.ID, dto.Name, dto.Type, dto.InternalURL, dbName, dbUser, dbRot, dto.CreatedAt, dto.UpdatedAt}}
out, err := report.RenderFormat(rows, cols, report.CliFormat(ctx))
fmt.Printf("\n%s\n", out)
return err
})
}

View File

@@ -0,0 +1,295 @@
package commands
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"time"
"github.com/urfave/cli/v2"
yaml "gopkg.in/yaml.v2"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/service/cluster"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/txt/report"
)
// flags for register
var (
regNameFlag = &cli.StringFlag{Name: "name", Usage: "node `NAME` (lowercase letters, digits, hyphens)"}
regTypeFlag = &cli.StringFlag{Name: "type", Usage: "node `TYPE` (instance, service)", Value: "instance"}
regIntUrlFlag = &cli.StringFlag{Name: "internal-url", Usage: "internal service `URL`"}
regLabelFlag = &cli.StringSliceFlag{Name: "label", Usage: "`k=v` label (repeatable)"}
regRotateDB = &cli.BoolFlag{Name: "rotate", Usage: "rotates the node's database password"}
regRotateSec = &cli.BoolFlag{Name: "rotate-secret", Usage: "rotates the node's secret used for JWT"}
regPortalURL = &cli.StringFlag{Name: "portal-url", Usage: "Portal base `URL` (defaults to config)"}
regPortalTok = &cli.StringFlag{Name: "portal-token", Usage: "Portal access `TOKEN` (defaults to config)"}
regWriteConf = &cli.BoolFlag{Name: "write-config", Usage: "persists returned secrets and DB settings to local config"}
regForceFlag = &cli.BoolFlag{Name: "force", Aliases: []string{"f"}, Usage: "confirm actions that may overwrite/replace local data (e.g., --write-config)"}
)
// ClusterRegisterCommand registers a node with the Portal via HTTP.
var ClusterRegisterCommand = &cli.Command{
Name: "register",
Usage: "Registers/rotates a node via Portal (HTTP)",
Flags: append(append([]cli.Flag{regNameFlag, regTypeFlag, regIntUrlFlag, regLabelFlag, regRotateDB, regRotateSec, regPortalURL, regPortalTok, regWriteConf, regForceFlag, JsonFlag}, report.CliFlags...)),
Action: clusterRegisterAction,
}
func clusterRegisterAction(ctx *cli.Context) error {
return CallWithDependencies(ctx, func(conf *config.Config) error {
// Resolve inputs
name := clean.TypeLowerDash(ctx.String("name"))
if name == "" { // default from config if set
name = clean.TypeLowerDash(conf.NodeName())
}
if name == "" {
return fmt.Errorf("node name is required (use --name or set node-name)")
}
nodeType := clean.TypeLowerDash(ctx.String("type"))
switch nodeType {
case "instance", "service":
default:
return fmt.Errorf("invalid --type (must be instance or service)")
}
portalURL := ctx.String("portal-url")
if portalURL == "" {
portalURL = conf.PortalUrl()
}
if portalURL == "" {
return fmt.Errorf("portal URL is required (use --portal-url or set portal-url)")
}
token := ctx.String("portal-token")
if token == "" {
token = conf.PortalToken()
}
if token == "" {
return fmt.Errorf("portal token is required (use --portal-token or set portal-token)")
}
body := map[string]interface{}{
"nodeName": name,
"nodeType": nodeType,
"labels": parseLabelSlice(ctx.StringSlice("label")),
"internalUrl": ctx.String("internal-url"),
"rotate": ctx.Bool("rotate"),
"rotateSecret": ctx.Bool("rotate-secret"),
}
b, _ := json.Marshal(body)
// POST with bounded backoff on 429
url := stringsTrimRightSlash(portalURL) + "/api/v1/cluster/nodes/register"
var resp cluster.RegisterResponse
if err := postWithBackoff(url, token, b, &resp); err != nil {
var httpErr *httpError
if errors.As(err, &httpErr) && httpErr.Status == http.StatusTooManyRequests {
return fmt.Errorf("portal rate-limited registration attempts")
}
// Map common errors
if errors.As(err, &httpErr) {
switch httpErr.Status {
case http.StatusUnauthorized, http.StatusForbidden:
return fmt.Errorf("%s", httpErr.Error())
case http.StatusConflict:
return fmt.Errorf("%s", httpErr.Error())
case http.StatusBadRequest:
return fmt.Errorf("%s", httpErr.Error())
case http.StatusNotFound:
return fmt.Errorf("%s", httpErr.Error())
}
}
return err
}
// Output
if ctx.Bool("json") {
jb, _ := json.Marshal(resp)
fmt.Println(string(jb))
} else {
// Human-readable: node row and credentials if present
cols := []string{"ID", "Name", "Type", "DB Name", "DB User", "Host", "Port"}
var dbName, dbUser string
if resp.DB.Name != "" {
dbName = resp.DB.Name
}
if resp.DB.User != "" {
dbUser = resp.DB.User
}
rows := [][]string{{resp.Node.ID, resp.Node.Name, resp.Node.Type, dbName, dbUser, resp.DB.Host, fmt.Sprintf("%d", resp.DB.Port)}}
out, _ := report.RenderFormat(rows, cols, report.CliFormat(ctx))
fmt.Printf("\n%s\n", out)
// Secrets/credentials block if any
// Show secrets in up to two tables, then print DSN if present
if (resp.Secrets != nil && resp.Secrets.NodeSecret != "") || resp.DB.Password != "" {
fmt.Println("PLEASE WRITE DOWN THE FOLLOWING CREDENTIALS; THEY WILL NOT BE SHOWN AGAIN:")
if resp.Secrets != nil && resp.Secrets.NodeSecret != "" && resp.DB.Password != "" {
fmt.Printf("\n%s\n", report.Credentials("Node Secret", resp.Secrets.NodeSecret, "DB Password", resp.DB.Password))
} else if resp.Secrets != nil && resp.Secrets.NodeSecret != "" {
fmt.Printf("\n%s\n", report.Credentials("Node Secret", resp.Secrets.NodeSecret, "", ""))
} else if resp.DB.Password != "" {
fmt.Printf("\n%s\n", report.Credentials("DB User", resp.DB.User, "DB Password", resp.DB.Password))
}
if resp.DB.DSN != "" {
fmt.Printf("DSN: %s\n", resp.DB.DSN)
}
}
}
// Optional persistence
if ctx.Bool("write-config") {
if err := persistRegisterResponse(conf, &resp); err != nil {
return err
}
}
return nil
})
}
// HTTP helpers and backoff
type httpError struct {
Status int
Body string
}
func (e *httpError) Error() string { return fmt.Sprintf("http %d: %s", e.Status, e.Body) }
func postWithBackoff(url, token string, payload []byte, out any) error {
// backoff: 500ms -> max ~8s, 6 attempts with jitter
delay := 500 * time.Millisecond
for attempt := 0; attempt < 6; attempt++ {
req, _ := http.NewRequest(http.MethodPost, url, bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusTooManyRequests {
// backoff and retry
time.Sleep(jitter(delay, 0.25))
if delay < 8*time.Second {
delay *= 2
}
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(resp.Body)
return &httpError{Status: resp.StatusCode, Body: string(b)}
}
dec := json.NewDecoder(resp.Body)
return dec.Decode(out)
}
return &httpError{Status: http.StatusTooManyRequests, Body: "rate limited"}
}
func jitter(d time.Duration, frac float64) time.Duration {
// simple +/- jitter
n := time.Duration(float64(d) * (1 + (randFloat()*2-1)*frac))
if n <= 0 {
return d
}
return n
}
// tiny rand without pulling math/rand global state unpredictably
func randFloat() float64 { return float64(time.Now().UnixNano()%1000) / 1000.0 }
func stringsTrimRightSlash(s string) string {
for len(s) > 0 && s[len(s)-1] == '/' {
s = s[:len(s)-1]
}
return s
}
// Persistence helpers for --write-config
func parseLabelSlice(labels []string) map[string]string {
if len(labels) == 0 {
return nil
}
m := make(map[string]string)
for _, kv := range labels {
if i := bytes.IndexByte([]byte(kv), '='); i > 0 && i < len(kv)-1 {
k := kv[:i]
v := kv[i+1:]
m[k] = v
}
}
if len(m) == 0 {
return nil
}
return m
}
// Persistence helpers for --write-config
func persistRegisterResponse(conf *config.Config, resp *cluster.RegisterResponse) error {
// Node secret file
if resp.Secrets != nil && resp.Secrets.NodeSecret != "" {
// Prefer PHOTOPRISM_NODE_SECRET_FILE; otherwise config cluster path
fileName := os.Getenv(config.FlagFileVar("NODE_SECRET"))
if fileName == "" {
fileName = filepath.Join(conf.PortalConfigPath(), "node-secret")
}
if err := fs.MkdirAll(filepath.Dir(fileName)); err != nil {
return err
}
if err := os.WriteFile(fileName, []byte(resp.Secrets.NodeSecret), 0o600); err != nil {
return err
}
log.Infof("wrote node secret to %s", clean.Log(fileName))
}
// DB settings (MySQL/MariaDB only)
if resp.DB.Name != "" && resp.DB.User != "" {
if err := mergeOptionsYaml(conf, map[string]any{
"DatabaseDriver": config.MySQL,
"DatabaseName": resp.DB.Name,
"DatabaseServer": fmt.Sprintf("%s:%d", resp.DB.Host, resp.DB.Port),
"DatabaseUser": resp.DB.User,
"DatabasePassword": resp.DB.Password,
}); err != nil {
return err
}
log.Infof("updated options.yml with database settings for node %s", clean.LogQuote(resp.Node.Name))
}
return nil
}
func mergeOptionsYaml(conf *config.Config, kv map[string]any) error {
fileName := conf.OptionsYaml()
if err := fs.MkdirAll(filepath.Dir(fileName)); err != nil {
return err
}
var m map[string]any
if fs.FileExists(fileName) {
if b, err := os.ReadFile(fileName); err == nil && len(b) > 0 {
_ = yaml.Unmarshal(b, &m)
}
}
if m == nil {
m = map[string]any{}
}
for k, v := range kv {
m[k] = v
}
b, err := yaml.Marshal(m)
if err != nil {
return err
}
return os.WriteFile(fileName, b, 0o644)
}

View File

@@ -0,0 +1,435 @@
package commands
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/tidwall/gjson"
cfg "github.com/photoprism/photoprism/internal/config"
)
func TestClusterRegister_HTTPHappyPath(t *testing.T) {
// Fake Portal register endpoint
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/cluster/nodes/register" {
http.NotFound(w, r)
return
}
if r.Header.Get("Authorization") != "Bearer test-token" {
w.WriteHeader(http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]any{
"node": map[string]any{"id": "n1", "name": "pp-node-02", "type": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
"db": map[string]any{"host": "db", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwd", "dsn": "user:pwd@tcp(db:3306)/pp_db?parseTime=true", "dbLastRotatedAt": "2025-09-15T00:00:00Z"},
"secrets": map[string]any{"nodeSecret": "secret", "nodeSecretLastRotatedAt": "2025-09-15T00:00:00Z"},
"alreadyRegistered": false,
"alreadyProvisioned": false,
})
}))
defer ts.Close()
out, err := RunWithTestContext(ClusterRegisterCommand, []string{
"register", "--name", "pp-node-02", "--type", "instance", "--portal-url", ts.URL, "--portal-token", "test-token", "--json",
})
assert.NoError(t, err)
// Parse JSON
assert.Equal(t, "pp-node-02", gjson.Get(out, "node.name").String())
assert.Equal(t, "secret", gjson.Get(out, "secrets.nodeSecret").String())
assert.Equal(t, "pwd", gjson.Get(out, "db.password").String())
dsn := gjson.Get(out, "db.dsn").String()
parsed := cfg.NewDSN(dsn)
assert.Equal(t, "user", parsed.User)
assert.Equal(t, "pwd", parsed.Password)
assert.Equal(t, "tcp", parsed.Net)
assert.Equal(t, "db:3306", parsed.Server)
assert.Equal(t, "pp_db", parsed.Name)
}
func TestClusterNodesRotate_HTTPHappyPath(t *testing.T) {
// Fake Portal register endpoint for rotation
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/cluster/nodes/register" {
http.NotFound(w, r)
return
}
if r.Header.Get("Authorization") != "Bearer test-token" {
w.WriteHeader(http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]any{
"node": map[string]any{"id": "n1", "name": "pp-node-03", "type": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
"db": map[string]any{"host": "db", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwd2", "dsn": "user:pwd2@tcp(db:3306)/pp_db?parseTime=true", "dbLastRotatedAt": "2025-09-15T00:00:00Z"},
"secrets": map[string]any{"nodeSecret": "secret2", "nodeSecretLastRotatedAt": "2025-09-15T00:00:00Z"},
"alreadyRegistered": true,
"alreadyProvisioned": true,
})
}))
defer ts.Close()
_ = os.Setenv("PHOTOPRISM_PORTAL_URL", ts.URL)
_ = os.Setenv("PHOTOPRISM_PORTAL_TOKEN", "test-token")
_ = os.Setenv("PHOTOPRISM_CLI", "noninteractive")
defer os.Unsetenv("PHOTOPRISM_PORTAL_URL")
defer os.Unsetenv("PHOTOPRISM_PORTAL_TOKEN")
defer os.Unsetenv("PHOTOPRISM_CLI")
out, err := RunWithTestContext(ClusterNodesRotateCommand, []string{
"rotate", "--portal-url=" + ts.URL, "--portal-token=test-token", "--db", "--secret", "--yes", "pp-node-03",
})
assert.NoError(t, err)
assert.Contains(t, out, "pp-node-03")
assert.Contains(t, out, "Node Secret")
assert.Contains(t, out, "DB Password")
}
func TestClusterNodesRotate_HTTPJson(t *testing.T) {
// Fake Portal register endpoint for rotation in JSON mode
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/cluster/nodes/register" {
http.NotFound(w, r)
return
}
if r.Header.Get("Authorization") != "Bearer test-token" {
w.WriteHeader(http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]any{
"node": map[string]any{"id": "n2", "name": "pp-node-04", "type": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
"db": map[string]any{"host": "db", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwd3", "dsn": "user:pwd3@tcp(db:3306)/pp_db?parseTime=true", "dbLastRotatedAt": "2025-09-15T00:00:00Z"},
"secrets": map[string]any{"nodeSecret": "secret3", "nodeSecretLastRotatedAt": "2025-09-15T00:00:00Z"},
"alreadyRegistered": true,
"alreadyProvisioned": true,
})
}))
defer ts.Close()
_ = os.Setenv("PHOTOPRISM_PORTAL_URL", ts.URL)
_ = os.Setenv("PHOTOPRISM_PORTAL_TOKEN", "test-token")
_ = os.Setenv("PHOTOPRISM_CLI", "noninteractive")
defer os.Unsetenv("PHOTOPRISM_PORTAL_URL")
defer os.Unsetenv("PHOTOPRISM_PORTAL_TOKEN")
defer os.Unsetenv("PHOTOPRISM_CLI")
out, err := RunWithTestContext(ClusterNodesRotateCommand, []string{
"rotate", "--json", "--db", "--secret", "--yes", "pp-node-04",
})
assert.NoError(t, err)
assert.Equal(t, "pp-node-04", gjson.Get(out, "node.name").String())
assert.Equal(t, "secret3", gjson.Get(out, "secrets.nodeSecret").String())
assert.Equal(t, "pwd3", gjson.Get(out, "db.password").String())
dsn := gjson.Get(out, "db.dsn").String()
parsed := cfg.NewDSN(dsn)
assert.Equal(t, "user", parsed.User)
assert.Equal(t, "pwd3", parsed.Password)
assert.Equal(t, "tcp", parsed.Net)
assert.Equal(t, "db:3306", parsed.Server)
assert.Equal(t, "pp_db", parsed.Name)
}
func TestClusterNodesRotate_DBOnly_JSON(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/cluster/nodes/register" {
http.NotFound(w, r)
return
}
if r.Header.Get("Authorization") != "Bearer test-token" {
w.WriteHeader(http.StatusUnauthorized)
return
}
// Read payload to assert rotate flags
b, _ := io.ReadAll(r.Body)
rotate := gjson.GetBytes(b, "rotate").Bool()
rotateSecret := gjson.GetBytes(b, "rotateSecret").Bool()
// Expect DB rotation only
if !rotate || rotateSecret {
w.WriteHeader(http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]any{
"node": map[string]any{"id": "n3", "name": "pp-node-05", "type": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
"db": map[string]any{"host": "db", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwd4", "dsn": "pp_user:pwd4@tcp(db:3306)/pp_db?parseTime=true", "dbLastRotatedAt": "2025-09-15T00:00:00Z"},
// secrets omitted on DB-only rotate
"alreadyRegistered": true,
"alreadyProvisioned": true,
})
}))
defer ts.Close()
_ = os.Setenv("PHOTOPRISM_PORTAL_URL", ts.URL)
_ = os.Setenv("PHOTOPRISM_PORTAL_TOKEN", "test-token")
_ = os.Setenv("PHOTOPRISM_YES", "true")
defer os.Unsetenv("PHOTOPRISM_PORTAL_URL")
defer os.Unsetenv("PHOTOPRISM_PORTAL_TOKEN")
defer os.Unsetenv("PHOTOPRISM_YES")
out, err := RunWithTestContext(ClusterNodesRotateCommand, []string{
"rotate", "--json", "--db", "--yes", "pp-node-05",
})
assert.NoError(t, err)
assert.Equal(t, "pp-node-05", gjson.Get(out, "node.name").String())
assert.Equal(t, "pwd4", gjson.Get(out, "db.password").String())
dsn := gjson.Get(out, "db.dsn").String()
parsed := cfg.NewDSN(dsn)
assert.Equal(t, "pp_user", parsed.User)
assert.Equal(t, "pwd4", parsed.Password)
assert.Equal(t, "tcp", parsed.Net)
assert.Equal(t, "db:3306", parsed.Server)
assert.Equal(t, "pp_db", parsed.Name)
assert.Equal(t, "", gjson.Get(out, "secrets.nodeSecret").String())
}
func TestClusterNodesRotate_SecretOnly_JSON(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/cluster/nodes/register" {
http.NotFound(w, r)
return
}
if r.Header.Get("Authorization") != "Bearer test-token" {
w.WriteHeader(http.StatusUnauthorized)
return
}
b, _ := io.ReadAll(r.Body)
rotate := gjson.GetBytes(b, "rotate").Bool()
rotateSecret := gjson.GetBytes(b, "rotateSecret").Bool()
// Expect secret-only rotation
if rotate || !rotateSecret {
w.WriteHeader(http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]any{
"node": map[string]any{"id": "n4", "name": "pp-node-06", "type": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
"db": map[string]any{"host": "db", "port": 3306, "name": "pp_db", "user": "pp_user", "dbLastRotatedAt": "2025-09-15T00:00:00Z"},
"secrets": map[string]any{"nodeSecret": "secret4", "nodeSecretLastRotatedAt": "2025-09-15T00:00:00Z"},
"alreadyRegistered": true,
"alreadyProvisioned": true,
})
}))
defer ts.Close()
_ = os.Setenv("PHOTOPRISM_PORTAL_URL", ts.URL)
_ = os.Setenv("PHOTOPRISM_PORTAL_TOKEN", "test-token")
defer os.Unsetenv("PHOTOPRISM_PORTAL_URL")
defer os.Unsetenv("PHOTOPRISM_PORTAL_TOKEN")
out, err := RunWithTestContext(ClusterNodesRotateCommand, []string{
"rotate", "--json", "--secret", "--yes", "pp-node-06",
})
assert.NoError(t, err)
assert.Equal(t, "pp-node-06", gjson.Get(out, "node.name").String())
assert.Equal(t, "secret4", gjson.Get(out, "secrets.nodeSecret").String())
assert.Equal(t, "", gjson.Get(out, "db.password").String())
}
func TestClusterRegister_HTTPUnauthorized(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer ts.Close()
_, err := RunWithTestContext(ClusterRegisterCommand, []string{
"register", "--name", "pp-node-unauth", "--type", "instance", "--portal-url", ts.URL, "--portal-token", "wrong", "--json",
})
assert.Error(t, err)
}
func TestClusterRegister_HTTPConflict(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusConflict)
}))
defer ts.Close()
_, err := RunWithTestContext(ClusterRegisterCommand, []string{
"register", "--name", "pp-node-conflict", "--type", "instance", "--portal-url", ts.URL, "--portal-token", "test-token", "--json",
})
assert.Error(t, err)
}
func TestClusterRegister_HTTPBadRequest(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
}))
defer ts.Close()
_, err := RunWithTestContext(ClusterRegisterCommand, []string{
"register", "--name", "pp node invalid", "--type", "instance", "--portal-url", ts.URL, "--portal-token", "test-token", "--json",
})
assert.Error(t, err)
}
func TestClusterRegister_HTTPRateLimitOnceThenOK(t *testing.T) {
calls := 0
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if calls == 1 {
w.WriteHeader(http.StatusTooManyRequests)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]any{
"node": map[string]any{"id": "n7", "name": "pp-node-rl", "type": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
"db": map[string]any{"host": "db", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwdrl", "dsn": "pp_user:pwdrl@tcp(db:3306)/pp_db?parseTime=true", "dbLastRotatedAt": "2025-09-15T00:00:00Z"},
"alreadyRegistered": true,
"alreadyProvisioned": true,
})
}))
defer ts.Close()
out, err := RunWithTestContext(ClusterRegisterCommand, []string{
"register", "--name", "pp-node-rl", "--type", "instance", "--portal-url", ts.URL, "--portal-token", "test-token", "--rotate", "--json",
})
assert.NoError(t, err)
assert.Equal(t, "pp-node-rl", gjson.Get(out, "node.name").String())
}
func TestClusterNodesRotate_HTTPUnauthorized_JSON(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer ts.Close()
_, err := RunWithTestContext(ClusterNodesRotateCommand, []string{
"rotate", "--json", "--portal-url=" + ts.URL, "--portal-token=wrong", "--db", "--yes", "pp-node-x",
})
assert.Error(t, err)
}
func TestClusterNodesRotate_HTTPConflict_JSON(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusConflict)
}))
defer ts.Close()
_, err := RunWithTestContext(ClusterNodesRotateCommand, []string{
"rotate", "--json", "--portal-url=" + ts.URL, "--portal-token=test-token", "--db", "--yes", "pp-node-x",
})
assert.Error(t, err)
}
func TestClusterNodesRotate_HTTPBadRequest_JSON(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
}))
defer ts.Close()
_, err := RunWithTestContext(ClusterNodesRotateCommand, []string{
"rotate", "--json", "--portal-url=" + ts.URL, "--portal-token=test-token", "--db", "--yes", "pp node invalid",
})
assert.Error(t, err)
}
func TestClusterNodesRotate_HTTPRateLimitOnceThenOK_JSON(t *testing.T) {
calls := 0
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if calls == 1 {
w.WriteHeader(http.StatusTooManyRequests)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]any{
"node": map[string]any{"id": "n8", "name": "pp-node-rl2", "type": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
"db": map[string]any{"host": "db", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwdrl2", "dsn": "pp_user:pwdrl2@tcp(db:3306)/pp_db?parseTime=true", "dbLastRotatedAt": "2025-09-15T00:00:00Z"},
"alreadyRegistered": true,
"alreadyProvisioned": true,
})
}))
defer ts.Close()
out, err := RunWithTestContext(ClusterNodesRotateCommand, []string{
"rotate", "--json", "--portal-url=" + ts.URL, "--portal-token=test-token", "--db", "--yes", "pp-node-rl2",
})
assert.NoError(t, err)
assert.Equal(t, "pp-node-rl2", gjson.Get(out, "node.name").String())
}
func TestClusterRegister_RotateDB_JSON(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/cluster/nodes/register" {
http.NotFound(w, r)
return
}
if r.Header.Get("Authorization") != "Bearer test-token" {
w.WriteHeader(http.StatusUnauthorized)
return
}
b, _ := io.ReadAll(r.Body)
if !gjson.GetBytes(b, "rotate").Bool() || gjson.GetBytes(b, "rotateSecret").Bool() {
w.WriteHeader(http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]any{
"node": map[string]any{"id": "n5", "name": "pp-node-07", "type": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
"db": map[string]any{"host": "db", "port": 3306, "name": "pp_db", "user": "pp_user", "password": "pwd7", "dsn": "pp_user:pwd7@tcp(db:3306)/pp_db?parseTime=true", "dbLastRotatedAt": "2025-09-15T00:00:00Z"},
"alreadyRegistered": true,
"alreadyProvisioned": true,
})
}))
defer ts.Close()
out, err := RunWithTestContext(ClusterRegisterCommand, []string{
"register", "--name", "pp-node-07", "--type", "instance", "--portal-url", ts.URL, "--portal-token", "test-token", "--rotate", "--json",
})
assert.NoError(t, err)
assert.Equal(t, "pp-node-07", gjson.Get(out, "node.name").String())
assert.Equal(t, "pwd7", gjson.Get(out, "db.password").String())
dsn := gjson.Get(out, "db.dsn").String()
parsed := cfg.NewDSN(dsn)
assert.Equal(t, "pp_user", parsed.User)
assert.Equal(t, "pwd7", parsed.Password)
assert.Equal(t, "tcp", parsed.Net)
assert.Equal(t, "db:3306", parsed.Server)
assert.Equal(t, "pp_db", parsed.Name)
}
func TestClusterRegister_RotateSecret_JSON(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/cluster/nodes/register" {
http.NotFound(w, r)
return
}
if r.Header.Get("Authorization") != "Bearer test-token" {
w.WriteHeader(http.StatusUnauthorized)
return
}
b, _ := io.ReadAll(r.Body)
if gjson.GetBytes(b, "rotate").Bool() || !gjson.GetBytes(b, "rotateSecret").Bool() {
w.WriteHeader(http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(map[string]any{
"node": map[string]any{"id": "n6", "name": "pp-node-08", "type": "instance", "createdAt": "2025-09-15T00:00:00Z", "updatedAt": "2025-09-15T00:00:00Z"},
"db": map[string]any{"host": "db", "port": 3306, "name": "pp_db", "user": "pp_user", "dbLastRotatedAt": "2025-09-15T00:00:00Z"},
"secrets": map[string]any{"nodeSecret": "pwd8secret", "nodeSecretLastRotatedAt": "2025-09-15T00:00:00Z"},
"alreadyRegistered": true,
"alreadyProvisioned": true,
})
}))
defer ts.Close()
out, err := RunWithTestContext(ClusterRegisterCommand, []string{
"register", "--name", "pp-node-08", "--type", "instance", "--portal-url", ts.URL, "--portal-token", "test-token", "--rotate-secret", "--json",
})
assert.NoError(t, err)
assert.Equal(t, "pp-node-08", gjson.Get(out, "node.name").String())
assert.Equal(t, "pwd8secret", gjson.Get(out, "secrets.nodeSecret").String())
assert.Equal(t, "", gjson.Get(out, "db.password").String())
}

View File

@@ -0,0 +1,56 @@
package commands
import (
"encoding/json"
"fmt"
"time"
"github.com/urfave/cli/v2"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/service/cluster"
reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
"github.com/photoprism/photoprism/pkg/txt/report"
)
// ClusterSummaryCommand prints a minimal cluster summary (Portal-only).
var ClusterSummaryCommand = &cli.Command{
Name: "summary",
Usage: "Shows cluster summary (Portal-only)",
Flags: append(report.CliFlags, JsonFlag),
Action: clusterSummaryAction,
}
func clusterSummaryAction(ctx *cli.Context) error {
return CallWithDependencies(ctx, func(conf *config.Config) error {
if !conf.IsPortal() {
return fmt.Errorf("cluster summary is only available on a Portal node")
}
r, err := reg.NewFileRegistry(conf)
if err != nil {
return err
}
nodes, _ := r.List()
resp := cluster.SummaryResponse{
PortalUUID: conf.PortalUUID(),
Nodes: len(nodes),
DB: cluster.DBInfo{Driver: conf.DatabaseDriverName(), Host: conf.DatabaseHost(), Port: conf.DatabasePort()},
Time: time.Now().UTC().Format(time.RFC3339),
}
if ctx.Bool("json") {
b, _ := json.Marshal(resp)
fmt.Println(string(b))
return nil
}
cols := []string{"Portal UUID", "Nodes", "DB Driver", "DB Host", "DB Port", "Time"}
rows := [][]string{{resp.PortalUUID, fmt.Sprintf("%d", resp.Nodes), resp.DB.Driver, resp.DB.Host, fmt.Sprintf("%d", resp.DB.Port), resp.Time}}
out, err := report.RenderFormat(rows, cols, report.CliFormat(ctx))
fmt.Printf("\n%s\n", out)
return err
})
}

View File

@@ -0,0 +1,132 @@
package commands
import (
"archive/zip"
"bytes"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/photoprism/get"
reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
"github.com/photoprism/photoprism/pkg/fs"
)
func TestClusterSummaryCommand(t *testing.T) {
t.Run("NotPortal", func(t *testing.T) {
out, err := RunWithTestContext(ClusterSummaryCommand, []string{"summary"})
assert.Error(t, err)
_ = out
})
}
func TestClusterNodesListCommand(t *testing.T) {
t.Run("NotPortal", func(t *testing.T) {
out, err := RunWithTestContext(ClusterNodesListCommand, []string{"ls"})
assert.Error(t, err)
_ = out
})
}
func TestClusterNodesShowCommand(t *testing.T) {
t.Run("NotFound", func(t *testing.T) {
_ = os.Setenv("PHOTOPRISM_NODE_TYPE", "portal")
defer os.Unsetenv("PHOTOPRISM_NODE_TYPE")
out, err := RunWithTestContext(ClusterNodesShowCommand, []string{"show", "does-not-exist"})
assert.Error(t, err)
_ = out
})
}
func TestClusterThemePullCommand(t *testing.T) {
t.Run("NotPortal", func(t *testing.T) {
out, err := RunWithTestContext(ClusterThemePullCommand.Subcommands[0], []string{"pull"})
assert.Error(t, err)
_ = out
})
}
func TestClusterRegisterCommand(t *testing.T) {
t.Run("ValidationMissingURL", func(t *testing.T) {
out, err := RunWithTestContext(ClusterRegisterCommand, []string{"register", "--name", "pp-node-01", "--type", "instance", "--portal-token", "token"})
assert.Error(t, err)
_ = out
})
}
func TestClusterSuccessPaths_PortalLocal(t *testing.T) {
// Enable portal mode for local admin commands.
c := get.Config()
c.Options().NodeType = "portal"
// Ensure registry and theme paths exist.
portCfg := c.PortalConfigPath()
nodesDir := filepath.Join(portCfg, "nodes")
themeDir := filepath.Join(portCfg, "theme")
assert.NoError(t, fs.MkdirAll(nodesDir))
assert.NoError(t, fs.MkdirAll(themeDir))
// Create a theme file to zip.
themeFile := filepath.Join(themeDir, "test.txt")
assert.NoError(t, os.WriteFile(themeFile, []byte("ok"), 0o600))
// Create a registry node via FileRegistry.
r, err := reg.NewFileRegistry(c)
assert.NoError(t, err)
n := &reg.Node{Name: "pp-node-01", Type: "instance", Labels: map[string]string{"env": "test"}}
assert.NoError(t, r.Put(n))
// nodes ls (JSON)
out, err := RunWithTestContext(ClusterNodesListCommand, []string{"ls", "--json"})
assert.NoError(t, err)
assert.Contains(t, out, "pp-node-01")
// nodes show by name
out, err = RunWithTestContext(ClusterNodesShowCommand, []string{"show", "pp-node-01"})
assert.NoError(t, err)
assert.Contains(t, out, "pp-node-01")
// nodes mod: add another label (non-interactive)
out, err = RunWithTestContext(ClusterNodesModCommand, []string{"mod", "pp-node-01", "--label", "region=us-east-1", "-y"})
assert.NoError(t, err)
_ = out
// theme pull via HTTP: fake portal endpoint returns a zip with test.txt
// Prepare temp destination
destDir := t.TempDir()
// Create a fake portal theme zip server
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/cluster/theme" {
http.NotFound(w, r)
return
}
if r.Header.Get("Authorization") != "Bearer test-token" {
w.WriteHeader(http.StatusUnauthorized)
return
}
w.Header().Set("Content-Type", "application/zip")
// Build a small zip in-memory
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
f, _ := zw.Create("test.txt")
_, _ = f.Write([]byte("ok"))
_ = zw.Close()
_, _ = w.Write(buf.Bytes())
}))
defer ts.Close()
_ = os.Setenv("PHOTOPRISM_PORTAL_URL", ts.URL)
_ = os.Setenv("PHOTOPRISM_PORTAL_TOKEN", "test-token")
defer os.Unsetenv("PHOTOPRISM_PORTAL_URL")
defer os.Unsetenv("PHOTOPRISM_PORTAL_TOKEN")
out, err = RunWithTestContext(ClusterThemePullCommand.Subcommands[0], []string{"pull", "--dest", destDir, "-f", "--portal-url=" + ts.URL, "--portal-token=test-token"})
assert.NoError(t, err)
// Expect extracted file
assert.FileExists(t, filepath.Join(destDir, "test.txt"))
}

View File

@@ -0,0 +1,230 @@
package commands
import (
"archive/zip"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/urfave/cli/v2"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
)
// ClusterThemePullCommand downloads the Portal theme and installs it.
var ClusterThemePullCommand = &cli.Command{
Name: "theme",
Usage: "Theme subcommands",
Subcommands: []*cli.Command{
{
Name: "pull",
Usage: "Downloads the theme from a portal and installs it in config/theme or the dest path",
Flags: []cli.Flag{
&cli.PathFlag{Name: "dest", Usage: "extract destination `PATH` (defaults to config/theme)", Value: ""},
&cli.BoolFlag{Name: "force", Aliases: []string{"f"}, Usage: "replace existing files at destination"},
&cli.StringFlag{Name: "portal-url", Usage: "Portal base `URL` (defaults to global config)"},
&cli.StringFlag{Name: "portal-token", Usage: "Portal access `TOKEN` (defaults to global config)"},
JsonFlag,
},
Action: clusterThemePullAction,
},
},
}
func clusterThemePullAction(ctx *cli.Context) error {
return CallWithDependencies(ctx, func(conf *config.Config) error {
portalURL := strings.TrimRight(ctx.String("portal-url"), "/")
if portalURL == "" {
portalURL = strings.TrimRight(conf.PortalUrl(), "/")
}
if portalURL == "" {
portalURL = strings.TrimRight(os.Getenv(config.EnvVar("portal-url")), "/")
}
if portalURL == "" {
return fmt.Errorf("portal-url not configured; set --portal-url or PHOTOPRISM_PORTAL_URL")
}
token := ctx.String("portal-token")
if token == "" {
token = conf.PortalToken()
}
if token == "" {
token = os.Getenv(config.EnvVar("portal-token"))
}
if token == "" {
return fmt.Errorf("portal-token not configured; set --portal-token or PHOTOPRISM_PORTAL_TOKEN")
}
dest := ctx.Path("dest")
if dest == "" {
dest = conf.ThemePath()
}
dest = fs.Abs(dest)
// Destination must be a directory. Create if needed.
if fi, err := os.Stat(dest); err == nil && !fi.IsDir() {
return fmt.Errorf("destination is a file, expected a directory: %s", clean.Log(dest))
} else if err != nil {
if err := fs.MkdirAll(dest); err != nil {
return err
}
}
// If destination contains files and --force not set, refuse.
if !ctx.Bool("force") {
if nonEmpty, _ := dirNonEmpty(dest); nonEmpty {
return fmt.Errorf("destination is not empty; use --force to replace existing files: %s", clean.Log(dest))
}
} else {
// Clean destination contents, but keep the directory itself.
if err := removeDirContents(dest); err != nil {
return err
}
}
// Download zip to a temp file.
zipURL := portalURL + "/api/v1/cluster/theme"
tmpFile, err := os.CreateTemp("", "photoprism-theme-*.zip")
if err != nil {
return err
}
defer func() {
_ = os.Remove(tmpFile.Name())
}()
req, err := http.NewRequest(http.MethodGet, zipURL, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// Map common codes to clearer messages
switch resp.StatusCode {
case http.StatusUnauthorized, http.StatusForbidden:
return fmt.Errorf("unauthorized; check portal token and permissions (%s)", resp.Status)
case http.StatusTooManyRequests:
return fmt.Errorf("rate limited by portal (%s)", resp.Status)
case http.StatusNotFound:
return fmt.Errorf("portal theme not found (%s)", resp.Status)
default:
return fmt.Errorf("download failed: %s", resp.Status)
}
}
if _, err = io.Copy(tmpFile, resp.Body); err != nil {
return err
}
if err := tmpFile.Close(); err != nil {
return err
}
// Extract safely into destination.
if err := unzipSafe(tmpFile.Name(), dest); err != nil {
return err
}
if ctx.Bool("json") {
fmt.Printf("{\"installed\":\"%s\"}\n", clean.Log(dest))
} else {
log.Infof("installed theme files to %s", clean.Log(dest))
fmt.Println(dest)
}
return nil
})
}
func dirNonEmpty(dir string) (bool, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return false, err
}
for range entries {
// Ignore typical dotfiles? Keep it simple: any entry counts
return true, nil
}
return false, nil
}
func removeDirContents(dir string) error {
entries, err := os.ReadDir(dir)
if err != nil {
return err
}
for _, e := range entries {
p := filepath.Join(dir, e.Name())
if err := os.RemoveAll(p); err != nil {
return err
}
}
return nil
}
func unzipSafe(zipPath, dest string) error {
r, err := zip.OpenReader(zipPath)
if err != nil {
return err
}
defer r.Close()
if len(r.File) == 0 {
return errors.New("theme archive is empty")
}
for _, f := range r.File {
// Directories are indicated by trailing '/'; ensure canonical path
name := filepath.Clean(f.Name)
if name == "." || name == ".." || strings.HasPrefix(name, "../") || strings.Contains(name, ":") {
continue
}
// Disallow absolute and Windows drive paths
if filepath.IsAbs(name) {
continue
}
target := filepath.Join(dest, name)
// Ensure path stays within dest
if !strings.HasPrefix(target+string(os.PathSeparator), dest+string(os.PathSeparator)) && target != dest {
continue
}
// Skip entries that look like hidden files or directories
base := filepath.Base(name)
if fs.FileNameHidden(base) {
continue
}
if f.FileInfo().IsDir() {
if err := fs.MkdirAll(target); err != nil {
return err
}
continue
}
// Ensure parent exists
if err := fs.MkdirAll(filepath.Dir(target)); err != nil {
return err
}
// Open for read
rc, err := f.Open()
if err != nil {
return err
}
// Create/truncate target
out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, f.Mode())
if err != nil {
rc.Close()
return err
}
if _, err := io.Copy(out, rc); err != nil {
out.Close()
rc.Close()
return err
}
out.Close()
rc.Close()
}
return nil
}

View File

@@ -27,6 +27,7 @@ package commands
import ( import (
"context" "context"
"os" "os"
"strings"
"syscall" "syscall"
"github.com/sevlyar/go-daemon" "github.com/sevlyar/go-daemon"
@@ -37,7 +38,15 @@ import (
"github.com/photoprism/photoprism/pkg/fs" "github.com/photoprism/photoprism/pkg/fs"
) )
const NONINTERACTIVE = "noninteractive"
var log = event.Log var log = event.Log
var cliMode = strings.ToLower(os.Getenv(config.EnvVar("cli")))
// RunNonInteractively checks if command should run non-interactively.
func RunNonInteractively(confirmed bool) bool {
return confirmed || cliMode == NONINTERACTIVE
}
// PhotoPrism contains the photoprism CLI (sub-)commands. // PhotoPrism contains the photoprism CLI (sub-)commands.
var PhotoPrism = []*cli.Command{ var PhotoPrism = []*cli.Command{
@@ -66,6 +75,7 @@ var PhotoPrism = []*cli.Command{
PasswdCommand, PasswdCommand,
UsersCommands, UsersCommands,
ClientsCommands, ClientsCommands,
ClusterCommands,
AuthCommands, AuthCommands,
ShowCommands, ShowCommands,
VersionCommand, VersionCommand,

View File

@@ -63,7 +63,7 @@ func placesUpdateAction(ctx *cli.Context) error {
conf.InitDb() conf.InitDb()
defer conf.Shutdown() defer conf.Shutdown()
if !ctx.Bool("yes") { if !RunNonInteractively(ctx.Bool("yes")) {
confirmPrompt := promptui.Prompt{ confirmPrompt := promptui.Prompt{
Label: "Interrupting the update may lead to inconsistent location information. Continue?", Label: "Interrupting the update may lead to inconsistent location information. Continue?",
IsConfirm: true, IsConfirm: true,

View File

@@ -54,7 +54,7 @@ func resetAction(ctx *cli.Context) error {
defer conf.Shutdown() defer conf.Shutdown()
if !ctx.Bool("yes") { if !RunNonInteractively(ctx.Bool("yes")) {
log.Warnf("This will delete and recreate your index database after confirmation") log.Warnf("This will delete and recreate your index database after confirmation")
if !ctx.Bool("index") { if !ctx.Bool("index") {
@@ -67,7 +67,7 @@ func resetAction(ctx *cli.Context) error {
log.Infoln("reset: enabled trace mode") log.Infoln("reset: enabled trace mode")
} }
confirmed := ctx.Bool("yes") confirmed := RunNonInteractively(ctx.Bool("yes"))
// Show prompt? // Show prompt?
if !confirmed { if !confirmed {
@@ -94,48 +94,55 @@ func resetAction(ctx *cli.Context) error {
} }
// Clear cache. // Clear cache.
removeCachePrompt := promptui.Prompt{ if RunNonInteractively(false) {
Label: "Clear cache incl thumbnails?",
IsConfirm: true,
}
if _, err = removeCachePrompt.Run(); err == nil {
resetCache(conf)
} else {
log.Infof("keeping cache files") log.Infof("keeping cache files")
} else {
removeCachePrompt := promptui.Prompt{Label: "Clear cache incl thumbnails?", IsConfirm: true}
if _, err = removeCachePrompt.Run(); err == nil {
resetCache(conf)
} else {
log.Infof("keeping cache files")
}
} }
// *.json sidecar files. // *.json sidecar files.
removeSidecarJsonPrompt := promptui.Prompt{ if RunNonInteractively(false) {
Label: "Delete all *.json sidecar files?",
IsConfirm: true,
}
if _, err = removeSidecarJsonPrompt.Run(); err == nil {
resetSidecarJson(conf)
} else {
log.Infof("keeping *.json sidecar files") log.Infof("keeping *.json sidecar files")
} else {
removeSidecarJsonPrompt := promptui.Prompt{Label: "Delete all *.json sidecar files?", IsConfirm: true}
if _, err = removeSidecarJsonPrompt.Run(); err == nil {
resetSidecarJson(conf)
} else {
log.Infof("keeping *.json sidecar files")
}
} }
// *.yml metadata files. // *.yml metadata files.
removeSidecarYamlPrompt := promptui.Prompt{ if RunNonInteractively(false) {
Label: "Delete all *.yml metadata files?",
IsConfirm: true,
}
if _, err = removeSidecarYamlPrompt.Run(); err == nil {
resetSidecarYaml(conf)
} else {
log.Infof("keeping *.yml metadata files") log.Infof("keeping *.yml metadata files")
} else {
removeSidecarYamlPrompt := promptui.Prompt{Label: "Delete all *.yml metadata files?", IsConfirm: true}
if _, err = removeSidecarYamlPrompt.Run(); err == nil {
resetSidecarYaml(conf)
} else {
log.Infof("keeping *.yml metadata files")
}
} }
// *.yml album files. // *.yml album files.
removeAlbumYamlPrompt := promptui.Prompt{ if !RunNonInteractively(false) {
Label: "Delete all *.yml album files?", removeAlbumYamlPrompt := promptui.Prompt{Label: "Delete all *.yml album files?", IsConfirm: true}
IsConfirm: true, if _, err = removeAlbumYamlPrompt.Run(); err != nil {
log.Infof("keeping *.yml album files")
return nil
}
} else {
log.Infof("keeping *.yml album files")
return nil
} }
if _, err = removeAlbumYamlPrompt.Run(); err == nil { // If confirmed, proceed to delete album YAML files
{
start := time.Now() start := time.Now()
matches, globErr := filepath.Glob(regexp.QuoteMeta(conf.BackupAlbumsPath()) + "/**/*.yml") matches, globErr := filepath.Glob(regexp.QuoteMeta(conf.BackupAlbumsPath()) + "/**/*.yml")
@@ -161,8 +168,6 @@ func resetAction(ctx *cli.Context) error {
} else { } else {
log.Infof("found no *.yml album files") log.Infof("found no *.yml album files")
} }
} else {
log.Infof("keeping *.yml album files")
} }
return nil return nil

View File

@@ -54,7 +54,7 @@ func usersRemoveAction(ctx *cli.Context) error {
return fmt.Errorf("user %s has already been deleted", clean.LogQuote(id)) return fmt.Errorf("user %s has already been deleted", clean.LogQuote(id))
} }
if !ctx.Bool("force") { if !ctx.Bool("force") && !RunNonInteractively(false) {
actionPrompt := promptui.Prompt{ actionPrompt := promptui.Prompt{
Label: fmt.Sprintf("Delete user %s?", m.String()), Label: fmt.Sprintf("Delete user %s?", m.String()),
IsConfirm: true, IsConfirm: true,

View File

@@ -34,7 +34,7 @@ var UsersResetCommand = &cli.Command{
// usersResetAction deletes recreates the user management database tables. // usersResetAction deletes recreates the user management database tables.
func usersResetAction(ctx *cli.Context) error { func usersResetAction(ctx *cli.Context) error {
return CallWithDependencies(ctx, func(conf *config.Config) error { return CallWithDependencies(ctx, func(conf *config.Config) error {
confirmed := ctx.Bool("yes") confirmed := RunNonInteractively(ctx.Bool("yes"))
// Show prompt? // Show prompt?
if !confirmed { if !confirmed {

View File

@@ -86,6 +86,8 @@ func (r *FileRegistry) FindByName(name string) (*Node, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
var best *Node
var bestTime time.Time
for _, e := range entries { for _, e := range entries {
if e.IsDir() || filepath.Ext(e.Name()) != ".yaml" { if e.IsDir() || filepath.Ext(e.Name()) != ".yaml" {
continue continue
@@ -96,10 +98,18 @@ func (r *FileRegistry) FindByName(name string) (*Node, error) {
} }
var n Node var n Node
if yaml.Unmarshal(b, &n) == nil && n.Name == name { if yaml.Unmarshal(b, &n) == nil && n.Name == name {
return &n, nil // prefer most recently updated
if t, _ := time.Parse(time.RFC3339, n.UpdatedAt); best == nil || t.After(bestTime) {
cp := n
best = &cp
bestTime = t
}
} }
} }
return nil, os.ErrNotExist if best == nil {
return nil, os.ErrNotExist
}
return best, nil
} }
// List returns all registered nodes (without filtering), sorted by UpdatedAt descending. // List returns all registered nodes (without filtering), sorted by UpdatedAt descending.

View File

@@ -0,0 +1,62 @@
package registry
import (
"os"
"testing"
yaml "gopkg.in/yaml.v2"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/config"
)
// TestFindByNameDeterministic verifies that FindByName returns the most
// recently updated node when multiple registry entries share the same Name.
func TestFindByNameDeterministic(t *testing.T) {
// Isolate storage/config to avoid interference from other tests.
tmp := t.TempDir()
t.Setenv("PHOTOPRISM_STORAGE_PATH", tmp)
conf := config.NewTestConfig("cluster-registry-findbyname")
r, err := NewFileRegistry(conf)
assert.NoError(t, err)
// Two nodes with the same name but different UpdatedAt timestamps.
old := Node{
ID: "id-old",
Name: "pp-node-01",
Type: "instance",
CreatedAt: "2024-01-01T00:00:00Z",
UpdatedAt: "2024-01-01T00:00:00Z",
}
newer := Node{
ID: "id-new",
Name: "pp-node-01",
Type: "instance",
CreatedAt: "2024-02-01T00:00:00Z",
UpdatedAt: "2024-02-01T00:00:00Z",
}
// Write YAML files directly to avoid timing flakiness.
b1, err := yaml.Marshal(old)
assert.NoError(t, err)
assert.NoError(t, os.WriteFile(r.fileName(old.ID), b1, 0o600))
b2, err := yaml.Marshal(newer)
assert.NoError(t, err)
assert.NoError(t, os.WriteFile(r.fileName(newer.ID), b2, 0o600))
// Expect the most recently updated node (id-new).
got, err := r.FindByName("pp-node-01")
assert.NoError(t, err)
if assert.NotNil(t, got) {
assert.Equal(t, "id-new", got.ID)
assert.Equal(t, "pp-node-01", got.Name)
}
// Non-existent name should return os.ErrNotExist.
_, err = r.FindByName("does-not-exist")
assert.ErrorIs(t, err, os.ErrNotExist)
}