mirror of
https://github.com/photoprism/photoprism.git
synced 2025-09-26 21:01:58 +08:00
Compare commits
3 Commits
5a4e03eff0
...
dbf1650c1c
Author | SHA1 | Date | |
---|---|---|---|
![]() |
dbf1650c1c | ||
![]() |
5db044284b | ||
![]() |
3c821a3ea7 |
@@ -35,11 +35,13 @@ notice:
|
||||
@echo "Creating license report for frontend dependencies..."
|
||||
license-report --only=prod --config=.report.json > NOTICE
|
||||
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:
|
||||
npm install -g testcafe@latest
|
||||
npm install -g --ignore-scripts testcafe@latest
|
||||
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:
|
||||
$(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'
|
||||
@@ -51,6 +53,8 @@ npm-update:
|
||||
$(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
|
||||
update: npm-update npm-install
|
||||
security-check: # Scan for missing --ignore-scripts and unsafe v-html
|
||||
npm run -s security:scan
|
||||
watch:
|
||||
npm run watch
|
||||
build:
|
||||
|
241
frontend/package-lock.json
generated
241
frontend/package-lock.json
generated
@@ -35,7 +35,7 @@
|
||||
"babel-loader": "^10.0.0",
|
||||
"babel-plugin-istanbul": "^7.0.1",
|
||||
"babel-plugin-polyfill-corejs3": "^0.13.0",
|
||||
"browserslist": "^4.26.0",
|
||||
"browserslist": "^4.26.2",
|
||||
"cheerio": "1.0.0-rc.12",
|
||||
"core-js": "^3.45.1",
|
||||
"cross-env": "^7.0.3",
|
||||
@@ -100,7 +100,7 @@
|
||||
"vue-sanitize-directive": "^0.2.1",
|
||||
"vue-style-loader": "^4.1.3",
|
||||
"vue3-gettext": "^2.4.0",
|
||||
"vuetify": "^3.10.0",
|
||||
"vuetify": "^3.10.1",
|
||||
"webpack": "^5.101.3",
|
||||
"webpack-bundle-analyzer": "^4.10.2",
|
||||
"webpack-cli": "^6.0.1",
|
||||
@@ -3578,9 +3578,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@ioredis/commands": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.3.1.tgz",
|
||||
"integrity": "sha512-bYtU8avhGIcje3IhvF9aSjsa5URMZBHnwKtOvXsT4sfYy9gppW11gLPT/9oNqlJZD47yPKveQFTAFWpHjKvUoQ==",
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.4.0.tgz",
|
||||
"integrity": "sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
@@ -4404,9 +4404,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.50.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.50.1.tgz",
|
||||
"integrity": "sha512-HJXwzoZN4eYTdD8bVV22DN8gsPCAj3V20NHKOs8ezfXanGpmVPR7kalUHd+Y31IJp9stdB87VKPFbsGY3H/2ag==",
|
||||
"version": "4.50.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.50.2.tgz",
|
||||
"integrity": "sha512-uLN8NAiFVIRKX9ZQha8wy6UUs06UNSZ32xj6giK/rmMXAgKahwExvK6SsmgU5/brh4w/nSgj8e0k3c1HBQpa0A==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -4417,9 +4417,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.50.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.50.1.tgz",
|
||||
"integrity": "sha512-PZlsJVcjHfcH53mOImyt3bc97Ep3FJDXRpk9sMdGX0qgLmY0EIWxCag6EigerGhLVuL8lDVYNnSo8qnTElO4xw==",
|
||||
"version": "4.50.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.50.2.tgz",
|
||||
"integrity": "sha512-oEouqQk2/zxxj22PNcGSskya+3kV0ZKH+nQxuCCOGJ4oTXBdNTbv+f/E3c74cNLeMO1S5wVWacSws10TTSB77g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -4430,9 +4430,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.50.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.50.1.tgz",
|
||||
"integrity": "sha512-xc6i2AuWh++oGi4ylOFPmzJOEeAa2lJeGUGb4MudOtgfyyjr4UPNK+eEWTPLvmPJIY/pgw6ssFIox23SyrkkJw==",
|
||||
"version": "4.50.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.50.2.tgz",
|
||||
"integrity": "sha512-OZuTVTpj3CDSIxmPgGH8en/XtirV5nfljHZ3wrNwvgkT5DQLhIKAeuFSiwtbMto6oVexV0k1F1zqURPKf5rI1Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -4443,9 +4443,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.50.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.50.1.tgz",
|
||||
"integrity": "sha512-2ofU89lEpDYhdLAbRdeyz/kX3Y2lpYc6ShRnDjY35bZhd2ipuDMDi6ZTQ9NIag94K28nFMofdnKeHR7BT0CATw==",
|
||||
"version": "4.50.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.50.2.tgz",
|
||||
"integrity": "sha512-Wa/Wn8RFkIkr1vy1k1PB//VYhLnlnn5eaJkfTQKivirOvzu5uVd2It01ukeQstMursuz7S1bU+8WW+1UPXpa8A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -4456,9 +4456,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.50.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.50.1.tgz",
|
||||
"integrity": "sha512-wOsE6H2u6PxsHY/BeFHA4VGQN3KUJFZp7QJBmDYI983fgxq5Th8FDkVuERb2l9vDMs1D5XhOrhBrnqcEY6l8ZA==",
|
||||
"version": "4.50.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.50.2.tgz",
|
||||
"integrity": "sha512-QkzxvH3kYN9J1w7D1A+yIMdI1pPekD+pWx7G5rXgnIlQ1TVYVC6hLl7SOV9pi5q9uIDF9AuIGkuzcbF7+fAhow==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -4469,9 +4469,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.50.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.50.1.tgz",
|
||||
"integrity": "sha512-A/xeqaHTlKbQggxCqispFAcNjycpUEHP52mwMQZUNqDUJFFYtPHCXS1VAG29uMlDzIVr+i00tSFWFLivMcoIBQ==",
|
||||
"version": "4.50.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.50.2.tgz",
|
||||
"integrity": "sha512-dkYXB0c2XAS3a3jmyDkX4Jk0m7gWLFzq1C3qUnJJ38AyxIF5G/dyS4N9B30nvFseCfgtCEdbYFhk0ChoCGxPog==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -4482,9 +4482,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.50.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.50.1.tgz",
|
||||
"integrity": "sha512-54v4okehwl5TaSIkpp97rAHGp7t3ghinRd/vyC1iXqXMfjYUTm7TfYmCzXDoHUPTTf36L8pr0E7YsD3CfB3ZDg==",
|
||||
"version": "4.50.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.50.2.tgz",
|
||||
"integrity": "sha512-9VlPY/BN3AgbukfVHAB8zNFWB/lKEuvzRo1NKev0Po8sYFKx0i+AQlCYftgEjcL43F2h9Ui1ZSdVBc4En/sP2w==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -4495,9 +4495,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.50.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.50.1.tgz",
|
||||
"integrity": "sha512-p/LaFyajPN/0PUHjv8TNyxLiA7RwmDoVY3flXHPSzqrGcIp/c2FjwPPP5++u87DGHtw+5kSH5bCJz0mvXngYxw==",
|
||||
"version": "4.50.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.50.2.tgz",
|
||||
"integrity": "sha512-+GdKWOvsifaYNlIVf07QYan1J5F141+vGm5/Y8b9uCZnG/nxoGqgCmR24mv0koIWWuqvFYnbURRqw1lv7IBINw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -4508,9 +4508,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.50.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.50.1.tgz",
|
||||
"integrity": "sha512-2AbMhFFkTo6Ptna1zO7kAXXDLi7H9fGTbVaIq2AAYO7yzcAsuTNWPHhb2aTA6GPiP+JXh85Y8CiS54iZoj4opw==",
|
||||
"version": "4.50.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.50.2.tgz",
|
||||
"integrity": "sha512-df0Eou14ojtUdLQdPFnymEQteENwSJAdLf5KCDrmZNsy1c3YaCNaJvYsEUHnrg+/DLBH612/R0xd3dD03uz2dg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -4521,9 +4521,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.50.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.50.1.tgz",
|
||||
"integrity": "sha512-Cgef+5aZwuvesQNw9eX7g19FfKX5/pQRIyhoXLCiBOrWopjo7ycfB292TX9MDcDijiuIJlx1IzJz3IoCPfqs9w==",
|
||||
"version": "4.50.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.50.2.tgz",
|
||||
"integrity": "sha512-iPeouV0UIDtz8j1YFR4OJ/zf7evjauqv7jQ/EFs0ClIyL+by++hiaDAfFipjOgyz6y6xbDvJuiU4HwpVMpRFDQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -4533,10 +4533,10 @@
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loongarch64-gnu": {
|
||||
"version": "4.50.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.50.1.tgz",
|
||||
"integrity": "sha512-RPhTwWMzpYYrHrJAS7CmpdtHNKtt2Ueo+BlLBjfZEhYBhK00OsEqM08/7f+eohiF6poe0YRDDd8nAvwtE/Y62Q==",
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.50.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.50.2.tgz",
|
||||
"integrity": "sha512-OL6KaNvBopLlj5fTa5D5bau4W82f+1TyTZRr2BdnfsrnQnmdxh4okMxR2DcDkJuh4KeoQZVuvHvzuD/lyLn2Kw==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -4547,9 +4547,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.50.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.50.1.tgz",
|
||||
"integrity": "sha512-eSGMVQw9iekut62O7eBdbiccRguuDgiPMsw++BVUg+1K7WjZXHOg/YOT9SWMzPZA+w98G+Fa1VqJgHZOHHnY0Q==",
|
||||
"version": "4.50.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.50.2.tgz",
|
||||
"integrity": "sha512-I21VJl1w6z/K5OTRl6aS9DDsqezEZ/yKpbqlvfHbW0CEF5IL8ATBMuUx6/mp683rKTK8thjs/0BaNrZLXetLag==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -4560,9 +4560,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.50.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.50.1.tgz",
|
||||
"integrity": "sha512-S208ojx8a4ciIPrLgazF6AgdcNJzQE4+S9rsmOmDJkusvctii+ZvEuIC4v/xFqzbuP8yDjn73oBlNDgF6YGSXQ==",
|
||||
"version": "4.50.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.50.2.tgz",
|
||||
"integrity": "sha512-Hq6aQJT/qFFHrYMjS20nV+9SKrXL2lvFBENZoKfoTH2kKDOJqff5OSJr4x72ZaG/uUn+XmBnGhfr4lwMRrmqCQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -4573,9 +4573,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.50.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.50.1.tgz",
|
||||
"integrity": "sha512-3Ag8Ls1ggqkGUvSZWYcdgFwriy2lWo+0QlYgEFra/5JGtAd6C5Hw59oojx1DeqcA2Wds2ayRgvJ4qxVTzCHgzg==",
|
||||
"version": "4.50.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.50.2.tgz",
|
||||
"integrity": "sha512-82rBSEXRv5qtKyr0xZ/YMF531oj2AIpLZkeNYxmKNN6I2sVE9PGegN99tYDLK2fYHJITL1P2Lgb4ZXnv0PjQvw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -4586,9 +4586,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.50.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.50.1.tgz",
|
||||
"integrity": "sha512-t9YrKfaxCYe7l7ldFERE1BRg/4TATxIg+YieHQ966jwvo7ddHJxPj9cNFWLAzhkVsbBvNA4qTbPVNsZKBO4NSg==",
|
||||
"version": "4.50.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.50.2.tgz",
|
||||
"integrity": "sha512-4Q3S3Hy7pC6uaRo9gtXUTJ+EKo9AKs3BXKc2jYypEcMQ49gDPFU2P1ariX9SEtBzE5egIX6fSUmbmGazwBVF9w==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -4599,9 +4599,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.50.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.50.1.tgz",
|
||||
"integrity": "sha512-MCgtFB2+SVNuQmmjHf+wfI4CMxy3Tk8XjA5Z//A0AKD7QXUYFMQcns91K6dEHBvZPCnhJSyDWLApk40Iq/H3tA==",
|
||||
"version": "4.50.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.50.2.tgz",
|
||||
"integrity": "sha512-9Jie/At6qk70dNIcopcL4p+1UirusEtznpNtcq/u/C5cC4HBX7qSGsYIcG6bdxj15EYWhHiu02YvmdPzylIZlA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -4612,9 +4612,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.50.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.50.1.tgz",
|
||||
"integrity": "sha512-nEvqG+0jeRmqaUMuwzlfMKwcIVffy/9KGbAGyoa26iu6eSngAYQ512bMXuqqPrlTyfqdlB9FVINs93j534UJrg==",
|
||||
"version": "4.50.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.50.2.tgz",
|
||||
"integrity": "sha512-HPNJwxPL3EmhzeAnsWQCM3DcoqOz3/IC6de9rWfGR8ZCuEHETi9km66bH/wG3YH0V3nyzyFEGUZeL5PKyy4xvw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -4625,9 +4625,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.50.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.50.1.tgz",
|
||||
"integrity": "sha512-RDsLm+phmT3MJd9SNxA9MNuEAO/J2fhW8GXk62G/B4G7sLVumNFbRwDL6v5NrESb48k+QMqdGbHgEtfU0LCpbA==",
|
||||
"version": "4.50.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.50.2.tgz",
|
||||
"integrity": "sha512-nMKvq6FRHSzYfKLHZ+cChowlEkR2lj/V0jYj9JnGUVPL2/mIeFGmVM2mLaFeNa5Jev7W7TovXqXIG2d39y1KYA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -4638,9 +4638,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.50.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.50.1.tgz",
|
||||
"integrity": "sha512-hpZB/TImk2FlAFAIsoElM3tLzq57uxnGYwplg6WDyAxbYczSi8O2eQ+H2Lx74504rwKtZ3N2g4bCUkiamzS6TQ==",
|
||||
"version": "4.50.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.50.2.tgz",
|
||||
"integrity": "sha512-eFUvvnTYEKeTyHEijQKz81bLrUQOXKZqECeiWH6tb8eXXbZk+CXSG2aFrig2BQ/pjiVRj36zysjgILkqarS2YA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -4651,9 +4651,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.50.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.50.1.tgz",
|
||||
"integrity": "sha512-SXjv8JlbzKM0fTJidX4eVsH+Wmnp0/WcD8gJxIZyR6Gay5Qcsmdbi9zVtnbkGPG8v2vMR1AD06lGWy5FLMcG7A==",
|
||||
"version": "4.50.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.50.2.tgz",
|
||||
"integrity": "sha512-cBaWmXqyfRhH8zmUxK3d3sAhEWLrtMjWBRwdMMHJIXSjvjLKvv49adxiEz+FJ8AP90apSDDBx2Tyd/WylV6ikA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -4664,9 +4664,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.50.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.50.1.tgz",
|
||||
"integrity": "sha512-StxAO/8ts62KZVRAm4JZYq9+NqNsV7RvimNK+YM7ry//zebEH6meuugqW/P5OFUCjyQgui+9fUxT6d5NShvMvA==",
|
||||
"version": "4.50.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.50.2.tgz",
|
||||
"integrity": "sha512-APwKy6YUhvZaEoHyM+9xqmTpviEI+9eL7LoCH+aLcvWYHJ663qG5zx7WzWZY+a9qkg5JtzcMyJ9z0WtQBMDmgA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -4943,12 +4943,12 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.4.0.tgz",
|
||||
"integrity": "sha512-gUuVEAK4/u6F9wRLznPUU4WGUacSEBDPoC2TrBkw3GAnOLHBL45QdfHOXp1kJ4ypBGLxTOB+t7NJLpKoC3gznQ==",
|
||||
"version": "24.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.5.0.tgz",
|
||||
"integrity": "sha512-y1dMvuvJspJiPSDZUQ+WMBvF7dpnEqN4x9DDC9ie5Fs/HUZJA3wFp7EhHoVaKX/iI0cRoECV8X2jL8zi0xrHCg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.11.0"
|
||||
"undici-types": "~7.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/parse-json": {
|
||||
@@ -7439,9 +7439,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.8.3",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.3.tgz",
|
||||
"integrity": "sha512-mcE+Wr2CAhHNWxXN/DdTI+n4gsPc5QpXpWnyCQWiQYIYZX+ZMJ8juXZgjRa/0/YPJo/NSsgW15/YgmI4nbysYw==",
|
||||
"version": "2.8.4",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.4.tgz",
|
||||
"integrity": "sha512-L+YvJwGAgwJBV1p6ffpSTa2KRc69EeeYGYjRVWKs0GKrK+LON0GC0gV+rKSNtALEDvMDqkvCFq9r1r94/Gjwxw==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"baseline-browser-mapping": "dist/cli.js"
|
||||
@@ -7565,9 +7565,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.26.0",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.0.tgz",
|
||||
"integrity": "sha512-P9go2WrP9FiPwLv3zqRD/Uoxo0RSHjzFCiQz7d4vbmwNqQFo9T9WCeP/Qn5EbcKQY6DBbkxEXNcpJOmncNrb7A==",
|
||||
"version": "4.26.2",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.2.tgz",
|
||||
"integrity": "sha512-ECFzp6uFOSB+dcZ5BK/IBaGWssbSYBHvuMeMt3MMFyhI0Z8SqGgEkBLARgpRH3hutIgPVsALcMwbDrJqPxQ65A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -7584,7 +7584,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.2",
|
||||
"baseline-browser-mapping": "^2.8.3",
|
||||
"caniuse-lite": "^1.0.30001741",
|
||||
"electron-to-chromium": "^1.5.218",
|
||||
"node-releases": "^2.0.21",
|
||||
@@ -7733,9 +7733,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001741",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001741.tgz",
|
||||
"integrity": "sha512-QGUGitqsc8ARjLdgAfxETDhRbJ0REsP6O3I96TAth/mVjh2cYzN2u+3AzPP3aVSm2FehEItaJw1xd+IGBXWeSw==",
|
||||
"version": "1.0.30001743",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001743.tgz",
|
||||
"integrity": "sha512-e6Ojr7RV14Un7dz6ASD0aZDmQPT/A+eZU+nuTNfjqmRrmkmQlnTNWH0SKmqagx9PeW87UVqapSurtAXifmtdmw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -9689,9 +9689,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/error-ex": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
|
||||
"integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==",
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz",
|
||||
"integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-arrayish": "^0.2.1"
|
||||
@@ -17786,9 +17786,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.50.1",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.50.1.tgz",
|
||||
"integrity": "sha512-78E9voJHwnXQMiQdiqswVLZwJIzdBKJ1GdI5Zx6XwoFKUIk09/sSrr+05QFzvYb8q6Y9pPV45zzDuYa3907TZA==",
|
||||
"version": "4.50.2",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.50.2.tgz",
|
||||
"integrity": "sha512-BgLRGy7tNS9H66aIMASq1qSYbAAJV6Z6WR4QYTvj5FgF15rZ/ympT1uixHXwzbZUBDbkvqUI1KR0fH1FhMaQ9w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
@@ -17801,27 +17801,27 @@
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-android-arm-eabi": "4.50.1",
|
||||
"@rollup/rollup-android-arm64": "4.50.1",
|
||||
"@rollup/rollup-darwin-arm64": "4.50.1",
|
||||
"@rollup/rollup-darwin-x64": "4.50.1",
|
||||
"@rollup/rollup-freebsd-arm64": "4.50.1",
|
||||
"@rollup/rollup-freebsd-x64": "4.50.1",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.50.1",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.50.1",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.50.1",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.50.1",
|
||||
"@rollup/rollup-linux-loongarch64-gnu": "4.50.1",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.50.1",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.50.1",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.50.1",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.50.1",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.50.1",
|
||||
"@rollup/rollup-linux-x64-musl": "4.50.1",
|
||||
"@rollup/rollup-openharmony-arm64": "4.50.1",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.50.1",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.50.1",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.50.1",
|
||||
"@rollup/rollup-android-arm-eabi": "4.50.2",
|
||||
"@rollup/rollup-android-arm64": "4.50.2",
|
||||
"@rollup/rollup-darwin-arm64": "4.50.2",
|
||||
"@rollup/rollup-darwin-x64": "4.50.2",
|
||||
"@rollup/rollup-freebsd-arm64": "4.50.2",
|
||||
"@rollup/rollup-freebsd-x64": "4.50.2",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.50.2",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.50.2",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.50.2",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.50.2",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.50.2",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.50.2",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.50.2",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.50.2",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.50.2",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.50.2",
|
||||
"@rollup/rollup-linux-x64-musl": "4.50.2",
|
||||
"@rollup/rollup-openharmony-arm64": "4.50.2",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.50.2",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.50.2",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.50.2",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
@@ -20145,9 +20145,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.11.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.11.0.tgz",
|
||||
"integrity": "sha512-kt1ZriHTi7MU+Z/r9DOdAI3ONdaR3M3csEaRc6ewa4f4dTvX4cQCbJ4NkEn0ohE4hHtq85+PhPSTY+pO/1PwgA==",
|
||||
"version": "7.12.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.12.0.tgz",
|
||||
"integrity": "sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unicode-canonical-property-names-ecmascript": {
|
||||
@@ -20182,9 +20182,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/unicode-property-aliases-ecmascript": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz",
|
||||
"integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==",
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz",
|
||||
"integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
@@ -21280,13 +21280,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vuetify": {
|
||||
"version": "3.10.0",
|
||||
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-3.10.0.tgz",
|
||||
"integrity": "sha512-cgtssO0yriqwEdOd6jGfUqUUztunxYPDhyY+iog0Q8i5WEa+3+eQ/dGpXeFoU80qMVm0k6uPd8aJpc5wEVXu3g==",
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/vuetify/-/vuetify-3.10.1.tgz",
|
||||
"integrity": "sha512-4mQcdANVTgGC9TgsTEzueTe/OXvEvdCLwrJFJDeDYrNlNJmcH6jAeefEl0z1j5z7CH/AQM4NQDoQ+tMqZPOK/g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.20 || >=14.13"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/johnleider"
|
||||
|
@@ -23,7 +23,10 @@
|
||||
"test-component": "cross-env TZ=UTC BUILD_ENV=development NODE_ENV=development BABEL_ENV=test vitest run tests/vitest/component",
|
||||
"testcafe": "testcafe",
|
||||
"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"
|
||||
},
|
||||
"browserslist": [
|
||||
@@ -56,7 +59,7 @@
|
||||
"babel-loader": "^10.0.0",
|
||||
"babel-plugin-istanbul": "^7.0.1",
|
||||
"babel-plugin-polyfill-corejs3": "^0.13.0",
|
||||
"browserslist": "^4.26.0",
|
||||
"browserslist": "^4.26.2",
|
||||
"cheerio": "1.0.0-rc.12",
|
||||
"core-js": "^3.45.1",
|
||||
"cross-env": "^7.0.3",
|
||||
@@ -121,7 +124,7 @@
|
||||
"vue-sanitize-directive": "^0.2.1",
|
||||
"vue-style-loader": "^4.1.3",
|
||||
"vue3-gettext": "^2.4.0",
|
||||
"vuetify": "^3.10.0",
|
||||
"vuetify": "^3.10.1",
|
||||
"webpack": "^5.101.3",
|
||||
"webpack-bundle-analyzer": "^4.10.2",
|
||||
"webpack-cli": "^6.0.1",
|
||||
@@ -137,6 +140,7 @@
|
||||
"yarn": "please use npm"
|
||||
},
|
||||
"overrides": {
|
||||
"@ctrl/tinycolor": "^4.1.3",
|
||||
"color-convert": "2.0.1",
|
||||
"color-name": "1.1.4"
|
||||
}
|
||||
|
@@ -50,7 +50,7 @@ func ClusterGetTheme(router *gin.RouterGroup) {
|
||||
}
|
||||
|
||||
clientIp := ClientIP(c)
|
||||
themePath := conf.PortalThemePath()
|
||||
themePath := conf.ThemePath()
|
||||
|
||||
// Resolve symbolic links.
|
||||
if resolved, err := filepath.EvalSymlinks(themePath); err != nil {
|
||||
|
@@ -30,11 +30,17 @@ func authRemoveAction(ctx *cli.Context) error {
|
||||
return cli.ShowSubcommandHelp(ctx)
|
||||
}
|
||||
|
||||
actionPrompt := promptui.Prompt{
|
||||
Label: fmt.Sprintf("Remove session %s?", clean.LogQuote(id)),
|
||||
IsConfirm: true,
|
||||
if cliMode == NONINTERACTIVE {
|
||||
// proceed without prompt
|
||||
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 {
|
||||
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")
|
||||
@@ -46,6 +52,7 @@ func authRemoveAction(ctx *cli.Context) error {
|
||||
} else {
|
||||
log.Infof("session %s was not removed", clean.LogQuote(id))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
@@ -35,7 +35,7 @@ var AuthResetCommand = &cli.Command{
|
||||
// authResetAction removes all sessions and resets the related database table to a clean state.
|
||||
func authResetAction(ctx *cli.Context) error {
|
||||
return CallWithDependencies(ctx, func(conf *config.Config) error {
|
||||
confirmed := ctx.Bool("yes")
|
||||
confirmed := RunNonInteractively(ctx.Bool("yes"))
|
||||
|
||||
// Show prompt?
|
||||
if !confirmed {
|
||||
|
@@ -50,7 +50,7 @@ func clientsRemoveAction(ctx *cli.Context) error {
|
||||
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{
|
||||
Label: fmt.Sprintf("Delete client %s?", m.GetUID()),
|
||||
IsConfirm: true,
|
||||
|
@@ -31,7 +31,7 @@ var ClientsResetCommand = &cli.Command{
|
||||
// clientsResetAction removes all registered client applications.
|
||||
func clientsResetAction(ctx *cli.Context) error {
|
||||
return CallWithDependencies(ctx, func(conf *config.Config) error {
|
||||
confirmed := ctx.Bool("yes")
|
||||
confirmed := RunNonInteractively(ctx.Bool("yes"))
|
||||
|
||||
// Show prompt?
|
||||
if !confirmed {
|
||||
|
31
internal/commands/cluster.go
Normal file
31
internal/commands/cluster.go
Normal 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,
|
||||
},
|
||||
}
|
47
internal/commands/cluster_health.go
Normal file
47
internal/commands/cluster_health.go
Normal 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
|
||||
})
|
||||
}
|
113
internal/commands/cluster_nodes_list.go
Normal file
113
internal/commands/cluster_nodes_list.go
Normal 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, ", ")
|
||||
}
|
103
internal/commands/cluster_nodes_mod.go
Normal file
103
internal/commands/cluster_nodes_mod.go
Normal 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
|
||||
}
|
67
internal/commands/cluster_nodes_remove.go
Normal file
67
internal/commands/cluster_nodes_remove.go
Normal 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
|
||||
})
|
||||
}
|
139
internal/commands/cluster_nodes_rotate.go
Normal file
139
internal/commands/cluster_nodes_rotate.go
Normal 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
|
||||
})
|
||||
}
|
72
internal/commands/cluster_nodes_show.go
Normal file
72
internal/commands/cluster_nodes_show.go
Normal 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
|
||||
})
|
||||
}
|
295
internal/commands/cluster_register.go
Normal file
295
internal/commands/cluster_register.go
Normal 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)
|
||||
}
|
435
internal/commands/cluster_register_http_test.go
Normal file
435
internal/commands/cluster_register_http_test.go
Normal 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())
|
||||
}
|
56
internal/commands/cluster_summary.go
Normal file
56
internal/commands/cluster_summary.go
Normal 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
|
||||
})
|
||||
}
|
132
internal/commands/cluster_test.go
Normal file
132
internal/commands/cluster_test.go
Normal 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 := ®.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"))
|
||||
}
|
230
internal/commands/cluster_theme_pull.go
Normal file
230
internal/commands/cluster_theme_pull.go
Normal 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
|
||||
}
|
@@ -27,6 +27,7 @@ package commands
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/sevlyar/go-daemon"
|
||||
@@ -37,7 +38,15 @@ import (
|
||||
"github.com/photoprism/photoprism/pkg/fs"
|
||||
)
|
||||
|
||||
const NONINTERACTIVE = "noninteractive"
|
||||
|
||||
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.
|
||||
var PhotoPrism = []*cli.Command{
|
||||
@@ -66,6 +75,7 @@ var PhotoPrism = []*cli.Command{
|
||||
PasswdCommand,
|
||||
UsersCommands,
|
||||
ClientsCommands,
|
||||
ClusterCommands,
|
||||
AuthCommands,
|
||||
ShowCommands,
|
||||
VersionCommand,
|
||||
|
@@ -63,7 +63,7 @@ func placesUpdateAction(ctx *cli.Context) error {
|
||||
conf.InitDb()
|
||||
defer conf.Shutdown()
|
||||
|
||||
if !ctx.Bool("yes") {
|
||||
if !RunNonInteractively(ctx.Bool("yes")) {
|
||||
confirmPrompt := promptui.Prompt{
|
||||
Label: "Interrupting the update may lead to inconsistent location information. Continue?",
|
||||
IsConfirm: true,
|
||||
|
@@ -54,7 +54,7 @@ func resetAction(ctx *cli.Context) error {
|
||||
|
||||
defer conf.Shutdown()
|
||||
|
||||
if !ctx.Bool("yes") {
|
||||
if !RunNonInteractively(ctx.Bool("yes")) {
|
||||
log.Warnf("This will delete and recreate your index database after confirmation")
|
||||
|
||||
if !ctx.Bool("index") {
|
||||
@@ -67,7 +67,7 @@ func resetAction(ctx *cli.Context) error {
|
||||
log.Infoln("reset: enabled trace mode")
|
||||
}
|
||||
|
||||
confirmed := ctx.Bool("yes")
|
||||
confirmed := RunNonInteractively(ctx.Bool("yes"))
|
||||
|
||||
// Show prompt?
|
||||
if !confirmed {
|
||||
@@ -94,48 +94,55 @@ func resetAction(ctx *cli.Context) error {
|
||||
}
|
||||
|
||||
// Clear cache.
|
||||
removeCachePrompt := promptui.Prompt{
|
||||
Label: "Clear cache incl thumbnails?",
|
||||
IsConfirm: true,
|
||||
}
|
||||
|
||||
if RunNonInteractively(false) {
|
||||
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.
|
||||
removeSidecarJsonPrompt := promptui.Prompt{
|
||||
Label: "Delete all *.json sidecar files?",
|
||||
IsConfirm: true,
|
||||
}
|
||||
|
||||
// *.json sidecar files.
|
||||
if RunNonInteractively(false) {
|
||||
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.
|
||||
removeSidecarYamlPrompt := promptui.Prompt{
|
||||
Label: "Delete all *.yml metadata files?",
|
||||
IsConfirm: true,
|
||||
}
|
||||
|
||||
// *.yml metadata files.
|
||||
if RunNonInteractively(false) {
|
||||
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.
|
||||
removeAlbumYamlPrompt := promptui.Prompt{
|
||||
Label: "Delete all *.yml album files?",
|
||||
IsConfirm: true,
|
||||
}
|
||||
|
||||
if _, err = removeAlbumYamlPrompt.Run(); err == nil {
|
||||
// *.yml album files.
|
||||
if !RunNonInteractively(false) {
|
||||
removeAlbumYamlPrompt := promptui.Prompt{Label: "Delete all *.yml album files?", 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 confirmed, proceed to delete album YAML files
|
||||
{
|
||||
start := time.Now()
|
||||
|
||||
matches, globErr := filepath.Glob(regexp.QuoteMeta(conf.BackupAlbumsPath()) + "/**/*.yml")
|
||||
@@ -161,8 +168,6 @@ func resetAction(ctx *cli.Context) error {
|
||||
} else {
|
||||
log.Infof("found no *.yml album files")
|
||||
}
|
||||
} else {
|
||||
log.Infof("keeping *.yml album files")
|
||||
}
|
||||
|
||||
return nil
|
||||
|
@@ -54,7 +54,7 @@ func usersRemoveAction(ctx *cli.Context) error {
|
||||
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{
|
||||
Label: fmt.Sprintf("Delete user %s?", m.String()),
|
||||
IsConfirm: true,
|
||||
|
@@ -34,7 +34,7 @@ var UsersResetCommand = &cli.Command{
|
||||
// usersResetAction deletes recreates the user management database tables.
|
||||
func usersResetAction(ctx *cli.Context) error {
|
||||
return CallWithDependencies(ctx, func(conf *config.Config) error {
|
||||
confirmed := ctx.Bool("yes")
|
||||
confirmed := RunNonInteractively(ctx.Bool("yes"))
|
||||
|
||||
// Show prompt?
|
||||
if !confirmed {
|
||||
|
@@ -86,6 +86,8 @@ func (r *FileRegistry) FindByName(name string) (*Node, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var best *Node
|
||||
var bestTime time.Time
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || filepath.Ext(e.Name()) != ".yaml" {
|
||||
continue
|
||||
@@ -96,11 +98,19 @@ func (r *FileRegistry) FindByName(name string) (*Node, error) {
|
||||
}
|
||||
var n Node
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
if best == nil {
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
return best, nil
|
||||
}
|
||||
|
||||
// List returns all registered nodes (without filtering), sorted by UpdatedAt descending.
|
||||
func (r *FileRegistry) List() ([]Node, error) {
|
||||
|
62
internal/service/cluster/registry/file_test.go
Normal file
62
internal/service/cluster/registry/file_test.go
Normal 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)
|
||||
}
|
Reference in New Issue
Block a user