Auth: Session and ACL enhancements #98 #1746

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2022-09-28 09:01:17 +02:00
parent 8be80aec49
commit f5a8c5a45d
386 changed files with 10403 additions and 5316 deletions

View File

@@ -229,6 +229,9 @@ acceptance-auth-short:
acceptance-auth-firefox:
$(info Running JS acceptance-auth tests in Firefox...)
(cd frontend && npm run testcafe -- firefox:headless --test-grep "^(Common|Core)\:*" --test-meta mode=auth --config-file ./testcaferc.json "tests/acceptance")
reset-mariadb:
$(info Resetting photoprism database...)
mysql < scripts/sql/reset-photoprism.sql
reset-mariadb-testdb:
$(info Resetting testdb database...)
mysql < scripts/sql/reset-testdb.sql
@@ -238,10 +241,7 @@ reset-mariadb-local:
reset-mariadb-acceptance:
$(info Resetting acceptance database...)
mysql < scripts/sql/reset-acceptance.sql
reset-mariadb-photoprism:
$(info Resetting photoprism database...)
mysql < scripts/sql/reset-photoprism.sql
reset-mariadb: reset-mariadb-testdb reset-mariadb-local reset-mariadb-acceptance reset-mariadb-photoprism
reset-mariadb-all: reset-mariadb-testdb reset-mariadb-local reset-mariadb-acceptance reset-mariadb-photoprism
reset-testdb: reset-sqlite reset-mariadb-testdb
reset-acceptance: reset-mariadb-acceptance
reset-sqlite:

View File

@@ -122,9 +122,9 @@
}
},
"node_modules/@babel/cli": {
"version": "7.18.10",
"resolved": "https://registry.npmjs.org/@babel/cli/-/cli-7.18.10.tgz",
"integrity": "sha512-dLvWH+ZDFAkd2jPBSghrsFBuXrREvFwjpDycXbmUoeochqKYe4zNSLEJYErpLg8dvxvZYe79/MkN461XCwpnGw==",
"version": "7.19.3",
"resolved": "https://registry.npmjs.org/@babel/cli/-/cli-7.19.3.tgz",
"integrity": "sha512-643/TybmaCAe101m2tSVHi9UKpETXP9c/Ff4mD2tAwkdP6esKIfaauZFc67vGEM6r9fekbEGid+sZhbEnSe3dg==",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.8",
"commander": "^4.0.1",
@@ -161,28 +161,28 @@
}
},
"node_modules/@babel/compat-data": {
"version": "7.19.1",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.19.1.tgz",
"integrity": "sha512-72a9ghR0gnESIa7jBN53U32FOVCEoztyIlKaNoU05zRhEecduGK9L9c3ww7Mp06JiR+0ls0GBPFJQwwtjn9ksg==",
"version": "7.19.3",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.19.3.tgz",
"integrity": "sha512-prBHMK4JYYK+wDjJF1q99KK4JLL+egWS4nmNqdlMUgCExMZ+iZW0hGhyC3VEbsPjvaN0TBhW//VIFwBrk8sEiw==",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/core": {
"version": "7.19.1",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.19.1.tgz",
"integrity": "sha512-1H8VgqXme4UXCRv7/Wa1bq7RVymKOzC7znjyFM8KiEzwFqcKUKYNoQef4GhdklgNvoBXyW4gYhuBNCM5o1zImw==",
"version": "7.19.3",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.19.3.tgz",
"integrity": "sha512-WneDJxdsjEvyKtXKsaBGbDeiyOjR5vYq4HcShxnIbG0qixpoHjI3MqeZM9NDvsojNCEBItQE4juOo/bU6e72gQ==",
"dependencies": {
"@ampproject/remapping": "^2.1.0",
"@babel/code-frame": "^7.18.6",
"@babel/generator": "^7.19.0",
"@babel/helper-compilation-targets": "^7.19.1",
"@babel/generator": "^7.19.3",
"@babel/helper-compilation-targets": "^7.19.3",
"@babel/helper-module-transforms": "^7.19.0",
"@babel/helpers": "^7.19.0",
"@babel/parser": "^7.19.1",
"@babel/parser": "^7.19.3",
"@babel/template": "^7.18.10",
"@babel/traverse": "^7.19.1",
"@babel/types": "^7.19.0",
"@babel/traverse": "^7.19.3",
"@babel/types": "^7.19.3",
"convert-source-map": "^1.7.0",
"debug": "^4.1.0",
"gensync": "^1.0.0-beta.2",
@@ -215,11 +215,11 @@
}
},
"node_modules/@babel/generator": {
"version": "7.19.0",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.19.0.tgz",
"integrity": "sha512-S1ahxf1gZ2dpoiFgA+ohK9DIpz50bJ0CWs7Zlzb54Z4sG8qmdIrGrVqmy1sAtTVRb+9CU6U8VqT9L0Zj7hxHVg==",
"version": "7.19.3",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.19.3.tgz",
"integrity": "sha512-fqVZnmp1ncvZU757UzDheKZpfPgatqY59XtW2/j/18H7u76akb8xqvjw82f+i2UKd/ksYsSick/BCLQUUtJ/qQ==",
"dependencies": {
"@babel/types": "^7.19.0",
"@babel/types": "^7.19.3",
"@jridgewell/gen-mapping": "^0.3.2",
"jsesc": "^2.5.1"
},
@@ -264,11 +264,11 @@
}
},
"node_modules/@babel/helper-compilation-targets": {
"version": "7.19.1",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.19.1.tgz",
"integrity": "sha512-LlLkkqhCMyz2lkQPvJNdIYU7O5YjWRgC2R4omjCTpZd8u8KMQzZvX4qce+/BluN1rcQiV7BoGUpmQ0LeHerbhg==",
"version": "7.19.3",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.19.3.tgz",
"integrity": "sha512-65ESqLGyGmLvgR0mst5AdW1FkNlj9rQsCKduzEoEPhBCDFGXvz2jW6bXFG6i0/MrV2s7hhXjjb2yAzcPuQlLwg==",
"dependencies": {
"@babel/compat-data": "^7.19.1",
"@babel/compat-data": "^7.19.3",
"@babel/helper-validator-option": "^7.18.6",
"browserslist": "^4.21.3",
"semver": "^6.3.0"
@@ -562,9 +562,9 @@
}
},
"node_modules/@babel/parser": {
"version": "7.19.1",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.19.1.tgz",
"integrity": "sha512-h7RCSorm1DdTVGJf3P2Mhj3kdnkmF/EiysUkzS2TdgAYqyjFdMQJbVuXOBej2SBJaXan/lIVtT6KkGbyyq753A==",
"version": "7.19.3",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.19.3.tgz",
"integrity": "sha512-pJ9xOlNWHiy9+FuFP09DEAFbAn4JskgRsVcc169w2xRBC3FRGuQEwjeIMMND9L2zc0iEhO/tGv4Zq+km+hxNpQ==",
"bin": {
"parser": "bin/babel-parser.js"
},
@@ -1510,12 +1510,12 @@
}
},
"node_modules/@babel/preset-env": {
"version": "7.19.1",
"resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.19.1.tgz",
"integrity": "sha512-c8B2c6D16Lp+Nt6HcD+nHl0VbPKVnNPTpszahuxJJnurfMtKeZ80A+qUv48Y7wqvS+dTFuLuaM9oYxyNHbCLWA==",
"version": "7.19.3",
"resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.19.3.tgz",
"integrity": "sha512-ziye1OTc9dGFOAXSWKUqQblYHNlBOaDl8wzqf2iKXJAltYiR3hKHUKmkt+S9PppW7RQpq4fFCrwwpIDj/f5P4w==",
"dependencies": {
"@babel/compat-data": "^7.19.1",
"@babel/helper-compilation-targets": "^7.19.1",
"@babel/compat-data": "^7.19.3",
"@babel/helper-compilation-targets": "^7.19.3",
"@babel/helper-plugin-utils": "^7.19.0",
"@babel/helper-validator-option": "^7.18.6",
"@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.18.6",
@@ -1583,7 +1583,7 @@
"@babel/plugin-transform-unicode-escapes": "^7.18.10",
"@babel/plugin-transform-unicode-regex": "^7.18.6",
"@babel/preset-modules": "^0.1.5",
"@babel/types": "^7.19.0",
"@babel/types": "^7.19.3",
"babel-plugin-polyfill-corejs2": "^0.3.3",
"babel-plugin-polyfill-corejs3": "^0.6.0",
"babel-plugin-polyfill-regenerator": "^0.4.1",
@@ -1655,18 +1655,18 @@
}
},
"node_modules/@babel/traverse": {
"version": "7.19.1",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.19.1.tgz",
"integrity": "sha512-0j/ZfZMxKukDaag2PtOPDbwuELqIar6lLskVPPJDjXMXjfLb1Obo/1yjxIGqqAJrmfaTIY3z2wFLAQ7qSkLsuA==",
"version": "7.19.3",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.19.3.tgz",
"integrity": "sha512-qh5yf6149zhq2sgIXmwjnsvmnNQC2iw70UFjp4olxucKrWd/dvlUsBI88VSLUsnMNF7/vnOiA+nk1+yLoCqROQ==",
"dependencies": {
"@babel/code-frame": "^7.18.6",
"@babel/generator": "^7.19.0",
"@babel/generator": "^7.19.3",
"@babel/helper-environment-visitor": "^7.18.9",
"@babel/helper-function-name": "^7.19.0",
"@babel/helper-hoist-variables": "^7.18.6",
"@babel/helper-split-export-declaration": "^7.18.6",
"@babel/parser": "^7.19.1",
"@babel/types": "^7.19.0",
"@babel/parser": "^7.19.3",
"@babel/types": "^7.19.3",
"debug": "^4.1.0",
"globals": "^11.1.0"
},
@@ -1675,12 +1675,12 @@
}
},
"node_modules/@babel/types": {
"version": "7.19.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.19.0.tgz",
"integrity": "sha512-YuGopBq3ke25BVSiS6fgF49Ul9gH1x70Bcr6bqRLjWCkcX8Hre1/5+z+IiWOIerRMSSEfGZVB9z9kyq7wVs9YA==",
"version": "7.19.3",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.19.3.tgz",
"integrity": "sha512-hGCaQzIY22DJlDh9CH7NOxgKkFjBk0Cw9xDO1Xmh2151ti7wiGfQ3LauXzL4HP1fmFlTX6XjpRETTpUcv7wQLw==",
"dependencies": {
"@babel/helper-string-parser": "^7.18.10",
"@babel/helper-validator-identifier": "^7.18.6",
"@babel/helper-validator-identifier": "^7.19.1",
"to-fast-properties": "^2.0.0"
},
"engines": {
@@ -2033,9 +2033,9 @@
}
},
"node_modules/@humanwhocodes/config-array": {
"version": "0.10.4",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.10.4.tgz",
"integrity": "sha512-mXAIHxZT3Vcpg83opl1wGlVZ9xydbfZO3r5YfRSH6Gpp2J/PfdBP0wbDa2sO6/qRbcalpoevVyW6A/fI6LfeMw==",
"version": "0.10.5",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.10.5.tgz",
"integrity": "sha512-XVVDtp+dVvRxMoxSiSfasYaG02VEe1qH5cKgMQJWhol6HwzbcqoCMJi8dAGoYAO57jhUyhI6cWuRiTcRaDaYug==",
"dependencies": {
"@humanwhocodes/object-schema": "^1.2.1",
"debug": "^4.1.1",
@@ -2350,9 +2350,9 @@
}
},
"node_modules/@types/node": {
"version": "18.7.18",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.7.18.tgz",
"integrity": "sha512-m+6nTEOadJZuTPkKR/SYK3A2d7FZrgElol9UP1Kae90VVU4a6mxnPuLiIW1m4Cq4gZ/nWb9GrdVXJCoCazDAbg=="
"version": "18.7.23",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.7.23.tgz",
"integrity": "sha512-DWNcCHolDq0ZKGizjx2DZjR/PqsYwAcYUJmfMWqtVU2MBMG5Mo+xFZrhGId5r/O5HOuMPyQEcM6KUBp5lBZZBg=="
},
"node_modules/@types/parse-json": {
"version": "4.0.0",
@@ -2370,36 +2370,36 @@
"integrity": "sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q=="
},
"node_modules/@vue/compiler-core": {
"version": "3.2.39",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.2.39.tgz",
"integrity": "sha512-mf/36OWXqWn0wsC40nwRRGheR/qoID+lZXbIuLnr4/AngM0ov8Xvv8GHunC0rKRIkh60bTqydlqTeBo49rlbqw==",
"version": "3.2.40",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.2.40.tgz",
"integrity": "sha512-2Dc3Stk0J/VyQ4OUr2yEC53kU28614lZS+bnrCbFSAIftBJ40g/2yQzf4mPBiFuqguMB7hyHaujdgZAQ67kZYA==",
"dependencies": {
"@babel/parser": "^7.16.4",
"@vue/shared": "3.2.39",
"@vue/shared": "3.2.40",
"estree-walker": "^2.0.2",
"source-map": "^0.6.1"
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.2.39",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.2.39.tgz",
"integrity": "sha512-HMFI25Be1C8vLEEv1hgEO1dWwG9QQ8LTTPmCkblVJY/O3OvWx6r1+zsox5mKPMGvqYEZa6l8j+xgOfUspgo7hw==",
"version": "3.2.40",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.2.40.tgz",
"integrity": "sha512-OZCNyYVC2LQJy4H7h0o28rtk+4v+HMQygRTpmibGoG9wZyomQiS5otU7qo3Wlq5UfHDw2RFwxb9BJgKjVpjrQw==",
"dependencies": {
"@vue/compiler-core": "3.2.39",
"@vue/shared": "3.2.39"
"@vue/compiler-core": "3.2.40",
"@vue/shared": "3.2.40"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.2.39",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.2.39.tgz",
"integrity": "sha512-fqAQgFs1/BxTUZkd0Vakn3teKUt//J3c420BgnYgEOoVdTwYpBTSXCMJ88GOBCylmUBbtquGPli9tVs7LzsWIA==",
"version": "3.2.40",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.2.40.tgz",
"integrity": "sha512-tzqwniIN1fu1PDHC3CpqY/dPCfN/RN1thpBC+g69kJcrl7mbGiHKNwbA6kJ3XKKy8R6JLKqcpVugqN4HkeBFFg==",
"dependencies": {
"@babel/parser": "^7.16.4",
"@vue/compiler-core": "3.2.39",
"@vue/compiler-dom": "3.2.39",
"@vue/compiler-ssr": "3.2.39",
"@vue/reactivity-transform": "3.2.39",
"@vue/shared": "3.2.39",
"@vue/compiler-core": "3.2.40",
"@vue/compiler-dom": "3.2.40",
"@vue/compiler-ssr": "3.2.40",
"@vue/reactivity-transform": "3.2.40",
"@vue/shared": "3.2.40",
"estree-walker": "^2.0.2",
"magic-string": "^0.25.7",
"postcss": "^8.1.10",
@@ -2407,12 +2407,12 @@
}
},
"node_modules/@vue/compiler-ssr": {
"version": "3.2.39",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.2.39.tgz",
"integrity": "sha512-EoGCJ6lincKOZGW+0Ky4WOKsSmqL7hp1ZYgen8M7u/mlvvEQUaO9tKKOy7K43M9U2aA3tPv0TuYYQFrEbK2eFQ==",
"version": "3.2.40",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.2.40.tgz",
"integrity": "sha512-80cQcgasKjrPPuKcxwuCx7feq+wC6oFl5YaKSee9pV3DNq+6fmCVwEEC3vvkf/E2aI76rIJSOYHsWSEIxK74oQ==",
"dependencies": {
"@vue/compiler-dom": "3.2.39",
"@vue/shared": "3.2.39"
"@vue/compiler-dom": "3.2.40",
"@vue/shared": "3.2.40"
}
},
"node_modules/@vue/component-compiler-utils": {
@@ -2455,26 +2455,26 @@
}
},
"node_modules/@vue/reactivity-transform": {
"version": "3.2.39",
"resolved": "https://registry.npmjs.org/@vue/reactivity-transform/-/reactivity-transform-3.2.39.tgz",
"integrity": "sha512-HGuWu864zStiWs9wBC6JYOP1E00UjMdDWIG5W+FpUx28hV3uz9ODOKVNm/vdOy/Pvzg8+OcANxAVC85WFBbl3A==",
"version": "3.2.40",
"resolved": "https://registry.npmjs.org/@vue/reactivity-transform/-/reactivity-transform-3.2.40.tgz",
"integrity": "sha512-HQUCVwEaacq6fGEsg2NUuGKIhUveMCjOk8jGHqLXPI2w6zFoPrlQhwWEaINTv5kkZDXKEnCijAp+4gNEHG03yw==",
"dependencies": {
"@babel/parser": "^7.16.4",
"@vue/compiler-core": "3.2.39",
"@vue/shared": "3.2.39",
"@vue/compiler-core": "3.2.40",
"@vue/shared": "3.2.40",
"estree-walker": "^2.0.2",
"magic-string": "^0.25.7"
}
},
"node_modules/@vue/shared": {
"version": "3.2.39",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.2.39.tgz",
"integrity": "sha512-D3dl2ZB9qE6mTuWPk9RlhDeP1dgNRUKC3NJxji74A4yL8M2MwlhLKUC/49WHjrNzSPug58fWx/yFbaTzGAQSBw=="
"version": "3.2.40",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.2.40.tgz",
"integrity": "sha512-0PLQ6RUtZM0vO3teRfzGi4ltLUO5aO+kLgwh4Um3THSR03rpQWLTuRCkuO5A41ITzwdWeKdPHtSARuPkoo5pCQ=="
},
"node_modules/@vvo/tzdb": {
"version": "6.64.0",
"resolved": "https://registry.npmjs.org/@vvo/tzdb/-/tzdb-6.64.0.tgz",
"integrity": "sha512-EdX+RDaodSS05iq+rK+zAYzpNQX/ZKzRpBSiKk0m5ZcLdOfjvZ1kIyCJSVN7weL2tLkCFZ1q0tdljJTTyPpkQA=="
"version": "6.68.0",
"resolved": "https://registry.npmjs.org/@vvo/tzdb/-/tzdb-6.68.0.tgz",
"integrity": "sha512-gTYX0c/zfvdeywLFZdHJzxczXjZf4oZHRnkTemziyn4p0R+qoqdrRK5PqY2DFnH64YkFcpreNS1JbbnfWiMQgQ=="
},
"node_modules/@webassemblyjs/ast": {
"version": "1.11.1",
@@ -3502,9 +3502,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001409",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001409.tgz",
"integrity": "sha512-V0mnJ5dwarmhYv8/MzhJ//aW68UpvnQBXv8lJ2QUsvn2pHcmAuNtu8hQEDz37XnA1iE+lRR9CIfGWWpgJ5QedQ==",
"version": "1.0.30001412",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001412.tgz",
"integrity": "sha512-+TeEIee1gS5bYOiuf+PS/kp2mrXic37Hl66VY6EAfxasIk5fELTktK2oOezYed12H8w7jt3s512PpulQidPjwA==",
"funding": [
{
"type": "opencollective",
@@ -3941,9 +3941,9 @@
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="
},
"node_modules/core-js": {
"version": "3.25.2",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.25.2.tgz",
"integrity": "sha512-YB4IAT1bjEfxTJ1XYy11hJAKskO+qmhuDBM8/guIfMz4JvdsAQAqvyb97zXX7JgSrfPLG5mRGFWJwJD39ruq2A==",
"version": "3.25.3",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.25.3.tgz",
"integrity": "sha512-y1hvKXmPHvm5B7w4ln1S4uc9eV/O5+iFExSRUimnvIph11uaizFR8LFMdONN8hG3P2pipUfX4Y/fR8rAEtcHcQ==",
"hasInstallScript": true,
"funding": {
"type": "opencollective",
@@ -3951,9 +3951,9 @@
}
},
"node_modules/core-js-compat": {
"version": "3.25.2",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.25.2.tgz",
"integrity": "sha512-TxfyECD4smdn3/CjWxczVtJqVLEEC2up7/82t7vC0AzNogr+4nQ8vyF7abxAuTXWvjTClSbvGhU0RgqA4ToQaQ==",
"version": "3.25.3",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.25.3.tgz",
"integrity": "sha512-xVtYpJQ5grszDHEUU9O7XbjjcZ0ccX3LgQsyqSvTnjX97ZqEgn9F5srmrwwwMtbKzDllyFPL+O+2OFMl1lU4TQ==",
"dependencies": {
"browserslist": "^4.21.4"
},
@@ -4376,9 +4376,9 @@
"integrity": "sha512-APql/TZ6FdLEpf2z7/X2a2zyqK8juYtqaSVqxw9mYoQ64CXkfU15AeLh8pUszT8+fnYjgm6t0aIYpWKJbnLkuA=="
},
"node_modules/date-format": {
"version": "4.0.13",
"resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.13.tgz",
"integrity": "sha512-bnYCwf8Emc3pTD8pXnre+wfnjGtfi5ncMDKy7+cWZXbmRAsdWkOQHrfC1yz/KiwP5thDp2kCHWYWKBX4HP1hoQ==",
"version": "4.0.14",
"resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz",
"integrity": "sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg==",
"engines": {
"node": ">=4.0"
}
@@ -4661,9 +4661,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.4.256",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.256.tgz",
"integrity": "sha512-x+JnqyluoJv8I0U9gVe+Sk2st8vF0CzMt78SXxuoWCooLLY2k5VerIBdpvG7ql6GKI4dzNnPjmqgDJ76EdaAKw=="
"version": "1.4.265",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.265.tgz",
"integrity": "sha512-38KaYBNs0oCzWCpr6j7fY/W9vF0vSp4tKFIshQTgdZMhUpkxgotkQgjJP6iGMdmlsgMs3i0/Hkko4UXLTrkYVQ=="
},
"node_modules/emoji-regex": {
"version": "8.0.0",
@@ -4822,21 +4822,21 @@
}
},
"node_modules/es-abstract": {
"version": "1.20.2",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.20.2.tgz",
"integrity": "sha512-XxXQuVNrySBNlEkTYJoDNFe5+s2yIOpzq80sUHEdPdQr0S5nTLz4ZPPPswNIpKseDDUS5yghX1gfLIHQZ1iNuQ==",
"version": "1.20.3",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.20.3.tgz",
"integrity": "sha512-AyrnaKVpMzljIdwjzrj+LxGmj8ik2LckwXacHqrJJ/jxz6dDDBcZ7I7nlHM0FvEW8MfbWJwOd+yT2XzYW49Frw==",
"dependencies": {
"call-bind": "^1.0.2",
"es-to-primitive": "^1.2.1",
"function-bind": "^1.1.1",
"function.prototype.name": "^1.1.5",
"get-intrinsic": "^1.1.2",
"get-intrinsic": "^1.1.3",
"get-symbol-description": "^1.0.0",
"has": "^1.0.3",
"has-property-descriptors": "^1.0.0",
"has-symbols": "^1.0.3",
"internal-slot": "^1.0.3",
"is-callable": "^1.2.4",
"is-callable": "^1.2.6",
"is-negative-zero": "^2.0.2",
"is-regex": "^1.1.4",
"is-shared-array-buffer": "^1.0.2",
@@ -4846,6 +4846,7 @@
"object-keys": "^1.1.1",
"object.assign": "^4.1.4",
"regexp.prototype.flags": "^1.4.3",
"safe-regex-test": "^1.0.0",
"string.prototype.trimend": "^1.0.5",
"string.prototype.trimstart": "^1.0.5",
"unbox-primitive": "^1.0.2"
@@ -5255,9 +5256,9 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
},
"node_modules/eslint-plugin-n": {
"version": "15.2.5",
"resolved": "https://registry.npmjs.org/eslint-plugin-n/-/eslint-plugin-n-15.2.5.tgz",
"integrity": "sha512-8+BYsqiyZfpu6NXmdLOXVUfk8IocpCjpd8nMRRH0A9ulrcemhb2VI9RSJMEy5udx++A/YcVPD11zT8hpFq368g==",
"version": "15.3.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-n/-/eslint-plugin-n-15.3.0.tgz",
"integrity": "sha512-IyzPnEWHypCWasDpxeJnim60jhlumbmq0pubL6IOcnk8u2y53s5QfT8JnXy7skjHJ44yWHRb11PLtDHuu1kg/Q==",
"peer": true,
"dependencies": {
"builtins": "^5.0.1",
@@ -7328,9 +7329,9 @@
}
},
"node_modules/is-callable": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.6.tgz",
"integrity": "sha512-krO72EO2NptOGAX2KYyqbP9vYMlNAXdB53rq6f8LXY6RY7JdSR/3BD6wLUlPHSAesmY9vstNrjvqGaCiRK/91Q==",
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
"integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==",
"engines": {
"node": ">= 0.4"
},
@@ -11049,15 +11050,28 @@
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
},
"node_modules/safe-regex-test": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.0.tgz",
"integrity": "sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA==",
"dependencies": {
"call-bind": "^1.0.2",
"get-intrinsic": "^1.1.3",
"is-regex": "^1.1.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"node_modules/sass": {
"version": "1.54.9",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.54.9.tgz",
"integrity": "sha512-xb1hjASzEH+0L0WI9oFjqhRi51t/gagWnxLiwUNMltA0Ab6jIDkAacgKiGYKM9Jhy109osM7woEEai6SXeJo5Q==",
"version": "1.55.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.55.0.tgz",
"integrity": "sha512-Pk+PMy7OGLs9WaxZGJMn7S96dvlyVBwwtToX895WmCpAOr5YiJYEUJfiJidMuKb613z2xNWcXCHEuOvjZbqC6A==",
"dependencies": {
"chokidar": ">=3.0.0 <4.0.0",
"immutable": "^4.0.0",
@@ -11640,11 +11654,11 @@
}
},
"node_modules/streamroller": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/streamroller/-/streamroller-3.1.2.tgz",
"integrity": "sha512-wZswqzbgGGsXYIrBYhOE0yP+nQ6XRk7xDcYwuQAGTYXdyAUmvgVFE0YU1g5pvQT0m7GBaQfYcSnlHbapuK0H0A==",
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/streamroller/-/streamroller-3.1.3.tgz",
"integrity": "sha512-CphIJyFx2SALGHeINanjFRKQ4l7x2c+rXYJ4BMq0gd+ZK0gi4VT8b+eHe2wi58x4UayBAKx4xtHpXT/ea1cz8w==",
"dependencies": {
"date-format": "^4.0.13",
"date-format": "^4.0.14",
"debug": "^4.3.4",
"fs-extra": "^8.1.0"
},
@@ -12236,9 +12250,9 @@
}
},
"node_modules/uglify-js": {
"version": "3.17.1",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.1.tgz",
"integrity": "sha512-+juFBsLLw7AqMaqJ0GFvlsGZwdQfI2ooKQB39PSBgMnMakcFosi9O8jCwE+2/2nMNcc0z63r9mwjoDG8zr+q0Q==",
"version": "3.17.2",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.2.tgz",
"integrity": "sha512-bbxglRjsGQMchfvXZNusUcYgiB9Hx2K4AHYXQy2DITZ9Rd+JzhX7+hoocE5Winr7z2oHvPsekkBwXtigvxevXg==",
"optional": true,
"bin": {
"uglifyjs": "bin/uglifyjs"
@@ -13428,9 +13442,9 @@
}
},
"@babel/cli": {
"version": "7.18.10",
"resolved": "https://registry.npmjs.org/@babel/cli/-/cli-7.18.10.tgz",
"integrity": "sha512-dLvWH+ZDFAkd2jPBSghrsFBuXrREvFwjpDycXbmUoeochqKYe4zNSLEJYErpLg8dvxvZYe79/MkN461XCwpnGw==",
"version": "7.19.3",
"resolved": "https://registry.npmjs.org/@babel/cli/-/cli-7.19.3.tgz",
"integrity": "sha512-643/TybmaCAe101m2tSVHi9UKpETXP9c/Ff4mD2tAwkdP6esKIfaauZFc67vGEM6r9fekbEGid+sZhbEnSe3dg==",
"requires": {
"@jridgewell/trace-mapping": "^0.3.8",
"@nicolo-ribaudo/chokidar-2": "2.1.8-no-fsevents.3",
@@ -13452,25 +13466,25 @@
}
},
"@babel/compat-data": {
"version": "7.19.1",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.19.1.tgz",
"integrity": "sha512-72a9ghR0gnESIa7jBN53U32FOVCEoztyIlKaNoU05zRhEecduGK9L9c3ww7Mp06JiR+0ls0GBPFJQwwtjn9ksg=="
"version": "7.19.3",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.19.3.tgz",
"integrity": "sha512-prBHMK4JYYK+wDjJF1q99KK4JLL+egWS4nmNqdlMUgCExMZ+iZW0hGhyC3VEbsPjvaN0TBhW//VIFwBrk8sEiw=="
},
"@babel/core": {
"version": "7.19.1",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.19.1.tgz",
"integrity": "sha512-1H8VgqXme4UXCRv7/Wa1bq7RVymKOzC7znjyFM8KiEzwFqcKUKYNoQef4GhdklgNvoBXyW4gYhuBNCM5o1zImw==",
"version": "7.19.3",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.19.3.tgz",
"integrity": "sha512-WneDJxdsjEvyKtXKsaBGbDeiyOjR5vYq4HcShxnIbG0qixpoHjI3MqeZM9NDvsojNCEBItQE4juOo/bU6e72gQ==",
"requires": {
"@ampproject/remapping": "^2.1.0",
"@babel/code-frame": "^7.18.6",
"@babel/generator": "^7.19.0",
"@babel/helper-compilation-targets": "^7.19.1",
"@babel/generator": "^7.19.3",
"@babel/helper-compilation-targets": "^7.19.3",
"@babel/helper-module-transforms": "^7.19.0",
"@babel/helpers": "^7.19.0",
"@babel/parser": "^7.19.1",
"@babel/parser": "^7.19.3",
"@babel/template": "^7.18.10",
"@babel/traverse": "^7.19.1",
"@babel/types": "^7.19.0",
"@babel/traverse": "^7.19.3",
"@babel/types": "^7.19.3",
"convert-source-map": "^1.7.0",
"debug": "^4.1.0",
"gensync": "^1.0.0-beta.2",
@@ -13489,11 +13503,11 @@
}
},
"@babel/generator": {
"version": "7.19.0",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.19.0.tgz",
"integrity": "sha512-S1ahxf1gZ2dpoiFgA+ohK9DIpz50bJ0CWs7Zlzb54Z4sG8qmdIrGrVqmy1sAtTVRb+9CU6U8VqT9L0Zj7hxHVg==",
"version": "7.19.3",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.19.3.tgz",
"integrity": "sha512-fqVZnmp1ncvZU757UzDheKZpfPgatqY59XtW2/j/18H7u76akb8xqvjw82f+i2UKd/ksYsSick/BCLQUUtJ/qQ==",
"requires": {
"@babel/types": "^7.19.0",
"@babel/types": "^7.19.3",
"@jridgewell/gen-mapping": "^0.3.2",
"jsesc": "^2.5.1"
},
@@ -13528,11 +13542,11 @@
}
},
"@babel/helper-compilation-targets": {
"version": "7.19.1",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.19.1.tgz",
"integrity": "sha512-LlLkkqhCMyz2lkQPvJNdIYU7O5YjWRgC2R4omjCTpZd8u8KMQzZvX4qce+/BluN1rcQiV7BoGUpmQ0LeHerbhg==",
"version": "7.19.3",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.19.3.tgz",
"integrity": "sha512-65ESqLGyGmLvgR0mst5AdW1FkNlj9rQsCKduzEoEPhBCDFGXvz2jW6bXFG6i0/MrV2s7hhXjjb2yAzcPuQlLwg==",
"requires": {
"@babel/compat-data": "^7.19.1",
"@babel/compat-data": "^7.19.3",
"@babel/helper-validator-option": "^7.18.6",
"browserslist": "^4.21.3",
"semver": "^6.3.0"
@@ -13742,9 +13756,9 @@
}
},
"@babel/parser": {
"version": "7.19.1",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.19.1.tgz",
"integrity": "sha512-h7RCSorm1DdTVGJf3P2Mhj3kdnkmF/EiysUkzS2TdgAYqyjFdMQJbVuXOBej2SBJaXan/lIVtT6KkGbyyq753A=="
"version": "7.19.3",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.19.3.tgz",
"integrity": "sha512-pJ9xOlNWHiy9+FuFP09DEAFbAn4JskgRsVcc169w2xRBC3FRGuQEwjeIMMND9L2zc0iEhO/tGv4Zq+km+hxNpQ=="
},
"@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": {
"version": "7.18.6",
@@ -14327,12 +14341,12 @@
}
},
"@babel/preset-env": {
"version": "7.19.1",
"resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.19.1.tgz",
"integrity": "sha512-c8B2c6D16Lp+Nt6HcD+nHl0VbPKVnNPTpszahuxJJnurfMtKeZ80A+qUv48Y7wqvS+dTFuLuaM9oYxyNHbCLWA==",
"version": "7.19.3",
"resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.19.3.tgz",
"integrity": "sha512-ziye1OTc9dGFOAXSWKUqQblYHNlBOaDl8wzqf2iKXJAltYiR3hKHUKmkt+S9PppW7RQpq4fFCrwwpIDj/f5P4w==",
"requires": {
"@babel/compat-data": "^7.19.1",
"@babel/helper-compilation-targets": "^7.19.1",
"@babel/compat-data": "^7.19.3",
"@babel/helper-compilation-targets": "^7.19.3",
"@babel/helper-plugin-utils": "^7.19.0",
"@babel/helper-validator-option": "^7.18.6",
"@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.18.6",
@@ -14400,7 +14414,7 @@
"@babel/plugin-transform-unicode-escapes": "^7.18.10",
"@babel/plugin-transform-unicode-regex": "^7.18.6",
"@babel/preset-modules": "^0.1.5",
"@babel/types": "^7.19.0",
"@babel/types": "^7.19.3",
"babel-plugin-polyfill-corejs2": "^0.3.3",
"babel-plugin-polyfill-corejs3": "^0.6.0",
"babel-plugin-polyfill-regenerator": "^0.4.1",
@@ -14451,29 +14465,29 @@
}
},
"@babel/traverse": {
"version": "7.19.1",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.19.1.tgz",
"integrity": "sha512-0j/ZfZMxKukDaag2PtOPDbwuELqIar6lLskVPPJDjXMXjfLb1Obo/1yjxIGqqAJrmfaTIY3z2wFLAQ7qSkLsuA==",
"version": "7.19.3",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.19.3.tgz",
"integrity": "sha512-qh5yf6149zhq2sgIXmwjnsvmnNQC2iw70UFjp4olxucKrWd/dvlUsBI88VSLUsnMNF7/vnOiA+nk1+yLoCqROQ==",
"requires": {
"@babel/code-frame": "^7.18.6",
"@babel/generator": "^7.19.0",
"@babel/generator": "^7.19.3",
"@babel/helper-environment-visitor": "^7.18.9",
"@babel/helper-function-name": "^7.19.0",
"@babel/helper-hoist-variables": "^7.18.6",
"@babel/helper-split-export-declaration": "^7.18.6",
"@babel/parser": "^7.19.1",
"@babel/types": "^7.19.0",
"@babel/parser": "^7.19.3",
"@babel/types": "^7.19.3",
"debug": "^4.1.0",
"globals": "^11.1.0"
}
},
"@babel/types": {
"version": "7.19.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.19.0.tgz",
"integrity": "sha512-YuGopBq3ke25BVSiS6fgF49Ul9gH1x70Bcr6bqRLjWCkcX8Hre1/5+z+IiWOIerRMSSEfGZVB9z9kyq7wVs9YA==",
"version": "7.19.3",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.19.3.tgz",
"integrity": "sha512-hGCaQzIY22DJlDh9CH7NOxgKkFjBk0Cw9xDO1Xmh2151ti7wiGfQ3LauXzL4HP1fmFlTX6XjpRETTpUcv7wQLw==",
"requires": {
"@babel/helper-string-parser": "^7.18.10",
"@babel/helper-validator-identifier": "^7.18.6",
"@babel/helper-validator-identifier": "^7.19.1",
"to-fast-properties": "^2.0.0"
}
},
@@ -14653,9 +14667,9 @@
}
},
"@humanwhocodes/config-array": {
"version": "0.10.4",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.10.4.tgz",
"integrity": "sha512-mXAIHxZT3Vcpg83opl1wGlVZ9xydbfZO3r5YfRSH6Gpp2J/PfdBP0wbDa2sO6/qRbcalpoevVyW6A/fI6LfeMw==",
"version": "0.10.5",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.10.5.tgz",
"integrity": "sha512-XVVDtp+dVvRxMoxSiSfasYaG02VEe1qH5cKgMQJWhol6HwzbcqoCMJi8dAGoYAO57jhUyhI6cWuRiTcRaDaYug==",
"requires": {
"@humanwhocodes/object-schema": "^1.2.1",
"debug": "^4.1.1",
@@ -14925,9 +14939,9 @@
}
},
"@types/node": {
"version": "18.7.18",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.7.18.tgz",
"integrity": "sha512-m+6nTEOadJZuTPkKR/SYK3A2d7FZrgElol9UP1Kae90VVU4a6mxnPuLiIW1m4Cq4gZ/nWb9GrdVXJCoCazDAbg=="
"version": "18.7.23",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.7.23.tgz",
"integrity": "sha512-DWNcCHolDq0ZKGizjx2DZjR/PqsYwAcYUJmfMWqtVU2MBMG5Mo+xFZrhGId5r/O5HOuMPyQEcM6KUBp5lBZZBg=="
},
"@types/parse-json": {
"version": "4.0.0",
@@ -14945,36 +14959,36 @@
"integrity": "sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q=="
},
"@vue/compiler-core": {
"version": "3.2.39",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.2.39.tgz",
"integrity": "sha512-mf/36OWXqWn0wsC40nwRRGheR/qoID+lZXbIuLnr4/AngM0ov8Xvv8GHunC0rKRIkh60bTqydlqTeBo49rlbqw==",
"version": "3.2.40",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.2.40.tgz",
"integrity": "sha512-2Dc3Stk0J/VyQ4OUr2yEC53kU28614lZS+bnrCbFSAIftBJ40g/2yQzf4mPBiFuqguMB7hyHaujdgZAQ67kZYA==",
"requires": {
"@babel/parser": "^7.16.4",
"@vue/shared": "3.2.39",
"@vue/shared": "3.2.40",
"estree-walker": "^2.0.2",
"source-map": "^0.6.1"
}
},
"@vue/compiler-dom": {
"version": "3.2.39",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.2.39.tgz",
"integrity": "sha512-HMFI25Be1C8vLEEv1hgEO1dWwG9QQ8LTTPmCkblVJY/O3OvWx6r1+zsox5mKPMGvqYEZa6l8j+xgOfUspgo7hw==",
"version": "3.2.40",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.2.40.tgz",
"integrity": "sha512-OZCNyYVC2LQJy4H7h0o28rtk+4v+HMQygRTpmibGoG9wZyomQiS5otU7qo3Wlq5UfHDw2RFwxb9BJgKjVpjrQw==",
"requires": {
"@vue/compiler-core": "3.2.39",
"@vue/shared": "3.2.39"
"@vue/compiler-core": "3.2.40",
"@vue/shared": "3.2.40"
}
},
"@vue/compiler-sfc": {
"version": "3.2.39",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.2.39.tgz",
"integrity": "sha512-fqAQgFs1/BxTUZkd0Vakn3teKUt//J3c420BgnYgEOoVdTwYpBTSXCMJ88GOBCylmUBbtquGPli9tVs7LzsWIA==",
"version": "3.2.40",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.2.40.tgz",
"integrity": "sha512-tzqwniIN1fu1PDHC3CpqY/dPCfN/RN1thpBC+g69kJcrl7mbGiHKNwbA6kJ3XKKy8R6JLKqcpVugqN4HkeBFFg==",
"requires": {
"@babel/parser": "^7.16.4",
"@vue/compiler-core": "3.2.39",
"@vue/compiler-dom": "3.2.39",
"@vue/compiler-ssr": "3.2.39",
"@vue/reactivity-transform": "3.2.39",
"@vue/shared": "3.2.39",
"@vue/compiler-core": "3.2.40",
"@vue/compiler-dom": "3.2.40",
"@vue/compiler-ssr": "3.2.40",
"@vue/reactivity-transform": "3.2.40",
"@vue/shared": "3.2.40",
"estree-walker": "^2.0.2",
"magic-string": "^0.25.7",
"postcss": "^8.1.10",
@@ -14982,12 +14996,12 @@
}
},
"@vue/compiler-ssr": {
"version": "3.2.39",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.2.39.tgz",
"integrity": "sha512-EoGCJ6lincKOZGW+0Ky4WOKsSmqL7hp1ZYgen8M7u/mlvvEQUaO9tKKOy7K43M9U2aA3tPv0TuYYQFrEbK2eFQ==",
"version": "3.2.40",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.2.40.tgz",
"integrity": "sha512-80cQcgasKjrPPuKcxwuCx7feq+wC6oFl5YaKSee9pV3DNq+6fmCVwEEC3vvkf/E2aI76rIJSOYHsWSEIxK74oQ==",
"requires": {
"@vue/compiler-dom": "3.2.39",
"@vue/shared": "3.2.39"
"@vue/compiler-dom": "3.2.40",
"@vue/shared": "3.2.40"
}
},
"@vue/component-compiler-utils": {
@@ -15023,26 +15037,26 @@
}
},
"@vue/reactivity-transform": {
"version": "3.2.39",
"resolved": "https://registry.npmjs.org/@vue/reactivity-transform/-/reactivity-transform-3.2.39.tgz",
"integrity": "sha512-HGuWu864zStiWs9wBC6JYOP1E00UjMdDWIG5W+FpUx28hV3uz9ODOKVNm/vdOy/Pvzg8+OcANxAVC85WFBbl3A==",
"version": "3.2.40",
"resolved": "https://registry.npmjs.org/@vue/reactivity-transform/-/reactivity-transform-3.2.40.tgz",
"integrity": "sha512-HQUCVwEaacq6fGEsg2NUuGKIhUveMCjOk8jGHqLXPI2w6zFoPrlQhwWEaINTv5kkZDXKEnCijAp+4gNEHG03yw==",
"requires": {
"@babel/parser": "^7.16.4",
"@vue/compiler-core": "3.2.39",
"@vue/shared": "3.2.39",
"@vue/compiler-core": "3.2.40",
"@vue/shared": "3.2.40",
"estree-walker": "^2.0.2",
"magic-string": "^0.25.7"
}
},
"@vue/shared": {
"version": "3.2.39",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.2.39.tgz",
"integrity": "sha512-D3dl2ZB9qE6mTuWPk9RlhDeP1dgNRUKC3NJxji74A4yL8M2MwlhLKUC/49WHjrNzSPug58fWx/yFbaTzGAQSBw=="
"version": "3.2.40",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.2.40.tgz",
"integrity": "sha512-0PLQ6RUtZM0vO3teRfzGi4ltLUO5aO+kLgwh4Um3THSR03rpQWLTuRCkuO5A41ITzwdWeKdPHtSARuPkoo5pCQ=="
},
"@vvo/tzdb": {
"version": "6.64.0",
"resolved": "https://registry.npmjs.org/@vvo/tzdb/-/tzdb-6.64.0.tgz",
"integrity": "sha512-EdX+RDaodSS05iq+rK+zAYzpNQX/ZKzRpBSiKk0m5ZcLdOfjvZ1kIyCJSVN7weL2tLkCFZ1q0tdljJTTyPpkQA=="
"version": "6.68.0",
"resolved": "https://registry.npmjs.org/@vvo/tzdb/-/tzdb-6.68.0.tgz",
"integrity": "sha512-gTYX0c/zfvdeywLFZdHJzxczXjZf4oZHRnkTemziyn4p0R+qoqdrRK5PqY2DFnH64YkFcpreNS1JbbnfWiMQgQ=="
},
"@webassemblyjs/ast": {
"version": "1.11.1",
@@ -15822,9 +15836,9 @@
}
},
"caniuse-lite": {
"version": "1.0.30001409",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001409.tgz",
"integrity": "sha512-V0mnJ5dwarmhYv8/MzhJ//aW68UpvnQBXv8lJ2QUsvn2pHcmAuNtu8hQEDz37XnA1iE+lRR9CIfGWWpgJ5QedQ=="
"version": "1.0.30001412",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001412.tgz",
"integrity": "sha512-+TeEIee1gS5bYOiuf+PS/kp2mrXic37Hl66VY6EAfxasIk5fELTktK2oOezYed12H8w7jt3s512PpulQidPjwA=="
},
"chai": {
"version": "4.3.6",
@@ -16157,14 +16171,14 @@
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="
},
"core-js": {
"version": "3.25.2",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.25.2.tgz",
"integrity": "sha512-YB4IAT1bjEfxTJ1XYy11hJAKskO+qmhuDBM8/guIfMz4JvdsAQAqvyb97zXX7JgSrfPLG5mRGFWJwJD39ruq2A=="
"version": "3.25.3",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.25.3.tgz",
"integrity": "sha512-y1hvKXmPHvm5B7w4ln1S4uc9eV/O5+iFExSRUimnvIph11uaizFR8LFMdONN8hG3P2pipUfX4Y/fR8rAEtcHcQ=="
},
"core-js-compat": {
"version": "3.25.2",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.25.2.tgz",
"integrity": "sha512-TxfyECD4smdn3/CjWxczVtJqVLEEC2up7/82t7vC0AzNogr+4nQ8vyF7abxAuTXWvjTClSbvGhU0RgqA4ToQaQ==",
"version": "3.25.3",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.25.3.tgz",
"integrity": "sha512-xVtYpJQ5grszDHEUU9O7XbjjcZ0ccX3LgQsyqSvTnjX97ZqEgn9F5srmrwwwMtbKzDllyFPL+O+2OFMl1lU4TQ==",
"requires": {
"browserslist": "^4.21.4"
}
@@ -16451,9 +16465,9 @@
"integrity": "sha512-APql/TZ6FdLEpf2z7/X2a2zyqK8juYtqaSVqxw9mYoQ64CXkfU15AeLh8pUszT8+fnYjgm6t0aIYpWKJbnLkuA=="
},
"date-format": {
"version": "4.0.13",
"resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.13.tgz",
"integrity": "sha512-bnYCwf8Emc3pTD8pXnre+wfnjGtfi5ncMDKy7+cWZXbmRAsdWkOQHrfC1yz/KiwP5thDp2kCHWYWKBX4HP1hoQ=="
"version": "4.0.14",
"resolved": "https://registry.npmjs.org/date-format/-/date-format-4.0.14.tgz",
"integrity": "sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg=="
},
"de-indent": {
"version": "1.0.2",
@@ -16648,9 +16662,9 @@
}
},
"electron-to-chromium": {
"version": "1.4.256",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.256.tgz",
"integrity": "sha512-x+JnqyluoJv8I0U9gVe+Sk2st8vF0CzMt78SXxuoWCooLLY2k5VerIBdpvG7ql6GKI4dzNnPjmqgDJ76EdaAKw=="
"version": "1.4.265",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.265.tgz",
"integrity": "sha512-38KaYBNs0oCzWCpr6j7fY/W9vF0vSp4tKFIshQTgdZMhUpkxgotkQgjJP6iGMdmlsgMs3i0/Hkko4UXLTrkYVQ=="
},
"emoji-regex": {
"version": "8.0.0",
@@ -16773,21 +16787,21 @@
}
},
"es-abstract": {
"version": "1.20.2",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.20.2.tgz",
"integrity": "sha512-XxXQuVNrySBNlEkTYJoDNFe5+s2yIOpzq80sUHEdPdQr0S5nTLz4ZPPPswNIpKseDDUS5yghX1gfLIHQZ1iNuQ==",
"version": "1.20.3",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.20.3.tgz",
"integrity": "sha512-AyrnaKVpMzljIdwjzrj+LxGmj8ik2LckwXacHqrJJ/jxz6dDDBcZ7I7nlHM0FvEW8MfbWJwOd+yT2XzYW49Frw==",
"requires": {
"call-bind": "^1.0.2",
"es-to-primitive": "^1.2.1",
"function-bind": "^1.1.1",
"function.prototype.name": "^1.1.5",
"get-intrinsic": "^1.1.2",
"get-intrinsic": "^1.1.3",
"get-symbol-description": "^1.0.0",
"has": "^1.0.3",
"has-property-descriptors": "^1.0.0",
"has-symbols": "^1.0.3",
"internal-slot": "^1.0.3",
"is-callable": "^1.2.4",
"is-callable": "^1.2.6",
"is-negative-zero": "^2.0.2",
"is-regex": "^1.1.4",
"is-shared-array-buffer": "^1.0.2",
@@ -16797,6 +16811,7 @@
"object-keys": "^1.1.1",
"object.assign": "^4.1.4",
"regexp.prototype.flags": "^1.4.3",
"safe-regex-test": "^1.0.0",
"string.prototype.trimend": "^1.0.5",
"string.prototype.trimstart": "^1.0.5",
"unbox-primitive": "^1.0.2"
@@ -17228,9 +17243,9 @@
}
},
"eslint-plugin-n": {
"version": "15.2.5",
"resolved": "https://registry.npmjs.org/eslint-plugin-n/-/eslint-plugin-n-15.2.5.tgz",
"integrity": "sha512-8+BYsqiyZfpu6NXmdLOXVUfk8IocpCjpd8nMRRH0A9ulrcemhb2VI9RSJMEy5udx++A/YcVPD11zT8hpFq368g==",
"version": "15.3.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-n/-/eslint-plugin-n-15.3.0.tgz",
"integrity": "sha512-IyzPnEWHypCWasDpxeJnim60jhlumbmq0pubL6IOcnk8u2y53s5QfT8JnXy7skjHJ44yWHRb11PLtDHuu1kg/Q==",
"peer": true,
"requires": {
"builtins": "^5.0.1",
@@ -18549,9 +18564,9 @@
"integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ=="
},
"is-callable": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.6.tgz",
"integrity": "sha512-krO72EO2NptOGAX2KYyqbP9vYMlNAXdB53rq6f8LXY6RY7JdSR/3BD6wLUlPHSAesmY9vstNrjvqGaCiRK/91Q=="
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz",
"integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="
},
"is-core-module": {
"version": "2.10.0",
@@ -21126,15 +21141,25 @@
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
},
"safe-regex-test": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.0.tgz",
"integrity": "sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA==",
"requires": {
"call-bind": "^1.0.2",
"get-intrinsic": "^1.1.3",
"is-regex": "^1.1.4"
}
},
"safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"sass": {
"version": "1.54.9",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.54.9.tgz",
"integrity": "sha512-xb1hjASzEH+0L0WI9oFjqhRi51t/gagWnxLiwUNMltA0Ab6jIDkAacgKiGYKM9Jhy109osM7woEEai6SXeJo5Q==",
"version": "1.55.0",
"resolved": "https://registry.npmjs.org/sass/-/sass-1.55.0.tgz",
"integrity": "sha512-Pk+PMy7OGLs9WaxZGJMn7S96dvlyVBwwtToX895WmCpAOr5YiJYEUJfiJidMuKb613z2xNWcXCHEuOvjZbqC6A==",
"requires": {
"chokidar": ">=3.0.0 <4.0.0",
"immutable": "^4.0.0",
@@ -21594,11 +21619,11 @@
"integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA=="
},
"streamroller": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/streamroller/-/streamroller-3.1.2.tgz",
"integrity": "sha512-wZswqzbgGGsXYIrBYhOE0yP+nQ6XRk7xDcYwuQAGTYXdyAUmvgVFE0YU1g5pvQT0m7GBaQfYcSnlHbapuK0H0A==",
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/streamroller/-/streamroller-3.1.3.tgz",
"integrity": "sha512-CphIJyFx2SALGHeINanjFRKQ4l7x2c+rXYJ4BMq0gd+ZK0gi4VT8b+eHe2wi58x4UayBAKx4xtHpXT/ea1cz8w==",
"requires": {
"date-format": "^4.0.13",
"date-format": "^4.0.14",
"debug": "^4.3.4",
"fs-extra": "^8.1.0"
}
@@ -22004,9 +22029,9 @@
"integrity": "sha512-qLK/Xe9E2uzmYI3qLeOmI0tEOt+TBBQyUIAh4aAgU05FVYzeZrKUdkAZfBNVGRaHVgV0TDkdEngJSw/SyQchkQ=="
},
"uglify-js": {
"version": "3.17.1",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.1.tgz",
"integrity": "sha512-+juFBsLLw7AqMaqJ0GFvlsGZwdQfI2ooKQB39PSBgMnMakcFosi9O8jCwE+2/2nMNcc0z63r9mwjoDG8zr+q0Q==",
"version": "3.17.2",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.2.tgz",
"integrity": "sha512-bbxglRjsGQMchfvXZNusUcYgiB9Hx2K4AHYXQy2DITZ9Rd+JzhX7+hoocE5Winr7z2oHvPsekkBwXtigvxevXg==",
"optional": true
},
"uid-safe": {

View File

@@ -54,39 +54,40 @@ import "common/maptiler-lang";
import { T, Mount } from "common/vm";
import * as offline from "@lcdp/offline-plugin/runtime";
// Initialize helpers
const viewer = new Viewer();
const isPublic = config.get("public");
const isMobile =
config.load().finally(() => {
// Initialize helpers.
const viewer = new Viewer();
const isPublic = config.get("public");
const isMobile =
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
(navigator.maxTouchPoints && navigator.maxTouchPoints > 2);
// Initialize language and detect alignment
Vue.config.language = config.values.settings.ui.language;
Settings.defaultLocale = Vue.config.language.substring(0, 2);
// Detect right-to-left languages such as Arabic and Hebrew
const rtl = config.rtl();
// Initialize language and detect alignment.
Vue.config.language = config.getLanguage();
Settings.defaultLocale = Vue.config.language.substring(0, 2);
// Detect right-to-left languages such as Arabic and Hebrew
const rtl = config.rtl();
// Get initial theme colors from config
const theme = config.theme.colors;
// Get initial theme colors from config.
const theme = config.theme.colors;
// HTTP Live Streaming (video support)
window.Hls = Hls;
// HTTP Live Streaming (video support).
window.Hls = Hls;
// Assign helpers to VueJS prototype
Vue.prototype.$event = Event;
Vue.prototype.$notify = Notify;
Vue.prototype.$scrollbar = Scrollbar;
Vue.prototype.$viewer = viewer;
Vue.prototype.$session = session;
Vue.prototype.$api = Api;
Vue.prototype.$log = Log;
Vue.prototype.$socket = Socket;
Vue.prototype.$config = config;
Vue.prototype.$clipboard = Clipboard;
Vue.prototype.$isMobile = isMobile;
Vue.prototype.$rtl = rtl;
Vue.prototype.$sponsorFeatures = () => {
// Assign helpers to VueJS prototype.
Vue.prototype.$event = Event;
Vue.prototype.$notify = Notify;
Vue.prototype.$scrollbar = Scrollbar;
Vue.prototype.$viewer = viewer;
Vue.prototype.$session = session;
Vue.prototype.$api = Api;
Vue.prototype.$log = Log;
Vue.prototype.$socket = Socket;
Vue.prototype.$config = config;
Vue.prototype.$clipboard = Clipboard;
Vue.prototype.$isMobile = isMobile;
Vue.prototype.$rtl = rtl;
Vue.prototype.$sponsorFeatures = () => {
return config.load().finally(() => {
if (config.values.sponsor) {
return Promise.resolve();
@@ -94,29 +95,29 @@ Vue.prototype.$sponsorFeatures = () => {
return Promise.reject();
}
});
};
};
// Register Vuetify
Vue.use(Vuetify, { rtl, icons, theme });
// Register Vuetify.
Vue.use(Vuetify, { rtl, icons, theme });
// Register other VueJS plugins
Vue.use(GetTextPlugin, {
// Register other VueJS plugins.
Vue.use(GetTextPlugin, {
translations: config.translations,
silent: true, // !config.values.debug,
defaultLanguage: Vue.config.language,
autoAddKeyAttributes: true,
});
});
Vue.use(VueLuxon);
Vue.use(VueInfiniteScroll);
Vue.use(VueFullscreen);
Vue.use(VueFilters);
Vue.use(Components);
Vue.use(Dialogs);
Vue.use(Router);
Vue.use(VueLuxon);
Vue.use(VueInfiniteScroll);
Vue.use(VueFullscreen);
Vue.use(VueFilters);
Vue.use(Components);
Vue.use(Dialogs);
Vue.use(Router);
// Configure client-side routing
const router = new Router({
// Configure client-side routing.
const router = new Router({
routes: Routes,
mode: "history",
base: config.baseUri + "/",
@@ -134,13 +135,16 @@ const router = new Router({
return { x: 0, y: 0 };
}
},
});
});
router.beforeEach((to, from, next) => {
router.beforeEach((to, from, next) => {
if (document.querySelector(".v-dialog--active.v-dialog--fullscreen")) {
// Disable back button in full-screen viewers and editors.
next(false);
} else if (to.matched.some((record) => record.meta.settings) && config.values.disable.settings) {
} else if (
to.matched.some((record) => record.meta.settings) &&
config.values.disable.settings
) {
next({ name: "home" });
} else if (to.matched.some((record) => record.meta.admin)) {
if (isPublic || session.isAdmin()) {
@@ -163,9 +167,9 @@ router.beforeEach((to, from, next) => {
} else {
next();
}
});
});
router.afterEach((to) => {
router.afterEach((to) => {
const t = to.meta["title"] ? to.meta["title"] : "";
if (t !== "" && config.values.siteTitle !== t && config.values.name !== t) {
@@ -189,18 +193,19 @@ router.afterEach((to) => {
window.document.title = config.values.siteCaption;
}
}
});
});
if (isMobile) {
if (isMobile) {
document.body.classList.add("mobile");
} else {
} else {
// Pull client config every 10 minutes in case push fails (except on mobile to save battery).
setInterval(() => config.update(), 600000);
}
}
// Start application.
Mount(Vue, PhotoPrism, router);
// Start application.
Mount(Vue, PhotoPrism, router);
if (config.baseUri === "") {
if (config.baseUri === "") {
offline.install();
}
}
});

View File

@@ -83,7 +83,7 @@ export default [
meta: { title: siteTitle, auth: false, hideNav: true },
beforeEnter: (to, from, next) => {
if (session.isUser()) {
next({ name: "home" });
next({ name: session.getHome() });
} else {
next();
}
@@ -283,7 +283,11 @@ export default [
config.load().finally(() => {
// Open new faces tab when there are no people.
if (config.values.count.people === 0) {
if (config.allow("people", "manage")) {
next({ name: "people_faces" });
} else {
next({ name: "albums" });
}
} else {
next();
}
@@ -409,6 +413,6 @@ export default [
},
{
path: "*",
redirect: "/browse",
redirect: "/albums",
},
];

View File

@@ -129,7 +129,7 @@ export default class Config {
return Api.get("config")
.then(
(response) => this.setValues(response.data),
() => console.warn("failed pulling updated client config")
() => console.warn("config update failed")
)
.finally(() => Promise.resolve());
}
@@ -138,7 +138,7 @@ export default class Config {
if (!values) return;
if (this.debug) {
console.log("config: new values", values);
console.log("config: updated", values);
}
if (values.jsUri && this.values.jsUri !== values.jsUri) {
@@ -146,13 +146,14 @@ export default class Config {
}
for (let key in values) {
if (values.hasOwnProperty(key)) {
if (values.hasOwnProperty(key) && values[key] != null) {
this.set(key, values[key]);
}
}
if (values.settings) {
this.setBatchSize(values.settings);
this.setLanguage(values.settings.ui.language);
this.setTheme(values.settings.ui.theme);
}
@@ -232,7 +233,7 @@ export default class Config {
return result[0];
} else {
if (this.debug) {
console.warn("more than one person matching the same name", result);
console.warn("more than one person having the same name", result);
}
return result[0];
}
@@ -343,6 +344,82 @@ export default class Config {
}
}
aclClasses(resource) {
let result = [];
const perms = ["update", "search", "manage", "share", "delete"];
perms.forEach((perm) => {
if (this.deny(resource, perm)) result.push(`disable-${perm}`);
});
return result;
}
allow(resource, perm) {
if (this.values["acl"] && this.values["acl"][resource]) {
return !!this.values["acl"][resource][perm] || !!this.values["acl"][resource]["full_access"];
}
return false;
}
deny(resource, perm) {
return !this.allow(resource, perm);
}
settings() {
return this.values.settings;
}
setSettings(settings) {
if (!settings) return;
if (this.debug) {
console.log("config: new settings", settings);
}
this.values.settings = settings;
this.setBatchSize(settings);
this.setLanguage(settings.ui.language);
this.setTheme(settings.ui.theme);
return this;
}
setLanguage(locale) {
if (!locale || this.loading()) {
return;
}
if (this.values.settings && this.values.settings.ui) {
this.values.settings.ui.language = locale;
this.storage.setItem(this.storage_key + ".locale", locale);
Api.defaults.headers.common["Accept-Language"] = locale;
}
return this;
}
getLanguage() {
let locale = "en";
if (this.loading()) {
const stored = this.storage.getItem(this.storage_key + ".locale");
if (stored) {
locale = stored;
}
} else if (
this.values.settings &&
this.values.settings.ui &&
this.values.settings.ui.language
) {
locale = this.values.settings.ui.language;
}
return locale;
}
setTheme(name) {
let theme = onSetTheme(name, this);
@@ -383,6 +460,14 @@ export default class Config {
return this;
}
restoreValues() {
const json = this.storage.getItem(this.storage_key);
if (json !== "undefined") {
this.setValues(JSON.parse(json));
}
return this;
}
set(key, value) {
this.values[key] = value;
return this;
@@ -397,11 +482,7 @@ export default class Config {
}
feature(name) {
return this.values.settings.features[name];
}
settings() {
return this.values.settings;
return this.values.settings.features[name] === true;
}
rtl() {
@@ -472,4 +553,8 @@ export default class Config {
getVersion() {
return this.get("version");
}
getSiteDescription() {
return this.values.siteDescription ? this.values.siteDescription : this.values.siteCaption;
}
}

View File

@@ -28,34 +28,46 @@ import Event from "pubsub-js";
import User from "model/user";
import Socket from "websocket.js";
const SessionHeader = "X-Session-ID";
export default class Session {
/**
* @param {Storage} storage
* @param {Config} config
*/
constructor(storage, config) {
this.storage_key = "session_storage";
this.auth = false;
this.config = config;
this.user = new User(false);
this.data = null;
if (storage.getItem("session_storage") === "true") {
// Set session storage.
if (storage.getItem(this.storage_key) === "true") {
this.storage = window.sessionStorage;
} else {
this.storage = storage;
}
// Restore from session storage.
if (this.applyId(this.storage.getItem("session_id"))) {
const dataJson = this.storage.getItem("data");
this.data = dataJson !== "undefined" ? JSON.parse(dataJson) : null;
if (dataJson !== "undefined") {
this.data = JSON.parse(dataJson);
}
if (this.data && this.data.user) {
this.user = new User(this.data.user);
const userJson = this.storage.getItem("user");
if (userJson !== "undefined") {
this.user = new User(JSON.parse(userJson));
}
}
// Authenticated?
if (this.isUser()) {
this.auth = true;
}
// Subscribe to session events.
Event.subscribe("session.logout", () => {
return this.onLogout();
});
@@ -64,17 +76,18 @@ export default class Session {
this.sendClientInfo();
});
// Say hello.
this.sendClientInfo();
}
useSessionStorage() {
this.deleteId();
this.storage.setItem("session_storage", "true");
this.storage.setItem(this.storage_key, "true");
this.storage = window.sessionStorage;
}
useLocalStorage() {
this.storage.setItem("session_storage", "false");
this.storage.setItem(this.storage_key, "false");
this.storage = window.localStorage;
}
@@ -85,7 +98,8 @@ export default class Session {
}
this.session_id = id;
Api.defaults.headers.common["X-Session-ID"] = id;
Api.defaults.headers.common[SessionHeader] = id;
return true;
}
@@ -110,8 +124,10 @@ export default class Session {
deleteId() {
this.session_id = null;
this.storage.removeItem("session_id");
delete Api.defaults.headers.common["X-Session-ID"];
this.deleteData();
delete Api.defaults.headers.common[SessionHeader];
this.deleteAll();
}
setData(data) {
@@ -120,13 +136,11 @@ export default class Session {
}
this.data = data;
this.user = new User(this.data.user);
this.storage.setItem("data", JSON.stringify(data));
this.auth = true;
}
getUser() {
return this.user;
if (data.user) {
this.setUser(data.user);
}
}
getEmail() {
@@ -137,39 +151,42 @@ export default class Session {
return "";
}
getUsername() {
if (this.isUser()) {
if (this.user.Username) {
return this.user.Username;
}
}
return "";
}
getDisplayName() {
if (this.isUser()) {
if (this.user.DisplayName) {
return this.user.DisplayName;
}
if (this.user.ArtistName) {
return this.user.ArtistName;
}
if (this.user.FullName) {
return this.user.FullName;
}
if (this.user.Username) {
return this.user.Username;
}
return this.user.getEntityName();
}
return "";
}
setUser(user) {
if (!user) {
return;
}
this.user = new User(user);
this.storage.setItem("user", JSON.stringify(user));
this.auth = this.isUser();
}
getUser() {
return this.user;
}
isUser() {
return this.user && this.user.hasId();
}
getHome() {
if (!this.isUser()) {
return "login";
} else if (this.user.Role === "guest") {
return "albums";
} else {
return "browse";
}
}
isAdmin() {
return this.user && this.user.hasId() && (this.user.Role === "admin" || this.user.SuperAdmin);
}
@@ -187,12 +204,21 @@ export default class Session {
}
deleteData() {
this.auth = false;
this.user = new User();
this.data = null;
this.storage.removeItem("data");
}
deleteUser() {
this.auth = false;
this.user = new User(false);
this.storage.removeItem("user");
}
deleteAll() {
this.deleteData();
this.deleteUser();
}
sendClientInfo() {
const hasConfig = !!window.__CONFIG__;
const clientInfo = {
@@ -211,14 +237,19 @@ export default class Session {
}
}
login(username, password, token) {
login(name, password, token) {
this.deleteId();
return Api.post("session", { username, password, token }).then((resp) => {
return Api.post("session", { name, password, token }).then((resp) => {
const reload = this.config.getLanguage() !== resp.data?.config?.settings?.ui?.language;
this.setConfig(resp.data.config);
this.setId(resp.data.id);
this.setUser(resp.data.user);
this.setData(resp.data.data);
this.sendClientInfo();
return Promise.resolve(reload);
});
}
@@ -226,6 +257,7 @@ export default class Session {
return Api.post("session", { token }).then((resp) => {
this.setConfig(resp.data.config);
this.setId(resp.data.id);
this.setUser(resp.data.user);
this.setData(resp.data.data);
this.sendClientInfo();
});

View File

@@ -175,6 +175,8 @@ export default class Util {
case "avc":
case "avc1":
return "Advanced Video Coding (AVC) / H.264";
case "avif":
return "AV1 Image File Format (AVIF)";
case "hevc":
case "hvc":
case "hvc1":

View File

@@ -22,7 +22,7 @@
</template>
<v-btn
v-if="features.share"
v-if="canShare"
fab dark small
:title="$gettext('Share')"
color="share"
@@ -33,6 +33,7 @@
<v-icon>share</v-icon>
</v-btn>
<v-btn
v-if="canManage"
fab dark small
:title="$gettext('Edit')"
color="edit"
@@ -43,18 +44,17 @@
<v-icon>edit</v-icon>
</v-btn>
<v-btn
v-if="features.download"
fab dark small
:title="$gettext('Download')"
color="download"
class="action-download"
:disabled="selection.length !== 1"
:disabled="!canDownload || selection.length !== 1"
@click.stop="download()"
>
<v-icon>get_app</v-icon>
</v-btn>
<v-btn
v-if="features.albums"
v-if="canManage"
fab dark small
:title="$gettext('Add to album')"
color="album"
@@ -65,7 +65,7 @@
<v-icon>bookmark</v-icon>
</v-btn>
<v-btn
v-if="deletable.includes(context)"
v-if="canDelete && deletable.includes(context)"
fab dark small
color="remove"
:title="$gettext('Delete')"
@@ -104,16 +104,40 @@ export default {
type: Array,
default: () => [],
},
refresh: Function,
clearSelection: Function,
share: Function,
edit: Function,
context: String,
refresh: {
type: Function,
default: () => {
},
},
clearSelection: {
type: Function,
default: () => {
},
},
share: {
type: Function,
default: () => {
},
},
edit: {
type: Function,
default: () => {
},
},
context: {
type: String,
default: "",
},
},
data() {
const features = this.$config.settings().features;
return {
canDelete: this.$config.allow("albums", "delete"),
canDownload: this.$config.allow("albums", "download") && features.download,
canShare: this.$config.allow("albums", "share") && features.share,
canManage: this.$config.allow("albums", "manage"),
deletable: ["album", "moment", "state"],
features: this.$config.settings().features,
expanded: false,
dialog: {
delete: false,

View File

@@ -13,16 +13,16 @@
<v-icon>refresh</v-icon>
</v-btn>
<v-btn icon class="action-edit" :title="$gettext('Edit')" @click.stop="dialog.edit = true">
<v-btn v-if="canManage" icon class="action-edit" :title="$gettext('Edit')" @click.stop="dialog.edit = true">
<v-icon>edit</v-icon>
</v-btn>
<v-btn v-if="$config.feature('share')" icon class="action-share" :title="$gettext('Share')"
<v-btn v-if="canShare" icon class="action-share" :title="$gettext('Share')"
@click.stop="dialog.share = true">
<v-icon>share</v-icon>
</v-btn>
<v-btn v-if="$config.feature('download')" icon class="hidden-xs-only action-download" :title="$gettext('Download')"
<v-btn v-if="canDownload" icon class="hidden-xs-only action-download" :title="$gettext('Download')"
@click.stop="download()">
<v-icon>get_app</v-icon>
</v-btn>
@@ -37,7 +37,7 @@
<v-icon>view_column</v-icon>
</v-btn>
<v-btn v-if="!$config.values.readonly && $config.feature('upload')" icon class="hidden-sm-and-down action-upload"
<v-btn v-if="canUpload" icon class="hidden-sm-and-down action-upload"
:title="$gettext('Upload')" @click.stop="showUpload()">
<v-icon>cloud_upload</v-icon>
</v-btn>
@@ -109,8 +109,12 @@ export default {
ID: '',
Name: this.$gettext('All Countries')
}].concat(this.$config.get('countries'));
const features = this.$config.settings().features;
return {
canUpload: this.$config.allow("albums", "upload") && features.upload,
canDownload: this.$config.allow("albums", "download") && features.download,
canShare: this.$config.allow("albums", "share") && features.share,
canManage: this.$config.allow("albums", "manage"),
experimental: this.$config.get("experimental"),
isFullScreen: !!document.fullscreenElement,
categories: this.$config.albumCategories(),

View File

@@ -4,7 +4,7 @@
<v-toolbar dark fixed flat scroll-off-screen dense color="navigation darken-1" class="nav-small elevation-2"
@click.stop.prevent>
<v-avatar tile :size="28" :class="{'clickable': auth}" @click.stop.prevent="showNavigation()">
<img :src="appIcon" :alt="config.name">
<img :src="appIcon" :alt="config.name" :class="{'animate-hue': indexing}">
</v-avatar>
<v-toolbar-title class="nav-title">
<span :class="{'clickable': auth}" @click.stop.prevent="showNavigation()">{{ page.title }}</span>
@@ -77,7 +77,7 @@
</v-list-tile-action>
</v-list-tile>
<v-list-tile v-if="isMini" to="/browse" class="nav-browse" @click.stop="">
<v-list-tile v-if="isMini && $config.feature('search')" to="/browse" class="nav-browse" @click.stop="">
<v-list-tile-action :title="$gettext('Search')">
<v-icon>search</v-icon>
</v-list-tile-action>
@@ -89,7 +89,7 @@
</v-list-tile-content>
</v-list-tile>
<v-list-group v-if="!isMini" prepend-icon="search" no-action>
<v-list-group v-if="!isMini && $config.feature('search')" prepend-icon="search" no-action>
<template #activator>
<v-list-tile to="/browse" class="nav-browse" @click.stop="">
<v-list-tile-content>
@@ -177,9 +177,10 @@
</v-list-tile-content>
</v-list-tile>
<v-list-group v-if="!isMini && $config.feature('albums')" prepend-icon="bookmark" no-action>
<template v-if="!isMini && $config.feature('albums')">
<v-list-group v-if="canSearch" prepend-icon="bookmark" no-action>
<template #activator>
<v-list-tile to="/albums" class="nav-albums" @click.stop="">
<v-list-tile :to="{ name: 'albums' }" class="nav-albums" @click.stop="">
<v-list-tile-content>
<v-list-tile-title class="p-flex-menuitem">
<translate key="Albums">Albums</translate>
@@ -198,6 +199,20 @@
</v-list-tile-content>
</v-list-tile>
</v-list-group>
<v-list-tile v-else :to="{ name: 'albums' }" class="nav-albums" @click.stop="">
<v-list-tile-action :title="$gettext('Albums')">
<v-icon>bookmark</v-icon>
</v-list-tile-action>
<v-list-tile-content>
<v-list-tile-title class="p-flex-menuitem">
<translate key="Albums">Albums</translate>
<span v-if="config.count.albums > 0"
:class="`nav-count ${rtl ? '--rtl' : ''}`">{{ config.count.albums | abbreviateCount }}</span>
</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>
</template>
<v-list-tile v-if="isMini && $config.feature('videos')" to="/videos" class="nav-video" @click.stop="">
<v-list-tile-action :title="$gettext('Videos')">
@@ -235,7 +250,7 @@
</v-list-tile>
</v-list-group>
<v-list-tile v-show="$config.feature('people')" :to="{ name: 'people' }" class="nav-people" @click.stop="">
<v-list-tile v-show="$config.feature('people') && (canManagePeople || config.count.people > 0)" :to="{ name: 'people' }" class="nav-people" @click.stop="">
<v-list-tile-action :title="$gettext('People')">
<v-icon>person</v-icon>
</v-list-tile-action>
@@ -249,7 +264,7 @@
</v-list-tile-content>
</v-list-tile>
<v-list-tile to="/favorites" class="nav-favorites" @click.stop="">
<v-list-tile v-show="$config.feature('favorites')" :to="{ name: 'favorites' }" class="nav-favorites" @click.stop="">
<v-list-tile-action :title="$gettext('Favorites')">
<v-icon>favorite</v-icon>
</v-list-tile-action>
@@ -278,7 +293,7 @@
</v-list-tile-content>
</v-list-tile>
<v-list-tile :to="{ name: 'calendar' }" class="nav-calendar" @click.stop="">
<v-list-tile v-show="$config.feature('moments')" :to="{ name: 'calendar' }" class="nav-calendar" @click.stop="">
<v-list-tile-action :title="$gettext('Calendar')">
<v-icon>date_range</v-icon>
</v-list-tile-action>
@@ -455,7 +470,7 @@
</v-list-tile-content>
</v-list-tile>
<v-list-tile v-show="!isPublic" :to="{ name: 'feedback' }" :exact="true" class="nav-feedback"
<v-list-tile v-show="!isPublic && isAdmin" :to="{ name: 'feedback' }" :exact="true" class="nav-feedback"
@click.stop="">
<v-list-tile-content>
<v-list-tile-title :class="`menu-item ${rtl ? '--rtl' : ''}`">
@@ -575,10 +590,16 @@
<translate>Albums</translate>
</router-link>
</div>
<div v-if="auth && !routeName('library') && $config.feature('library')" class="menu-action nav-library">
<router-link :to="{ name: 'library' }">
<v-icon>camera_roll</v-icon>
<translate>Index</translate>
<div v-if="auth && canManagePeople && !routeName('people') && $config.feature('people')" class="menu-action nav-people">
<router-link to="/places">
<v-icon>person</v-icon>
<translate>People</translate>
</router-link>
</div>
<div v-if="auth && !routeName('places') && $config.feature('places')" class="menu-action nav-places">
<router-link to="/places">
<v-icon>place</v-icon>
<translate>Places</translate>
</router-link>
</div>
<div v-if="auth && !routeName('files') && $config.feature('files') && $config.feature('library')"
@@ -588,23 +609,29 @@
<translate>Files</translate>
</router-link>
</div>
<div v-if="auth && !config.disable.settings && !routeName('settings')" class="menu-action nav-sync">
<router-link :to="{ name: 'settings_sync' }">
<v-icon>sync</v-icon>
<translate>Connect</translate>
<div v-if="auth && !routeName('library') && $config.feature('library')" class="menu-action nav-library">
<router-link :to="{ name: 'library' }">
<v-icon>camera_roll</v-icon>
<translate>Index</translate>
</router-link>
</div>
<div v-if="auth && !config.disable.settings && !routeName('settings')" class="menu-action nav-account">
<div v-if="auth && $config.feature('sync') && !routeName('settings')" class="menu-action nav-sync">
<router-link :to="{ name: 'settings_sync' }">
<v-icon>sync</v-icon>
<translate>Sync</translate>
</router-link>
</div>
<!-- div v-if="auth && $config.feature('account') && !routeName('settings')" class="menu-action nav-account">
<router-link :to="{ name: 'settings_account' }">
<v-icon>person</v-icon>
<translate>Account</translate>
</router-link>
</div>
</div -->
<div class="menu-action nav-manual"><a href="https://link.photoprism.app/docs" target="_blank">
<v-icon>auto_stories</v-icon>
<translate>User Guide</translate>
</a></div>
<div v-if="!isSponsor" class="menu-action nav-membership"><a href="https://link.photoprism.app/membership"
<div v-if="!isSponsor && isAdmin" class="menu-action nav-membership"><a href="https://link.photoprism.app/membership"
target="_blank">
<v-icon>workspace_premium</v-icon>
<translate>Become a sponsor</translate>
@@ -657,6 +684,8 @@ export default {
}
return {
canSearch: this.$config.allow("photos", "search"),
canManagePeople: this.$config.allow("people", "manage"),
appNameSuffix: appNameSuffix,
appName: this.$config.getName(),
appEdition: this.$config.getEdition(),
@@ -666,6 +695,7 @@ export default {
isMini: localStorage.getItem('last_navigation_mode') !== 'false',
isPublic: this.$config.get("public"),
isDemo: this.$config.get("demo"),
isAdmin: this.$session.isAdmin(),
isSponsor: this.$config.isSponsor(),
isTest: this.$config.test,
isReadOnly: this.$config.get("readonly"),
@@ -698,19 +728,19 @@ export default {
},
displayName() {
const user = this.$session.getUser();
if (!user) {
return '';
} else if (user.DisplayName) {
return user.DisplayName;
} else if (user.Username) {
return user.Username;
} else {
return 'User';
if (user) {
return user.getDisplayName();
}
return this.$gettext("Unregistered");
},
accountInfo() {
const user = this.$session.getUser();
return user.Email ? user.Email : this.$gettext("Account");
if (user) {
return user.getAccountInfo();
}
return this.$gettext("Account");
},
},
created() {
@@ -734,6 +764,10 @@ export default {
},
methods: {
routeName(name) {
if (!name || !this.$route.name) {
return false;
}
return this.$route.name.startsWith(name);
},
reloadApp() {

View File

@@ -17,12 +17,12 @@
<button class="pswp__button pswp__button--close action-close" :title="$gettext('Close')"></button>
<button v-if="config.settings.features.download" class="pswp__button action-download" style="background: none;"
<button v-if="canDownload" class="pswp__button action-download" style="background: none;"
:title="$gettext('Download')" @click.exact="onDownload">
<v-icon size="16" color="white">get_app</v-icon>
</button>
<button class="pswp__button action-edit hidden-shared-only" style="background: none;" :title="$gettext('Edit')"
<button v-if="canEdit" class="pswp__button action-edit hidden-shared-only" style="background: none;" :title="$gettext('Edit')"
@click.exact="onEdit">
<v-icon size="16" color="white">edit</v-icon>
</button>
@@ -33,7 +33,7 @@
<v-icon v-else size="16" color="white">radio_button_off</v-icon>
</button>
<button class="pswp__button action-like hidden-shared-only" style="background: none;"
<button v-if="canLike" class="pswp__button action-like hidden-shared-only" style="background: none;"
:title="$gettext('Like')" @click.exact="onLike">
<v-icon v-if="item.Favorite" size="16" color="white">favorite</v-icon>
<v-icon v-else size="16" color="white">favorite_border</v-icon>
@@ -96,6 +96,9 @@ export default {
name: "PPhotoViewer",
data() {
return {
canEdit: this.$config.allow("photos", "update") && this.$config.feature("edit"),
canLike: this.$config.allow("photos", "manage") && this.$config.feature("favorites"),
canDownload: this.$config.allow("photos", "download") && this.$config.feature("download"),
selection: this.$clipboard.selection,
config: this.$config.values,
item: new Thumb(),

View File

@@ -188,36 +188,36 @@
</button>
</h3>
<div v-if="photo.Description" class="caption mb-2" :title="$gettext('Description')">
<button @[!isSharedView&&`click`].exact="editPhoto(index)">
<button @click.exact="editPhoto(index)">
{{ photo.Description }}
</button>
</div>
<div class="caption">
<button class="action-date-edit" :data-uid="photo.UID"
@[!isSharedView&&`click`].exact="editPhoto(index)">
@click.exact="editPhoto(index)">
<i :title="$gettext('Taken')">date_range</i>
{{ photo.getDateString(true) }}
</button>
<br>
<button v-if="photo.Type === 'video'" :title="$gettext('Video')"
@[!isSharedView&&`click`].exact="openPhoto(index)">
@click.exact="openPhoto(index)">
<i>movie</i>
{{ photo.getVideoInfo() }}
</button>
<button v-else-if="photo.Type === 'animated'" :title="$gettext('Animated')+' GIF'"
@[!isSharedView&&`click`].exact="openPhoto(index)">
@click.exact="openPhoto(index)">
<i>gif_box</i>
{{ photo.getVideoInfo() }}
</button>
<button v-else :title="$gettext('Camera')" class="action-camera-edit"
:data-uid="photo.UID" @[!isSharedView&&`click`].exact="editPhoto(index)">
:data-uid="photo.UID" @click.exact="editPhoto(index)">
<i>photo_camera</i>
{{ photo.getPhotoInfo() }}
</button>
<template v-if="filter.order === 'name' && $config.feature('download')">
<br>
<button :title="$gettext('Name')"
@[!isSharedView&&`click`].exact="downloadFile(index)">
@click.exact="downloadFile(index)">
<i>insert_drive_file</i>
{{ photo.baseName() }}
</button>
@@ -225,7 +225,7 @@
<template v-if="featPlaces && photo.Country !== 'zz'">
<br>
<button :title="$gettext('Location')" class="action-location"
:data-uid="photo.UID" @[!isSharedView&&`click`].exact="openLocation(index)">
:data-uid="photo.UID" @click.exact="openLocation(index)">
<i>location_on</i>
{{ photo.locationInfo() }}
</button>
@@ -269,13 +269,11 @@ export default {
},
album: {
type: Object,
default: () => {
},
default: () => {},
},
filter: {
type: Object,
default: () => {
},
default: () => {},
},
context: {
type: String,
@@ -349,7 +347,7 @@ export default {
);
// we observe only every 5th item, so we increase the rendered
// range here by 4 items in every directio just to be safe
// range here by 4 items in every direction just to be safe
this.firstVisibleElementIndex = smallestIndex - 4;
this.lastVisibileElementIndex = largestIndex + 4;
},

View File

@@ -23,7 +23,7 @@
</template>
<v-btn
v-if="context !== 'archive' && context !== 'review' && features.share" fab dark
v-if="canShare && context !== 'archive' && context !== 'review'" fab dark
small
:title="$gettext('Share')"
color="share"
@@ -35,7 +35,7 @@
</v-btn>
<v-btn
v-if="context === 'review'" fab dark
v-if="canManage && context === 'review'" fab dark
small
:title="$gettext('Approve')"
color="share"
@@ -46,7 +46,7 @@
<v-icon>check</v-icon>
</v-btn>
<v-btn
v-if="context !== 'archive' && features.edit" fab dark
v-if="canEdit" fab dark
small
:title="$gettext('Edit')"
color="edit"
@@ -57,7 +57,7 @@
<v-icon>edit</v-icon>
</v-btn>
<v-btn
v-if="context !== 'archive' && features.private" fab dark
v-if="canTogglePrivate" fab dark
small
:title="$gettext('Change private flag')"
color="private"
@@ -68,7 +68,7 @@
<v-icon>lock</v-icon>
</v-btn>
<v-btn
v-if="context !== 'archive' && features.download" fab dark
v-if="canDownload && context !== 'archive'" fab dark
small
:title="$gettext('Download')"
:disabled="busy"
@@ -79,7 +79,7 @@
<v-icon>get_app</v-icon>
</v-btn>
<v-btn
v-if="context !== 'archive' && features.albums" fab dark
v-if="canEditAlbum && context !== 'archive'" fab dark
small
:title="$gettext('Add to album')"
color="album"
@@ -90,7 +90,7 @@
<v-icon>bookmark</v-icon>
</v-btn>
<v-btn
v-if="!isAlbum && context !== 'archive' && features.archive" fab dark
v-if="canArchive && !isAlbum && context !== 'archive'" fab dark
small
color="remove"
:title="$gettext('Archive')"
@@ -101,7 +101,7 @@
<v-icon>archive</v-icon>
</v-btn>
<v-btn
v-if="!album && context === 'archive'" fab dark
v-if="canArchive && !album && context === 'archive'" fab dark
small
color="restore"
:title="$gettext('Restore')"
@@ -112,7 +112,7 @@
<v-icon>unarchive</v-icon>
</v-btn>
<v-btn
v-if="isAlbum && features.albums" fab dark
v-if="canEditAlbum && isAlbum" fab dark
small
:title="$gettext('Remove from album')"
color="remove"
@@ -123,7 +123,7 @@
<v-icon>eject</v-icon>
</v-btn>
<v-btn
v-if="!album && context === 'archive' && features.delete" fab dark
v-if="canDelete && !album && context === 'archive'" fab dark
small
:title="$gettext('Delete')"
color="remove"
@@ -181,10 +181,19 @@ export default {
},
},
data() {
const features = this.$config.settings().features;
return {
canTogglePrivate: this.$config.allow("photos", "manage") && this.context !== 'archive' && features.private,
canArchive: this.$config.allow("photos", "delete") && features.archive,
canDelete: this.$config.allow("photos", "delete") && features.delete,
canDownload: this.$config.allow("photos", "download") && features.download,
canShare: this.$config.allow("photos", "share") && features.share,
canManage: this.$config.allow("photos", "manage") && features.albums,
canEdit: this.$config.allow("photos", "update") && features.edit,
canEditAlbum: this.$config.allow("albums", "update") && features.albums,
busy: false,
config: this.$config.values,
features: this.$config.settings().features,
expanded: false,
isAlbum: this.album && this.album.Type === 'album',
dialog: {
@@ -202,7 +211,7 @@ export default {
this.expanded = false;
},
batchApprove() {
if (this.busy) {
if (this.busy || !this.canManage) {
return;
}
@@ -219,14 +228,18 @@ export default {
this.clearClipboard();
},
archivePhotos() {
if (!this.features.delete) {
if (!this.canArchive) {
return;
}
if (!this.canDelete) {
this.dialog.archive = true;
} else {
this.batchArchive();
}
},
batchArchive() {
if (this.busy) {
if (this.busy || !this.canArchive) {
return;
}
@@ -244,9 +257,17 @@ export default {
this.clearClipboard();
},
deletePhotos() {
if (!this.canDelete) {
return;
}
this.dialog.delete = true;
},
batchDelete() {
if (!this.canDelete) {
return;
}
this.dialog.delete = false;
Api.post("batch/photos/delete", {"photos": this.selection}).then(() => this.onDeleted());
@@ -269,7 +290,7 @@ export default {
this.clearClipboard();
},
addToAlbum(ppid) {
if (!ppid) {
if (!ppid || !this.canManage) {
return;
}
@@ -295,7 +316,7 @@ export default {
return;
}
if (this.busy) {
if (this.busy || !this.canManage) {
return;
}
@@ -315,7 +336,7 @@ export default {
this.clearClipboard();
},
download() {
if (this.busy) {
if (this.busy || !this.canDownload) {
return;
}

View File

@@ -278,7 +278,7 @@ nav .v-list__tile__title.title {
fill: rgba(255,255,255,.85);
color: rgba(255,255,255,.85);
display: inline-block;
min-width: 200px;
min-width: 210px;
font-size: 15px;
line-height: 50px;
text-decoration: none;

View File

@@ -343,6 +343,10 @@ body.chrome #photoprism .search-results .result {
display: none;
}
#photoprism .disable-manage .search-results .result:not(.is-favorite) .input-favorite {
visibility: hidden;
}
#photoprism .cards-view .input-reject,
#photoprism .mosaic-view .input-reject {
opacity: 0.75;

View File

@@ -23,6 +23,7 @@
hide-details autofocus
:rules="[titleRule]"
:label="$gettext('Name')"
:disabled="disabled"
color="secondary-dark"
class="input-title"
@keyup.enter.native="confirm"
@@ -32,6 +33,7 @@
<v-text-field v-model="model.Location"
hide-details
:label="$gettext('Location')"
:disabled="disabled"
color="secondary-dark"
class="input-location"
></v-text-field>
@@ -43,6 +45,7 @@
browser-autocomplete="off"
:label="$gettext('Description')"
:rows="1"
:disabled="disabled"
class="input-description"
color="secondary-dark">
</v-textarea>
@@ -51,6 +54,7 @@
<v-combobox v-model="model.Category" hide-details
:search-input.sync="model.Category"
:items="categories"
:disabled="disabled"
:label="$gettext('Category')"
:allow-overflow="false"
return-masked-value
@@ -65,6 +69,7 @@
:label="$gettext('Sort Order')"
hide-details
:items="sorting"
:disabled="disabled"
item-value="value"
item-text="text"
color="secondary-dark">
@@ -83,6 +88,7 @@
</v-btn>
<v-btn depressed dark color="primary-button"
class="action-confirm"
:disabled="disabled"
@click.stop="confirm">
<translate>Save</translate>
</v-btn>
@@ -107,6 +113,7 @@ export default {
},
data() {
return {
disabled: !this.$config.allow("albums", "manage"),
model: new Album(),
growDesc: false,
loading: false,
@@ -138,6 +145,11 @@ export default {
this.$emit('close');
},
confirm() {
if (this.disabled) {
this.close();
return;
}
this.model.update().then((m) => {
this.categories = this.$config.albumCategories();
this.$emit('close');

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -25,81 +25,121 @@ Additional information can be found in our Developer Guide:
import RestModel from "model/rest";
import Form from "common/form";
import Util from "common/util";
import Api from "common/api";
import { $gettext } from "common/vm";
import { T, $gettext } from "common/vm";
export class User extends RestModel {
getDefaults() {
return {
UID: "",
Slug: "",
Username: "",
UUID: "",
AuthProvider: "",
AuthID: "",
Name: "",
DisplayName: "",
Email: "",
BackupEmail: "",
Role: "",
Attr: "",
SuperAdmin: false,
CanLogin: false,
WebLogin: false,
WebDAV: false,
CanInvite: false,
AuthUID: "",
AuthSrc: "",
WebUID: "",
WebDAV: "",
AvatarURL: "",
AvatarSrc: "",
Country: "",
TimeZone: "",
Thumb: "",
ThumbSrc: "",
Settings: {
UITheme: "",
UILanguage: "",
UITimeZone: "",
MapsStyle: "",
MapsAnimate: 0,
IndexPath: "",
IndexRescan: 0,
ImportPath: "",
ImportMove: 0,
UploadPath: "",
CreatedAt: "",
UpdatedAt: "",
},
Details: {
SubjUID: "",
SubjSrc: "",
PlaceID: "",
PlaceSrc: "",
CellID: "",
SubjUID: "",
Bio: "",
Status: "",
URL: "",
Phone: "",
DisplayName: "",
FullName: "",
Alias: "",
ArtistName: "",
Artist: false,
Favorite: false,
Hidden: false,
Private: false,
Excluded: false,
CompanyName: "",
DepartmentName: "",
IdURL: "",
AvatarURL: "",
SiteURL: "",
FeedURL: "",
UserGender: "",
NamePrefix: "",
GivenName: "",
MiddleName: "",
FamilyName: "",
NameSuffix: "",
NickName: "",
UserPhone: "",
UserAddress: "",
UserCountry: "",
UserBio: "",
JobTitle: "",
BusinessURL: "",
BusinessPhone: "",
BusinessEmail: "",
BackupEmail: "",
Department: "",
Company: "",
CompanyURL: "",
BirthYear: -1,
BirthMonth: -1,
BirthDay: -1,
FileRoot: "",
FilePath: "",
InviteToken: "",
InvitedBy: "",
DownloadToken: "",
PreviewToken: "",
ConfirmedAt: "",
TermsAccepted: "",
CreatedAt: "",
UpdatedAt: "",
DeletedAt: "",
},
VerifiedAt: "",
ConsentAt: "",
BornAt: "",
CreatedAt: "",
UpdatedAt: "",
ExpiresAt: "",
};
}
getEntityName() {
getDisplayName() {
if (this.DisplayName) {
return this.DisplayName;
} else if (this.Details && this.Details.NickName) {
return this.Details.NickName;
} else if (this.Details && this.Details.GivenName) {
return this.Details.GivenName;
} else if (this.Name) {
return Util.capitalize(this.Name);
} else if (this.Details && this.Details.JobTitle) {
return this.Details.JobTitle;
} else if (this.Email) {
return this.Email;
} else if (this.Role) {
return T(Util.capitalize(this.Role));
}
if (this.FullName) {
return this.FullName;
return this.$gettext("Unregistered");
}
getAccountInfo() {
if (this.Email) {
return this.Email;
} else if (this.Details && this.Details.JobTitle) {
return this.Details.JobTitle;
} else if (this.Role) {
return T(Util.capitalize(this.Role));
} else if (this.Name) {
return this.Name;
}
return this.$gettext("Account");
}
getEntityName() {
return this.getDisplayName();
}
getRegisterForm() {
return Api.options(this.getEntityResource() + "/register").then((response) =>
Promise.resolve(new Form(response.data))

View File

@@ -405,3 +405,16 @@ export const ThumbFilters = () => [
{ value: "cubic", text: $gettext("Cubic: Moderate Quality, Good Performance") },
{ value: "linear", text: $gettext("Linear: Very Smooth, Best Performance") },
];
export const UserRoles = () => [
{ value: "admin", text: $gettext("Admin") },
{ value: "user", text: $gettext("User") },
{ value: "family", text: $gettext("Family") },
{ value: "friend", text: $gettext("Friend") },
{ value: "viewer", text: $gettext("Viewer") },
{ value: "contributor", text: $gettext("Contributor") },
{ value: "guest", text: $gettext("Guest") },
{ value: "visitor", text: $gettext("Visitor") },
{ value: "unauthorized", text: $gettext("Unauthorized") },
{ value: "", text: $gettext("Unknown") },
];

View File

@@ -22,7 +22,8 @@
:filter="filter"
:album="model"
:edit-photo="editPhoto"
:open-photo="openPhoto"></p-photo-mosaic>
:open-photo="openPhoto"
:is-shared-view="isShared"></p-photo-mosaic>
<p-photo-list v-else-if="settings.view === 'list'"
context="album"
:photos="results"
@@ -31,7 +32,8 @@
:album="model"
:open-photo="openPhoto"
:edit-photo="editPhoto"
:open-location="openLocation"></p-photo-list>
:open-location="openLocation"
:is-shared-view="isShared"></p-photo-list>
<p-photo-cards v-else
context="album"
:photos="results"
@@ -40,7 +42,8 @@
:album="model"
:open-photo="openPhoto"
:edit-photo="editPhoto"
:open-location="openLocation"></p-photo-cards>
:open-location="openLocation"
:is-shared-view="isShared"></p-photo-cards>
</v-container>
</div>
</template>
@@ -74,6 +77,9 @@ export default {
const batchSize = Photo.batchSize();
return {
isShared: this.$config.deny("photos", "manage"),
canEdit: this.$config.allow("photos", "update") && this.$config.feature("edit"),
hasPlaces: this.$config.allow("places", "view") && this.$config.feature("places"),
subscriptions: [],
listen: false,
dirty: false,
@@ -159,6 +165,10 @@ export default {
return 'cards';
},
openLocation(index) {
if (!this.hasPlaces) {
return;
}
const photo = this.results[index];
if (photo.CellID && photo.CellID !== "zz") {
@@ -170,6 +180,10 @@ export default {
}
},
editPhoto(index) {
if (!this.canEdit) {
return this.openPhoto(index);
}
let selection = this.results.map((p) => {
return p.getId();
});

View File

@@ -1,5 +1,5 @@
<template>
<div v-infinite-scroll="loadMore" class="p-page p-page-albums" style="user-select: none"
<div v-infinite-scroll="loadMore" :class="$config.aclClasses('albums')" class="p-page p-page-albums" style="user-select: none"
:infinite-scroll-disabled="scrollDisabled" :infinite-scroll-distance="scrollDistance"
:infinite-scroll-listen-for-event="'scrollRefresh'">
@@ -103,7 +103,7 @@
@mousedown.stop.prevent="input.mouseDown($event, index)"
@click.stop.prevent="onClick($event, index)"
>
<v-btn v-if="featureShare && album.LinkCount > 0" :ripple="false"
<v-btn v-if="canShare && album.LinkCount > 0" :ripple="false"
icon flat absolute
class="action-share"
@touchstart.stop.prevent="input.touchStart($event, index)"
@@ -191,7 +191,7 @@
</v-card>
</v-flex>
</v-layout>
<div v-if="staticFilter.type === 'album' && config.count.albums === 0" class="text-xs-center my-2">
<div v-if="canManage && staticFilter.type === 'album' && config.count.albums === 0" class="text-xs-center my-2">
<v-btn class="action-add" color="secondary" round @click.prevent="create">
<translate>Add Album</translate>
</v-btn>
@@ -234,6 +234,7 @@ export default {
const category = query["category"] ? query["category"] : "";
const filter = {q, category};
const settings = {};
const features = this.$config.settings().features;
let categories = [{"value": "", "text": this.$gettext("All Categories")}];
@@ -244,8 +245,12 @@ export default {
}
return {
canUpload: features.upload,
canShare: this.$config.allow("albums", "share") && features.share,
canManage: this.$config.allow("albums", "manage"),
canEdit: this.$config.allow("albums", "update"),
config: this.$config.values,
featureShare: this.$config.feature('share'),
featureShare: features.share,
categories: categories,
subscriptions: [],
listen: false,
@@ -271,7 +276,7 @@ export default {
upload: false,
edit: false,
},
model: new Album(),
model: new Album(false),
};
},
computed: {
@@ -328,7 +333,7 @@ export default {
window.localStorage.setItem("albums_offset", offset);
},
share(album) {
if (!album) {
if (!album || !this.canShare) {
return;
}
@@ -338,19 +343,34 @@ export default {
edit(album) {
if (!album) {
return;
} else if (!this.canManage) {
this.$router.push(album.route(this.view));
return;
}
this.model = album;
this.dialog.edit = true;
},
webdavUpload() {
if (!this.canShare) {
return;
}
this.dialog.share = false;
this.dialog.upload = true;
},
showUpload() {
if (!this.canUpload) {
return;
}
Event.publish("dialog.upload");
},
toggleLike(ev, index) {
if (!this.canManage) {
return;
}
const inputType = this.input.eval(ev, index);
if (inputType !== ClickShort) {
@@ -391,6 +411,10 @@ export default {
return (rangeEnd - rangeStart) + 1;
},
onShare(ev, index) {
if (!this.canShare) {
return;
}
const inputType = this.input.eval(ev, index);
if (inputType !== ClickShort) {

View File

@@ -10,11 +10,11 @@
</div>
<v-spacer></v-spacer>
<v-text-field
v-model="username"
v-model="name"
required hide-details solo flat light autofocus
type="text"
:disabled="loading"
name="username"
name="name"
autocorrect="off"
autocapitalize="none"
:label="$gettext('Name')"
@@ -99,9 +99,6 @@
export default {
name: "PPageLogin",
data() {
const c = this.$config.values;
const sponsor = this.$config.isSponsor();
return {
colors: {
accent: "#05dde1",
@@ -110,19 +107,19 @@ export default {
},
loading: false,
showPassword: false,
username: "",
name: "",
password: "",
sponsor: sponsor,
sponsor: this.$config.isSponsor(),
config: this.$config.values,
siteDescription: c.siteDescription ? c.siteDescription : c.siteCaption,
siteDescription: this.$config.getSiteDescription(),
nextUrl: this.$route.params.nextUrl ? this.$route.params.nextUrl : "/",
wallpaperUri: c.wallpaperUri,
wallpaperUri: this.$config.values.wallpaperUri,
rtl: this.$rtl,
};
},
computed: {
loginDisabled() {
return this.loading || this.username.trim() === "" || this.password.trim() === "";
return this.loading || this.name.trim() === "" || this.password.trim() === "";
}
},
created() {
@@ -139,19 +136,27 @@ export default {
return "";
},
load() {
this.$notify.blockUI();
let route = this.$router.resolve({
name: this.$session.getHome(),
});
setTimeout(() => { window.location = route.href; }, 100);
},
login() {
const username = this.username.trim();
const name = this.name.trim();
const password = this.password.trim();
if (username === "" || password === "") {
if (name === "" || password === "") {
return;
}
this.loading = true;
this.$session.login(username, password).then(
this.$session.login(name, password).then(
() => {
this.loading = false;
this.$router.push(this.nextUrl);
this.load();
}
).catch(() => this.loading = false);
},

View File

@@ -1,5 +1,5 @@
<template>
<div v-infinite-scroll="loadMore" class="p-page p-page-labels" style="user-select: none"
<div v-infinite-scroll="loadMore" :class="$config.aclClasses('labels')" class="p-page p-page-labels" style="user-select: none"
:infinite-scroll-disabled="scrollDisabled" :infinite-scroll-distance="scrollDistance"
:infinite-scroll-listen-for-event="'scrollRefresh'">

View File

@@ -1,5 +1,5 @@
<template>
<div class="p-page p-page-library">
<div :class="$config.aclClasses('library')" class="p-page p-page-library">
<v-tabs
v-model="active"
flat
@@ -44,7 +44,10 @@ function initTabs(flag, tabs) {
export default {
name: 'PPageLibrary',
props: {
tab: String,
tab: {
type: String,
default: "",
},
},
data() {
const config = this.$config.values;

View File

@@ -3,7 +3,7 @@
<v-layout row wrap fill-height class="pa-0 ma-3">
<v-flex grow xs12 class="pa-2 terminal elevation-0 p-logs" style="overflow: auto;">
<p v-if="logs.length === 0" class="p-log-empty">
<translate>Nothing to see here yet. Be patient.</translate>
<translate>Nothing to see here yet.</translate>
</p>
<p v-for="(log, index) in logs" :key="index.id" class="p-log-message text-selectable" :class="'p-log-' + log.level">
{{ formatTime(log.time) }} {{ level(log) }} <span>{{ log.message }}</span>
@@ -17,12 +17,14 @@
import {DateTime} from "luxon";
export default {
name: 'p-tab-logs',
name: 'PTabLogs',
data() {
return {
logs: this.$log.logs,
};
},
created() {
},
methods: {
level(log) {
return log.level.substr(0, 4).toUpperCase();
@@ -35,7 +37,5 @@ export default {
return DateTime.fromISO(s).toFormat("yyyy-LL-dd HH:mm:ss");
},
},
created() {
},
};
</script>

View File

@@ -1,5 +1,5 @@
<template>
<div class="p-page p-page-people">
<div class="p-page p-page-people" :class="$config.aclClasses('people')">
<v-tabs
v-model="active"
flat
@@ -33,8 +33,8 @@
</template>
<script>
import Subjects from "pages/people/subjects.vue";
import Faces from "pages/people/faces.vue";
import Recognized from "pages/people/recognized.vue";
import NewFaces from "pages/people/new.vue";
export default {
name: 'PPagePeople',
@@ -47,24 +47,27 @@ export default {
const tabs = [
{
'name': 'people',
'component': Subjects,
'component': Recognized,
'filter': {files: 1, type: "person"},
'label': this.$gettext('Recognized'),
'class': '',
'path': '/people',
'icon': 'people_alt',
},
{
];
if (this.$config.allow("people", "manage")) {
tabs.push({
'name': 'people_faces',
'component': Faces,
'component': NewFaces,
'filter': {markers: true, unknown: true},
'label': this.$gettext('New'),
'class': '',
'path': '/people/new',
'icon': 'person_add',
'count': 0,
},
];
});
}
return {
tabs: tabs,

View File

@@ -1,6 +1,5 @@
<template>
<div class="p-page p-page-faces" style="user-select: none">
<v-form ref="form" class="p-faces-search" lazy-validation dense @submit.prevent="updateQuery">
<v-toolbar dense class="page-toolbar" flat color="secondary-light pa-0">
<v-spacer></v-spacer>

View File

@@ -5,7 +5,7 @@
<v-form ref="form" class="p-people-search" lazy-validation dense @submit.prevent="updateQuery()">
<v-toolbar dense flat class="page-toolbar" color="secondary-light pa-0">
<v-text-field :value="filter.q"
<v-text-field v-if="canSearch" :value="filter.q"
solo hide-details clearable overflow single-line validate-on-blur
class="input-search background-inherit elevation-0"
:label="$gettext('Search')"
@@ -25,12 +25,14 @@
<v-icon>refresh</v-icon>
</v-btn>
<template v-if="canManage">
<v-btn v-if="!filter.hidden" icon class="action-show-hidden" :title="$gettext('Show hidden')" @click.stop="onShowHidden()">
<v-icon>visibility</v-icon>
</v-btn>
<v-btn v-else icon class="action-exclude-hidden" :title="$gettext('Exclude hidden')" @click.stop="onExcludeHidden()">
<v-icon>visibility_off</v-icon>
</v-btn>
</template>
</v-toolbar>
</v-form>
@@ -85,7 +87,7 @@
@mousedown.stop.prevent="input.mouseDown($event, index)"
@click.stop.prevent="onClick($event, index)"
>
<v-btn :ripple="false" :depressed="false" class="input-hidden"
<v-btn v-if="canManage" :ripple="false" :depressed="false" class="input-hidden"
icon flat small absolute
@touchstart.stop.prevent="input.touchStart($event, index)"
@touchend.stop.prevent="onToggleHidden($event, index)"
@@ -119,6 +121,7 @@
<v-card-title primary-title class="pa-3 card-details" style="user-select: none;" @click.stop.prevent="">
<v-edit-dialog
v-if="canManage"
:return-value.sync="model.Name"
lazy
class="inline-edit"
@@ -142,6 +145,9 @@
></v-text-field>
</template>
</v-edit-dialog>
<span v-else class="body-2 ma-0">
{{ model.Name }}
</span>
</v-card-title>
<v-card-text primary-title class="pb-2 pt-0 card-details" style="user-select: none;"
@@ -195,6 +201,9 @@ export default {
const order = this.sortOrder();
return {
canView: this.$config.allow("people", "view"),
canSearch: this.$config.allow("people", "search"),
canManage: this.$config.allow("people", "manage"),
view: 'all',
config: this.$config.values,
subscriptions: [],
@@ -263,7 +272,7 @@ export default {
},
methods: {
onSave(m) {
if (!m.Name || m.Name.trim() === "") {
if (!this.canManage || !m.Name || m.Name.trim() === "") {
// Refuse to save empty name.
return;
}
@@ -288,6 +297,10 @@ export default {
this.merge.subj2 = null;
},
onMerge() {
if(!this.canManage) {
return;
}
this.busy = true;
this.merge.show = false;
this.$notify.blockUI();
@@ -316,6 +329,10 @@ export default {
window.localStorage.setItem("subjects_offset", offset);
},
toggleLike(ev, index) {
if(!this.canManage) {
return;
}
const inputType = this.input.eval(ev, index);
if (inputType !== ClickShort) {
@@ -397,12 +414,24 @@ export default {
}
},
onShowHidden() {
if(!this.canManage) {
return;
}
this.showHidden("yes");
},
onExcludeHidden() {
if(!this.canManage) {
return;
}
this.showHidden("");
},
showHidden(value) {
if(!this.canManage) {
return;
}
this.$sponsorFeatures().then(() => {
this.filter.hidden = value;
this.updateQuery();
@@ -411,6 +440,10 @@ export default {
});
},
onToggleHidden(ev, index) {
if(!this.canManage) {
return;
}
const inputType = this.input.eval(ev, index);
if (inputType !== ClickShort) {
@@ -424,7 +457,7 @@ export default {
});
},
toggleHidden(model) {
if (!model) {
if (!model || !this.canManage) {
return;
}
this.busy = true;

View File

@@ -1,5 +1,5 @@
<template>
<div v-infinite-scroll="loadMore" class="p-page p-page-photos" style="user-select: none"
<div v-infinite-scroll="loadMore" :class="$config.aclClasses('photos')" class="p-page p-page-photos" style="user-select: none"
:infinite-scroll-disabled="scrollDisabled" :infinite-scroll-distance="scrollDistance"
:infinite-scroll-listen-for-event="'scrollRefresh'">
@@ -20,7 +20,8 @@
:select-mode="selectMode"
:filter="filter"
:edit-photo="editPhoto"
:open-photo="openPhoto"></p-photo-mosaic>
:open-photo="openPhoto"
:is-shared-view="isShared"></p-photo-mosaic>
<p-photo-list v-else-if="settings.view === 'list'"
:context="context"
:photos="results"
@@ -28,7 +29,8 @@
:filter="filter"
:open-photo="openPhoto"
:edit-photo="editPhoto"
:open-location="openLocation"></p-photo-list>
:open-location="openLocation"
:is-shared-view="isShared"></p-photo-list>
<p-photo-cards v-else
:context="context"
:photos="results"
@@ -36,7 +38,8 @@
:filter="filter"
:open-photo="openPhoto"
:edit-photo="editPhoto"
:open-location="openLocation"></p-photo-cards>
:open-location="openLocation"
:is-shared-view="isShared"></p-photo-cards>
</v-container>
</div>
</template>
@@ -82,13 +85,14 @@ export default {
};
const settings = this.$config.settings();
const features = settings.features;
if (settings) {
if (settings.features.private) {
if (features.private) {
filter.public = "true";
}
if (settings.features.review && (!this.staticFilter || !("quality" in this.staticFilter))) {
if (features.review && (!this.staticFilter || !("quality" in this.staticFilter))) {
filter.quality = "3";
}
}
@@ -96,6 +100,9 @@ export default {
const batchSize = Photo.batchSize();
return {
isShared: this.$config.deny("photos", "manage"),
canEdit: this.$config.allow("photos", "update") && features.edit,
hasPlaces: this.$config.allow("places", "view") && features.places,
subscriptions: [],
listen: false,
dirty: false,
@@ -226,6 +233,10 @@ export default {
return "newest";
},
openLocation(index) {
if (!this.hasPlaces) {
return;
}
const photo = this.results[index];
if (photo.CellID && photo.CellID !== "zz") {
@@ -237,6 +248,10 @@ export default {
}
},
editPhoto(index) {
if (!this.canEdit) {
return this.openPhoto(index);
}
let selection = this.results.map((p) => {
return p.getId();
});

View File

@@ -1,5 +1,5 @@
<template>
<v-container fluid fill-height class="pa-0 p-page p-page-places">
<v-container fluid fill-height :class="$config.aclClasses('places')" class="pa-0 p-page p-page-places">
<div id="map" style="width: 100%; height: 100%;">
<div class="map-control">
<div class="maplibregl-ctrl maplibregl-ctrl-group">

View File

@@ -1,5 +1,5 @@
<template>
<div class="p-page p-page-settings">
<div :class="$config.aclClasses('settings')" class="p-page p-page-settings">
<v-tabs
v-model="active"
flat
@@ -13,7 +13,8 @@
@click="changePath(item.path)">
<v-icon v-if="$vuetify.breakpoint.smAndDown" :title="item.label">{{ item.icon }}</v-icon>
<template v-else>
<v-icon :size="18" :left="!rtl" :right="rtl">{{ item.icon }}</v-icon> {{ item.label }}
<v-icon :size="18" :left="!rtl" :right="rtl">{{ item.icon }}</v-icon>
{{ item.label }}
</template>
</v-tab>
@@ -32,6 +33,7 @@ import Library from "pages/settings/library.vue";
import Advanced from "pages/settings/advanced.vue";
import Sync from "pages/settings/sync.vue";
import Account from "pages/settings/account.vue";
import {config} from "app/session";
function initTabs(flag, tabs) {
let i = 0;
@@ -47,11 +49,15 @@ function initTabs(flag, tabs) {
export default {
name: 'PPageSettings',
props: {
tab: String,
tab: {
type: String,
default: "",
},
},
data() {
const isDemo = this.$config.get("demo");
const isPublic = this.$config.get("public");
const tabs = [
{
'name': 'settings-general',
@@ -63,6 +69,7 @@ export default {
'public': true,
'admin': true,
'demo': true,
'show': config.feature('settings'),
},
{
'name': 'settings-library',
@@ -74,6 +81,7 @@ export default {
'public': true,
'admin': true,
'demo': true,
'show': config.feature('advanced'),
},
{
'name': 'settings-advanced',
@@ -85,6 +93,7 @@ export default {
'public': false,
'admin': true,
'demo': true,
'show': config.feature('advanced'),
},
{
'name': 'settings-sync',
@@ -96,6 +105,7 @@ export default {
'public': false,
'admin': true,
'demo': true,
'show': config.feature('sync'),
},
{
'name': 'settings-account',
@@ -107,13 +117,16 @@ export default {
'public': false,
'admin': true,
'demo': true,
'show': config.feature('account'),
},
];
if(isDemo) {
if (isDemo) {
initTabs("demo", tabs);
} else if(isPublic) {
} else if (isPublic) {
initTabs("public", tabs);
} else {
initTabs("show", tabs);
}
let active = 0;

View File

@@ -43,13 +43,13 @@
</v-card-actions>
</v-card>
<v-card flat tile class="mt-0 px-1 application">
<v-card v-if="isDemo || isAdmin" flat tile class="mt-0 px-1 application">
<v-card-actions>
<v-layout wrap align-top>
<v-flex xs12 sm6 lg3 class="px-2 pb-2 pt-2">
<v-checkbox
v-model="settings.features.upload"
:disabled="busy || config.readonly || demo"
:disabled="busy || config.readonly || isDemo"
class="ma-0 pa-0 input-upload"
color="secondary-dark"
:label="$gettext('Upload')"
@@ -64,7 +64,7 @@
<v-flex xs12 sm6 lg3 class="px-2 pb-2 pt-2">
<v-checkbox
v-model="settings.features.download"
:disabled="busy || demo"
:disabled="busy || isDemo"
class="ma-0 pa-0 input-download"
color="secondary-dark"
:label="$gettext('Download')"
@@ -79,7 +79,7 @@
<v-flex xs12 sm6 lg3 class="px-2 pb-2 pt-2">
<v-checkbox
v-model="settings.features.edit"
:disabled="busy || demo"
:disabled="busy || isDemo"
class="ma-0 pa-0 input-edit"
color="secondary-dark"
:label="$gettext('Edit')"
@@ -109,7 +109,7 @@
<v-flex xs12 sm6 lg3 class="px-2 pb-2 pt-2">
<v-checkbox
v-model="settings.features.import"
:disabled="busy || config.readonly || demo"
:disabled="busy || config.readonly || isDemo"
class="ma-0 pa-0 input-import"
color="secondary-dark"
:label="$gettext('Import')"
@@ -229,7 +229,7 @@
<v-flex xs12 sm6 lg3 class="px-2 pb-2 pt-2">
<v-checkbox
v-model="settings.features.library"
:disabled="busy || demo"
:disabled="busy || isDemo"
class="ma-0 pa-0 input-library"
color="secondary-dark"
:label="$gettext('Library')"
@@ -259,7 +259,7 @@
<v-flex v-if="!config.disable.places" xs12 sm6 lg3 class="px-2 pb-2 pt-2">
<v-checkbox
v-model="settings.features.places"
:disabled="busy || demo"
:disabled="busy || isDemo"
class="ma-0 pa-0 input-places"
color="secondary-dark"
:label="$gettext('Places')"
@@ -331,10 +331,10 @@ import Event from "pubsub-js";
export default {
name: 'PSettingsGeneral',
data() {
const isDemo = this.$config.get("demo");
return {
demo: isDemo,
isDemo: this.$config.get("demo"),
isAdmin: this.$session.isAdmin(),
isPublic: this.$config.get("public"),
config: this.$config.values,
settings: new Settings(this.$config.settings()),
options: options,
@@ -421,6 +421,7 @@ export default {
}
this.settings.save().then(() => {
this.$config.setSettings(this.settings);
if (reload) {
this.$notify.info(this.$gettext("Reloading…"));
this.$notify.blockUI();

View File

@@ -53,60 +53,61 @@ import Hls from "hls.js";
import { $gettext, Mount } from "common/vm";
import * as options from "./options/options";
// Initialize helpers
const viewer = new Viewer();
const isPublic = config.get("public");
const isMobile =
config.load().finally(() => {
// Initialize helpers.
const viewer = new Viewer();
const isPublic = config.get("public");
const isMobile =
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
(navigator.maxTouchPoints && navigator.maxTouchPoints > 2);
// Initialize language and detect alignment
Vue.config.language = config.values.settings.ui.language;
Settings.defaultLocale = Vue.config.language.substring(0, 2);
const languages = options.Languages();
const rtl = languages.some((lang) => lang.value === Vue.config.language && lang.rtl);
// Initialize language and detect alignment.
Vue.config.language = config.getLanguage();
Settings.defaultLocale = Vue.config.language.substring(0, 2);
const languages = options.Languages();
const rtl = languages.some((lang) => lang.value === Vue.config.language && lang.rtl);
// Get initial theme colors from config
const theme = config.theme.colors;
// Get initial theme colors from config.
const theme = config.theme.colors;
// HTTP Live Streaming (video support)
window.Hls = Hls;
// HTTP Live Streaming (video support)
window.Hls = Hls;
// Assign helpers to VueJS prototype
Vue.prototype.$event = Event;
Vue.prototype.$notify = Notify;
Vue.prototype.$scrollbar = Scrollbar;
Vue.prototype.$viewer = viewer;
Vue.prototype.$session = session;
Vue.prototype.$api = Api;
Vue.prototype.$log = Log;
Vue.prototype.$socket = Socket;
Vue.prototype.$config = config;
Vue.prototype.$clipboard = Clipboard;
Vue.prototype.$isMobile = isMobile;
Vue.prototype.$rtl = rtl;
// Assign helpers to VueJS prototype.
Vue.prototype.$event = Event;
Vue.prototype.$notify = Notify;
Vue.prototype.$scrollbar = Scrollbar;
Vue.prototype.$viewer = viewer;
Vue.prototype.$session = session;
Vue.prototype.$api = Api;
Vue.prototype.$log = Log;
Vue.prototype.$socket = Socket;
Vue.prototype.$config = config;
Vue.prototype.$clipboard = Clipboard;
Vue.prototype.$isMobile = isMobile;
Vue.prototype.$rtl = rtl;
// Register Vuetify
Vue.use(Vuetify, { rtl, icons, theme });
// Register Vuetify.
Vue.use(Vuetify, { rtl, icons, theme });
// Register other VueJS plugins
Vue.use(GetTextPlugin, {
// Register other VueJS plugins.
Vue.use(GetTextPlugin, {
translations: config.translations,
silent: true, // !config.values.debug,
defaultLanguage: Vue.config.language,
autoAddKeyAttributes: true,
});
});
Vue.use(VueLuxon);
Vue.use(VueInfiniteScroll);
Vue.use(VueFullscreen);
Vue.use(VueFilters);
Vue.use(Components);
Vue.use(Dialogs);
Vue.use(Router);
Vue.use(VueLuxon);
Vue.use(VueInfiniteScroll);
Vue.use(VueFullscreen);
Vue.use(VueFilters);
Vue.use(Components);
Vue.use(Dialogs);
Vue.use(Router);
// Configure client-side routing
const router = new Router({
// Configure client-side routing.
const router = new Router({
routes: Routes,
mode: "history",
base: config.baseUri + "/",
@@ -124,13 +125,16 @@ const router = new Router({
return { x: 0, y: 0 };
}
},
});
});
router.beforeEach((to, from, next) => {
router.beforeEach((to, from, next) => {
if (document.querySelector(".v-dialog--active.v-dialog--fullscreen")) {
// Disable back button in full-screen viewers and editors.
next(false);
} else if (to.matched.some((record) => record.meta.settings) && config.values.disable.settings) {
} else if (
to.matched.some((record) => record.meta.settings) &&
config.values.disable.settings
) {
next({ name: "home" });
} else if (to.matched.some((record) => record.meta.admin)) {
if (isPublic || session.isAdmin()) {
@@ -153,9 +157,9 @@ router.beforeEach((to, from, next) => {
} else {
next();
}
});
});
router.afterEach((to) => {
router.afterEach((to) => {
if (to.meta.title && config.values.siteTitle !== to.meta.title) {
config.page.title = $gettext(to.meta.title);
window.document.title = config.page.title;
@@ -163,14 +167,15 @@ router.afterEach((to) => {
config.page.title = config.values.siteTitle;
window.document.title = config.values.siteTitle;
}
});
});
if (isMobile) {
if (isMobile) {
document.body.classList.add("mobile");
} else {
} else {
// Pull client config every 10 minutes in case push fails (except on mobile to save battery).
setInterval(() => config.update(), 600000);
}
}
// Start application.
Mount(Vue, PhotoPrism, router);
// Start application.
Mount(Vue, PhotoPrism, router);
});

View File

@@ -34,24 +34,46 @@ describe("common/session", () => {
const storage = new StorageShim();
const session = new Session(storage, config);
assert.isFalse(session.user.hasId());
const values = {
user: {
const user = {
ID: 5,
NickName: "Foo",
FullName: "Max Last",
PrimaryEmail: "test@test.com",
RoleAdmin: true,
},
GivenName: "Max",
DisplayName: "Max Example",
Email: "test@test.com",
SuperAdmin: true,
Role: "admin",
};
const data = {
user,
};
session.setData();
assert.equal(session.user.FullName, "");
session.setData(values);
assert.equal(session.user.FullName, "Max Last");
assert.equal(session.user.RoleAdmin, true);
assert.equal(session.user.DisplayName, "");
session.setData(data);
assert.equal(session.user.DisplayName, "Max Example");
assert.equal(session.user.SuperAdmin, true);
assert.equal(session.user.Role, "admin");
session.deleteAll();
assert.equal(session.user.DisplayName, "");
assert.equal(session.user.SuperAdmin, false);
assert.equal(session.user.Role, "");
session.setUser(user);
assert.equal(session.user.DisplayName, "Max Example");
assert.equal(session.user.SuperAdmin, true);
assert.equal(session.user.Role, "admin");
const result = session.getUser();
assert.equal(result.DisplayName, "Max Example");
assert.equal(result.SuperAdmin, true);
assert.equal(result.Role, "admin");
assert.equal(result.Email, "test@test.com");
assert.equal(result.ID, 5);
assert.equal(result.PrimaryEmail, "test@test.com");
session.deleteData();
assert.isTrue(session.user.hasId());
session.deleteUser();
assert.isFalse(session.user.hasId());
});
@@ -61,8 +83,8 @@ describe("common/session", () => {
const values = {
user: {
ID: 5,
DisplayName: "Foo",
FullName: "Max Last",
Name: "foo",
DisplayName: "Max Last",
Email: "test@test.com",
Role: "admin",
},
@@ -72,8 +94,8 @@ describe("common/session", () => {
assert.equal(result, "test@test.com");
const values2 = {
user: {
DisplayName: "Foo",
FullName: "Max Last",
Name: "foo",
DisplayName: "Max Last",
Email: "test@test.com",
Role: "admin",
},
@@ -84,32 +106,33 @@ describe("common/session", () => {
session.deleteData();
});
it("should get user nick name", () => {
it("should get user display name", () => {
const storage = new StorageShim();
const session = new Session(storage, config);
const values = {
user: {
ID: 5,
DisplayName: "Foo",
FullName: "Max Last",
Name: "foo",
DisplayName: "Max Last",
Email: "test@test.com",
Role: "admin",
},
};
session.setData(values);
const result = session.getDisplayName();
assert.equal(result, "Foo");
assert.equal(result, "Max Last");
const values2 = {
user: {
DisplayName: "Bar",
FullName: "Max Last",
ID: 5,
Name: "bar",
DisplayName: "",
Email: "test@test.com",
Role: "admin",
},
};
session.setData(values2);
const result2 = session.getDisplayName();
assert.equal(result2, "");
assert.equal(result2, "Bar");
session.deleteData();
});
@@ -119,19 +142,19 @@ describe("common/session", () => {
const values = {
user: {
ID: 5,
DisplayName: "Foo",
FullName: "Max Last",
Name: "foo",
DisplayName: "Max Last",
Email: "test@test.com",
Role: "admin",
},
};
session.setData(values);
const result = session.getDisplayName();
assert.equal(result, "Foo");
assert.equal(result, "Max Last");
const values2 = {
user: {
DisplayName: "Bar",
FullName: "Max New",
Name: "bar",
DisplayName: "Max New",
Email: "test@test.com",
Role: "admin",
},
@@ -148,8 +171,8 @@ describe("common/session", () => {
const values = {
user: {
ID: 5,
DisplayName: "Foo",
FullName: "Max Last",
Name: "foo",
DisplayName: "Max Last",
Email: "test@test.com",
Role: "admin",
},
@@ -166,8 +189,8 @@ describe("common/session", () => {
const values = {
user: {
ID: 5,
DisplayName: "Foo",
FullName: "Max Last",
Name: "foo",
DisplayName: "Max Last",
Email: "test@test.com",
Role: "admin",
},

View File

@@ -36,7 +36,11 @@ const putEntityResponse = {
};
const deleteEntityResponse = null;
Mock.onPost("api/v1/users/55/profile").reply(200, { FullName: "Max New" }, mockHeaders);
Mock.onPost("api/v1/users/urii20d30w2wqzjf/profile").reply(
200,
{ DisplayName: "Max New" },
mockHeaders
);
Mock.onGet("api/v1/foo").reply(200, getCollectionResponse, mockHeaders);
Mock.onGet("api/v1/foo/123").reply(200, getEntityResponse, mockHeaders);
Mock.onPost("api/v1/foo").reply(201, postEntityResponse, mockHeaders);
@@ -260,9 +264,9 @@ Mock.onPut("api/v1/albums/abc").reply(
mockHeaders
);
//Mock.onPost("api/v1/users/55/profile").reply(200, { FullName: "Max New" }, mockHeaders);
//Mock.onPost("users/55/profile").reply(200, { FullName: "Max New" }, mockHeaders);
//Mock.onPost("api/v1/users/55/profile").reply(200, { FullName: "Max New" }, mockHeaders);
//Mock.onPost("api/v1/users/55/profile").reply(200, { DisplayName: "Max New" }, mockHeaders);
//Mock.onPost("users/55/profile").reply(200, { DisplayName: "Max New" }, mockHeaders);
//Mock.onPost("api/v1/users/55/profile").reply(200, { DisplayName: "Max New" }, mockHeaders);
Mock.onAny("api/v1/users/52/register").reply(200, { foo: "register" }, mockHeaders);
@@ -336,7 +340,6 @@ Mock.onGet("api/v1/config/options").reply(200, { success: "ok" }, mockHeaders);
Mock.onPost("api/v1/config/options").reply(200, { success: "ok" }, mockHeaders);
Mock.onPost("api/v1/albums").reply(200, { success: "ok" }, mockHeaders);
//Mock.onPost().reply(200);
//Mock.onDelete().reply(200);
/*

View File

@@ -6,14 +6,28 @@ let assert = chai.assert;
describe("model/user", () => {
it("should get entity name", () => {
const values = { ID: 5, Username: "max", FullName: "Max Last", Email: "test@test.com", Role: "admin" };
const values = {
ID: 5,
Name: "max",
DisplayName: "Max Last",
Email: "test@test.com",
Role: "admin",
};
const user = new User(values);
const result = user.getEntityName();
assert.equal(result, "Max Last");
});
it("should get id", () => {
const values = { ID: 5, Username: "max", FullName: "Max Last", Email: "test@test.com", Role: "admin" };
const values = {
ID: 5,
Name: "max",
DisplayName: "Max Last",
Email: "test@test.com",
Role: "admin",
};
const user = new User(values);
const result = user.getId();
assert.equal(result, 5);
@@ -30,31 +44,45 @@ describe("model/user", () => {
});
it("should get register form", async () => {
const values = { ID: 52, Username: "max", FullName: "Max Last" };
const values = { ID: 52, Name: "max", DisplayName: "Max Last" };
const user = new User(values);
const result = await user.getRegisterForm();
assert.equal(result.definition.foo, "register");
});
it("should get profile form", async () => {
const values = { ID: 53, Username: "max", FullName: "Max Last" };
const values = { ID: 53, Name: "max", DisplayName: "Max Last" };
const user = new User(values);
const result = await user.getProfileForm();
assert.equal(result.definition.foo, "profile");
});
it("should get change password", async () => {
const values = { ID: 54, Username: "max", FullName: "Max Last", Email: "test@test.com", Role: "admin" };
const values = {
ID: 54,
Name: "max",
DisplayName: "Max Last",
Email: "test@test.com",
Role: "admin",
};
const user = new User(values);
const result = await user.changePassword("old", "new");
assert.equal(result.new_password, "new");
});
it("should save profile", async () => {
const values = { ID: 55, Username: "max", FullName: "Max Last", Email: "test@test.com", Role: "admin" };
const values = {
UID: "urii20d30w2wqzjf",
Name: "max",
DisplayName: "Max Last",
Email: "test@test.com",
Role: "admin",
};
const user = new User(values);
assert.equal(user.FullName, "Max Last");
assert.equal(user.DisplayName, "Max Last");
await user.saveProfile();
assert.equal(user.FullName, "Max New");
assert.equal(user.DisplayName, "Max New");
});
});

6
go.mod
View File

@@ -20,7 +20,7 @@ require (
github.com/go-errors/errors v1.4.2 // indirect
github.com/go-playground/validator/v10 v10.11.0 // indirect
github.com/golang/geo v0.0.0-20210211234256-740aa86cb551
github.com/google/open-location-code/go v0.0.0-20220627184029-8a4173398f7e
github.com/google/open-location-code/go v0.0.0-20220922185916-75f4f40254f8
github.com/gorilla/websocket v1.5.0
github.com/gosimple/slug v1.12.0
github.com/h2non/filetype v1.1.3
@@ -50,8 +50,8 @@ require (
github.com/ulule/deepcopier v0.0.0-20200430083143-45decc6639b6
github.com/urfave/cli v1.22.10
go4.org v0.0.0-20201209231011-d4a079459e60 // indirect
golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90
golang.org/x/net v0.0.0-20220909164309-bea034e7d591
golang.org/x/crypto v0.0.0-20220926161630-eccd6366d1be
golang.org/x/net v0.0.0-20220927171203-f486391704dc
gonum.org/v1/gonum v0.12.0
gopkg.in/photoprism/go-tz.v2 v2.1.1
gopkg.in/yaml.v2 v2.4.0

12
go.sum
View File

@@ -164,8 +164,8 @@ github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/open-location-code/go v0.0.0-20220627184029-8a4173398f7e h1:6yfwVPy5ecxRjRsS2LcQERwIFJg6QRkMjs7K2mKYMvM=
github.com/google/open-location-code/go v0.0.0-20220627184029-8a4173398f7e/go.mod h1:eJfRN6aj+kR/rnua/rw9jAgYhqoMHldQkdTi+sePRKk=
github.com/google/open-location-code/go v0.0.0-20220922185916-75f4f40254f8 h1:k2VIEPX7uDmceLb5cOKws0cHrvIMak1TT+Le4WcFreU=
github.com/google/open-location-code/go v0.0.0-20220922185916-75f4f40254f8/go.mod h1:eJfRN6aj+kR/rnua/rw9jAgYhqoMHldQkdTi+sePRKk=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
@@ -336,8 +336,8 @@ golang.org/x/crypto v0.0.0-20191205180655-e7c4368fe9dd/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90 h1:Y/gsMcFOcR+6S6f3YeMKl5g+dZMEWqcz5Czj/GWYbkM=
golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220926161630-eccd6366d1be h1:fmw3UbQh+nxngCAHrDCCztao/kbYFnWjoqop8dHx05A=
golang.org/x/crypto v0.0.0-20220926161630-eccd6366d1be/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@@ -412,8 +412,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220909164309-bea034e7d591 h1:D0B/7al0LLrVC8aWF4+oxpv/m8bc7ViFfVS8/gXGdqI=
golang.org/x/net v0.0.0-20220909164309-bea034e7d591/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/net v0.0.0-20220927171203-f486391704dc h1:FxpXZdoBqT8RjqTy6i1E8nXHhW21wK7ptQ/EPIGxzPQ=
golang.org/x/net v0.0.0-20220927171203-f486391704dc/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=

View File

@@ -1,5 +1,5 @@
/*
Package acl provides access control lists for authorization checking of user actions.
Package acl provides access control lists for authorization checks.
Copyright (c) 2018 - 2022 PhotoPrism UG. All rights reserved.
@@ -24,43 +24,56 @@ Additional information can be found in our Developer Guide:
*/
package acl
type Permission struct {
Roles Roles
Actions Actions
}
// ACL represents an access control list based on Resource, Roles, and Permissions.
type ACL map[Resource]Roles
func (l ACL) Deny(resource Resource, role Role, action Action) bool {
return !l.Allow(resource, role, action)
// Deny checks whether the role must be denied access to the specified resource.
func (acl ACL) Deny(resource Resource, role Role, perm Permission) bool {
return !acl.Allow(resource, role, perm)
}
func (l ACL) Allow(resource Resource, role Role, action Action) bool {
if p, ok := l[resource]; ok {
return p.Allow(role, action)
} else if p, ok := l[ResourceDefault]; ok {
return p.Allow(role, action)
// DenyAll checks whether the role is granted none of the permissions for the specified resource.
func (acl ACL) DenyAll(resource Resource, role Role, perms Permissions) bool {
return !acl.AllowAny(resource, role, perms)
}
// Allow checks whether the role is granted permission for the specified resource.
func (acl ACL) Allow(resource Resource, role Role, perm Permission) bool {
if p, ok := acl[resource]; ok {
return p.Allow(role, perm)
} else if p, ok = acl[ResourceDefault]; ok {
return p.Allow(role, perm)
}
return false
}
func (a Actions) Allow(action Action) bool {
if result, ok := a[action]; ok {
return result
} else if result, ok := a[ActionDefault]; ok {
return result
// AllowAny checks whether the role is granted any of the permissions for the specified resource.
func (acl ACL) AllowAny(resource Resource, role Role, perms Permissions) bool {
if len(perms) == 0 {
return false
}
for i := range perms {
if acl.Allow(resource, role, perms[i]) {
return true
}
}
return false
}
func (r Roles) Allow(role Role, action Action) bool {
if a, ok := r[role]; ok {
return a.Allow(action)
} else if a, ok := r[RoleDefault]; ok {
return a.Allow(action)
// AllowAll checks whether the role is granted all of the permissions for the specified resource.
func (acl ACL) AllowAll(resource Resource, role Role, perms Permissions) bool {
if len(perms) == 0 {
return false
}
for i := range perms {
if acl.Deny(resource, role, perms[i]) {
return false
}
}
return true
}

View File

@@ -0,0 +1,12 @@
package acl
// Events specifies granted permissions by event channel and Role.
var Events = ACL{
ResourceDefault: Roles{
RoleAdmin: GrantFullAccess,
},
ChannelSession: Roles{
RoleAdmin: GrantFullAccess,
RoleVisitor: GrantSubscribeOwn,
},
}

View File

@@ -0,0 +1,67 @@
package acl
// Resources specifies granted permissions by Resource and Role.
var Resources = ACL{
ResourceDefault: Roles{
RoleAdmin: GrantFullAccess,
},
ResourceConfig: Roles{
RoleAdmin: GrantFullAccess,
},
ResourceSettings: Roles{
RoleAdmin: GrantFullAccess,
},
ResourceCalendar: Roles{
RoleAdmin: GrantFullAccess,
},
ResourceMoments: Roles{
RoleAdmin: GrantFullAccess,
},
ResourceFiles: Roles{
RoleAdmin: GrantFullAccess,
},
ResourcePeople: Roles{
RoleAdmin: GrantFullAccess,
},
ResourceFavorites: Roles{
RoleAdmin: GrantFullAccess,
},
ResourceFeedback: Roles{
RoleAdmin: GrantFullAccess,
},
ResourceShares: Roles{
RoleAdmin: GrantFullAccess,
},
ResourcePassword: Roles{
RoleAdmin: GrantFullAccess,
},
ResourceAccounts: Roles{
RoleAdmin: GrantFullAccess,
},
ResourceLogs: Roles{
RoleAdmin: GrantFullAccess,
},
ResourceLabels: Roles{
RoleAdmin: GrantFullAccess,
},
ResourcePhotos: Roles{
RoleAdmin: GrantFullAccess,
RoleVisitor: Grant{AccessShared: true, ActionView: true, ActionDownload: true},
},
ResourceAlbums: Roles{
RoleAdmin: GrantFullAccess,
RoleVisitor: Grant{AccessShared: true, ActionView: true, ActionDownload: true},
},
ResourceVideos: Roles{
RoleAdmin: GrantFullAccess,
RoleVisitor: Grant{AccessShared: true, ActionView: true, ActionDownload: true},
},
ResourcePlaces: Roles{
RoleAdmin: GrantFullAccess,
RoleVisitor: Grant{AccessShared: true, ActionView: true, ActionDownload: true},
},
ResourceFolders: Roles{
RoleAdmin: GrantFullAccess,
RoleVisitor: Grant{AccessShared: true, ActionView: true, ActionDownload: true},
},
}

107
internal/acl/acl_test.go Normal file
View File

@@ -0,0 +1,107 @@
package acl
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestACL_Allow(t *testing.T) {
t.Run("ResourcePhotosRoleAdminActionModify", func(t *testing.T) {
assert.True(t, Resources.Allow(ResourcePhotos, RoleAdmin, ActionUpdate))
})
t.Run("ResourceDefaultRoleAdminActionDefault", func(t *testing.T) {
assert.True(t, Resources.Allow(ResourceDefault, RoleAdmin, FullAccess))
})
t.Run("ResourceDefaultRoleVisitorActionDefault", func(t *testing.T) {
assert.False(t, Resources.Allow(ResourceDefault, RoleVisitor, FullAccess))
})
t.Run("ResourcePhotosRoleVisitorActionDefault", func(t *testing.T) {
assert.False(t, Resources.Allow(ResourcePhotos, RoleVisitor, FullAccess))
})
t.Run("ResourceAlbumsRoleVisitorAccessShared", func(t *testing.T) {
assert.True(t, Resources.Allow(ResourceAlbums, RoleVisitor, AccessShared))
})
t.Run("ResourceAlbumsRoleVisitorActionDefault", func(t *testing.T) {
assert.False(t, Resources.Allow(ResourceAlbums, RoleVisitor, FullAccess))
})
}
func TestACL_AllowAny(t *testing.T) {
t.Run("Empty", func(t *testing.T) {
assert.False(t, Resources.AllowAny(ResourceAlbums, RoleVisitor, Permissions{}))
})
t.Run("VisitorAccess", func(t *testing.T) {
assert.True(t, Resources.AllowAny(ResourceAlbums, RoleVisitor, Permissions{AccessAll, AccessShared}))
assert.True(t, Resources.AllowAny(ResourceAlbums, RoleVisitor, Permissions{AccessShared}))
assert.False(t, Resources.AllowAny(ResourceAlbums, RoleVisitor, Permissions{AccessAll}))
})
t.Run("ResourcePhotosRoleAdminActionModify", func(t *testing.T) {
assert.True(t, Resources.AllowAny(ResourcePhotos, RoleAdmin, Permissions{ActionUpdate}))
})
t.Run("ResourceDefaultRoleAdminActionDefault", func(t *testing.T) {
assert.True(t, Resources.AllowAny(ResourceDefault, RoleAdmin, Permissions{FullAccess}))
})
t.Run("ResourceDefaultRoleVisitorActionDefault", func(t *testing.T) {
assert.False(t, Resources.AllowAny(ResourceDefault, RoleVisitor, Permissions{FullAccess}))
})
t.Run("ResourcePhotosRoleVisitorActionDefault", func(t *testing.T) {
assert.False(t, Resources.AllowAny(ResourcePhotos, RoleVisitor, Permissions{FullAccess}))
})
t.Run("ResourceAlbumsRoleVisitorAccessShared", func(t *testing.T) {
assert.True(t, Resources.AllowAny(ResourceAlbums, RoleVisitor, Permissions{AccessShared}))
})
t.Run("ResourceAlbumsRoleVisitorActionDefault", func(t *testing.T) {
assert.False(t, Resources.AllowAny(ResourceAlbums, RoleVisitor, Permissions{FullAccess}))
})
}
func TestACL_AllowAll(t *testing.T) {
t.Run("Empty", func(t *testing.T) {
assert.False(t, Resources.AllowAll(ResourceAlbums, RoleVisitor, Permissions{}))
})
t.Run("VisitorAccess", func(t *testing.T) {
assert.False(t, Resources.AllowAll(ResourceAlbums, RoleVisitor, Permissions{AccessAll, AccessShared}))
assert.True(t, Resources.AllowAll(ResourceAlbums, RoleVisitor, Permissions{AccessShared}))
assert.False(t, Resources.AllowAll(ResourceAlbums, RoleVisitor, Permissions{AccessAll}))
})
t.Run("ResourcePhotosRoleAdminActionModify", func(t *testing.T) {
assert.True(t, Resources.AllowAll(ResourcePhotos, RoleAdmin, Permissions{ActionUpdate}))
})
t.Run("ResourceDefaultRoleAdminActionDefault", func(t *testing.T) {
assert.True(t, Resources.AllowAll(ResourceDefault, RoleAdmin, Permissions{FullAccess}))
})
t.Run("ResourceDefaultRoleVisitorActionDefault", func(t *testing.T) {
assert.False(t, Resources.AllowAll(ResourceDefault, RoleVisitor, Permissions{FullAccess}))
})
t.Run("ResourcePhotosRoleVisitorActionDefault", func(t *testing.T) {
assert.False(t, Resources.AllowAll(ResourcePhotos, RoleVisitor, Permissions{FullAccess}))
})
t.Run("ResourceAlbumsRoleVisitorAccessShared", func(t *testing.T) {
assert.True(t, Resources.AllowAll(ResourceAlbums, RoleVisitor, Permissions{AccessShared}))
})
t.Run("ResourceAlbumsRoleVisitorActionDefault", func(t *testing.T) {
assert.False(t, Resources.AllowAll(ResourceAlbums, RoleVisitor, Permissions{FullAccess}))
})
t.Run("Empty", func(t *testing.T) {
assert.False(t, Resources.AllowAll(ResourceAlbums, RoleVisitor, Permissions{}))
})
}
func TestACL_Deny(t *testing.T) {
t.Run("ResourceDefaultRoleAdminActionDefault", func(t *testing.T) {
assert.False(t, Resources.Deny(ResourceDefault, RoleAdmin, FullAccess))
})
t.Run("ResourceDefaultRoleVisitorActionDefault", func(t *testing.T) {
assert.True(t, Resources.Deny(ResourceDefault, RoleVisitor, FullAccess))
})
t.Run("ResourceAlbumsRoleVisitorActionAccessShared", func(t *testing.T) {
assert.False(t, Resources.Deny(ResourceAlbums, RoleVisitor, AccessShared))
})
t.Run("ResourcePhotosRoleVisitorActionDefault", func(t *testing.T) {
assert.True(t, Resources.Deny(ResourcePhotos, RoleVisitor, FullAccess))
})
t.Run("ResourceAlbumsRoleVisitorActionDefault", func(t *testing.T) {
assert.True(t, Resources.Deny(ResourceAlbums, RoleVisitor, FullAccess))
})
}

View File

@@ -1,22 +0,0 @@
package acl
type Action string
type Actions map[Action]bool
const (
ActionDefault Action = "*"
ActionSearch Action = "search"
ActionCreate Action = "create"
ActionRead Action = "read"
ActionUpdate Action = "update"
ActionUpdateSelf Action = "update-self"
ActionDelete Action = "delete"
ActionPrivate Action = "private"
ActionUpload Action = "upload"
ActionDownload Action = "download"
ActionShare Action = "share"
ActionLike Action = "like"
ActionComment Action = "comment"
ActionExport Action = "export"
ActionImport Action = "import"
)

22
internal/acl/channels.go Normal file
View File

@@ -0,0 +1,22 @@
package acl
const (
ChannelSession Resource = "session"
ChannelAudit Resource = "audit"
ChannelLog Resource = "log"
ChannelNotify Resource = "notify"
ChannelIndex Resource = "index"
ChannelUpload Resource = "upload"
ChannelImport Resource = "import"
ChannelConfig Resource = "config"
ChannelCount Resource = "count"
ChannelPhotos Resource = "photos"
ChannelCameras Resource = "cameras"
ChannelLenses Resource = "lenses"
ChannelCountries Resource = "countries"
ChannelAlbums Resource = "albums"
ChannelLabels Resource = "labels"
ChannelSubjects Resource = "subjects"
ChannelPeople Resource = "people"
ChannelSync Resource = "sync"
)

22
internal/acl/grant.go Normal file
View File

@@ -0,0 +1,22 @@
package acl
// Predefined grants to simplify configuration.
var (
GrantFullAccess = Grant{FullAccess: true, AccessAll: true, ActionCreate: true, ActionUpdate: true, ActionDelete: true, ActionDownload: true, ActionShare: true, ActionRate: true, ActionReact: true, ActionManage: true, ActionSubscribe: true}
GrantSubscribeAll = Grant{AccessAll: true, ActionSubscribe: true}
GrantSubscribeOwn = Grant{AccessOwn: true, ActionSubscribe: true}
)
// Grant represents permissions granted or denied.
type Grant map[Permission]bool
// Allow checks whether the permission is granted.
func (grant Grant) Allow(perm Permission) bool {
if result, ok := grant[perm]; ok {
return result
} else if result, ok = grant[FullAccess]; ok {
return result
}
return false
}

15
internal/acl/grants.go Normal file
View File

@@ -0,0 +1,15 @@
package acl
// Grants represents Permission Grant by Resource.
type Grants map[Resource]Grant
// Grants returns the permissions granted to the specified Role by Resource.
func (acl ACL) Grants(role Role) Grants {
result := make(map[Resource]Grant, len(acl))
for resource := range acl {
result[resource] = acl[resource][role]
}
return result
}

View File

@@ -0,0 +1,20 @@
package acl
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestACL_Grants(t *testing.T) {
t.Run("RoleAdmin", func(t *testing.T) {
result := Resources.Grants(RoleAdmin)
assert.True(t, result[ResourcePhotos][ActionManage])
assert.True(t, result[ResourceConfig][ActionManage])
})
t.Run("RoleVisitor", func(t *testing.T) {
result := Resources.Grants(RoleVisitor)
assert.False(t, result[ResourcePhotos][ActionUpdate])
assert.False(t, result[ResourceConfig][ActionManage])
})
}

View File

@@ -1,117 +0,0 @@
package acl
var Permissions = ACL{
ResourceDefault: Roles{
RoleAdmin: Actions{ActionDefault: true},
},
ResourceConfig: Roles{
RoleAdmin: Actions{ActionDefault: true},
RoleEditor: Actions{ActionRead: true},
RoleViewer: Actions{ActionRead: true},
RoleGuest: Actions{ActionRead: true},
},
ResourceConfigOptions: Roles{
RoleAdmin: Actions{ActionDefault: true},
RoleEditor: Actions{ActionRead: true},
},
ResourceSettings: Roles{
RoleAdmin: Actions{ActionDefault: true},
RoleEditor: Actions{ActionDefault: true},
},
ResourceLogs: Roles{
RoleAdmin: Actions{ActionDefault: true},
RoleEditor: Actions{ActionRead: true},
RoleViewer: Actions{ActionRead: true},
},
ResourceAccounts: Roles{
RoleAdmin: Actions{ActionDefault: true},
RoleEditor: Actions{ActionRead: true},
},
ResourceSubjects: Roles{
RoleAdmin: Actions{ActionDefault: true},
RoleEditor: Actions{ActionRead: true},
RoleViewer: Actions{ActionRead: true},
},
ResourceAlbums: Roles{
RoleAdmin: Actions{ActionDefault: true},
RoleEditor: Actions{ActionDefault: true},
RoleViewer: Actions{ActionSearch: true, ActionRead: true, ActionComment: true},
RoleGuest: Actions{ActionSearch: true, ActionRead: true},
},
ResourceCameras: Roles{
RoleAdmin: Actions{ActionDefault: true},
RoleEditor: Actions{ActionDefault: true},
RoleViewer: Actions{ActionSearch: true, ActionRead: true},
RoleGuest: Actions{ActionSearch: true, ActionRead: true},
},
ResourceCategories: Roles{
RoleAdmin: Actions{ActionDefault: true},
RoleEditor: Actions{ActionDefault: true},
RoleViewer: Actions{ActionSearch: true, ActionRead: true},
RoleGuest: Actions{ActionSearch: true, ActionRead: true},
},
ResourceCountries: Roles{
RoleAdmin: Actions{ActionDefault: true},
RoleEditor: Actions{ActionDefault: true},
RoleViewer: Actions{ActionSearch: true, ActionRead: true},
RoleGuest: Actions{ActionSearch: true, ActionRead: true},
},
ResourceFiles: Roles{
RoleAdmin: Actions{ActionDefault: true},
RoleEditor: Actions{ActionDefault: true},
RoleViewer: Actions{ActionSearch: true, ActionRead: true},
RoleGuest: Actions{ActionSearch: true, ActionRead: true},
},
ResourceFolders: Roles{
RoleAdmin: Actions{ActionDefault: true},
RoleEditor: Actions{ActionDefault: true},
RoleViewer: Actions{ActionSearch: true, ActionRead: true},
RoleGuest: Actions{ActionSearch: true, ActionRead: true},
},
ResourceLabels: Roles{
RoleAdmin: Actions{ActionDefault: true},
RoleEditor: Actions{ActionDefault: true},
RoleViewer: Actions{ActionSearch: true, ActionRead: true},
RoleGuest: Actions{ActionSearch: true, ActionRead: true},
},
ResourceLenses: Roles{
RoleAdmin: Actions{ActionDefault: true},
RoleEditor: Actions{ActionDefault: true},
RoleViewer: Actions{ActionSearch: true, ActionRead: true},
RoleGuest: Actions{ActionSearch: true, ActionRead: true},
},
ResourceLinks: Roles{
RoleAdmin: Actions{ActionDefault: true},
RoleEditor: Actions{ActionDefault: true},
},
ResourceGeo: Roles{
RoleAdmin: Actions{ActionDefault: true},
RoleEditor: Actions{ActionDefault: true},
RoleViewer: Actions{ActionSearch: true, ActionRead: true, ActionComment: true},
RoleGuest: Actions{ActionSearch: true, ActionRead: true},
},
ResourcePhotos: Roles{
RoleAdmin: Actions{ActionDefault: true},
RoleEditor: Actions{ActionDefault: true},
RoleViewer: Actions{ActionSearch: true, ActionRead: true, ActionDownload: true, ActionComment: true},
RoleGuest: Actions{ActionSearch: true, ActionRead: true, ActionDownload: true},
},
ResourcePrivate: Roles{
RoleAdmin: Actions{ActionDefault: true},
RoleEditor: Actions{ActionDefault: true},
},
ResourcePlaces: Roles{
RoleAdmin: Actions{ActionDefault: true},
RoleEditor: Actions{ActionDefault: true},
RoleViewer: Actions{ActionSearch: true, ActionRead: true, ActionDownload: true},
},
ResourceUsers: Roles{
RoleAdmin: Actions{ActionDefault: true},
RoleDefault: Actions{ActionUpdateSelf: true},
},
ResourcePasswords: Roles{
RoleAdmin: Actions{ActionDefault: true},
RoleEditor: Actions{ActionUpdateSelf: true},
RoleViewer: Actions{ActionUpdateSelf: true},
},
}

View File

@@ -1,52 +0,0 @@
package acl
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestACL_Allow(t *testing.T) {
t.Run("photos/admin/update", func(t *testing.T) {
assert.True(t, Permissions.Allow(ResourcePhotos, RoleAdmin, ActionUpdate))
})
t.Run("default/admin", func(t *testing.T) {
assert.True(t, Permissions.Allow(ResourceDefault, RoleAdmin, ActionDefault))
})
t.Run("default/guest", func(t *testing.T) {
assert.False(t, Permissions.Allow(ResourceDefault, RoleGuest, ActionDefault))
})
t.Run("photos/guest/search", func(t *testing.T) {
assert.True(t, Permissions.Allow(ResourcePhotos, RoleGuest, ActionSearch))
})
t.Run("photos/guest/default", func(t *testing.T) {
assert.False(t, Permissions.Allow(ResourcePhotos, RoleGuest, ActionDefault))
})
t.Run("albums/guest/search", func(t *testing.T) {
assert.True(t, Permissions.Allow(ResourceAlbums, RoleGuest, ActionSearch))
})
t.Run("albums/guest/default", func(t *testing.T) {
assert.False(t, Permissions.Allow(ResourceAlbums, RoleGuest, ActionDefault))
})
}
func TestACL_Deny(t *testing.T) {
t.Run("default/admin", func(t *testing.T) {
assert.False(t, Permissions.Deny(ResourceDefault, RoleAdmin, ActionDefault))
})
t.Run("default/guest", func(t *testing.T) {
assert.True(t, Permissions.Deny(ResourceDefault, RoleGuest, ActionDefault))
})
t.Run("photos/guest/search", func(t *testing.T) {
assert.False(t, Permissions.Deny(ResourcePhotos, RoleGuest, ActionSearch))
})
t.Run("photos/guest/default", func(t *testing.T) {
assert.True(t, Permissions.Deny(ResourcePhotos, RoleGuest, ActionDefault))
})
t.Run("albums/guest/search", func(t *testing.T) {
assert.False(t, Permissions.Deny(ResourceAlbums, RoleGuest, ActionSearch))
})
t.Run("albums/guest/default", func(t *testing.T) {
assert.True(t, Permissions.Deny(ResourceAlbums, RoleGuest, ActionDefault))
})
}

62
internal/acl/perms.go Normal file
View File

@@ -0,0 +1,62 @@
package acl
import "strings"
// Permissions that can be granted to roles.
const (
FullAccess Permission = "full_access"
AccessShared Permission = "access_shared"
AccessLibrary Permission = "access_library"
AccessPrivate Permission = "access_private"
AccessOwn Permission = "access_own"
AccessAll Permission = "access_all"
ActionSearch Permission = "search"
ActionView Permission = "view"
ActionUpload Permission = "upload"
ActionCreate Permission = "create"
ActionUpdate Permission = "update"
ActionDownload Permission = "download"
ActionShare Permission = "share"
ActionDelete Permission = "delete"
ActionRate Permission = "rate"
ActionReact Permission = "react"
ActionManage Permission = "manage"
ActionSubscribe Permission = "subscribe"
)
// Permission represents a single ability.
type Permission string
// String returns the type as string.
func (p Permission) String() string {
return strings.ReplaceAll(string(p), "_", " ")
}
// LogId returns an identifier string for use in log messages.
func (p Permission) LogId() string {
return p.String()
}
// Equal checks if the type matches.
func (p Permission) Equal(s string) bool {
return strings.EqualFold(s, p.String())
}
// NotEqual checks if the type is different.
func (p Permission) NotEqual(s string) bool {
return !p.Equal(s)
}
// Permissions is a list of permissions.
type Permissions []Permission
// String returns the permissions as a comma-separated string.
func (perm Permissions) String() string {
s := make([]string, len(perm))
for i := range perm {
s[i] = perm[i].String()
}
return strings.Join(s, ", ")
}

View File

@@ -0,0 +1,22 @@
package acl
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestPermissions_String(t *testing.T) {
t.Run("Empty", func(t *testing.T) {
perms := Permissions{}
assert.Equal(t, "", perms.String())
})
t.Run("FullAccess", func(t *testing.T) {
perms := Permissions{FullAccess}
assert.Equal(t, "full access", perms.String())
})
t.Run("ManageUploadAll", func(t *testing.T) {
perms := Permissions{ActionManage, ActionUpload, AccessAll}
assert.Equal(t, "manage, upload, access all", perms.String())
})
}

54
internal/acl/resource.go Normal file
View File

@@ -0,0 +1,54 @@
package acl
import "strings"
// Resources that Roles can be granted Permission.
const (
ResourceDefault Resource = "default"
ResourcePhotos Resource = "photos"
ResourceFavorites Resource = "favorites"
ResourceAlbums Resource = "albums"
ResourcePeople Resource = "people"
ResourceMoments Resource = "moments"
ResourceCalendar Resource = "calendar"
ResourcePlaces Resource = "places"
ResourceLabels Resource = "labels"
ResourceLogs Resource = "logs"
ResourceConfig Resource = "config"
ResourceSettings Resource = "settings"
ResourcePassword Resource = "password"
ResourceUsers Resource = "users"
ResourceAccounts Resource = "accounts"
ResourceFiles Resource = "files"
ResourceFolders Resource = "folders"
ResourceShares Resource = "shares"
ResourceVideos Resource = "videos"
ResourceFeedback Resource = "feedback"
)
// Resource represents a resource for which roles can be granted Permission.
type Resource string
// String returns the type as string.
func (r Resource) String() string {
if r == "" {
return "default"
}
return string(r)
}
// LogId returns an identifier string for use in log messages.
func (r Resource) LogId() string {
return r.String()
}
// Equal checks if the type matches.
func (r Resource) Equal(s string) bool {
return strings.EqualFold(s, r.String())
}
// NotEqual checks if the type is different.
func (r Resource) NotEqual(s string) bool {
return !r.Equal(s)
}

View File

@@ -1,29 +0,0 @@
package acl
type Resource string
const (
ResourceDefault Resource = "*"
ResourceConfig Resource = "config"
ResourceConfigOptions Resource = "config_options"
ResourceSettings Resource = "settings"
ResourceLogs Resource = "logs"
ResourceAccounts Resource = "accounts"
ResourceSubjects Resource = "subjects"
ResourceAlbums Resource = "albums"
ResourceCameras Resource = "cameras"
ResourceCategories Resource = "categories"
ResourceCountries Resource = "countries"
ResourceFiles Resource = "files"
ResourceFolders Resource = "folders"
ResourceLabels Resource = "labels"
ResourceLenses Resource = "lenses"
ResourceLinks Resource = "links"
ResourceGeo Resource = "geo"
ResourcePasswords Resource = "passwords"
ResourceUsers Resource = "users"
ResourcePhotos Resource = "photos"
ResourcePrivate Resource = "private"
ResourcePlaces Resource = "places"
ResourceFeedback Resource = "feedback"
)

42
internal/acl/role.go Normal file
View File

@@ -0,0 +1,42 @@
package acl
import (
"strings"
)
// Role represents a user role.
type Role string
// String returns the type as string.
func (r Role) String() string {
if r == "" {
return "unauthorized"
}
return string(r)
}
// LogId returns an identifier string for use in log messages.
func (r Role) LogId() string {
return "role " + r.String()
}
// Equal checks if the type matches.
func (r Role) Equal(s string) bool {
return strings.EqualFold(s, r.String())
}
// NotEqual checks if the type is different.
func (r Role) NotEqual(s string) bool {
return !r.Equal(s)
}
// Valid checks if the role is valid.
func (r Role) Valid(s string) bool {
return ValidRoles[s] != ""
}
// Invalid checks if the role is invalid.
func (r Role) Invalid(s string) bool {
return !r.Valid(s)
}

View File

@@ -1,33 +1,31 @@
package acl
import (
"strings"
"github.com/photoprism/photoprism/pkg/clean"
)
type Role string
type Roles map[Role]Actions
// Roles that can be assigned to users.
const (
RoleAdmin Role = "admin"
RoleEditor Role = "editor"
RoleViewer Role = "viewer"
RoleGuest Role = "guest"
RoleDefault Role = "*"
RoleVisitor Role = "visitor"
RoleUnauthorized Role = "unauthorized"
RoleDefault Role = "default"
RoleUnknown Role = ""
)
// String returns the type as string.
func (t Role) String() string {
return clean.Role(string(t))
// ValidRoles specifies the valid user roles.
var ValidRoles = map[string]Role{
string(RoleAdmin): RoleAdmin,
string(RoleVisitor): RoleVisitor,
string(RoleUnauthorized): RoleUnauthorized,
}
// Equal checks if the type matches.
func (t Role) Equal(s string) bool {
return strings.EqualFold(s, t.String())
}
// Roles grants permissions to roles.
type Roles map[Role]Grant
// NotEqual checks if the type is different.
func (t Role) NotEqual(s string) bool {
return !t.Equal(s)
// Allow checks whether the permission is granted based on the role.
func (roles Roles) Allow(role Role, grant Permission) bool {
if a, ok := roles[role]; ok {
return a.Allow(grant)
} else if a, ok = roles[RoleDefault]; ok {
return a.Allow(grant)
}
return false
}

79
internal/api/abort.go Normal file
View File

@@ -0,0 +1,79 @@
package api
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/pkg/clean"
)
func Abort(c *gin.Context, code int, id i18n.Message, params ...interface{}) {
resp := i18n.NewResponse(code, id, params...)
log.Debugf("api-v1: abort %s with code %d (%s)", clean.Log(c.FullPath()), code, strings.ToLower(resp.String()))
c.AbortWithStatusJSON(code, resp)
}
func Error(c *gin.Context, code int, err error, id i18n.Message, params ...interface{}) {
resp := i18n.NewResponse(code, id, params...)
if err != nil {
resp.Details = err.Error()
log.Errorf("api-v1: error %s with code %d in %s (%s)", clean.Log(err.Error()), code, clean.Log(c.FullPath()), strings.ToLower(resp.String()))
}
c.AbortWithStatusJSON(code, resp)
}
// AbortUnauthorized aborts with status code 401.
func AbortUnauthorized(c *gin.Context) {
Abort(c, http.StatusUnauthorized, i18n.ErrUnauthorized)
}
// AbortForbidden aborts with status code 403.
func AbortForbidden(c *gin.Context) {
Abort(c, http.StatusForbidden, i18n.ErrForbidden)
}
// AbortNotFound aborts with status code 404.
func AbortNotFound(c *gin.Context) {
Abort(c, http.StatusNotFound, i18n.ErrNotFound)
}
// AbortEntityNotFound aborts with status code 404.
func AbortEntityNotFound(c *gin.Context) {
Abort(c, http.StatusNotFound, i18n.ErrEntityNotFound)
}
// AbortAlbumNotFound aborts with status code 404.
func AbortAlbumNotFound(c *gin.Context) {
Abort(c, http.StatusNotFound, i18n.ErrAlbumNotFound)
}
func AbortSaveFailed(c *gin.Context) {
Abort(c, http.StatusInternalServerError, i18n.ErrSaveFailed)
}
func AbortDeleteFailed(c *gin.Context) {
Abort(c, http.StatusInternalServerError, i18n.ErrDeleteFailed)
}
func AbortUnexpected(c *gin.Context) {
Abort(c, http.StatusInternalServerError, i18n.ErrUnexpected)
}
func AbortBadRequest(c *gin.Context) {
Abort(c, http.StatusBadRequest, i18n.ErrBadRequest)
}
func AbortFeatureDisabled(c *gin.Context) {
Abort(c, http.StatusForbidden, i18n.ErrFeatureDisabled)
}
func AbortBusy(c *gin.Context) {
Abort(c, http.StatusTooManyRequests, i18n.ErrBusy)
}

View File

@@ -35,17 +35,16 @@ const (
// id: string Account ID as returned by the API
func GetAccount(router *gin.RouterGroup) {
router.GET("/accounts/:id", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceAccounts, acl.ActionRead)
s := Auth(c, acl.ResourceAccounts, acl.ActionView)
if s.Invalid() {
AbortUnauthorized(c)
if s.Abort(c) {
return
}
conf := service.Config()
if conf.Demo() || conf.DisableSettings() {
AbortUnauthorized(c)
AbortForbidden(c)
return
}
@@ -68,17 +67,16 @@ func GetAccount(router *gin.RouterGroup) {
// id: string Account ID as returned by the API
func GetAccountFolders(router *gin.RouterGroup) {
router.GET("/accounts/:id/folders", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceAccounts, acl.ActionRead)
s := Auth(c, acl.ResourceAccounts, acl.ActionView)
if s.Invalid() {
AbortUnauthorized(c)
if s.Abort(c) {
return
}
conf := service.Config()
if conf.Demo() || conf.DisableSettings() {
AbortUnauthorized(c)
AbortForbidden(c)
return
}
@@ -127,10 +125,9 @@ func GetAccountFolders(router *gin.RouterGroup) {
// id: string Account ID as returned by the API
func ShareWithAccount(router *gin.RouterGroup) {
router.POST("/accounts/:id/share", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceAccounts, acl.ActionUpload)
s := Auth(c, acl.ResourceAccounts, acl.ActionUpload)
if s.Invalid() {
AbortUnauthorized(c)
if s.Abort(c) {
return
}
@@ -187,17 +184,16 @@ func ShareWithAccount(router *gin.RouterGroup) {
// POST /api/v1/accounts
func CreateAccount(router *gin.RouterGroup) {
router.POST("/accounts", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceAccounts, acl.ActionCreate)
s := Auth(c, acl.ResourceAccounts, acl.ActionCreate)
if s.Invalid() {
AbortUnauthorized(c)
if s.Abort(c) {
return
}
conf := service.Config()
if conf.Demo() || conf.DisableSettings() {
AbortUnauthorized(c)
AbortForbidden(c)
return
}
@@ -237,17 +233,16 @@ func CreateAccount(router *gin.RouterGroup) {
// id: string Account ID as returned by the API
func UpdateAccount(router *gin.RouterGroup) {
router.PUT("/accounts/:id", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceAccounts, acl.ActionUpdate)
s := Auth(c, acl.ResourceAccounts, acl.ActionUpdate)
if s.Invalid() {
AbortUnauthorized(c)
if s.Abort(c) {
return
}
conf := service.Config()
if conf.Demo() || conf.DisableSettings() {
AbortUnauthorized(c)
AbortForbidden(c)
return
}
@@ -309,17 +304,16 @@ func UpdateAccount(router *gin.RouterGroup) {
// id: string Account ID as returned by the API
func DeleteAccount(router *gin.RouterGroup) {
router.DELETE("/accounts/:id", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceAccounts, acl.ActionDelete)
s := Auth(c, acl.ResourceAccounts, acl.ActionDelete)
if s.Invalid() {
AbortUnauthorized(c)
if s.Abort(c) {
return
}
conf := service.Config()
if conf.Demo() || conf.DisableSettings() {
AbortUnauthorized(c)
AbortForbidden(c)
return
}

View File

@@ -18,10 +18,9 @@ import (
// GET /api/v1/accounts
func SearchAccounts(router *gin.RouterGroup) {
router.GET("/accounts", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceAccounts, acl.ActionSearch)
s := Auth(c, acl.ResourceAccounts, acl.ActionSearch)
if s.Invalid() {
AbortUnauthorized(c)
if s.Abort(c) {
return
}

View File

@@ -44,14 +44,13 @@ func SaveAlbumAsYaml(a entity.Album) {
// GET /api/v1/albums/:uid
func GetAlbum(router *gin.RouterGroup) {
router.GET("/albums/:uid", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceAlbums, acl.ActionRead)
s := Auth(c, acl.ResourceAlbums, acl.ActionView)
if s.Invalid() {
AbortUnauthorized(c)
if s.Abort(c) {
return
}
id := clean.IdString(c.Param("uid"))
id := clean.UID(c.Param("uid"))
a, err := query.AlbumByUID(id)
if err != nil {
@@ -68,10 +67,9 @@ func GetAlbum(router *gin.RouterGroup) {
// POST /api/v1/albums
func CreateAlbum(router *gin.RouterGroup) {
router.POST("/albums", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceAlbums, acl.ActionCreate)
s := Auth(c, acl.ResourceAlbums, acl.ActionCreate)
if s.Invalid() {
AbortUnauthorized(c)
if s.Abort(c) {
return
}
@@ -89,12 +87,9 @@ func CreateAlbum(router *gin.RouterGroup) {
a.AlbumFavorite = f.AlbumFavorite
// Existing album?
if err := a.Find(); err != nil {
if found := a.Find(); found == nil {
// Not found, create new album.
err = a.Create()
// Should never happen.
if err != nil {
if err := a.Create(); err != nil {
// Report unexpected error.
log.Errorf("album: %s (create)", err)
AbortUnexpected(c)
@@ -104,11 +99,12 @@ func CreateAlbum(router *gin.RouterGroup) {
event.SuccessMsg(i18n.MsgAlbumCreated)
} else {
// Exists, restore if necessary.
a = found
if !a.Deleted() {
event.InfoMsg(i18n.ErrAlreadyExists, a.Title())
c.JSON(http.StatusOK, a)
return
} else if err = a.Restore(); err == nil {
} else if err := a.Restore(); err == nil {
event.SuccessMsg(i18n.MsgRestored, a.Title())
} else {
// Report unexpected error.
@@ -133,14 +129,13 @@ func CreateAlbum(router *gin.RouterGroup) {
// PUT /api/v1/albums/:uid
func UpdateAlbum(router *gin.RouterGroup) {
router.PUT("/albums/:uid", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceAlbums, acl.ActionUpdate)
s := Auth(c, acl.ResourceAlbums, acl.ActionUpdate)
if s.Invalid() {
AbortUnauthorized(c)
if s.Abort(c) {
return
}
uid := clean.IdString(c.Param("uid"))
uid := clean.UID(c.Param("uid"))
a, err := query.AlbumByUID(uid)
if err != nil {
@@ -188,14 +183,13 @@ func UpdateAlbum(router *gin.RouterGroup) {
// DELETE /api/v1/albums/:uid
func DeleteAlbum(router *gin.RouterGroup) {
router.DELETE("/albums/:uid", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceAlbums, acl.ActionDelete)
s := Auth(c, acl.ResourceAlbums, acl.ActionDelete)
if s.Invalid() {
AbortUnauthorized(c)
if s.Abort(c) {
return
}
id := clean.IdString(c.Param("uid"))
id := clean.UID(c.Param("uid"))
a, err := query.AlbumByUID(id)
@@ -243,14 +237,13 @@ func DeleteAlbum(router *gin.RouterGroup) {
// uid: string Album UID
func LikeAlbum(router *gin.RouterGroup) {
router.POST("/albums/:uid/like", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceAlbums, acl.ActionLike)
s := Auth(c, acl.ResourceAlbums, acl.ActionUpdate)
if s.Invalid() {
AbortUnauthorized(c)
if s.Abort(c) {
return
}
id := clean.IdString(c.Param("uid"))
id := clean.UID(c.Param("uid"))
a, err := query.AlbumByUID(id)
if err != nil {
@@ -282,14 +275,13 @@ func LikeAlbum(router *gin.RouterGroup) {
// uid: string Album UID
func DislikeAlbum(router *gin.RouterGroup) {
router.DELETE("/albums/:uid/like", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceAlbums, acl.ActionLike)
s := Auth(c, acl.ResourceAlbums, acl.ActionUpdate)
if s.Invalid() {
AbortUnauthorized(c)
if s.Abort(c) {
return
}
id := clean.IdString(c.Param("uid"))
id := clean.UID(c.Param("uid"))
a, err := query.AlbumByUID(id)
if err != nil {
@@ -317,14 +309,13 @@ func DislikeAlbum(router *gin.RouterGroup) {
// POST /api/v1/albums/:uid/clone
func CloneAlbums(router *gin.RouterGroup) {
router.POST("/albums/:uid/clone", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceAlbums, acl.ActionUpdate)
s := Auth(c, acl.ResourceAlbums, acl.ActionCreate)
if s.Invalid() {
AbortUnauthorized(c)
if s.Abort(c) {
return
}
a, err := query.AlbumByUID(clean.IdString(c.Param("uid")))
a, err := query.AlbumByUID(clean.UID(c.Param("uid")))
if err != nil {
AbortAlbumNotFound(c)
@@ -375,10 +366,9 @@ func CloneAlbums(router *gin.RouterGroup) {
// POST /api/v1/albums/:uid/photos
func AddPhotosToAlbum(router *gin.RouterGroup) {
router.POST("/albums/:uid/photos", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceAlbums, acl.ActionUpdate)
s := Auth(c, acl.ResourceAlbums, acl.ActionUpdate)
if s.Invalid() {
AbortUnauthorized(c)
if s.Abort(c) {
return
}
@@ -389,7 +379,7 @@ func AddPhotosToAlbum(router *gin.RouterGroup) {
return
}
uid := clean.IdString(c.Param("uid"))
uid := clean.UID(c.Param("uid"))
a, err := query.AlbumByUID(uid)
if err != nil {
@@ -431,10 +421,9 @@ func AddPhotosToAlbum(router *gin.RouterGroup) {
// DELETE /api/v1/albums/:uid/photos
func RemovePhotosFromAlbum(router *gin.RouterGroup) {
router.DELETE("/albums/:uid/photos", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceAlbums, acl.ActionUpdate)
s := Auth(c, acl.ResourceAlbums, acl.ActionUpdate)
if s.Invalid() {
AbortUnauthorized(c)
if s.Abort(c) {
return
}
@@ -450,7 +439,7 @@ func RemovePhotosFromAlbum(router *gin.RouterGroup) {
return
}
a, err := query.AlbumByUID(clean.IdString(c.Param("uid")))
a, err := query.AlbumByUID(clean.UID(c.Param("uid")))
if err != nil {
AbortAlbumNotFound(c)
@@ -463,7 +452,7 @@ func RemovePhotosFromAlbum(router *gin.RouterGroup) {
if len(removed) == 1 {
event.SuccessMsg(i18n.MsgEntryRemovedFrom, clean.Log(a.Title()))
} else {
event.SuccessMsg(i18n.MsgEntriesRemovedFrom, len(removed), clean.Log(clean.Log(a.Title())))
event.SuccessMsg(i18n.MsgEntriesRemovedFrom, len(removed), clean.Log(a.Title()))
}
RemoveFromAlbumCoverCache(a.AlbumUID)

View File

@@ -7,6 +7,7 @@ import (
"github.com/gin-gonic/gin/binding"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/search"
"github.com/photoprism/photoprism/internal/service"
@@ -18,10 +19,10 @@ import (
// GET /api/v1/albums
func SearchAlbums(router *gin.RouterGroup) {
router.GET("/albums", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceAlbums, acl.ActionSearch)
s := AuthAny(c, acl.ResourceAlbums, acl.Permissions{acl.ActionSearch, acl.ActionView, acl.AccessShared})
if s.Invalid() {
AbortUnauthorized(c)
// Abort if permission was not granted.
if s.Abort(c) {
return
}
@@ -29,24 +30,32 @@ func SearchAlbums(router *gin.RouterGroup) {
err := c.MustBindWith(&f, binding.Form)
// Abort if request params are invalid.
if err != nil {
event.AuditWarn([]string{ClientIP(c), "session %s", "albums", "search", "form invalid", "%s"}, s.RefID, err)
AbortBadRequest(c)
return
}
conf := service.Config()
// Guest permissions are limited to shared albums.
if s.Guest() {
f.UID = s.Shares.Join(txt.Or)
// Sharing link visitors permissions are limited to shared albums.
if s.IsVisitor() {
f.UID = s.SharedUIDs().Join(txt.Or)
f.Public = true
event.AuditDebug([]string{ClientIP(c), "session %s", "albums", "search", "shared", "%s"}, s.RefID, f.UID)
} else if conf.Settings().Features.Private {
f.Public = true
event.AuditDebug([]string{ClientIP(c), "session %s", "albums", "search", "all public"}, s.RefID)
} else {
f.Public = conf.Settings().Features.Private
f.Public = false
event.AuditDebug([]string{ClientIP(c), "session %s", "albums", "search", "all public and private"}, s.RefID)
}
result, err := search.Albums(f)
if err != nil {
event.AuditWarn([]string{ClientIP(c), "session %s", "albums", "search", "%s"}, s.RefID, err)
c.AbortWithStatusJSON(400, gin.H{"error": txt.UpperFirst(err.Error())})
return
}

View File

@@ -1,5 +1,5 @@
/*
Package api provides REST API request handlers.
Package api provides REST API authentication and request handlers.
Copyright (c) 2018 - 2022 PhotoPrism UG. All rights reserved.
@@ -23,94 +23,3 @@ Additional information can be found in our Developer Guide:
<https://docs.photoprism.app/developer-guide/>
*/
package api
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/pkg/clean"
)
var log = event.Log
func logError(prefix string, err error) {
if err != nil {
log.Errorf("%s: %s", prefix, err.Error())
}
}
func logWarn(prefix string, err error) {
if err != nil {
log.Warnf("%s: %s", prefix, err.Error())
}
}
func UpdateClientConfig() {
conf := service.Config()
event.Publish("config.updated", event.Data{"config": conf.UserConfig()})
}
func Abort(c *gin.Context, code int, id i18n.Message, params ...interface{}) {
resp := i18n.NewResponse(code, id, params...)
log.Debugf("api-v1: abort %s with code %d (%s)", clean.Log(c.FullPath()), code, strings.ToLower(resp.String()))
c.AbortWithStatusJSON(code, resp)
}
func Error(c *gin.Context, code int, err error, id i18n.Message, params ...interface{}) {
resp := i18n.NewResponse(code, id, params...)
if err != nil {
resp.Details = err.Error()
log.Errorf("api-v1: error %s with code %d in %s (%s)", clean.Log(err.Error()), code, clean.Log(c.FullPath()), strings.ToLower(resp.String()))
}
c.AbortWithStatusJSON(code, resp)
}
func AbortUnauthorized(c *gin.Context) {
Abort(c, http.StatusUnauthorized, i18n.ErrUnauthorized)
}
func AbortEntityNotFound(c *gin.Context) {
Abort(c, http.StatusNotFound, i18n.ErrEntityNotFound)
}
func AbortAlbumNotFound(c *gin.Context) {
Abort(c, http.StatusNotFound, i18n.ErrAlbumNotFound)
}
func AbortSaveFailed(c *gin.Context) {
Abort(c, http.StatusInternalServerError, i18n.ErrSaveFailed)
}
func AbortDeleteFailed(c *gin.Context) {
Abort(c, http.StatusInternalServerError, i18n.ErrDeleteFailed)
}
func AbortUnexpected(c *gin.Context) {
Abort(c, http.StatusInternalServerError, i18n.ErrUnexpected)
}
func AbortBadRequest(c *gin.Context) {
Abort(c, http.StatusBadRequest, i18n.ErrBadRequest)
}
func AbortAlreadyExists(c *gin.Context, s string) {
Abort(c, http.StatusConflict, i18n.ErrAlreadyExists, s)
}
func AbortFeatureDisabled(c *gin.Context) {
Abort(c, http.StatusForbidden, i18n.ErrFeatureDisabled)
}
func AbortBusy(c *gin.Context) {
Abort(c, http.StatusTooManyRequests, i18n.ErrBusy)
}

View File

@@ -0,0 +1,29 @@
package api
import (
"github.com/gin-gonic/gin"
)
// ClientIP returns the client IP address from the request context or a placeholder if it is unknown.
func ClientIP(c *gin.Context) (ip string) {
if c == nil {
// Should never happen.
return "0.0.0.0"
} else if ip = c.ClientIP(); ip == "" {
// Unit tests generally do not set a client IP. According to RFC 5737, the 192.0.2.0/24 subnet
// is intended for use in documentation and examples.
return "192.0.2.42"
}
return ip
}
// UserAgent returns the user agent from the request context or an empty string if it is unknown.
func UserAgent(c *gin.Context) string {
if c == nil {
// Should never happen.
return ""
}
return c.Request.UserAgent()
}

View File

@@ -0,0 +1,35 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/service"
)
// UpdateClientConfig publishes updated client configuration values over the websocket connections.
func UpdateClientConfig() {
event.Publish("config.updated", event.Data{"config": service.Config().ClientUser(false)})
}
// GetClientConfig returns the client configuration values as JSON.
//
// GET /api/v1/config
func GetClientConfig(router *gin.RouterGroup) {
router.GET("/config", func(c *gin.Context) {
s := Session(SessionID(c))
conf := service.Config()
if s == nil {
c.JSON(http.StatusOK, conf.ClientPublic())
} else if s.User().IsVisitor() {
c.JSON(http.StatusOK, conf.ClientShare())
} else if s.User().IsRegistered() {
c.JSON(http.StatusOK, conf.ClientSession(s))
} else {
c.JSON(http.StatusOK, conf.ClientPublic())
}
})
}

21
internal/api/api_log.go Normal file
View File

@@ -0,0 +1,21 @@
package api
import (
"github.com/photoprism/photoprism/internal/event"
)
var log = event.Log
// logError logs an error if err is not nil.
func logError(prefix string, err error) {
if err != nil {
log.Errorf("%s: %s", prefix, err.Error())
}
}
// logWarn logs a warning if err is not nil.
func logWarn(prefix string, err error) {
if err != nil {
log.Warnf("%s: %s", prefix, err.Error())
}
}

View File

@@ -1,7 +1,6 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
@@ -12,81 +11,14 @@ import (
"github.com/sirupsen/logrus"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/service"
)
// NewApiTest returns new API test helper.
func NewApiTest() (app *gin.Engine, router *gin.RouterGroup, conf *config.Config) {
gin.SetMode(gin.TestMode)
app = gin.New()
router = app.Group("/api/v1")
return app, router, service.Config()
}
// AuthenticateAdmin Register session routes and returns valid SessionId.
// Call this func after registering other routes and before performing other requests.
func AuthenticateAdmin(app *gin.Engine, router *gin.RouterGroup) (sessId string) {
return AuthenticateUser(app, router, "admin", "photoprism")
}
// AuthenticateUser Register session routes and returns valid SessionId.
// Call this func after registering other routes and before performing other requests.
func AuthenticateUser(app *gin.Engine, router *gin.RouterGroup, username string, password string) (sessId string) {
CreateSession(router)
f := form.Login{
Username: username,
Password: password,
}
loginStr, err := json.Marshal(f)
if err != nil {
log.Fatal(err)
}
r0 := PerformRequestWithBody(app, http.MethodPost, "/api/v1/session", string(loginStr))
sessId = r0.Header().Get("X-Session-ID")
return
}
// Executes an API request with an empty request body.
// See https://medium.com/@craigchilds94/testing-gin-json-responses-1f258ce3b0b1
func PerformRequest(r http.Handler, method, path string) *httptest.ResponseRecorder {
req, _ := http.NewRequest(method, path, nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
return w
}
// Performs authenticated API request with empty request body.
func AuthenticatedRequest(r http.Handler, method, path, sess string) *httptest.ResponseRecorder {
req, _ := http.NewRequest(method, path, nil)
req.Header.Add("X-Session-ID", sess)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
return w
}
// Executes an API request with the request body as a string.
func PerformRequestWithBody(r http.Handler, method, path, body string) *httptest.ResponseRecorder {
reader := strings.NewReader(body)
req, _ := http.NewRequest(method, path, reader)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
return w
}
// Performs an authenticated API request containing the request body as a string.
func AuthenticatedRequestWithBody(r http.Handler, method, path, body string, sessionId string) *httptest.ResponseRecorder {
reader := strings.NewReader(body)
req, _ := http.NewRequest(method, path, reader)
req.Header.Add("X-Session-ID", sessionId)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
return w
}
func TestMain(m *testing.M) {
log = logrus.StandardLogger()
log.SetLevel(logrus.TraceLevel)
event.AuditLog = log
c := config.NewTestConfig("api")
service.SetConfig(c)
@@ -97,3 +29,34 @@ func TestMain(m *testing.M) {
os.Exit(code)
}
// NewApiTest returns new API test helper.
func NewApiTest() (app *gin.Engine, router *gin.RouterGroup, conf *config.Config) {
gin.SetMode(gin.TestMode)
app = gin.New()
router = app.Group("/api/v1")
return app, router, service.Config()
}
// Executes an API request with an empty request body.
// See https://medium.com/@craigchilds94/testing-gin-json-responses-1f258ce3b0b1
func PerformRequest(r http.Handler, method, path string) *httptest.ResponseRecorder {
req, _ := http.NewRequest(method, path, nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
return w
}
// Executes an API request with the request body as a string.
func PerformRequestWithBody(r http.Handler, method, path, body string) *httptest.ResponseRecorder {
reader := strings.NewReader(body)
req, _ := http.NewRequest(method, path, reader)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
return w
}

View File

@@ -0,0 +1,247 @@
package api
import (
"encoding/json"
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/pkg/rnd"
)
// wsTimeout specifies the timeout duration for WebSocket connections.
var wsTimeout = 90 * time.Second
// wsSubPerm specifies the permissions required to subscribe to a channel.
var wsSubscribePerms = acl.Permissions{acl.ActionSubscribe}
// wsAuth maps connection IDs to specific users and session IDs.
var wsAuth = struct {
sid map[string]string
rid map[string]string
user map[string]entity.User
mutex sync.RWMutex
}{
sid: make(map[string]string),
rid: make(map[string]string),
user: make(map[string]entity.User),
}
// wsConnection upgrades the HTTP server connection to the WebSocket protocol.
var wsConnection = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true
},
}
// clientInfo represents information provided by the WebSocket client.
type clientInfo struct {
SessionID string `json:"session"`
CssUri string `json:"css"`
JsUri string `json:"js"`
Version string `json:"version"`
}
// WebSocket registers the /ws endpoint for establishing websocket connections.
func WebSocket(router *gin.RouterGroup) {
if router == nil {
return
}
conf := service.Config()
if conf == nil {
return
}
router.GET("/ws", func(c *gin.Context) {
w := c.Writer
r := c.Request
ws, err := wsConnection.Upgrade(w, r, nil)
if err != nil {
return
}
var writeMutex sync.Mutex
defer ws.Close()
connId := rnd.UUID()
// Init connection.
wsAuth.mutex.Lock()
if conf.Public() {
wsAuth.user[connId] = entity.Admin
} else {
wsAuth.user[connId] = entity.UnknownUser
}
wsAuth.mutex.Unlock()
// Init writer.
go wsWriter(ws, &writeMutex, connId)
// Init reader.
wsReader(ws, &writeMutex, connId, conf)
})
}
// wsReader initializes a WebSocket reader for receiving messages.
func wsReader(ws *websocket.Conn, writeMutex *sync.Mutex, connId string, conf *config.Config) {
defer ws.Close()
ws.SetReadLimit(4096)
if err := ws.SetReadDeadline(time.Now().Add(wsTimeout)); err != nil {
return
}
ws.SetPongHandler(func(string) error { _ = ws.SetReadDeadline(time.Now().Add(wsTimeout)); return nil })
for {
_, m, err := ws.ReadMessage()
if err != nil {
break
}
var info clientInfo
if err := json.Unmarshal(m, &info); err != nil {
// Do nothing.
} else {
if s := Session(info.SessionID); s != nil {
wsAuth.mutex.Lock()
wsAuth.sid[connId] = s.ID
wsAuth.rid[connId] = s.RefID
wsAuth.user[connId] = *s.User()
wsAuth.mutex.Unlock()
var clientConfig config.ClientConfig
if s.User().IsVisitor() {
clientConfig = conf.ClientShare()
} else if s.User().IsRegistered() {
clientConfig = conf.ClientSession(s)
} else {
clientConfig = conf.ClientPublic()
}
wsSendMessage("config.updated", event.Data{"config": clientConfig}, ws, writeMutex)
}
}
}
}
// wsWriter initializes a WebSocket writer for sending messages.
func wsWriter(ws *websocket.Conn, writeMutex *sync.Mutex, connId string) {
pingTicker := time.NewTicker(15 * time.Second)
// Subscribe to events.
e := event.Subscribe(
"session.*",
"log.fatal",
"log.error",
"log.warning",
"log.warn",
"log.info",
"notify.*",
"index.*",
"upload.*",
"import.*",
"config.*",
"count.*",
"photos.*",
"cameras.*",
"lenses.*",
"countries.*",
"albums.*",
"labels.*",
"subjects.*",
"people.*",
"sync.*",
)
defer func() {
pingTicker.Stop()
event.Unsubscribe(e)
_ = ws.Close()
wsAuth.mutex.Lock()
delete(wsAuth.sid, connId)
delete(wsAuth.rid, connId)
delete(wsAuth.user, connId)
wsAuth.mutex.Unlock()
}()
for {
select {
case <-pingTicker.C:
writeMutex.Lock()
if err := ws.SetWriteDeadline(time.Now().Add(30 * time.Second)); err != nil {
writeMutex.Unlock()
return
} else if err := ws.WriteMessage(websocket.PingMessage, []byte{}); err != nil {
writeMutex.Unlock()
return
}
writeMutex.Unlock()
case msg := <-e.Receiver:
wsAuth.mutex.RLock()
sid := wsAuth.sid[connId] // Session ID.
// rid := wsAuth.rid[connId] // Session RefID.
user := entity.UnknownUser // User.
if hit, ok := wsAuth.user[connId]; ok {
user = hit
}
wsAuth.mutex.RUnlock()
// Split topic into channel and event name.
ch, ev := event.Topic(msg.Topic())
// Message intended for a specific session only?
if acl.ChannelSession.Equal(ch) {
if s, topic := event.Topic(ev); s == sid && topic != "" {
// Send to client with the matching session ID.
wsSendMessage(topic, msg.Fields, ws, writeMutex)
}
} else if chRes := acl.Resource(ch); acl.Events.AllowAll(chRes, user.AclRole(), wsSubscribePerms) {
// Send the message to authorized recipient.
// event.AuditDebug([]string{"websocket", "session %s", "%s %s as %s", "granted"}, rid, wsSubscribePerms.String(), chRes.String(), user.AclRole().String())
wsSendMessage(msg.Topic(), msg.Fields, ws, writeMutex)
}
}
}
}
// wsSendMessage sends a message to the WebSocket client.
func wsSendMessage(topic string, data interface{}, ws *websocket.Conn, writeMutex *sync.Mutex) {
if topic == "" || ws == nil || writeMutex == nil {
return
}
writeMutex.Lock()
defer writeMutex.Unlock()
if err := ws.SetWriteDeadline(time.Now().Add(30 * time.Second)); err != nil {
return
} else if err := ws.WriteJSON(gin.H{"event": topic, "data": data}); err != nil {
return
}
}

View File

@@ -10,14 +10,14 @@ import (
func TestWebsocket(t *testing.T) {
t.Run("bad request", func(t *testing.T) {
app, router, _ := NewApiTest()
Websocket(router)
WebSocket(router)
r := PerformRequest(app, "GET", "/api/v1/ws")
assert.Equal(t, http.StatusBadRequest, r.Code)
})
t.Run("router nil", func(t *testing.T) {
app, _, _ := NewApiTest()
Websocket(nil)
WebSocket(nil)
r := PerformRequest(app, "GET", "/api/v1/ws")
assert.Equal(t, http.StatusNotFound, r.Code)
})

34
internal/api/auth.go Normal file
View File

@@ -0,0 +1,34 @@
package api
import (
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event"
)
// Auth checks if the user has permission to access the specified resource and returns the session if so.
func Auth(c *gin.Context, resource acl.Resource, grant acl.Permission) *entity.Session {
return AuthAny(c, resource, acl.Permissions{grant})
}
// AuthAny checks if at least one permission allows access and returns the session in this case.
func AuthAny(c *gin.Context, resource acl.Resource, grants acl.Permissions) *entity.Session {
// Get session ID, if any.
sessId := SessionID(c)
// Find and return the client session after all checks have passed.
if s := Session(sessId); s == nil {
event.AuditWarn([]string{ClientIP(c), "unauthenticated", "%s %s as unknown user", "denied"}, grants.String(), string(resource))
return entity.SessionStatusUnauthorized()
} else if s.User() == nil {
event.AuditWarn([]string{ClientIP(c), "session %s", "%s %s as unknown user", "denied"}, s.RefID, grants.String(), string(resource))
return entity.SessionStatusUnauthorized()
} else if acl.Resources.DenyAll(resource, s.User().AclRole(), grants) {
event.AuditErr([]string{ClientIP(c), "session %s", "%s %s as %s", "denied"}, s.RefID, grants.String(), string(resource), s.User().AclRole().String())
return entity.SessionStatusForbidden()
} else {
event.AuditInfo([]string{ClientIP(c), "session %s", "%s %s as %s", "granted"}, s.RefID, grants.String(), string(resource), s.User().AclRole().String())
return s
}
}

View File

@@ -3,38 +3,44 @@ package api
import (
"net/http"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/dustin/go-humanize/english"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/pkg/clean"
)
// ChangePassword changes the password of the currently authenticated user.
//
// PUT /api/v1/users/:uid/password
func ChangePassword(router *gin.RouterGroup) {
router.PUT("/users/:uid/password", func(c *gin.Context) {
conf := service.Config()
// You cannot change any passwords without authentication and settings enabled.
if conf.Public() || conf.DisableSettings() {
Abort(c, http.StatusForbidden, i18n.ErrPublic)
return
}
s := Auth(SessionID(c), acl.ResourceUsers, acl.ActionUpdateSelf)
// Get session.
s := Auth(c, acl.ResourcePassword, acl.ActionUpdate)
if s.Invalid() {
AbortUnauthorized(c)
if s.Abort(c) {
return
}
uid := clean.IdString(c.Param("uid"))
uid := clean.UID(c.Param("uid"))
m := entity.FindUserByUID(uid)
if s.User.UserUID != m.UserUID {
AbortUnauthorized(c)
// Users may only change their own password.
if s.User().UserUID != m.UserUID {
AbortForbidden(c)
return
}
@@ -50,16 +56,23 @@ func ChangePassword(router *gin.RouterGroup) {
return
}
// Verify that the old password is correct.
if m.InvalidPassword(f.OldPassword) {
Abort(c, http.StatusBadRequest, i18n.ErrInvalidPassword)
return
}
// Change password.
if err := m.SetPassword(f.NewPassword); err != nil {
Error(c, http.StatusBadRequest, err, i18n.ErrInvalidPassword)
return
}
// Invalidate all other user sessions to protect the account:
// https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html
event.AuditInfo([]string{ClientIP(c), "session %s", "password changed", "invalidated %s"}, s.RefID,
english.Plural(m.DeleteSessions([]string{s.ID}), "session", "sessions"))
c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgPasswordChanged))
})
}

View File

@@ -11,16 +11,14 @@ import (
)
func TestChangePassword(t *testing.T) {
t.Run("not existing user", func(t *testing.T) {
t.Run("NonExistentUser", func(t *testing.T) {
app, router, _ := NewApiTest()
ChangePassword(router)
r := PerformRequestWithBody(app, "PUT", "/api/v1/users/xxx/password", `{}`)
assert.Equal(t, http.StatusForbidden, r.Code)
})
}
func TestChangeUserPasswords(t *testing.T) {
t.Run("alice: change password invalid", func(t *testing.T) {
t.Run("AliceProvidesWrongPassword", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetPublic(false)
defer conf.SetPublic(true)
@@ -39,32 +37,52 @@ func TestChangeUserPasswords(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, r.Code)
}
})
t.Run("alice: change password valid", func(t *testing.T) {
t.Run("Success", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetPublic(false)
defer conf.SetPublic(true)
ChangePassword(router)
oldPassword := "PleaseChange$42"
newPassword := "SoftwareDevelopmentIsAYoungProfession1234567890!@#$%^&*()_+[]{}|:<>?/.,"
sessId := AuthenticateUser(app, router, "fowler", oldPassword)
frm := form.ChangePassword{
OldPassword: oldPassword,
NewPassword: newPassword,
}
if jsonFrm, err := json.Marshal(frm); err != nil {
log.Fatal(err)
} else {
r := AuthenticatedRequestWithBody(app, "PUT", "/api/v1/users/urinotv3d6jedvlm/password",
string(jsonFrm), sessId)
assert.Equal(t, http.StatusOK, r.Code)
}
frm = form.ChangePassword{
OldPassword: newPassword,
NewPassword: oldPassword,
}
if jsonFrm, err := json.Marshal(frm); err != nil {
log.Fatal(err)
} else {
r := AuthenticatedRequestWithBody(app, "PUT", "/api/v1/users/urinotv3d6jedvlm/password",
string(jsonFrm), sessId)
assert.Equal(t, http.StatusOK, r.Code)
}
})
t.Run("AliceChangesOtherUsersPassword", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetPublic(false)
defer conf.SetPublic(true)
ChangePassword(router)
sessId := AuthenticateUser(app, router, "alice", "Alice123!")
f := form.ChangePassword{
OldPassword: "Alice123!",
NewPassword: "aliceinwonderland",
}
if pwStr, err := json.Marshal(f); err != nil {
log.Fatal(err)
} else {
r := AuthenticatedRequestWithBody(app, "PUT", "/api/v1/users/uqxetse3cy5eo9z2/password",
string(pwStr), sessId)
assert.Equal(t, http.StatusOK, r.Code)
}
})
t.Run("alice as admin: change bob's password", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetPublic(false)
defer conf.SetPublic(true)
ChangePassword(router)
sessId := AuthenticateUser(app, router, "alice", "aliceinwonderland")
f := form.ChangePassword{
OldPassword: "Bobbob123!",
NewPassword: "helloworld",
@@ -74,10 +92,11 @@ func TestChangeUserPasswords(t *testing.T) {
} else {
r := AuthenticatedRequestWithBody(app, "PUT", "/api/v1/users/uqxc08w3d0ej2283/password",
string(pwStr), sessId)
assert.Equal(t, http.StatusUnauthorized, r.Code)
assert.Equal(t, http.StatusForbidden, r.Code)
}
})
t.Run("bob: change wrong password", func(t *testing.T) {
t.Run("BobProvidesWrongPassword", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetPublic(false)
defer conf.SetPublic(true)
@@ -97,7 +116,7 @@ func TestChangeUserPasswords(t *testing.T) {
}
})
t.Run("friend: change password to same", func(t *testing.T) {
t.Run("SameNewPassword", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetPublic(false)
defer conf.SetPublic(true)
@@ -117,7 +136,7 @@ func TestChangeUserPasswords(t *testing.T) {
}
})
t.Run("bob: change alice's password", func(t *testing.T) {
t.Run("BobChangesOtherUsersPassword", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetPublic(false)
defer conf.SetPublic(true)
@@ -133,7 +152,7 @@ func TestChangeUserPasswords(t *testing.T) {
} else {
r := AuthenticatedRequestWithBody(app, "PUT", "/api/v1/users/uqxetse3cy5eo9z2/password",
string(pwStr), sessId)
assert.Equal(t, http.StatusUnauthorized, r.Code)
assert.Equal(t, http.StatusForbidden, r.Code)
}
})

View File

@@ -0,0 +1,40 @@
package api
import (
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/internal/session"
"github.com/photoprism/photoprism/pkg/clean"
)
// SessionID returns the session ID from the request context.
func SessionID(c *gin.Context) (sessId string) {
if c == nil {
// Should never happen.
return ""
}
// Get the authentication token from the HTTP headers.
return clean.ID(c.GetHeader(session.Header))
}
// Session finds the client session for the given ID or returns nil otherwise.
func Session(id string) *entity.Session {
// Return default session when public mode is enabled.
if service.Config().Public() {
return service.Session().Public()
} else if id == "" {
return nil
}
// Find session or otherwise return nil.
s, err := service.Session().Get(id)
if err != nil {
return nil
}
return &s
}

View File

@@ -0,0 +1,136 @@
package api
import (
"net/http"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/acl"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/pkg/clean"
)
// CreateSession creates a new client session and returns it as JSON if authentication was successful.
//
// POST /api/v1/session
func CreateSession(router *gin.RouterGroup) {
router.POST("/session", func(c *gin.Context) {
var err error
var f form.Login
if err = c.BindJSON(&f); err != nil {
event.AuditWarn([]string{ClientIP(c), "invalid create session request"})
AbortBadRequest(c)
return
}
var user *entity.User
var sess *entity.Session
var data *entity.SessionData
id := SessionID(c)
// Search existing session.
if s := Session(id); s != nil {
sess = s
data = s.Data()
user = s.User()
} else {
data = entity.NewSessionData()
user = &entity.User{}
id = ""
}
conf := service.Config()
// Share token provided?
if f.HasToken() {
if shares := data.RedeemToken(f.AuthToken); shares == 0 {
event.AuditWarn([]string{ClientIP(c), "share token %s", "invalid"}, clean.LogQuote(f.AuthToken))
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": i18n.Msg(i18n.ErrInvalidLink)})
event.LoginError(ClientIP(c), "", UserAgent(c), "invalid share token")
return
}
event.AuditInfo([]string{ClientIP(c), "share token %s", "redeemed", "%#v"}, clean.LogQuote(f.AuthToken), data)
// Upgrade from Unknown to Visitor. Don't downgrade.
if user.IsUnknown() {
user = &entity.Visitor
event.AuditDebug([]string{ClientIP(c), "share token %s", "upgrading session to user role %s"}, clean.LogQuote(f.AuthToken), acl.RoleVisitor.String())
}
} else if f.HasCredentials() {
// If not, authenticate with username and password.
userName := f.Name()
user = entity.FindUserByName(userName)
// User found?
if user == nil {
message := "account not found"
event.AuditWarn([]string{ClientIP(c), "login as %s", message}, clean.LogQuote(userName))
c.AbortWithStatusJSON(400, gin.H{"error": i18n.Msg(i18n.ErrInvalidCredentials)})
event.LoginError(ClientIP(c), f.Name(), UserAgent(c), message)
return
}
// Login allowed?
if !user.LoginAllowed() {
message := "account disabled"
event.AuditWarn([]string{ClientIP(c), "login as %s", message}, clean.LogQuote(userName))
c.AbortWithStatusJSON(400, gin.H{"error": i18n.Msg(i18n.ErrInvalidCredentials)})
event.LoginError(ClientIP(c), f.Name(), UserAgent(c), message)
return
}
// Password valid?
if user.InvalidPassword(f.Password) {
message := "incorrect password"
event.AuditErr([]string{ClientIP(c), "login as %s", message}, clean.LogQuote(userName))
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": i18n.Msg(i18n.ErrInvalidCredentials)})
event.LoginError(ClientIP(c), f.Name(), UserAgent(c), message)
return
} else {
event.AuditInfo([]string{ClientIP(c), "login as %s", "succeeded"}, clean.LogQuote(userName))
event.LoginSuccess(ClientIP(c), f.Name(), UserAgent(c))
}
} else {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": i18n.Msg(i18n.ErrInvalidCredentials)})
event.LoginError(ClientIP(c), f.Name(), UserAgent(c), "invalid request")
return
}
// Save session.
if sess, err = service.Session().Save(id, user, c, data); err != nil {
event.AuditWarn([]string{ClientIP(c), "%s"}, err)
} else if sess == nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": i18n.Msg(i18n.ErrUnexpected)})
return
}
// Log event.
event.AuditInfo([]string{ClientIP(c), "session %s", "created"}, sess.RefID)
// Add session id to response headers.
AddSessionHeader(c, sess.ID)
var clientConfig config.ClientConfig
if sess.User().IsVisitor() {
clientConfig = conf.ClientShare()
} else if sess.User().IsRegistered() {
clientConfig = conf.ClientSession(sess)
} else {
clientConfig = conf.ClientPublic()
}
// Send JSON response with user information, session data, and client config values.
c.JSON(http.StatusOK, gin.H{"status": "ok", "id": sess.ID, "user": sess.User(), "data": sess.Data(), "config": clientConfig})
})
}

View File

@@ -0,0 +1,36 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/pkg/clean"
)
// DeleteSession deletes an existing client session (logout).
//
// DELETE /api/v1/session/:id
func DeleteSession(router *gin.RouterGroup) {
router.DELETE("/session/:id", func(c *gin.Context) {
id := clean.ID(c.Param("id"))
if id == "" {
AbortBadRequest(c)
return
} else if service.Config().Public() {
c.JSON(http.StatusOK, gin.H{"status": "authentication disabled", "id": id})
return
}
if err := service.Session().Delete(id); err != nil {
event.AuditErr([]string{ClientIP(c), "session %s"}, err)
} else {
event.AuditDebug([]string{ClientIP(c), "session deleted"})
}
c.JSON(http.StatusOK, gin.H{"status": "ok", "id": id})
})
}

View File

@@ -0,0 +1,67 @@
package api
import (
"net/http"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/service"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/pkg/clean"
)
// GetSession returns the session data as JSON if authentication was successful.
//
// GET /api/v1/session/:id
func GetSession(router *gin.RouterGroup) {
router.GET("/session/:id", func(c *gin.Context) {
id := clean.ID(c.Param("id"))
if id == "" {
AbortBadRequest(c)
return
} else if id != SessionID(c) {
AbortForbidden(c)
return
}
sess := Session(id)
switch {
case sess == nil:
AbortUnauthorized(c)
return
case sess.Expired(), sess.ID == "":
AbortUnauthorized(c)
return
case sess.Invalid():
AbortForbidden(c)
return
}
// Update user information.
sess.RefreshUser()
// Add session id to response headers.
AddSessionHeader(c, sess.ID)
var clientConfig config.ClientConfig
if conf := service.Config(); conf == nil {
log.Errorf("session: config is nil - possible bug")
AbortUnexpected(c)
return
} else if sess.User().IsVisitor() {
clientConfig = conf.ClientShare()
} else if sess.User().IsRegistered() {
clientConfig = conf.ClientSession(sess)
} else {
clientConfig = conf.ClientPublic()
}
// Send JSON response with user information, session data, and client config values.
c.JSON(http.StatusOK, gin.H{"status": "ok", "id": sess.ID, "user": sess.User(), "data": sess.Data(), "config": clientConfig})
})
}

View File

@@ -0,0 +1,157 @@
package api
import (
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/tidwall/gjson"
"github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/internal/session"
)
func TestSessionID(t *testing.T) {
t.Run("NoContext", func(t *testing.T) {
result := SessionID(nil)
assert.Equal(t, "", result)
})
}
func TestSession(t *testing.T) {
t.Run("Public", func(t *testing.T) {
assert.Equal(t, session.Public, Session(""))
assert.Equal(t, session.Public, Session("638bffc9b86a8fda0d908ebee84a43930cb8d1e3507f4aa0"))
})
}
func TestCreateSession(t *testing.T) {
t.Run("Success", func(t *testing.T) {
app, router, _ := NewApiTest()
CreateSession(router)
r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"name": "admin", "password": "photoprism"}`)
log.Debugf("BODY: %s", r.Body.String())
val2 := gjson.Get(r.Body.String(), "user.Name")
assert.Equal(t, "admin", val2.String())
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("BadRequest", func(t *testing.T) {
app, router, _ := NewApiTest()
CreateSession(router)
r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"name": 123, "password": "xxx"}`)
assert.Equal(t, http.StatusBadRequest, r.Code)
})
t.Run("InvalidShareToken", func(t *testing.T) {
app, router, _ := NewApiTest()
CreateSession(router)
r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"name": "admin", "password": "photoprism", "token": "xxx"}`)
assert.Equal(t, http.StatusNotFound, r.Code)
})
t.Run("ValidShareToken", func(t *testing.T) {
app, router, _ := NewApiTest()
CreateSession(router)
r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"name": "admin", "password": "photoprism", "token": "1jxf3jfn2k"}`)
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("AdminInvalidPassword", func(t *testing.T) {
app, router, _ := NewApiTest()
CreateSession(router)
r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"name": "admin", "password": "xxx"}`)
val := gjson.Get(r.Body.String(), "error")
assert.Equal(t, i18n.Msg(i18n.ErrInvalidCredentials), val.String())
assert.Equal(t, http.StatusUnauthorized, r.Code)
})
t.Run("AliceSuccess", func(t *testing.T) {
app, router, _ := NewApiTest()
CreateSession(router)
r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"name": "alice", "password": "Alice123!"}`)
userEmail := gjson.Get(r.Body.String(), "user.Email")
userName := gjson.Get(r.Body.String(), "user.Name")
assert.Equal(t, "alice@example.com", userEmail.String())
assert.Equal(t, "alice", userName.String())
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("BobSuccess", func(t *testing.T) {
app, router, _ := NewApiTest()
CreateSession(router)
r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"name": "bob", "password": "Bobbob123!"}`)
userEmail := gjson.Get(r.Body.String(), "user.Email")
userName := gjson.Get(r.Body.String(), "user.Name")
assert.Equal(t, "bob@example.com", userEmail.String())
assert.Equal(t, "bob", userName.String())
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("BobInvalidPassword", func(t *testing.T) {
app, router, _ := NewApiTest()
CreateSession(router)
r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/session", `{"name": "bob", "password": "helloworld"}`)
val := gjson.Get(r.Body.String(), "error")
assert.Equal(t, i18n.Msg(i18n.ErrInvalidCredentials), val.String())
assert.Equal(t, http.StatusUnauthorized, r.Code)
})
}
func TestGetSession(t *testing.T) {
t.Run("AdminWithoutAuthentication", func(t *testing.T) {
app, router, _ := NewApiTest()
GetSession(router)
sessId := AuthenticateAdmin(app, router)
r := PerformRequest(app, http.MethodGet, "/api/v1/session/"+sessId)
assert.Equal(t, http.StatusForbidden, r.Code)
})
t.Run("AdminAuthenticatedRequest", func(t *testing.T) {
app, router, _ := NewApiTest()
GetSession(router)
sessId := AuthenticateAdmin(app, router)
r := AuthenticatedRequest(app, http.MethodGet, "/api/v1/session/"+sessId, sessId)
assert.Equal(t, http.StatusOK, r.Code)
})
}
func TestDeleteSession(t *testing.T) {
t.Run("AdminWithoutAuthentication", func(t *testing.T) {
app, router, _ := NewApiTest()
DeleteSession(router)
sessId := AuthenticateAdmin(app, router)
r := PerformRequest(app, http.MethodDelete, "/api/v1/session/"+sessId)
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("AdminAuthenticatedRequest", func(t *testing.T) {
app, router, _ := NewApiTest()
DeleteSession(router)
sessId := AuthenticateAdmin(app, router)
r := AuthenticatedRequest(app, http.MethodDelete, "/api/v1/session/"+sessId, sessId)
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("UserWithoutAuthentication", func(t *testing.T) {
app, router, _ := NewApiTest()
DeleteSession(router)
sessId := AuthenticateUser(app, router, "alice", "Alice123!")
r := PerformRequest(app, http.MethodDelete, "/api/v1/session/"+sessId)
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("UserAuthenticatedRequest", func(t *testing.T) {
app, router, _ := NewApiTest()
DeleteSession(router)
sessId := AuthenticateUser(app, router, "alice", "Alice123!")
r := AuthenticatedRequest(app, http.MethodDelete, "/api/v1/session/"+sessId, sessId)
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("InvalidSession", func(t *testing.T) {
sessId := "638bffc9b86a8fda0d908ebee84a43930cb8d1e3507f4aa0"
app, router, _ := NewApiTest()
DeleteSession(router)
r := PerformRequest(app, http.MethodDelete, "/api/v1/session/"+sessId)
assert.Equal(t, http.StatusOK, r.Code)
})
}

63
internal/api/auth_test.go Normal file
View File

@@ -0,0 +1,63 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/session"
)
// AuthenticateAdmin Register session routes and returns valid SessionId.
// Call this func after registering other routes and before performing other requests.
func AuthenticateAdmin(app *gin.Engine, router *gin.RouterGroup) (sessId string) {
return AuthenticateUser(app, router, "admin", "photoprism")
}
// AuthenticateUser Register session routes and returns valid SessionId.
// Call this func after registering other routes and before performing other requests.
func AuthenticateUser(app *gin.Engine, router *gin.RouterGroup, name string, password string) (sessId string) {
CreateSession(router)
f := form.Login{
UserName: name,
Password: password,
}
loginStr, err := json.Marshal(f)
if err != nil {
log.Fatal(err)
}
r := PerformRequestWithBody(app, http.MethodPost, "/api/v1/session", string(loginStr))
sessId = r.Header().Get(session.Header)
return
}
// Performs authenticated API request with empty request body.
func AuthenticatedRequest(r http.Handler, method, path, sess string) *httptest.ResponseRecorder {
req, _ := http.NewRequest(method, path, nil)
req.Header.Add(session.Header, sess)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
return w
}
// Performs an authenticated API request containing the request body as a string.
func AuthenticatedRequestWithBody(r http.Handler, method, path, body string, sess string) *httptest.ResponseRecorder {
reader := strings.NewReader(body)
req, _ := http.NewRequest(method, path, reader)
req.Header.Add(session.Header, sess)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
return w
}

View File

@@ -0,0 +1,24 @@
package api
import (
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/pkg/clean"
)
// InvalidPreviewToken checks if the token found in the request is valid for image thumbnails and video streams.
func InvalidPreviewToken(c *gin.Context) bool {
token := clean.UrlToken(c.Param("token"))
if token == "" {
token = clean.UrlToken(c.Query("t"))
}
return service.Config().InvalidPreviewToken(token)
}
// InvalidDownloadToken checks if the token found in the request is valid for file downloads.
func InvalidDownloadToken(c *gin.Context) bool {
return service.Config().InvalidDownloadToken(clean.UrlToken(c.Query("t")))
}

View File

@@ -25,10 +25,9 @@ import (
// POST /api/v1/batch/photos/archive
func BatchPhotosArchive(router *gin.RouterGroup) {
router.POST("/batch/photos/archive", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourcePhotos, acl.ActionDelete)
s := Auth(c, acl.ResourcePhotos, acl.ActionDelete)
if s.Invalid() {
AbortUnauthorized(c)
if s.Abort(c) {
return
}
@@ -89,10 +88,9 @@ func BatchPhotosArchive(router *gin.RouterGroup) {
// POST /api/v1/batch/photos/restore
func BatchPhotosRestore(router *gin.RouterGroup) {
router.POST("/batch/photos/restore", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourcePhotos, acl.ActionDelete)
s := Auth(c, acl.ResourcePhotos, acl.ActionDelete)
if s.Invalid() {
AbortUnauthorized(c)
if s.Abort(c) {
return
}
@@ -152,10 +150,9 @@ func BatchPhotosRestore(router *gin.RouterGroup) {
// POST /api/v1/batch/photos/approve
func BatchPhotosApprove(router *gin.RouterGroup) {
router.POST("batch/photos/approve", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourcePhotos, acl.ActionUpdate)
s := Auth(c, acl.ResourcePhotos, acl.ActionUpdate)
if s.Invalid() {
AbortUnauthorized(c)
if s.Abort(c) {
return
}
@@ -205,10 +202,9 @@ func BatchPhotosApprove(router *gin.RouterGroup) {
// POST /api/v1/batch/albums/delete
func BatchAlbumsDelete(router *gin.RouterGroup) {
router.POST("/batch/albums/delete", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceAlbums, acl.ActionDelete)
s := Auth(c, acl.ResourceAlbums, acl.ActionDelete)
if s.Invalid() {
AbortUnauthorized(c)
if s.Abort(c) {
return
}
@@ -247,10 +243,9 @@ func BatchAlbumsDelete(router *gin.RouterGroup) {
// POST /api/v1/batch/photos/private
func BatchPhotosPrivate(router *gin.RouterGroup) {
router.POST("/batch/photos/private", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourcePhotos, acl.ActionPrivate)
s := Auth(c, acl.ResourcePhotos, acl.AccessPrivate)
if s.Invalid() {
AbortUnauthorized(c)
if s.Abort(c) {
return
}
@@ -300,10 +295,9 @@ func BatchPhotosPrivate(router *gin.RouterGroup) {
// POST /api/v1/batch/labels/delete
func BatchLabelsDelete(router *gin.RouterGroup) {
router.POST("/batch/labels/delete", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceLabels, acl.ActionDelete)
s := Auth(c, acl.ResourceLabels, acl.ActionDelete)
if s.Invalid() {
AbortUnauthorized(c)
if s.Abort(c) {
return
}
@@ -346,10 +340,9 @@ func BatchLabelsDelete(router *gin.RouterGroup) {
// POST /api/v1/batch/photos/delete
func BatchPhotosDelete(router *gin.RouterGroup) {
router.POST("/batch/photos/delete", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourcePhotos, acl.ActionDelete)
s := Auth(c, acl.ResourcePhotos, acl.ActionDelete)
if s.Invalid() {
AbortUnauthorized(c)
if s.Abort(c) {
return
}

View File

@@ -16,40 +16,17 @@ import (
"github.com/photoprism/photoprism/pkg/fs"
)
// GetConfig returns client config values.
//
// GET /api/v1/config
func GetConfig(router *gin.RouterGroup) {
router.GET("/config", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceConfig, acl.ActionRead)
if s.Invalid() {
AbortUnauthorized(c)
return
}
conf := service.Config()
if s.User.IsGuest() {
c.JSON(http.StatusOK, conf.GuestConfig())
} else if s.User.IsRegistered() {
c.JSON(http.StatusOK, conf.UserConfig())
} else {
c.JSON(http.StatusOK, conf.PublicConfig())
}
})
}
// GetConfigOptions returns backend config options.
//
// GET /api/v1/config/options
func GetConfigOptions(router *gin.RouterGroup) {
router.GET("/config/options", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceConfigOptions, acl.ActionRead)
s := Auth(c, acl.ResourceConfig, acl.AccessAll)
conf := service.Config()
// Abort if permission was not granted.
if s.Invalid() || conf.Public() || conf.DisableSettings() {
AbortUnauthorized(c)
AbortForbidden(c)
return
}
@@ -62,11 +39,11 @@ func GetConfigOptions(router *gin.RouterGroup) {
// POST /api/v1/config/options
func SaveConfigOptions(router *gin.RouterGroup) {
router.POST("/config/options", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceConfigOptions, acl.ActionUpdate)
s := Auth(c, acl.ResourceConfig, acl.ActionManage)
conf := service.Config()
if s.Invalid() || conf.Public() || conf.DisableSettings() {
AbortUnauthorized(c)
AbortForbidden(c)
return
}

View File

@@ -12,7 +12,7 @@ import (
func TestGetConfig(t *testing.T) {
t.Run("successful request", func(t *testing.T) {
app, router, _ := NewApiTest()
GetConfig(router)
GetClientConfig(router)
r := PerformRequest(app, "GET", "/api/v1/config")
val := gjson.Get(r.Body.String(), "flags")
assert.Equal(t, "public debug test sponsor experimental settings", val.String())
@@ -25,7 +25,7 @@ func TestGetConfigOptions(t *testing.T) {
app, router, _ := NewApiTest()
GetConfigOptions(router)
r := PerformRequest(app, "GET", "/api/v1/config/options")
assert.Equal(t, http.StatusUnauthorized, r.Code)
assert.Equal(t, http.StatusForbidden, r.Code)
})
}
@@ -34,6 +34,6 @@ func TestSaveConfigOptions(t *testing.T) {
app, router, _ := NewApiTest()
SaveConfigOptions(router)
r := PerformRequest(app, "POST", "/api/v1/config/options")
assert.Equal(t, http.StatusUnauthorized, r.Code)
assert.Equal(t, http.StatusForbidden, r.Code)
})
}

View File

@@ -0,0 +1,102 @@
package api
import (
"net/http"
"github.com/photoprism/photoprism/internal/customize"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/internal/service"
)
// GetSettings returns the user app settings as JSON.
//
// GET /api/v1/settings
func GetSettings(router *gin.RouterGroup) {
router.GET("/settings", func(c *gin.Context) {
s := AuthAny(c, acl.ResourceSettings, acl.Permissions{acl.AccessAll, acl.AccessOwn})
// Abort if permission was not granted.
if s.Abort(c) {
return
}
settings := service.Config().SessionSettings(s)
if settings == nil {
Abort(c, http.StatusNotFound, i18n.ErrNotFound)
return
}
c.JSON(http.StatusOK, settings)
})
}
// SaveSettings saved the user app settings.
//
// POST /api/v1/settings
func SaveSettings(router *gin.RouterGroup) {
router.POST("/settings", func(c *gin.Context) {
s := AuthAny(c, acl.ResourceSettings, acl.Permissions{acl.ActionUpdate, acl.ActionManage})
// Abort if permission was not granted.
if s.Abort(c) {
return
}
conf := service.Config()
if conf.DisableSettings() {
AbortForbidden(c)
return
}
var settings *customize.Settings
if acl.Resources.Allow(acl.ResourceSettings, s.User().AclRole(), acl.ActionManage) {
settings = conf.Settings()
if err := c.BindJSON(settings); err != nil {
AbortBadRequest(c)
return
}
if err := settings.Save(conf.SettingsYaml()); err != nil {
log.Debugf("config: %s (save app settings)", err)
c.AbortWithStatusJSON(http.StatusInternalServerError, err)
return
}
UpdateClientConfig()
} else {
user := s.User()
if user == nil {
AbortUnexpected(c)
return
}
settings = &customize.Settings{}
if err := c.BindJSON(settings); err != nil {
AbortBadRequest(c)
return
}
// Apply to user preferences and keep current values if unspecified.
if err := user.Settings().Apply(settings).Save(); err != nil {
log.Debugf("config: %s (save user settings)", err)
AbortSaveFailed(c)
return
}
}
event.InfoMsg(i18n.MsgSettingsSaved)
c.JSON(http.StatusOK, service.Config().SessionSettings(s))
})
}

View File

@@ -9,7 +9,7 @@ import (
)
func TestGetSettings(t *testing.T) {
t.Run("successful request", func(t *testing.T) {
t.Run("Success", func(t *testing.T) {
app, router, _ := NewApiTest()
GetSettings(router)
r := PerformRequest(app, "GET", "/api/v1/settings")
@@ -22,14 +22,13 @@ func TestGetSettings(t *testing.T) {
}
func TestSaveSettings(t *testing.T) {
t.Run("successful request", func(t *testing.T) {
t.Run("Success", func(t *testing.T) {
app, router, _ := NewApiTest()
GetSettings(router)
r := PerformRequest(app, "GET", "/api/v1/settings")
val := gjson.Get(r.Body.String(), "ui.language")
assert.Equal(t, "en", val.String())
assert.Equal(t, http.StatusOK, r.Code)
SaveSettings(router)
r2 := PerformRequestWithBody(app, "POST", "/api/v1/settings", `{"ui":{"language": "de"}}`)
assert.Equal(t, http.StatusOK, r2.Code)
@@ -39,7 +38,7 @@ func TestSaveSettings(t *testing.T) {
r3 := PerformRequestWithBody(app, "POST", "/api/v1/settings", `{"ui":{"language": "en"}}`)
assert.Equal(t, http.StatusOK, r3.Code)
})
t.Run("bad request", func(t *testing.T) {
t.Run("BadRequest", func(t *testing.T) {
app, router, _ := NewApiTest()
SaveSettings(router)
r := PerformRequestWithBody(app, "POST", "/api/v1/settings", `{"ui":{"language":123}}`)

View File

@@ -17,7 +17,7 @@ import (
// PUT /api/v1/connect/:name
func Connect(router *gin.RouterGroup) {
router.PUT("/connect/:name", func(c *gin.Context) {
name := clean.IdString(c.Param("name"))
name := clean.ID(c.Param("name"))
if name == "" {
log.Errorf("connect: empty service name")
@@ -46,11 +46,11 @@ func Connect(router *gin.RouterGroup) {
return
}
s := Auth(SessionID(c), acl.ResourceConfigOptions, acl.ActionUpdate)
s := Auth(c, acl.ResourceConfig, acl.ActionUpdate)
if s.Invalid() {
log.Errorf("connect: %s not authorized", clean.Log(s.User.Username))
AbortUnauthorized(c)
log.Errorf("connect: %s not authorized", clean.Log(s.User().UserName))
AbortForbidden(c)
return
}

View File

@@ -40,7 +40,7 @@ func AlbumCover(router *gin.RouterGroup) {
start := time.Now()
conf := service.Config()
thumbName := thumb.Name(clean.Token(c.Param("size")))
uid := clean.IdString(c.Param("uid"))
uid := clean.UID(c.Param("uid"))
size, ok := thumb.Sizes[thumbName]
@@ -153,7 +153,7 @@ func LabelCover(router *gin.RouterGroup) {
start := time.Now()
conf := service.Config()
thumbName := thumb.Name(clean.Token(c.Param("size")))
uid := clean.IdString(c.Param("uid"))
uid := clean.UID(c.Param("uid"))
size, ok := thumb.Sizes[thumbName]

View File

@@ -24,7 +24,7 @@ import (
func DownloadAlbum(router *gin.RouterGroup) {
router.GET("/albums/:uid/dl", func(c *gin.Context) {
if InvalidDownloadToken(c) {
AbortUnauthorized(c)
AbortForbidden(c)
return
}
@@ -36,7 +36,7 @@ func DownloadAlbum(router *gin.RouterGroup) {
}
start := time.Now()
a, err := query.AlbumByUID(clean.IdString(c.Param("uid")))
a, err := query.AlbumByUID(clean.UID(c.Param("uid")))
if err != nil {
AbortAlbumNotFound(c)

View File

@@ -3,9 +3,10 @@ package api
import (
"net/http"
"github.com/photoprism/photoprism/internal/customize"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/internal/service"
@@ -19,14 +20,14 @@ import (
// TODO: GET /api/v1/dl/album/:uid
// DownloadName returns the download file name type.
func DownloadName(c *gin.Context) entity.DownloadName {
func DownloadName(c *gin.Context) customize.DownloadName {
switch c.Query("name") {
case "file":
return entity.DownloadNameFile
return customize.DownloadNameFile
case "share":
return entity.DownloadNameShare
return customize.DownloadNameShare
case "original":
return entity.DownloadNameOriginal
return customize.DownloadNameOriginal
default:
return service.Config().Settings().Download.Name
}

View File

@@ -9,7 +9,6 @@ import (
"github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/pkg/txt"
)
@@ -19,10 +18,9 @@ import (
func GetErrors(router *gin.RouterGroup) {
router.GET("/errors", func(c *gin.Context) {
// Check authentication and authorization.
s := Auth(SessionID(c), acl.ResourceLogs, acl.ActionSearch)
s := Auth(c, acl.ResourceLogs, acl.ActionSearch)
if s.Invalid() {
AbortUnauthorized(c)
if s.Abort(c) {
return
}
@@ -57,10 +55,9 @@ func DeleteErrors(router *gin.RouterGroup) {
}
// Check authentication and authorization.
s := Auth(SessionID(c), acl.ResourceLogs, acl.ActionDelete)
s := Auth(c, acl.ResourceLogs, acl.ActionDelete)
if s.Invalid() {
AbortUnauthorized(c)
if s.Abort(c) {
return
}

View File

@@ -14,6 +14,7 @@ const (
EntityUpdated EntityEvent = "updated"
EntityCreated EntityEvent = "created"
EntityDeleted EntityEvent = "deleted"
EntityReacted EntityEvent = "reacted"
)
func PublishPhotoEvent(e EntityEvent, uid string, c *gin.Context) {

View File

@@ -3,8 +3,6 @@ package api
import (
"net/http"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/acl"
@@ -13,6 +11,7 @@ import (
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/internal/search"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/txt"
)
@@ -21,10 +20,10 @@ import (
// GET /api/v1/faces/:id
func GetFace(router *gin.RouterGroup) {
router.GET("/faces/:id", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceSubjects, acl.ActionRead)
s := Auth(c, acl.ResourcePeople, acl.ActionView)
if s.Invalid() {
AbortUnauthorized(c)
// Abort if permission was not granted.
if s.Abort(c) {
return
}
@@ -44,10 +43,10 @@ func GetFace(router *gin.RouterGroup) {
// PUT /api/v1/faces/:id
func UpdateFace(router *gin.RouterGroup) {
router.PUT("/faces/:id", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceSubjects, acl.ActionUpdate)
s := Auth(c, acl.ResourcePeople, acl.ActionUpdate)
if s.Invalid() {
AbortUnauthorized(c)
// Abort if permission was not granted.
if s.Abort(c) {
return
}

View File

@@ -17,10 +17,10 @@ import (
// GET /api/v1/faces
func SearchFaces(router *gin.RouterGroup) {
router.GET("/faces", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceSubjects, acl.ActionSearch)
s := Auth(c, acl.ResourcePeople, acl.ActionSearch)
if s.Invalid() {
AbortUnauthorized(c)
// Abort if permission was not granted.
if s.Abort(c) {
return
}

View File

@@ -23,10 +23,10 @@ func SendFeedback(router *gin.RouterGroup) {
return
}
s := Auth(SessionID(c), acl.ResourceFeedback, acl.ActionCreate)
s := Auth(c, acl.ResourceFeedback, acl.ActionCreate)
if s.Invalid() {
AbortUnauthorized(c)
// Abort if permission was not granted.
if s.Abort(c) {
return
}

View File

@@ -24,10 +24,9 @@ import (
// file_uid: string File UID as returned by the API
func DeleteFile(router *gin.RouterGroup) {
router.DELETE("/photos/:uid/files/:file_uid", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceFiles, acl.ActionDelete)
s := Auth(c, acl.ResourceFiles, acl.ActionDelete)
if s.Invalid() {
AbortUnauthorized(c)
if s.Abort(c) {
return
}
@@ -38,8 +37,8 @@ func DeleteFile(router *gin.RouterGroup) {
return
}
photoUID := clean.IdString(c.Param("uid"))
fileUID := clean.IdString(c.Param("file_uid"))
photoUID := clean.UID(c.Param("uid"))
fileUID := clean.UID(c.Param("file_uid"))
file, err := query.FileByUID(fileUID)

View File

@@ -3,24 +3,24 @@ package api
import (
"net/http"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/pkg/clean"
)
// GetFile returns file details as JSON.
//
// Route: GET /api/v1/files/:hash
// GET /api/v1/files/:hash
// Params:
// - hash (string) SHA-1 hash of the file
func GetFile(router *gin.RouterGroup) {
router.GET("/files/:hash", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceFiles, acl.ActionRead)
s := Auth(c, acl.ResourceFiles, acl.ActionView)
if s.Invalid() {
AbortUnauthorized(c)
// Abort if permission was not granted.
if s.Abort(c) {
return
}

Some files were not shown because too many files have changed in this diff Show More