Security: Use individual preview tokens for each user account #98

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2022-10-13 22:11:02 +02:00
parent ccfdf22590
commit 884dea17de
119 changed files with 1394 additions and 581 deletions

View file

@ -426,15 +426,29 @@ docker-release-impish:
docker pull --platform=arm64 ubuntu:impish
scripts/docker/buildx-multi.sh photoprism linux/amd64,linux/arm64 impish /impish
start-local:
docker-compose -f docker-compose.local.yml up -d
docker-compose -f docker-compose.local.yml up -d --wait
stop-local:
docker-compose -f docker-compose.local.yml stop
mysql:
docker-compose -f docker-compose.mysql.yml pull mysql
docker-compose -f docker-compose.mysql.yml stop mysql
docker-compose -f docker-compose.mysql.yml up -d --wait mysql
start-mysql:
docker-compose -f docker-compose.mysql.yml up -d mysql
docker-compose -f docker-compose.mysql.yml up -d --wait mysql
stop-mysql:
docker-compose -f docker-compose.mysql.yml stop mysql
logs-mysql:
docker-compose -f docker-compose.mysql.yml logs -f mysql
latest:
docker-compose -f docker-compose.latest.yml pull photoprism-latest
docker-compose -f docker-compose.latest.yml stop photoprism-latest
docker-compose -f docker-compose.latest.yml up -d --wait photoprism-latest
start-latest:
docker-compose -f docker-compose.latest.yml up -d --wait photoprism-latest
stop-latest:
docker-compose -f docker-compose.latest.yml stop photoprism-latest
logs-latest:
docker-compose -f docker-compose.latest.yml logs -f photoprism-latest
docker-local: docker-local-bookworm
docker-local-all: docker-local-bookworm docker-local-bullseye docker-local-buster docker-local-jammy
docker-local-bookworm:

View file

@ -37,9 +37,9 @@ services:
PHOTOPRISM_HTTP_COMPRESSION: "gzip" # improves transfer speed and bandwidth utilization (none or gzip)
PHOTOPRISM_DATABASE_DRIVER: "mysql"
PHOTOPRISM_DATABASE_SERVER: "mariadb:4001"
PHOTOPRISM_DATABASE_NAME: "latest"
PHOTOPRISM_DATABASE_USER: "latest"
PHOTOPRISM_DATABASE_PASSWORD: "latest"
PHOTOPRISM_DATABASE_NAME: "photoprism"
PHOTOPRISM_DATABASE_USER: "root"
PHOTOPRISM_DATABASE_PASSWORD: "photoprism"
PHOTOPRISM_DISABLE_CHOWN: "false" # disables updating storage permissions via chmod and chown on startup
PHOTOPRISM_DISABLE_BACKUPS: "false" # disables backing up albums and photo metadata to YAML files
PHOTOPRISM_DISABLE_WEBDAV: "false" # disables built-in WebDAV server

View file

@ -2350,9 +2350,9 @@
}
},
"node_modules/@types/node": {
"version": "18.8.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.8.4.tgz",
"integrity": "sha512-WdlVphvfR/GJCLEMbNA8lJ0lhFNBj4SW3O+O5/cEGw9oYrv0al9zTwuQsq+myDUXgNx2jgBynoVgZ2MMJ6pbow=="
"version": "18.8.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.8.5.tgz",
"integrity": "sha512-Bq7G3AErwe5A/Zki5fdD3O6+0zDChhg671NfPjtIcbtzDNZTv4NPKMRFr7gtYPG7y+B8uTiNK4Ngd9T0FTar6Q=="
},
"node_modules/@types/parse-json": {
"version": "4.0.0",
@ -3503,9 +3503,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001418",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001418.tgz",
"integrity": "sha512-oIs7+JL3K9JRQ3jPZjlH6qyYDp+nBTCais7hjh0s+fuBwufc7uZ7hPYMXrDOJhV360KGMTcczMRObk0/iMqZRg==",
"version": "1.0.30001419",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001419.tgz",
"integrity": "sha512-aFO1r+g6R7TW+PNQxKzjITwLOyDhVRLjW0LcwS/HCZGUUKTGNp9+IwLC4xyDSZBygVL/mxaFR3HIV6wEKQuSzw==",
"funding": [
{
"type": "opencollective",
@ -4645,9 +4645,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.4.279",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.279.tgz",
"integrity": "sha512-xs7vEuSZ84+JsHSTFqqG0TE3i8EAivHomRQZhhcRvsmnjsh5C2KdhwNKf4ZRYtzq75wojpFyqb62m32Oam57wA=="
"version": "1.4.281",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.281.tgz",
"integrity": "sha512-yer0w5wCYdFoZytfmbNhwiGI/3cW06+RV7E23ln4490DVMxs7PvYpbsrSmAiBn/V6gode8wvJlST2YfWgvzWIg=="
},
"node_modules/emoji-regex": {
"version": "8.0.0",
@ -5429,9 +5429,9 @@
}
},
"node_modules/eslint-plugin-promise": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-6.0.1.tgz",
"integrity": "sha512-uM4Tgo5u3UWQiroOyDEsYcVMOo7re3zmno0IZmB5auxoaQNIceAbXEkSt8RNrKtaYehARHG06pYK6K1JhtP0Zw==",
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-6.1.0.tgz",
"integrity": "sha512-NYCfDZF/KHt27p06nFAttgWuFyIDSUMnNaJBIY1FY9GpBFhdT2vMG64HlFguSgcJeyM5by6Yr5csSOuJm60eXQ==",
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
},
@ -6356,19 +6356,19 @@
"integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ=="
},
"node_modules/flow-parser": {
"version": "0.188.2",
"resolved": "https://registry.npmjs.org/flow-parser/-/flow-parser-0.188.2.tgz",
"integrity": "sha512-Qnvihm7h4YDgFVQV2h0TcLE421D20/giBg93Dtobj+CHRnZ39vmsbDPM9IenUBtZuY0vWRiJp6slOv7dvmlKbg==",
"version": "0.189.0",
"resolved": "https://registry.npmjs.org/flow-parser/-/flow-parser-0.189.0.tgz",
"integrity": "sha512-dS8FC7Ek6UyhDkjoTBSvZNLvNI6ukDzUtuugaSlANQfVwdQwiIwAVVdqnbczHr5uuNLQc/mWCR0Ag6nPUIBH9g==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/flow-remove-types": {
"version": "2.188.2",
"resolved": "https://registry.npmjs.org/flow-remove-types/-/flow-remove-types-2.188.2.tgz",
"integrity": "sha512-X2KZLLzpBMLkt+4rCJKQquK9hIHaHbY0BFE0XTbfpPcBDOa/XVDL3RW9/Qb7X9AM2x9T6ex5vetnGIHAV+j0fA==",
"version": "2.189.0",
"resolved": "https://registry.npmjs.org/flow-remove-types/-/flow-remove-types-2.189.0.tgz",
"integrity": "sha512-adE+pINez4sWdpTDtQtY3+0bY+G+7s8z75GTyoPUTDLmxjFmjR3BJctHy130fyaxJQ+7bH5pyg1KSDjTFOaceg==",
"dependencies": {
"flow-parser": "^0.188.2",
"flow-parser": "^0.189.0",
"pirates": "^3.0.2",
"vlq": "^0.2.1"
},
@ -9236,9 +9236,9 @@
"integrity": "sha512-sk96pUvpNwDV6PLrnhr68Uu1S5NohsxqLKz0GuracgrDo40BdF/r1RhHnjakUk6Q4Z0OKIybOQ7GevLKGN1iYw=="
},
"node_modules/postcss": {
"version": "8.4.17",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.17.tgz",
"integrity": "sha512-UNxNOLQydcOFi41yHNMcKRZ39NeXlr8AxGuZJsdub8vIb12fHzcq37DTU/QtbI6WLxNg2gF9Z+8qtRwTj1UI1Q==",
"version": "8.4.18",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.18.tgz",
"integrity": "sha512-Wi8mWhncLJm11GATDaQKobXSNEYGUHeQLiQqDFG1qQ5UTDPTEvKw0Xt5NsTpktGTwLps3ByrWsBrG0rB8YQ9oA==",
"funding": [
{
"type": "opencollective",
@ -12444,11 +12444,11 @@
}
},
"node_modules/vue": {
"version": "2.7.11",
"resolved": "https://registry.npmjs.org/vue/-/vue-2.7.11.tgz",
"integrity": "sha512-VPAW5QelT7Tx6UoSw/cwx/jDROOKAK1y/Q0o7HkmVJ1WAypE7w1+UoFa+KsGxy1aYdHPU1oODB3vR6XwSfVhDg==",
"version": "2.7.12",
"resolved": "https://registry.npmjs.org/vue/-/vue-2.7.12.tgz",
"integrity": "sha512-yRS44vPsCj6b5IZQHdEYqIwnay8stCnL8RsaVsm5aGtOhka00aoG+3ybaBAELDsXtNlzECe8myb2ukdzn19IOg==",
"dependencies": {
"@vue/compiler-sfc": "2.7.11",
"@vue/compiler-sfc": "2.7.12",
"csstype": "^3.1.0"
}
},
@ -12698,9 +12698,9 @@
}
},
"node_modules/vue-template-compiler": {
"version": "2.7.11",
"resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.7.11.tgz",
"integrity": "sha512-17QnXkFiBLOH3gGCA3nWAWpmdlTjOWLyP/2eg5ptgY1OvDBuIDGOW9FZ7ZSKmF1UFyf56mLR3/E1SlCTml1LWQ==",
"version": "2.7.12",
"resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.7.12.tgz",
"integrity": "sha512-6rhJAuo2vRzJMs8X/pd9yqtsJmnPEnv4E0cb9KCu0sfGhoDt8roCCa/6qbrvpc1b38zYgdmY/xrk4qfNWZIjwA==",
"dependencies": {
"de-indent": "^1.0.2",
"he": "^1.2.0"
@ -12712,9 +12712,9 @@
"integrity": "sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw=="
},
"node_modules/vue/node_modules/@vue/compiler-sfc": {
"version": "2.7.11",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-2.7.11.tgz",
"integrity": "sha512-Cf8zvrZWjROgd8yPL8Tc+O3q/Y8ZGM0Y+8blrAvj1RQsVouzUY0oHcx8BA7Hybhb90JRnzeApFrlQGZRUdYpRw==",
"version": "2.7.12",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-2.7.12.tgz",
"integrity": "sha512-7FOITA2+4ND7lMAfegljHBpNSG3X9mVzgQwcS3g928QZM1EADedUw2JLKcgOm1ZEJEkvyDHh6lwa08vrLmoCOA==",
"dependencies": {
"@babel/parser": "^7.18.4",
"postcss": "^8.4.14",
@ -14877,9 +14877,9 @@
}
},
"@types/node": {
"version": "18.8.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.8.4.tgz",
"integrity": "sha512-WdlVphvfR/GJCLEMbNA8lJ0lhFNBj4SW3O+O5/cEGw9oYrv0al9zTwuQsq+myDUXgNx2jgBynoVgZ2MMJ6pbow=="
"version": "18.8.5",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.8.5.tgz",
"integrity": "sha512-Bq7G3AErwe5A/Zki5fdD3O6+0zDChhg671NfPjtIcbtzDNZTv4NPKMRFr7gtYPG7y+B8uTiNK4Ngd9T0FTar6Q=="
},
"@types/parse-json": {
"version": "4.0.0",
@ -15775,9 +15775,9 @@
}
},
"caniuse-lite": {
"version": "1.0.30001418",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001418.tgz",
"integrity": "sha512-oIs7+JL3K9JRQ3jPZjlH6qyYDp+nBTCais7hjh0s+fuBwufc7uZ7hPYMXrDOJhV360KGMTcczMRObk0/iMqZRg=="
"version": "1.0.30001419",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001419.tgz",
"integrity": "sha512-aFO1r+g6R7TW+PNQxKzjITwLOyDhVRLjW0LcwS/HCZGUUKTGNp9+IwLC4xyDSZBygVL/mxaFR3HIV6wEKQuSzw=="
},
"chai": {
"version": "4.3.6",
@ -16596,9 +16596,9 @@
}
},
"electron-to-chromium": {
"version": "1.4.279",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.279.tgz",
"integrity": "sha512-xs7vEuSZ84+JsHSTFqqG0TE3i8EAivHomRQZhhcRvsmnjsh5C2KdhwNKf4ZRYtzq75wojpFyqb62m32Oam57wA=="
"version": "1.4.281",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.281.tgz",
"integrity": "sha512-yer0w5wCYdFoZytfmbNhwiGI/3cW06+RV7E23ln4490DVMxs7PvYpbsrSmAiBn/V6gode8wvJlST2YfWgvzWIg=="
},
"emoji-regex": {
"version": "8.0.0",
@ -17306,9 +17306,9 @@
}
},
"eslint-plugin-promise": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-6.0.1.tgz",
"integrity": "sha512-uM4Tgo5u3UWQiroOyDEsYcVMOo7re3zmno0IZmB5auxoaQNIceAbXEkSt8RNrKtaYehARHG06pYK6K1JhtP0Zw==",
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-6.1.0.tgz",
"integrity": "sha512-NYCfDZF/KHt27p06nFAttgWuFyIDSUMnNaJBIY1FY9GpBFhdT2vMG64HlFguSgcJeyM5by6Yr5csSOuJm60eXQ==",
"requires": {}
},
"eslint-plugin-vue": {
@ -17855,16 +17855,16 @@
"integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ=="
},
"flow-parser": {
"version": "0.188.2",
"resolved": "https://registry.npmjs.org/flow-parser/-/flow-parser-0.188.2.tgz",
"integrity": "sha512-Qnvihm7h4YDgFVQV2h0TcLE421D20/giBg93Dtobj+CHRnZ39vmsbDPM9IenUBtZuY0vWRiJp6slOv7dvmlKbg=="
"version": "0.189.0",
"resolved": "https://registry.npmjs.org/flow-parser/-/flow-parser-0.189.0.tgz",
"integrity": "sha512-dS8FC7Ek6UyhDkjoTBSvZNLvNI6ukDzUtuugaSlANQfVwdQwiIwAVVdqnbczHr5uuNLQc/mWCR0Ag6nPUIBH9g=="
},
"flow-remove-types": {
"version": "2.188.2",
"resolved": "https://registry.npmjs.org/flow-remove-types/-/flow-remove-types-2.188.2.tgz",
"integrity": "sha512-X2KZLLzpBMLkt+4rCJKQquK9hIHaHbY0BFE0XTbfpPcBDOa/XVDL3RW9/Qb7X9AM2x9T6ex5vetnGIHAV+j0fA==",
"version": "2.189.0",
"resolved": "https://registry.npmjs.org/flow-remove-types/-/flow-remove-types-2.189.0.tgz",
"integrity": "sha512-adE+pINez4sWdpTDtQtY3+0bY+G+7s8z75GTyoPUTDLmxjFmjR3BJctHy130fyaxJQ+7bH5pyg1KSDjTFOaceg==",
"requires": {
"flow-parser": "^0.188.2",
"flow-parser": "^0.189.0",
"pirates": "^3.0.2",
"vlq": "^0.2.1"
},
@ -19934,9 +19934,9 @@
"integrity": "sha512-sk96pUvpNwDV6PLrnhr68Uu1S5NohsxqLKz0GuracgrDo40BdF/r1RhHnjakUk6Q4Z0OKIybOQ7GevLKGN1iYw=="
},
"postcss": {
"version": "8.4.17",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.17.tgz",
"integrity": "sha512-UNxNOLQydcOFi41yHNMcKRZ39NeXlr8AxGuZJsdub8vIb12fHzcq37DTU/QtbI6WLxNg2gF9Z+8qtRwTj1UI1Q==",
"version": "8.4.18",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.18.tgz",
"integrity": "sha512-Wi8mWhncLJm11GATDaQKobXSNEYGUHeQLiQqDFG1qQ5UTDPTEvKw0Xt5NsTpktGTwLps3ByrWsBrG0rB8YQ9oA==",
"requires": {
"nanoid": "^3.3.4",
"picocolors": "^1.0.0",
@ -22106,18 +22106,18 @@
}
},
"vue": {
"version": "2.7.11",
"resolved": "https://registry.npmjs.org/vue/-/vue-2.7.11.tgz",
"integrity": "sha512-VPAW5QelT7Tx6UoSw/cwx/jDROOKAK1y/Q0o7HkmVJ1WAypE7w1+UoFa+KsGxy1aYdHPU1oODB3vR6XwSfVhDg==",
"version": "2.7.12",
"resolved": "https://registry.npmjs.org/vue/-/vue-2.7.12.tgz",
"integrity": "sha512-yRS44vPsCj6b5IZQHdEYqIwnay8stCnL8RsaVsm5aGtOhka00aoG+3ybaBAELDsXtNlzECe8myb2ukdzn19IOg==",
"requires": {
"@vue/compiler-sfc": "2.7.11",
"@vue/compiler-sfc": "2.7.12",
"csstype": "^3.1.0"
},
"dependencies": {
"@vue/compiler-sfc": {
"version": "2.7.11",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-2.7.11.tgz",
"integrity": "sha512-Cf8zvrZWjROgd8yPL8Tc+O3q/Y8ZGM0Y+8blrAvj1RQsVouzUY0oHcx8BA7Hybhb90JRnzeApFrlQGZRUdYpRw==",
"version": "2.7.12",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-2.7.12.tgz",
"integrity": "sha512-7FOITA2+4ND7lMAfegljHBpNSG3X9mVzgQwcS3g928QZM1EADedUw2JLKcgOm1ZEJEkvyDHh6lwa08vrLmoCOA==",
"requires": {
"@babel/parser": "^7.18.4",
"postcss": "^8.4.14",
@ -22307,9 +22307,9 @@
}
},
"vue-template-compiler": {
"version": "2.7.11",
"resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.7.11.tgz",
"integrity": "sha512-17QnXkFiBLOH3gGCA3nWAWpmdlTjOWLyP/2eg5ptgY1OvDBuIDGOW9FZ7ZSKmF1UFyf56mLR3/E1SlCTml1LWQ==",
"version": "2.7.12",
"resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.7.12.tgz",
"integrity": "sha512-6rhJAuo2vRzJMs8X/pd9yqtsJmnPEnv4E0cb9KCu0sfGhoDt8roCCa/6qbrvpc1b38zYgdmY/xrk4qfNWZIjwA==",
"requires": {
"de-indent": "^1.0.2",
"he": "^1.2.0"

View file

@ -78,9 +78,13 @@ Api.interceptors.response.use(
// Update preview token.
if (resp.headers && resp.headers["x-preview-token"]) {
const previewToken = resp.headers["x-preview-token"];
const downloadToken = resp.headers["x-download-token"]
? resp.headers["x-download-token"]
: "";
if (config.previewToken !== previewToken) {
config.previewToken = previewToken;
Event.publish("config.updated", { config: { previewToken } });
config.downloadToken = downloadToken;
Event.publish("config.updated", { config: { previewToken, downloadToken } });
}
}

View file

@ -42,6 +42,8 @@ export default class Config {
this.disconnected = false;
this.storage = storage;
this.storage_key = "config";
this.previewToken = "";
this.downloadToken = "";
this.$vuetify = null;
this.translations = translations;
@ -101,6 +103,8 @@ export default class Config {
this.test = !!values.test;
this.demo = !!values.demo;
this.updateTokens();
Event.subscribe("config.updated", (ev, data) => this.setValues(data.config));
Event.subscribe("count", (ev, data) => this.onCount(ev, data));
Event.subscribe("people", (ev, data) => this.onPeople(ev, data));
@ -150,6 +154,8 @@ export default class Config {
}
}
this.updateTokens();
if (values.settings) {
this.setBatchSize(values.settings);
this.setLanguage(values.settings.ui.language);
@ -519,12 +525,13 @@ export default class Config {
return Languages().some((lang) => lang.value === this.values.settings.ui.language && lang.rtl);
}
downloadToken() {
return this.values["downloadToken"];
}
previewToken() {
return this.values["previewToken"];
updateTokens() {
if (this.values["previewToken"]) {
this.previewToken = this.values.previewToken;
}
if (this.values["downloadToken"]) {
this.downloadToken = this.values.downloadToken;
}
}
albumCategories() {

View file

@ -203,7 +203,7 @@ export default {
Notify.success(this.$gettext("Downloading…"));
this.onDownload(`${this.$config.apiUri}/albums/${this.selection[0]}/dl?t=${this.$config.downloadToken()}`);
this.onDownload(`${this.$config.apiUri}/albums/${this.selection[0]}/dl?t=${this.$config.downloadToken}`);
this.expanded = false;
},

View file

@ -182,7 +182,7 @@ export default {
}
},
download() {
this.onDownload(`${this.$config.apiUri}/albums/${this.album.UID}/dl?t=${this.$config.downloadToken()}`);
this.onDownload(`${this.$config.apiUri}/albums/${this.album.UID}/dl?t=${this.$config.downloadToken}`);
},
onDownload(path) {
Notify.success(this.$gettext("Downloading…"));

View file

@ -101,7 +101,7 @@ export default {
},
download() {
Api.post("zip", {"files": this.selection}).then(r => {
this.onDownload(`${this.$config.apiUri}/zip/${r.data.filename}?t=${this.$config.downloadToken()}`);
this.onDownload(`${this.$config.apiUri}/zip/${r.data.filename}?t=${this.$config.downloadToken}`);
});
this.expanded = false;

View file

@ -147,7 +147,7 @@ export default {
return;
}
this.onDownload(`${this.$config.apiUri}/labels/${this.selection[0]}/dl?t=${this.$config.downloadToken()}`);
this.onDownload(`${this.$config.apiUri}/labels/${this.selection[0]}/dl?t=${this.$config.downloadToken}`);
this.expanded = false;
},

View file

@ -374,7 +374,7 @@ export default {
Notify.success(this.$gettext("Downloading…"));
const photo = this.photos[index];
download(`${this.$config.apiUri}/dl/${photo.Hash}?t=${this.$config.downloadToken()}`, photo.FileName);
download(`${this.$config.apiUri}/dl/${photo.Hash}?t=${this.$config.downloadToken}`, photo.FileName);
},
toggleLike(ev, index) {
const inputType = this.input.eval(ev, index);

View file

@ -356,7 +356,7 @@ export default {
default:
Api.post("zip", {"photos": this.selection})
.then(r => {
this.onDownload(`${this.$config.apiUri}/zip/${r.data.filename}?t=${this.$config.downloadToken()}`);
this.onDownload(`${this.$config.apiUri}/zip/${r.data.filename}?t=${this.$config.downloadToken}`);
})
.finally(() => {
this.busy = false;

View file

@ -264,7 +264,7 @@ export default {
Notify.success(this.$gettext("Downloading…"));
const photo = this.photos[index];
download(`${this.$config.apiUri}/dl/${photo.Hash}?t=${this.$config.downloadToken()}`, photo.FileName);
download(`${this.$config.apiUri}/dl/${photo.Hash}?t=${this.$config.downloadToken}`, photo.FileName);
},
onSelect(ev, index) {
if (ev.shiftKey) {

View file

@ -118,7 +118,7 @@ export default {
Notify.success(this.$gettext("Downloading…"));
Api.post("zip", {"subjects": this.selection}).then(r => {
this.onDownload(`${this.$config.apiUri}/zip/${r.data.filename}?t=${this.$config.downloadToken()}`);
this.onDownload(`${this.$config.apiUri}/zip/${r.data.filename}?t=${this.$config.downloadToken}`);
});
this.expanded = false;

View file

@ -136,9 +136,9 @@ export class Album extends RestModel {
thumbnailUrl(size) {
if (this.Thumb) {
return `${config.contentUri}/t/${this.Thumb}/${config.previewToken()}/${size}`;
return `${config.contentUri}/t/${this.Thumb}/${config.previewToken}/${size}`;
} else if (this.UID) {
return `${config.contentUri}/albums/${this.UID}/t/${config.previewToken()}/${size}`;
return `${config.contentUri}/albums/${this.UID}/t/${config.previewToken}/${size}`;
} else {
return `${config.contentUri}/svg/album`;
}

View file

@ -91,7 +91,7 @@ export class Face extends RestModel {
}
if (this.Thumb) {
return `${config.contentUri}/t/${this.Thumb}/${config.previewToken()}/${size}`;
return `${config.contentUri}/t/${this.Thumb}/${config.previewToken}/${size}`;
} else {
return `${config.contentUri}/svg/portrait`;
}

View file

@ -124,11 +124,11 @@ export class File extends RestModel {
return `${config.contentUri}/svg/file`;
}
return `${config.contentUri}/t/${this.Hash}/${config.previewToken()}/${size}`;
return `${config.contentUri}/t/${this.Hash}/${config.previewToken}/${size}`;
}
getDownloadUrl() {
return `${config.apiUri}/dl/${this.Hash}?t=${config.downloadToken()}`;
return `${config.apiUri}/dl/${this.Hash}?t=${config.downloadToken}`;
}
download() {

View file

@ -93,7 +93,7 @@ export class Folder extends RestModel {
}
thumbnailUrl(size) {
return `${config.contentUri}/folders/t/${this.UID}/${config.previewToken()}/${size}`;
return `${config.contentUri}/folders/t/${this.UID}/${config.previewToken}/${size}`;
}
getDateString() {

View file

@ -73,9 +73,9 @@ export class Label extends RestModel {
thumbnailUrl(size) {
if (this.Thumb) {
return `${config.contentUri}/t/${this.Thumb}/${config.previewToken()}/${size}`;
return `${config.contentUri}/t/${this.Thumb}/${config.previewToken}/${size}`;
} else if (this.UID) {
return `${config.contentUri}/labels/${this.UID}/t/${config.previewToken()}/${size}`;
return `${config.contentUri}/labels/${this.UID}/t/${config.previewToken}/${size}`;
} else {
return `${config.contentUri}/svg/label`;
}

View file

@ -84,7 +84,7 @@ export class Marker extends RestModel {
}
if (this.Thumb) {
return `${config.contentUri}/t/${this.Thumb}/${config.previewToken()}/${size}`;
return `${config.contentUri}/t/${this.Thumb}/${config.previewToken}/${size}`;
} else {
return `${config.contentUri}/svg/portrait`;
}

View file

@ -501,10 +501,10 @@ export class Photo extends RestModel {
videoFormat = FormatWebM;
}
return `${config.apiUri}/videos/${file.Hash}/${config.previewToken()}/${videoFormat}`;
return `${config.apiUri}/videos/${file.Hash}/${config.previewToken}/${videoFormat}`;
}
return `${config.apiUri}/videos/${this.Hash}/${config.previewToken()}/${FormatAvc}`;
return `${config.apiUri}/videos/${this.Hash}/${config.previewToken}/${FormatAvc}`;
}
mainFile() {
@ -578,7 +578,7 @@ export class Photo extends RestModel {
this.mainFileHash(),
this.videoFile(),
config.contentUri,
config.previewToken(),
config.previewToken,
size
);
}
@ -598,7 +598,7 @@ export class Photo extends RestModel {
});
getDownloadUrl() {
return `${config.apiUri}/dl/${this.mainFileHash()}?t=${config.downloadToken()}`;
return `${config.apiUri}/dl/${this.mainFileHash()}?t=${config.downloadToken}`;
}
downloadAll() {
@ -609,7 +609,7 @@ export class Photo extends RestModel {
return;
}
const token = config.downloadToken();
const token = config.downloadToken;
if (!this.Files) {
const hash = this.mainFileHash();

View file

@ -95,7 +95,7 @@ export class Subject extends RestModel {
size = "tile_160";
}
return `${config.contentUri}/t/${this.Thumb}/${config.previewToken()}/${size}`;
return `${config.contentUri}/t/${this.Thumb}/${config.previewToken}/${size}`;
}
getDateString() {

View file

@ -235,7 +235,7 @@ export class Thumb extends Model {
return `${config.contentUri}/svg/photo`;
}
return `${config.contentUri}/t/${file.Hash}/${config.previewToken()}/${size}`;
return `${config.contentUri}/t/${file.Hash}/${config.previewToken}/${size}`;
}
static downloadUrl(file) {
@ -243,7 +243,7 @@ export class Thumb extends Model {
return "";
}
return `${config.apiUri}/dl/${file.Hash}?t=${config.downloadToken()}`;
return `${config.apiUri}/dl/${file.Hash}?t=${config.downloadToken}`;
}
}

View file

@ -44,10 +44,10 @@ export class User extends RestModel {
Attr: "",
SuperAdmin: false,
CanLogin: false,
CanInvite: false,
BasePath: "",
UploadPath: "",
CanSync: false,
CanInvite: false,
WebDAV: false,
Thumb: "",
ThumbSrc: "",
Settings: {

View file

@ -216,7 +216,7 @@ export default {
Notify.success(this.$gettext("Downloading…"));
const model = this.results[index];
download(`${this.$config.apiUri}/dl/${model.Hash}?t=${this.$config.downloadToken()}`, model.Name);
download(`${this.$config.apiUri}/dl/${model.Hash}?t=${this.$config.downloadToken}`, model.Name);
},
selectRange(rangeEnd, models) {
if (!models || !models[rangeEnd] || !(models[rangeEnd] instanceof RestModel)) {

View file

@ -355,7 +355,7 @@ export default {
let id = features[i].id;
let marker = this.markers[id];
let token = this.$config.previewToken();
let token = this.$config.previewToken;
if (!marker) {
let el = document.createElement('div');
el.className = 'marker';

View file

@ -9,10 +9,6 @@ type Role string
// String returns the type as string.
func (r Role) String() string {
if r == "" {
return "unauthorized"
}
return string(r)
}

View file

@ -2,18 +2,17 @@ package acl
// Roles that can be assigned to users.
const (
RoleAdmin Role = "admin"
RoleVisitor Role = "visitor"
RoleUnauthorized Role = "unauthorized"
RoleDefault Role = "default"
RoleUnknown Role = ""
RoleDefault Role = "default"
RoleAdmin Role = "admin"
RoleVisitor Role = "visitor"
RoleUnknown Role = ""
)
// ValidRoles specifies the valid user roles.
var ValidRoles = map[string]Role{
string(RoleAdmin): RoleAdmin,
string(RoleVisitor): RoleVisitor,
string(RoleUnauthorized): RoleUnauthorized,
string(RoleAdmin): RoleAdmin,
string(RoleVisitor): RoleVisitor,
string(RoleUnknown): RoleUnknown,
}
// Roles grants permissions to roles.

View file

@ -56,7 +56,7 @@ func SearchAlbums(router *gin.RouterGroup) {
AddCountHeader(c, len(result))
AddLimitHeader(c, f.Count)
AddOffsetHeader(c, f.Offset)
AddTokenHeaders(c)
AddTokenHeaders(c, s)
// Return as JSON.
c.JSON(http.StatusOK, result)

View file

@ -10,7 +10,7 @@ import (
)
func TestSearchAlbums(t *testing.T) {
t.Run("successful request", func(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
app, router, _ := NewApiTest()
SearchAlbums(router)
r := PerformRequest(app, "GET", "/api/v1/albums?count=10")
@ -18,7 +18,7 @@ func TestSearchAlbums(t *testing.T) {
assert.LessOrEqual(t, int64(3), count.Int())
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("invalid request", func(t *testing.T) {
t.Run("BadRequest", func(t *testing.T) {
app, router, _ := NewApiTest()
SearchAlbums(router)
r := PerformRequest(app, "GET", "/api/v1/albums?xxx=10")

View file

@ -24,12 +24,8 @@ func GetClientConfig(router *gin.RouterGroup) {
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())
c.JSON(http.StatusOK, conf.ClientSession(s))
}
})
}

View file

@ -129,17 +129,7 @@ func wsReader(ws *websocket.Conn, writeMutex *sync.Mutex, connId string, conf *c
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)
wsSendMessage("config.updated", event.Data{"config": conf.ClientSession(s)}, ws, writeMutex)
}
}
}

View file

@ -7,7 +7,6 @@ import (
"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"
@ -42,16 +41,15 @@ func ChangePassword(router *gin.RouterGroup) {
return
}
uid := clean.UID(c.Param("uid"))
m := entity.FindUserByUID(uid)
// Users may only change their own password.
if s.User().UserUID != m.UserUID {
if s.User().UserUID != clean.UID(c.Param("uid")) {
AbortForbidden(c)
return
}
if m == nil {
u := s.User()
if u == nil {
Abort(c, http.StatusNotFound, i18n.ErrUserNotFound)
return
}
@ -64,14 +62,14 @@ func ChangePassword(router *gin.RouterGroup) {
}
// Verify that the old password is correct.
if m.WrongPassword(f.OldPassword) {
if u.WrongPassword(f.OldPassword) {
limiter.Auth.Reserve(ClientIP(c))
Abort(c, http.StatusBadRequest, i18n.ErrInvalidPassword)
return
}
// Change password.
if err := m.SetPassword(f.NewPassword); err != nil {
if err := s.ChangePassword(f.NewPassword); err != nil {
Error(c, http.StatusBadRequest, err, i18n.ErrInvalidPassword)
return
}
@ -79,8 +77,9 @@ func ChangePassword(router *gin.RouterGroup) {
// 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"))
english.Plural(u.DeleteSessions([]string{s.ID}), "session", "sessions"))
AddTokenHeaders(c, s)
c.JSON(http.StatusOK, i18n.NewResponse(http.StatusOK, i18n.MsgPasswordChanged))
})
}

View file

@ -39,7 +39,7 @@ func TestChangePassword(t *testing.T) {
}
})
t.Run("Success", func(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
defer conf.SetAuthMode(config.AuthModePublic)

View file

@ -5,7 +5,6 @@ import (
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form"
@ -32,7 +31,14 @@ func CreateSession(router *gin.RouterGroup) {
// Skip authentication if app is running in public mode.
if conf.Public() {
sess := service.Session().Public()
c.JSON(http.StatusOK, gin.H{"status": "ok", "id": sess.ID, "user": sess.User(), "data": sess.Data(), "config": conf.ClientPublic()})
data := gin.H{
"status": "ok",
"id": sess.ID,
"user": sess.User(),
"data": sess.Data(),
"config": conf.ClientPublic(),
}
c.JSON(http.StatusOK, data)
return
}
@ -75,17 +81,19 @@ func CreateSession(router *gin.RouterGroup) {
// Add session id to response headers.
AddSessionHeader(c, sess.ID)
// Get config values for use by the JavaScript UI and other clients.
var clientConfig config.ClientConfig
if sess.User().IsVisitor() {
clientConfig = conf.ClientShare()
} else if sess.User().IsRegistered() {
clientConfig = conf.ClientSession(sess)
} else {
clientConfig = conf.ClientPublic()
// Get config values for the UI.
clientConfig := conf.ClientSession(sess)
// User information, session data, and client config values.
data := gin.H{
"status": "ok",
"id": sess.ID,
"user": sess.User(),
"data": sess.Data(),
"config": clientConfig,
}
// Send JSON response with user information, session data, and client config values.
c.JSON(sess.HttpStatus(), gin.H{"status": "ok", "id": sess.ID, "user": sess.User(), "data": sess.Data(), "config": clientConfig})
// Send JSON response.
c.JSON(sess.HttpStatus(), data)
})
}

View file

@ -5,7 +5,6 @@ import (
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/pkg/clean"
)
@ -45,21 +44,15 @@ func GetSession(router *gin.RouterGroup) {
// 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 not set - 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.
data := gin.H{
"status": "ok",
"id": sess.ID,
"user": sess.User(),
"data": sess.Data(),
"config": service.Config().ClientSession(sess),
}
// 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})
c.JSON(http.StatusOK, data)
})
}

View file

@ -28,7 +28,7 @@ func TestSession(t *testing.T) {
}
func TestCreateSession(t *testing.T) {
t.Run("Success", func(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
defer conf.SetAuthMode(config.AuthModePublic)

View file

@ -8,7 +8,7 @@ import (
)
func TestGetShares(t *testing.T) {
t.Run("invalid token or share", func(t *testing.T) {
t.Run("InvalidToken", func(t *testing.T) {
app, router, _ := NewApiTest()
Shares(router)
r := PerformRequest(app, "GET", "/api/v1/1jxf3jfn2k/st9lxuqxpogaaba7")
@ -21,7 +21,7 @@ func TestGetShares(t *testing.T) {
r := PerformRequest(app, "GET", "/api/v1/4jxf3jfn2k/at9lxuqxpogaaba7")
assert.Equal(t, http.StatusTemporaryRedirect, r.Code)
})*/
t.Run("invalid token", func(t *testing.T) {
t.Run("InvalidToken", func(t *testing.T) {
app, router, _ := NewApiTest()
Shares(router)
r := PerformRequest(app, "GET", "/api/v1/xxx")

View file

@ -3,7 +3,7 @@ package api
import (
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/pkg/clean"
)
@ -15,10 +15,10 @@ func InvalidPreviewToken(c *gin.Context) bool {
token = clean.UrlToken(c.Query("t"))
}
return service.Config().InvalidPreviewToken(token)
return entity.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")))
return entity.InvalidDownloadToken(clean.UrlToken(c.Query("t")))
}

View file

@ -9,7 +9,7 @@ import (
)
func TestGetSettings(t *testing.T) {
t.Run("Success", func(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
app, router, _ := NewApiTest()
GetSettings(router)
r := PerformRequest(app, "GET", "/api/v1/settings")
@ -22,7 +22,7 @@ func TestGetSettings(t *testing.T) {
}
func TestSaveSettings(t *testing.T) {
t.Run("Success", func(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
app, router, _ := NewApiTest()
GetSettings(router)
r := PerformRequest(app, "GET", "/api/v1/settings")

View file

@ -4,11 +4,13 @@ import (
"net/http"
"testing"
"github.com/photoprism/photoprism/internal/config"
"github.com/stretchr/testify/assert"
)
func TestAlbumCover(t *testing.T) {
t.Run("invalid type", func(t *testing.T) {
t.Run("InvalidType", func(t *testing.T) {
app, router, conf := NewApiTest()
AlbumCover(router)
r := PerformRequest(app, "GET", "/api/v1/albums/at9lxuqxpogaaba7/t/"+conf.PreviewToken()+"/xxx")
@ -27,8 +29,10 @@ func TestAlbumCover(t *testing.T) {
r := PerformRequest(app, "GET", "/api/v1/albums/at9lxuqxpogaaba9/t/"+conf.PreviewToken()+"/tile_500")
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("invalid token", func(t *testing.T) {
app, router, _ := NewApiTest()
t.Run("InvalidToken", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
defer conf.SetAuthMode(config.AuthModePublic)
AlbumCover(router)
r := PerformRequest(app, "GET", "/api/v1/albums/at9lxuqxpogaaba8/t/xxx/tile_500")
assert.Equal(t, http.StatusForbidden, r.Code)
@ -36,7 +40,7 @@ func TestAlbumCover(t *testing.T) {
}
func TestLabelCover(t *testing.T) {
t.Run("invalid type", func(t *testing.T) {
t.Run("InvalidType", func(t *testing.T) {
app, router, conf := NewApiTest()
LabelCover(router)
r := PerformRequest(app, "GET", "/api/v1/labels/lt9k3pw1wowuy3c2/t/"+conf.PreviewToken()+"/xxx")
@ -57,8 +61,10 @@ func TestLabelCover(t *testing.T) {
r := PerformRequest(app, "GET", "/api/v1/labels/lt9k3pw1wowuy3c2/t/"+conf.PreviewToken()+"/tile_500")
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("invalid token", func(t *testing.T) {
app, router, _ := NewApiTest()
t.Run("InvalidToken", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
defer conf.SetAuthMode(config.AuthModePublic)
LabelCover(router)
r := PerformRequest(app, "GET", "/api/v1/labels/lt9k3pw1wowuy3c3/t/xxx/tile_500")
assert.Equal(t, http.StatusForbidden, r.Code)

View file

@ -4,30 +4,31 @@ import (
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/tidwall/gjson"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/config"
)
func TestGetDownload(t *testing.T) {
t.Run("download not existing file", func(t *testing.T) {
t.Run("NotFound", func(t *testing.T) {
app, router, conf := NewApiTest()
GetDownload(router)
r := PerformRequest(app, "GET", "/api/v1/dl/123xxx?t="+conf.DownloadToken())
val := gjson.Get(r.Body.String(), "error")
assert.Equal(t, "record not found", val.String())
assert.Equal(t, http.StatusNotFound, r.Code)
})
t.Run("could not find original", func(t *testing.T) {
t.Run("MissingOriginal", func(t *testing.T) {
app, router, conf := NewApiTest()
GetDownload(router)
r := PerformRequest(app, "GET", "/api/v1/dl/3cad9168fa6acc5c5c2965ddf6ec465ca42fd818?t="+conf.DownloadToken())
assert.Equal(t, http.StatusNotFound, r.Code)
})
t.Run("invalid download token", func(t *testing.T) {
app, router, _ := NewApiTest()
t.Run("InvalidDownloadToken", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
defer conf.SetAuthMode(config.AuthModePublic)
GetDownload(router)
r := PerformRequest(app, "GET", "/api/v1/dl/3cad9168fa6acc5c5c2965ddf6ec465ca42fd818?t=xxx")
assert.Equal(t, http.StatusForbidden, r.Code)

View file

@ -43,7 +43,7 @@ func SearchFaces(router *gin.RouterGroup) {
AddCountHeader(c, len(result))
AddLimitHeader(c, f.Count)
AddOffsetHeader(c, f.Offset)
AddTokenHeaders(c)
AddTokenHeaders(c, s)
c.JSON(http.StatusOK, result)
})

View file

@ -10,7 +10,7 @@ import (
)
func TestSearchFaces(t *testing.T) {
t.Run("Success", func(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
app, router, _ := NewApiTest()
SearchFaces(router)
r := PerformRequest(app, "GET", "/api/v1/faces?count=15")

View file

@ -10,7 +10,7 @@ import (
)
func TestGetFace(t *testing.T) {
t.Run("Success", func(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
app, router, _ := NewApiTest()
GetFace(router)
// Example:
@ -46,7 +46,7 @@ func TestGetFace(t *testing.T) {
}
func TestUpdateFace(t *testing.T) {
t.Run("Success", func(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
app, router, _ := NewApiTest()
UpdateFace(router)
r := PerformRequestWithBody(app, "PUT", "/api/v1/faces/PN6QO5INYTUSAATOFL43LL2ABAV5ACzk", `{"SubjUID": "jqu0xs11qekk9jx8"}`)

View file

@ -5,31 +5,35 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/config"
)
func TestGetFolderCover(t *testing.T) {
t.Run("no cover yet", func(t *testing.T) {
t.Run("NoCover", func(t *testing.T) {
app, router, conf := NewApiTest()
FolderCover(router)
r := PerformRequest(app, "GET", "/api/v1/folders/t/dqo63pn35k2d495z/"+conf.PreviewToken()+"/tile_500")
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("invalid thumb type", func(t *testing.T) {
t.Run("InvalidType", func(t *testing.T) {
app, router, conf := NewApiTest()
FolderCover(router)
r := PerformRequest(app, "GET", "/api/v1/folders/t/dqo63pn35k2d495z/"+conf.PreviewToken()+"/xxx")
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("invalid token", func(t *testing.T) {
app, router, _ := NewApiTest()
t.Run("InvalidToken", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
defer conf.SetAuthMode(config.AuthModePublic)
FolderCover(router)
r := PerformRequest(app, "GET", "/api/v1/folders/t/dqo63pn35k2d495z/xxx/tile_500")
assert.Equal(t, http.StatusForbidden, r.Code)
})
t.Run("could not find original", func(t *testing.T) {
t.Run("NotFound", func(t *testing.T) {
app, router, conf := NewApiTest()
FolderCover(router)
r := PerformRequest(app, "GET", "/api/v1/folders/t/dqo63pn2f87f02oi/"+conf.PreviewToken()+"/fit_7680")

View file

@ -107,7 +107,7 @@ func SearchFolders(router *gin.RouterGroup, urlPath, rootName, rootPath string)
AddCountHeader(c, len(resp.Files)+len(resp.Folders))
AddLimitHeader(c, f.Count)
AddOffsetHeader(c, f.Offset)
AddTokenHeaders(c)
AddTokenHeaders(c, s)
c.JSON(http.StatusOK, resp)
}

View file

@ -6,7 +6,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/session"
)
@ -66,7 +66,11 @@ func AddFileCountHeaders(c *gin.Context, filesCount, foldersCount int) {
}
// AddTokenHeaders adds preview token headers to the response.
func AddTokenHeaders(c *gin.Context) {
c.Header("X-Preview-Token", service.Config().PreviewToken())
c.Header("X-Download-Token", service.Config().DownloadToken())
func AddTokenHeaders(c *gin.Context, s *entity.Session) {
if s.PreviewToken != "" {
c.Header("X-Preview-Token", s.PreviewToken)
}
if s.DownloadToken != "" {
c.Header("X-Download-Token", s.DownloadToken)
}
}

View file

@ -42,7 +42,7 @@ func SearchLabels(router *gin.RouterGroup) {
// TODO c.Header("X-Count", strconv.Itoa(count))
AddLimitHeader(c, f.Count)
AddOffsetHeader(c, f.Offset)
AddTokenHeaders(c)
AddTokenHeaders(c, s)
c.JSON(http.StatusOK, result)
})

View file

@ -76,7 +76,7 @@ func SearchPhotos(router *gin.RouterGroup) {
AddCountHeader(c, count)
AddLimitHeader(c, f.Count)
AddOffsetHeader(c, f.Offset)
AddTokenHeaders(c)
AddTokenHeaders(c, s)
// Return as JSON.
c.JSON(http.StatusOK, result)
@ -93,7 +93,7 @@ func SearchPhotos(router *gin.RouterGroup) {
conf := service.Config()
result, count, err := search.UserPhotosViewerResults(f, s, conf.ContentUri(), conf.ApiUri(), conf.PreviewToken(), conf.DownloadToken())
result, count, err := search.UserPhotosViewerResults(f, s, conf.ContentUri(), conf.ApiUri(), s.PreviewToken, s.DownloadToken)
if err != nil {
event.AuditWarn([]string{ClientIP(c), "session %s", string(acl.ResourcePhotos), "view", "%s"}, s.RefID, err)
@ -105,7 +105,7 @@ func SearchPhotos(router *gin.RouterGroup) {
AddCountHeader(c, count)
AddLimitHeader(c, f.Count)
AddOffsetHeader(c, f.Offset)
AddTokenHeaders(c)
AddTokenHeaders(c, s)
// Return as JSON.
c.JSON(http.StatusOK, result)

View file

@ -67,14 +67,14 @@ func SearchGeo(router *gin.RouterGroup) {
AddCountHeader(c, len(photos))
AddLimitHeader(c, f.Count)
AddOffsetHeader(c, f.Offset)
AddTokenHeaders(c)
AddTokenHeaders(c, s)
var resp []byte
// Render JSON response.
switch clean.Token(c.Param("format")) {
case "view":
resp, err = photos.ViewerJSON(conf.ContentUri(), conf.ApiUri(), conf.PreviewToken(), conf.DownloadToken())
resp, err = photos.ViewerJSON(conf.ContentUri(), conf.ApiUri(), s.PreviewToken, s.DownloadToken)
default:
resp, err = photos.GeoJSON()
}

View file

@ -10,7 +10,7 @@ import (
)
func TestSearchPhotos(t *testing.T) {
t.Run("Success", func(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
app, router, _ := NewApiTest()
SearchPhotos(router)
r := PerformRequest(app, "GET", "/api/v1/photos?count=10")

View file

@ -4,13 +4,14 @@ import (
"net/http"
"testing"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/i18n"
"github.com/stretchr/testify/assert"
"github.com/tidwall/gjson"
)
func TestGetPhoto(t *testing.T) {
t.Run("search for existing photo", func(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
app, router, _ := NewApiTest()
GetPhoto(router)
r := PerformRequest(app, "GET", "/api/v1/photos/pt9jtdre2lvl0yh7")
@ -19,7 +20,7 @@ func TestGetPhoto(t *testing.T) {
assert.Equal(t, "200", val.String())
})
t.Run("search for not existing photo", func(t *testing.T) {
t.Run("NotFound", func(t *testing.T) {
app, router, _ := NewApiTest()
GetPhoto(router)
r := PerformRequest(app, "GET", "/api/v1/photos/xxx")
@ -28,7 +29,7 @@ func TestGetPhoto(t *testing.T) {
}
func TestUpdatePhoto(t *testing.T) {
t.Run("successful request", func(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
app, router, _ := NewApiTest()
UpdatePhoto(router)
r := PerformRequestWithBody(app, "PUT", "/api/v1/photos/pt9jtdre2lvl0y13", `{"Title": "Updated01", "Country": "de"}`)
@ -39,14 +40,14 @@ func TestUpdatePhoto(t *testing.T) {
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("invalid request", func(t *testing.T) {
t.Run("BadRequest", func(t *testing.T) {
app, router, _ := NewApiTest()
UpdatePhoto(router)
r := PerformRequestWithBody(app, "PUT", "/api/v1/photos/pt9jtdre2lvl0y13", `{"Name": "Updated01", "Country": 123}`)
assert.Equal(t, http.StatusBadRequest, r.Code)
})
t.Run("not found", func(t *testing.T) {
t.Run("NotFound", func(t *testing.T) {
app, router, _ := NewApiTest()
UpdatePhoto(router)
r := PerformRequestWithBody(app, "PUT", "/api/v1/photos/xxx", `{"Name": "Updated01", "Country": "de"}`)
@ -57,22 +58,24 @@ func TestUpdatePhoto(t *testing.T) {
}
func TestGetPhotoDownload(t *testing.T) {
t.Run("could not find original", func(t *testing.T) {
t.Run("OriginalMissing", func(t *testing.T) {
app, router, conf := NewApiTest()
GetPhotoDownload(router)
r := PerformRequest(app, "GET", "/api/v1/photos/pt9jtdre2lvl0yh7/dl?t="+conf.DownloadToken())
assert.Equal(t, http.StatusNotFound, r.Code)
})
t.Run("not existing photo", func(t *testing.T) {
t.Run("NotFound", func(t *testing.T) {
app, router, conf := NewApiTest()
GetPhotoDownload(router)
r := PerformRequest(app, "GET", "/api/v1/photos/xxx/dl?t="+conf.DownloadToken())
assert.Equal(t, http.StatusNotFound, r.Code)
})
t.Run("invalid token", func(t *testing.T) {
app, router, _ := NewApiTest()
t.Run("InvalidToken", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
defer conf.SetAuthMode(config.AuthModePublic)
GetPhotoDownload(router)
r := PerformRequest(app, "GET", "/api/v1/photos/pt9jtdre2lvl0yh7/dl?t=xxx")
assert.Equal(t, http.StatusForbidden, r.Code)
@ -80,7 +83,7 @@ func TestGetPhotoDownload(t *testing.T) {
}
func TestLikePhoto(t *testing.T) {
t.Run("existing photo", func(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
app, router, _ := NewApiTest()
LikePhoto(router)
r := PerformRequest(app, "POST", "/api/v1/photos/pt9jtdre2lvl0yh9/like")
@ -91,7 +94,7 @@ func TestLikePhoto(t *testing.T) {
assert.Equal(t, "true", val.String())
})
t.Run("not existing photo", func(t *testing.T) {
t.Run("NotFound", func(t *testing.T) {
app, router, _ := NewApiTest()
LikePhoto(router)
r := PerformRequest(app, "POST", "/api/v1/photos/xxx/like")
@ -100,7 +103,7 @@ func TestLikePhoto(t *testing.T) {
}
func TestDislikePhoto(t *testing.T) {
t.Run("existing photo", func(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
app, router, _ := NewApiTest()
DislikePhoto(router)
r := PerformRequest(app, "DELETE", "/api/v1/photos/pt9jtdre2lvl0yh8/like")
@ -111,7 +114,7 @@ func TestDislikePhoto(t *testing.T) {
assert.Equal(t, "false", val.String())
})
t.Run("not existing photo", func(t *testing.T) {
t.Run("NotFound", func(t *testing.T) {
app, router, _ := NewApiTest()
DislikePhoto(router)
r := PerformRequest(app, "DELETE", "/api/v1/photos/xxx/like")
@ -120,7 +123,7 @@ func TestDislikePhoto(t *testing.T) {
}
func TestPhotoPrimary(t *testing.T) {
t.Run("existing photo", func(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
app, router, _ := NewApiTest()
PhotoPrimary(router)
r := PerformRequest(app, "POST", "/api/v1/photos/pt9jtdre2lvl0yh8/files/ft1es39w45bnlqdw/primary")
@ -134,7 +137,7 @@ func TestPhotoPrimary(t *testing.T) {
assert.Equal(t, "false", val2.String())
})
t.Run("incorrect photo uid", func(t *testing.T) {
t.Run("NotFound", func(t *testing.T) {
app, router, _ := NewApiTest()
PhotoPrimary(router)
r := PerformRequest(app, "POST", "/api/v1/photos/xxx/files/ft1es39w45bnlqdw/primary")
@ -145,14 +148,14 @@ func TestPhotoPrimary(t *testing.T) {
}
func TestGetPhotoYaml(t *testing.T) {
t.Run("success", func(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
app, router, _ := NewApiTest()
GetPhotoYaml(router)
r := PerformRequest(app, "GET", "/api/v1/photos/pt9jtdre2lvl0yh7/yaml")
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("not existing photo", func(t *testing.T) {
t.Run("NotFound", func(t *testing.T) {
app, router, _ := NewApiTest()
GetPhotoYaml(router)
r := PerformRequest(app, "GET", "/api/v1/photos/xxx/yaml")
@ -161,7 +164,7 @@ func TestGetPhotoYaml(t *testing.T) {
}
func TestApprovePhoto(t *testing.T) {
t.Run("existing photo", func(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
app, router, _ := NewApiTest()
GetPhoto(router)
r3 := PerformRequest(app, "GET", "/api/v1/photos/pt9jtxrexxvl0y20")
@ -175,7 +178,7 @@ func TestApprovePhoto(t *testing.T) {
assert.Equal(t, "3", val.String())
})
t.Run("not existing photo", func(t *testing.T) {
t.Run("NotFound", func(t *testing.T) {
app, router, _ := NewApiTest()
ApprovePhoto(router)
r := PerformRequest(app, "POST", "/api/v1/photos/xxx/approve")

View file

@ -5,17 +5,23 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/config"
)
func TestGetPreview(t *testing.T) {
t.Run("not found", func(t *testing.T) {
app, router, _ := NewApiTest()
t.Run("NotFound", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
defer conf.SetAuthMode(config.AuthModePublic)
SharePreview(router)
r := PerformRequest(app, "GET", "api/v1/s/1jxf3jfn2k/st9lxuqxpogaaba7/preview")
assert.Equal(t, http.StatusNotFound, r.Code)
})
t.Run("invalid token", func(t *testing.T) {
app, router, _ := NewApiTest()
t.Run("InvalidToken", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
defer conf.SetAuthMode(config.AuthModePublic)
SharePreview(router)
r := PerformRequest(app, "GET", "api/v1/s/xxx/st9lxuqxpogaaba7/preview")
assert.Equal(t, http.StatusNotFound, r.Code)

View file

@ -42,7 +42,7 @@ func SearchSubjects(router *gin.RouterGroup) {
AddCountHeader(c, len(result))
AddLimitHeader(c, f.Count)
AddOffsetHeader(c, f.Offset)
AddTokenHeaders(c)
AddTokenHeaders(c, s)
c.JSON(http.StatusOK, result)
})

View file

@ -10,7 +10,7 @@ import (
)
func TestSearchSubjects(t *testing.T) {
t.Run("Success", func(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
app, router, _ := NewApiTest()
SearchSubjects(router)
r := PerformRequest(app, "GET", "/api/v1/subjects?count=10")

View file

@ -10,7 +10,7 @@ import (
)
func TestGetSubject(t *testing.T) {
t.Run("Success", func(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
app, router, _ := NewApiTest()
GetSubject(router)
r := PerformRequest(app, "GET", "/api/v1/subjects/jqy1y111h1njaaaa")

View file

@ -5,42 +5,46 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/config"
)
func TestGetThumb(t *testing.T) {
t.Run("invalid type", func(t *testing.T) {
t.Run("InvalidType", func(t *testing.T) {
app, router, conf := NewApiTest()
GetThumb(router)
r := PerformRequest(app, "GET", "/api/v1/t/1/"+conf.PreviewToken()+"/xxx")
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("invalid hash", func(t *testing.T) {
t.Run("WrongHash", func(t *testing.T) {
app, router, conf := NewApiTest()
GetThumb(router)
r := PerformRequest(app, "GET", "/api/v1/t/1/"+conf.PreviewToken()+"/tile_500")
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("could not find original", func(t *testing.T) {
t.Run("WrongFile", func(t *testing.T) {
app, router, conf := NewApiTest()
GetThumb(router)
r := PerformRequest(app, "GET", "/api/v1/t/2cad9168fa6acc5c5c2965ddf6ec465ca42fd818/"+conf.PreviewToken()+"/fit_7680")
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("invalid token", func(t *testing.T) {
app, router, _ := NewApiTest()
t.Run("WrongToken", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
defer conf.SetAuthMode(config.AuthModePublic)
GetThumb(router)
r := PerformRequest(app, "GET", "/api/v1/t/2cad9168fa6acc5c5c2965ddf6ec465ca42fd818/xxx/tile_500")
assert.Equal(t, http.StatusForbidden, r.Code)
})
t.Run("no jpg", func(t *testing.T) {
t.Run("NoJPEG", func(t *testing.T) {
app, router, conf := NewApiTest()
GetThumb(router)
r := PerformRequest(app, "GET", "/api/v1/t/pcad9168fa6acc5c5ba965adf6ec465ca42fd819/"+conf.PreviewToken()+"/fit_7680")
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("file error", func(t *testing.T) {
t.Run("FileError", func(t *testing.T) {
app, router, conf := NewApiTest()
GetThumb(router)
r := PerformRequest(app, "GET", "/api/v1/t/acad9168fa6acc5c5c2965ddf6ec465ca42fd832/"+conf.PreviewToken()+"/fit_7680")
@ -53,24 +57,5 @@ func TestGetThumb(t *testing.T) {
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("InvalidType", func(t *testing.T) {
app, router, conf := NewApiTest()
GetThumb(router)
r := PerformRequest(app, "GET", "/api/v1/t/1-016014058037/"+conf.PreviewToken()+"/xxx")
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("InvalidHash", func(t *testing.T) {
app, router, conf := NewApiTest()
GetThumb(router)
r := PerformRequest(app, "GET", "/api/v1/t/1-016014058037/"+conf.PreviewToken()+"/tile_500")
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("invalid token", func(t *testing.T) {
app, router, _ := NewApiTest()
GetThumb(router)
r := PerformRequest(app, "GET", "/api/v1/t/2cad9168fa6acc5c5c2965ddf6ec465ca42fd818-016014058037/xxx/tile_500")
assert.Equal(t, http.StatusForbidden, r.Code)
})
}

View file

@ -5,9 +5,10 @@ import (
"net/http"
"testing"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/pkg/clean"
)
func TestGetVideo(t *testing.T) {
@ -15,41 +16,43 @@ func TestGetVideo(t *testing.T) {
assert.Equal(t, ContentTypeAvc, fmt.Sprintf("%s; codecs=\"%s\"", "video/mp4", clean.Codec("avc1")))
})
t.Run("invalid hash", func(t *testing.T) {
t.Run("InvalidHash", func(t *testing.T) {
app, router, conf := NewApiTest()
GetVideo(router)
r := PerformRequest(app, "GET", "/api/v1/videos/xxx/"+conf.PreviewToken()+"/mp4")
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("invalid type", func(t *testing.T) {
t.Run("InvalidType", func(t *testing.T) {
app, router, conf := NewApiTest()
GetVideo(router)
r := PerformRequest(app, "GET", "/api/v1/videos/acad9168fa6acc5c5c2965ddf6ec465ca42fd831/"+conf.PreviewToken()+"/xxx")
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("file for video not found", func(t *testing.T) {
t.Run("NotFound", func(t *testing.T) {
app, router, conf := NewApiTest()
GetVideo(router)
r := PerformRequest(app, "GET", "/api/v1/videos/acad9168fa6acc5c5c2965ddf6ec465ca42fd831/"+conf.PreviewToken()+"/mp4")
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("file with error", func(t *testing.T) {
t.Run("FileError", func(t *testing.T) {
app, router, conf := NewApiTest()
GetVideo(router)
r := PerformRequest(app, "GET", "/api/v1/videos/acad9168fa6acc5c5c2965ddf6ec465ca42fd832/"+conf.PreviewToken()+"/mp4")
assert.Equal(t, http.StatusOK, r.Code)
})
t.Run("invalid token", func(t *testing.T) {
app, router, _ := NewApiTest()
t.Run("InvalidToken", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
defer conf.SetAuthMode(config.AuthModePublic)
GetVideo(router)
r := PerformRequest(app, "GET", "/api/v1/videos/acad9168fa6acc5c5c2965ddf6ec465ca42fd832/xxx/mp4")
assert.Equal(t, http.StatusForbidden, r.Code)
})
t.Run("no video file", func(t *testing.T) {
t.Run("NoVideo", func(t *testing.T) {
app, router, conf := NewApiTest()
GetVideo(router)
r := PerformRequest(app, "GET", "/api/v1/videos/ocad9168fa6acc5c5c2965ddf6ec465ca42fd818/"+conf.PreviewToken()+"/mp4")

View file

@ -188,7 +188,7 @@ func backupAction(ctx *cli.Context) error {
elapsed := time.Since(start)
log.Infof("backup completed in %s", elapsed)
log.Infof("completed in %s", elapsed)
return nil
}

View file

@ -67,7 +67,7 @@ func convertAction(ctx *cli.Context) error {
elapsed := time.Since(start)
log.Infof("converting completed in %s", elapsed)
log.Infof("completed in %s", elapsed)
return nil
}

View file

@ -86,7 +86,7 @@ func copyAction(ctx *cli.Context) error {
elapsed := time.Since(start)
log.Infof("import completed in %s", elapsed)
log.Infof("completed in %s", elapsed)
return nil
}

View file

@ -86,7 +86,7 @@ func importAction(ctx *cli.Context) error {
elapsed := time.Since(start)
log.Infof("import completed in %s", elapsed)
log.Infof("completed in %s", elapsed)
return nil
}

View file

@ -169,7 +169,7 @@ func migrationsRunAction(ctx *cli.Context) error {
elapsed := time.Since(start)
log.Infof("migration completed in %s", elapsed)
log.Infof("completed in %s", elapsed)
return nil
}

View file

@ -184,7 +184,7 @@ func resetIndexDb(c *config.Config) {
entity.Admin.InitAccount(c.AdminUser(), c.AdminPassword())
}
log.Infof("database reset completed in %s", time.Since(start))
log.Infof("completed in %s", time.Since(start))
}
// resetCache removes all cache files and folders.

View file

@ -15,7 +15,7 @@ const (
UserAttrUsage = "custom user account `ATTRIBUTES`"
UserAdminUsage = "make user super admin with full access"
UserNoLoginUsage = "disable login on the web interface"
UserCanSyncUsage = "allow to sync files via WebDAV"
UserWebDAVUsage = "allow to sync files via WebDAV"
)
// UsersCommand registers the user management subcommands.
@ -25,6 +25,7 @@ var UsersCommand = cli.Command{
Usage: "User management subcommands",
Subcommands: []cli.Command{
UsersListCommand,
UsersLegacyCommand,
UsersAddCommand,
UsersShowCommand,
UsersModCommand,
@ -65,7 +66,7 @@ var UserFlags = []cli.Flag{
Usage: UserNoLoginUsage,
},
cli.BoolFlag{
Name: "can-sync, w",
Usage: UserCanSyncUsage,
Name: "webdav, w",
Usage: UserWebDAVUsage,
},
}

View file

@ -0,0 +1,53 @@
package commands
import (
"fmt"
"github.com/dustin/go-humanize/english"
"github.com/urfave/cli"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/pkg/report"
)
// UsersLegacyCommand configures the command name, flags, and action.
var UsersLegacyCommand = cli.Command{
Name: "legacy",
Usage: "Displays legacy user accounts",
Flags: report.CliFlags,
Action: usersLegacyAction,
}
// usersLegacyAction displays legacy user accounts.
func usersLegacyAction(ctx *cli.Context) error {
return CallWithDependencies(ctx, func(conf *config.Config) error {
cols := []string{"ID", "UID", "User Name", "Display Name", "Email", "Admin", "Created At"}
// Fetch users from database.
users := entity.FindLegacyUsers()
rows := make([][]string, len(users))
// Show log message.
log.Infof("found %s", english.Plural(len(users), "legacy user", "legacy users"))
// Display report.
for i, user := range users {
rows[i] = []string{
fmt.Sprintf("%d", user.ID),
user.UserUID,
user.UserName,
user.FullName,
user.PrimaryEmail,
report.Bool(user.Admin(), report.Yes, report.No),
user.CreatedAt.Format("2006-01-02 15:04:05"),
}
}
result, err := report.RenderFormat(rows, cols, report.CliFormat(ctx))
fmt.Printf("\n%s\n", result)
return err
})
}

View file

@ -40,8 +40,8 @@ func usersListAction(ctx *cli.Context) error {
user.Email(),
user.AclRole().String(),
report.Bool(user.SuperAdmin, report.Yes, report.No),
report.Bool(user.LoginAllowed(), report.Enabled, report.Disabled),
report.Bool(user.SyncAllowed(), report.Enabled, report.Disabled),
report.Bool(user.CanLogIn(), report.Enabled, report.Disabled),
report.Bool(user.CanUseWebDAV(), report.Enabled, report.Disabled),
user.Attr(),
}
}

View file

@ -3,6 +3,8 @@ package commands
import (
"fmt"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/urfave/cli"
"github.com/photoprism/photoprism/internal/config"
@ -24,28 +26,34 @@ func usersModAction(ctx *cli.Context) error {
return CallWithDependencies(ctx, func(conf *config.Config) error {
conf.MigrateDb(false, nil)
username := clean.Username(ctx.Args().First())
id := clean.Username(ctx.Args().First())
// Username provided?
if username == "" {
// Name or UID provided?
if id == "" {
return cli.ShowSubcommandHelp(ctx)
}
// Find user by name.
user := entity.FindUserByName(username)
// Find user record.
var m *entity.User
if user == nil {
return fmt.Errorf("user %s not found", clean.LogQuote(username))
if rnd.IsUID(id, entity.UserUID) {
m = entity.FindUserByUID(id)
} else {
m = entity.FindUserByName(id)
}
if m == nil {
return fmt.Errorf("user %s not found", clean.LogQuote(id))
}
// Set values.
if err := user.SetValuesFromCli(ctx); err != nil {
if err := m.SetValuesFromCli(ctx); err != nil {
return err
}
// Change password?
if val := clean.Password(ctx.String("password")); ctx.IsSet("password") && val != "" {
err := user.SetPassword(val)
err := m.SetPassword(val)
if err != nil {
return err
@ -55,11 +63,11 @@ func usersModAction(ctx *cli.Context) error {
}
// Save values.
if err := user.Save(); err != nil {
if err := m.Save(); err != nil {
return err
}
log.Infof("user %s has been updated", clean.LogQuote(user.Name()))
log.Infof("user %s has been updated", m.String())
return nil
})

View file

@ -1,9 +1,10 @@
package commands
import (
"errors"
"fmt"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/manifoldco/promptui"
"github.com/urfave/cli"
@ -25,28 +26,39 @@ func usersRemoveAction(ctx *cli.Context) error {
return CallWithDependencies(ctx, func(conf *config.Config) error {
conf.MigrateDb(false, nil)
username := clean.Username(ctx.Args().First())
id := clean.Username(ctx.Args().First())
// Username provided?
if username == "" {
// Name or UID provided?
if id == "" {
return cli.ShowSubcommandHelp(ctx)
}
// Find user record.
var m *entity.User
if rnd.IsUID(id, entity.UserUID) {
m = entity.FindUserByUID(id)
} else {
m = entity.FindUserByName(id)
}
if m == nil {
return fmt.Errorf("user %s not found", clean.LogQuote(id))
}
actionPrompt := promptui.Prompt{
Label: fmt.Sprintf("Remove user %s?", clean.LogQuote(username)),
Label: fmt.Sprintf("Remove user %s?", m.String()),
IsConfirm: true,
}
if _, err := actionPrompt.Run(); err == nil {
if m := entity.FindUserByName(username); m == nil {
return errors.New("user not found")
} else if err := m.Delete(); err != nil {
if err = m.Delete(); err != nil {
return err
} else {
log.Infof("user %s has been removed", clean.LogQuote(username))
log.Infof("user %s has been removed", m.String())
}
} else {
log.Infof("user %s was not removed", clean.LogQuote(username))
log.Infof("user %s was not removed", m.String())
}
return nil

View file

@ -3,6 +3,8 @@ package commands
import (
"fmt"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/urfave/cli"
"github.com/photoprism/photoprism/internal/config"
@ -23,22 +25,28 @@ var UsersShowCommand = cli.Command{
// usersShowAction Shows user account details.
func usersShowAction(ctx *cli.Context) error {
return CallWithDependencies(ctx, func(conf *config.Config) error {
username := clean.Username(ctx.Args().First())
id := clean.Username(ctx.Args().First())
// Username provided?
if username == "" {
// Name or UID provided?
if id == "" {
return cli.ShowSubcommandHelp(ctx)
}
// Find user by name.
user := entity.FindUserByName(username)
// Find user record.
var m *entity.User
if user == nil {
return fmt.Errorf("user %s not found", clean.LogQuote(username))
if rnd.IsUID(id, entity.UserUID) {
m = entity.FindUserByUID(id)
} else {
m = entity.FindUserByName(id)
}
if m == nil {
return fmt.Errorf("user %s not found", clean.LogQuote(id))
}
// Get user information.
rows, cols := user.Report(true)
rows, cols := m.Report(true)
// Sort values by name.
report.Sort(rows)

View file

@ -10,7 +10,7 @@ import (
func TestClientAssets_Load(t *testing.T) {
c := NewConfig(CliTestContext())
t.Run("Success", func(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
a := NewClientAssets(c.StaticUri())
err := a.Load("testdata/static/build/assets.json")

View file

@ -68,8 +68,8 @@ type ClientConfig struct {
Thumbs ThumbSizes `json:"thumbs"`
Status string `json:"status"`
MapKey string `json:"mapKey"`
DownloadToken string `json:"downloadToken"`
PreviewToken string `json:"previewToken"`
DownloadToken string `json:"downloadToken,omitempty"`
PreviewToken string `json:"previewToken,omitempty"`
Disable ClientDisable `json:"disable"`
Count ClientCounts `json:"count"`
Pos ClientPosition `json:"pos"`
@ -208,7 +208,7 @@ func (c *Config) ClientPublic() ClientConfig {
cfg := ClientConfig{
Settings: c.PublicSettings(),
ACL: acl.Resources.Grants(acl.RoleUnauthorized),
ACL: acl.Resources.Grants(acl.RoleUnknown),
Disable: ClientDisable{
Backups: true,
WebDAV: true,
@ -270,8 +270,8 @@ func (c *Config) ClientPublic() ClientConfig {
Colors: colors.All.List(),
ManifestUri: c.ClientManifestUri(),
Clip: txt.ClipDefault,
PreviewToken: "public",
DownloadToken: "public",
PreviewToken: entity.TokenPublic,
DownloadToken: entity.TokenPublic,
Ext: ClientExt(c, ClientPublic),
}
@ -582,9 +582,23 @@ func (c *Config) ClientRole(role acl.Role) ClientConfig {
}
// ClientSession provides the client config values for the specified session.
func (c *Config) ClientSession(sess *entity.Session) ClientConfig {
result := c.ClientUser(false).ApplyACL(acl.Resources, sess.User().AclRole())
result.Settings = c.SessionSettings(sess)
func (c *Config) ClientSession(sess *entity.Session) (cfg ClientConfig) {
if sess.User().IsVisitor() {
cfg = c.ClientShare()
} else if sess.User().IsRegistered() {
cfg = c.ClientUser(false).ApplyACL(acl.Resources, sess.User().AclRole())
cfg.Settings = c.SessionSettings(sess)
} else {
cfg = c.ClientPublic()
}
return result
if c.Public() {
cfg.PreviewToken = entity.TokenPublic
cfg.DownloadToken = entity.TokenPublic
} else if sess.PreviewToken != "" || sess.DownloadToken != "" {
cfg.PreviewToken = sess.PreviewToken
cfg.DownloadToken = sess.DownloadToken
}
return cfg
}

View file

@ -79,7 +79,6 @@ func TestConfig_ClientShareConfig(t *testing.T) {
func TestConfig_ClientRoleConfig(t *testing.T) {
c := NewTestConfig("config")
c.SetAuthMode(AuthModePasswd)
assert.Equal(t, AuthModePasswd, c.AuthMode())
@ -167,8 +166,8 @@ func TestConfig_ClientRoleConfig(t *testing.T) {
assert.Equal(t, expected, f)
})
t.Run("RoleUnauthorized", func(t *testing.T) {
cfg := c.ClientRole(acl.RoleUnauthorized)
t.Run("RoleUnknown", func(t *testing.T) {
cfg := c.ClientRole(acl.RoleUnknown)
f := cfg.Settings.Features
assert.NotEqual(t, adminFeatures, f)
@ -195,38 +194,11 @@ func TestConfig_ClientRoleConfig(t *testing.T) {
assert.False(t, f.Reactions)
assert.False(t, f.Ratings)
})
t.Run("RoleUnknown", func(t *testing.T) {
cfg := c.ClientRole(acl.RoleUnknown)
f := cfg.Settings.Features
assert.NotEqual(t, adminFeatures, f)
assert.False(t, f.Search)
assert.False(t, f.Videos)
assert.False(t, f.Albums)
assert.False(t, f.Settings)
assert.False(t, f.Edit)
assert.False(t, f.Private)
assert.False(t, f.Advanced)
assert.False(t, f.Upload)
assert.False(t, f.Download)
assert.False(t, f.Sync)
assert.False(t, f.Delete)
assert.False(t, f.Import)
assert.False(t, f.Library)
assert.False(t, f.Logs)
assert.True(t, f.Review)
assert.False(t, f.Share)
assert.False(t, f.Favorites)
assert.False(t, f.Reactions)
assert.False(t, f.Ratings)
})
}
func TestConfig_ClientSessionConfig(t *testing.T) {
c := NewTestConfig("config")
c.Options().AuthMode = AuthModePasswd
c.Options().Public = false
c.SetAuthMode(AuthModePasswd)
assert.Equal(t, AuthModePasswd, c.AuthMode())
@ -236,6 +208,8 @@ func TestConfig_ClientSessionConfig(t *testing.T) {
cfg := c.ClientSession(entity.SessionFixtures.Pointer("alice"))
assert.IsType(t, ClientConfig{}, cfg)
assert.Equal(t, false, cfg.Public)
assert.NotEmpty(t, cfg.PreviewToken)
assert.NotEmpty(t, cfg.DownloadToken)
f := cfg.Settings.Features
assert.Equal(t, adminFeatures, f)
@ -265,6 +239,8 @@ func TestConfig_ClientSessionConfig(t *testing.T) {
assert.IsType(t, ClientConfig{}, cfg)
assert.Equal(t, false, cfg.Public)
assert.NotEmpty(t, cfg.PreviewToken)
assert.NotEmpty(t, cfg.DownloadToken)
f := cfg.Settings.Features
assert.NotEqual(t, adminFeatures, f)
@ -290,13 +266,15 @@ func TestConfig_ClientSessionConfig(t *testing.T) {
assert.True(t, f.Review)
assert.False(t, f.Share)
})
t.Run("RoleUnauthorized", func(t *testing.T) {
t.Run("RoleUnknown", func(t *testing.T) {
sess := entity.SessionFixtures.Pointer("unauthorized")
cfg := c.ClientSession(sess)
assert.IsType(t, ClientConfig{}, cfg)
assert.Equal(t, false, cfg.Public)
assert.NotEmpty(t, cfg.PreviewToken)
assert.NotEmpty(t, cfg.DownloadToken)
f := cfg.Settings.Features
assert.NotEqual(t, adminFeatures, f)
@ -326,7 +304,8 @@ func TestConfig_ClientSessionConfig(t *testing.T) {
assert.IsType(t, ClientConfig{}, cfg)
assert.Equal(t, false, cfg.Public)
assert.NotEmpty(t, cfg.PreviewToken)
assert.NotEmpty(t, cfg.DownloadToken)
f := cfg.Settings.Features
assert.True(t, f.Search)

View file

@ -157,6 +157,11 @@ func (c *Config) Propagate() {
places.UserAgent = c.UserAgent()
entity.GeoApi = c.GeoApi()
// Set API preview and download default tokens.
entity.PreviewToken.Set(c.PreviewToken(), entity.TokenConfig)
entity.DownloadToken.Set(c.DownloadToken(), entity.TokenConfig)
entity.CheckTokens = !c.Public()
// Set face recognition parameters.
face.ScoreThreshold = c.FaceScore()
face.OverlapThreshold = c.FaceOverlap()

View file

@ -3,6 +3,8 @@ package config
import (
"regexp"
"github.com/photoprism/photoprism/internal/entity"
"golang.org/x/crypto/bcrypt"
"github.com/photoprism/photoprism/pkg/clean"
@ -75,9 +77,11 @@ func (c *Config) SetAuthMode(mode string) {
case AuthModePublic:
c.options.AuthMode = AuthModePublic
c.options.Public = true
entity.CheckTokens = false
default:
c.options.AuthMode = AuthModePasswd
c.options.Public = false
entity.CheckTokens = true
}
}
@ -112,31 +116,28 @@ func (c *Config) CheckPassword(p string) bool {
return ap == p
}
// InvalidDownloadToken checks if the token is invalid.
func (c *Config) InvalidDownloadToken(t string) bool {
return c.DownloadToken() != t
}
// DownloadToken returns the DOWNLOAD api token (you can optionally use a static value for permanent caching).
func (c *Config) DownloadToken() string {
if c.options.DownloadToken == "" {
if c.Public() {
return entity.TokenPublic
} else if c.options.DownloadToken == "" {
c.options.DownloadToken = rnd.GenerateToken(8)
}
return c.options.DownloadToken
}
// InvalidPreviewToken checks if the preview token is invalid.
func (c *Config) InvalidPreviewToken(t string) bool {
return c.PreviewToken() != t && c.DownloadToken() != t
// InvalidDownloadToken checks if the token is invalid.
func (c *Config) InvalidDownloadToken(t string) bool {
return entity.InvalidDownloadToken(t)
}
// PreviewToken returns the preview image api token (based on the unique storage serial by default).
func (c *Config) PreviewToken() string {
if c.options.PreviewToken == "" {
if c.Public() {
c.options.PreviewToken = "public"
} else if c.Serial() == "" {
if c.Public() {
return entity.TokenPublic
} else if c.options.PreviewToken == "" {
if c.Serial() == "" {
return "********"
} else {
c.options.PreviewToken = c.SerialChecksum()
@ -145,3 +146,8 @@ func (c *Config) PreviewToken() string {
return c.options.PreviewToken
}
// InvalidPreviewToken checks if the preview token is invalid.
func (c *Config) InvalidPreviewToken(t string) bool {
return entity.InvalidPreviewToken(t)
}

View file

@ -280,6 +280,7 @@ func (c *Config) InitDb() {
// MigrateDb initializes the database and migrates the schema if needed.
func (c *Config) MigrateDb(runFailed bool, ids []string) {
entity.Admin.UserName = c.AdminUser()
entity.InitDb(true, runFailed, ids)
// Init admin account?

View file

@ -56,7 +56,7 @@ type Options struct {
WakeupInterval time.Duration `yaml:"WakeupInterval" json:"WakeupInterval" flag:"wakeup-interval"`
AutoIndex int `yaml:"AutoIndex" json:"AutoIndex" flag:"auto-index"`
AutoImport int `yaml:"AutoImport" json:"AutoImport" flag:"auto-import"`
DisableWebDAV bool `yaml:"DisableWebDAV" json:"DisableWebDAV" flag:"disable-webdav"`
DisableWebDAV bool `yaml:"WebDAV" json:"WebDAV" flag:"disable-webdav"`
DisableBackups bool `yaml:"DisableBackups" json:"DisableBackups" flag:"disable-backups"`
DisableSettings bool `yaml:"DisableSettings" json:"-" flag:"disable-settings"`
DisablePlaces bool `yaml:"DisablePlaces" json:"DisablePlaces" flag:"disable-places"`

View file

@ -43,8 +43,18 @@ var PkgNameRegexp = regexp.MustCompile("[^a-zA-Z\\-_]+")
// NewTestOptions returns valid config options for tests.
func NewTestOptions(pkg string) *Options {
assetsPath := fs.Abs("../../assets")
storagePath := fs.Abs("../../storage")
// Find assets path.
assetsPath := os.Getenv("PHOTOPRISM_ASSETS_PATH")
if assetsPath == "" {
fs.Abs("../../assets")
}
// Find storage path.
storagePath := os.Getenv("PHOTOPRISM_STORAGE_PATH")
if storagePath == "" {
storagePath = fs.Abs("../../storage")
}
dataPath := filepath.Join(storagePath, "testdata")
pkg = PkgNameRegexp.ReplaceAllString(pkg, "")

View file

@ -0,0 +1,25 @@
/*
Package customize provides user settings to customize the app.
Copyright (c) 2018 - 2022 PhotoPrism UG. All rights reserved.
This program is free software: you can redistribute it and/or modify
it under Version 3 of the GNU Affero General Public License (the "AGPL"):
<https://docs.photoprism.app/license/agpl>
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
The AGPL is supplemented by our Trademark and Brand Guidelines,
which describe how our Brand Assets may be used:
<https://photoprism.app/trademark>
Feel free to send an email to hello@photoprism.app if you have questions,
want to support our work, or just want to say hello.
Additional information can be found in our Developer Guide:
<https://docs.photoprism.app/developer-guide/>
*/
package customize

View file

@ -7,7 +7,7 @@ import (
)
func TestFlushAlbumCache(t *testing.T) {
t.Run("Success", func(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
FlushAlbumCache()
})
}

View file

@ -2,6 +2,7 @@ package entity
import (
"encoding/json"
"fmt"
"net"
"net/http"
"time"
@ -83,6 +84,16 @@ func NewSession(maxAge, timeout int64) (m *Session) {
return m
}
// Expires sets an explicit expiration time.
func (m *Session) Expires(t time.Time) *Session {
if t.IsZero() {
return m
}
m.SessExpires = t.Unix()
return m
}
// DeleteExpiredSessions deletes expired sessions.
func DeleteExpiredSessions() (deleted int) {
expired := Sessions{}
@ -139,7 +150,7 @@ func (m *Session) CacheDuration(d time.Duration) {
return
}
sessionCache.Set(m.ID, m, d)
CacheSession(m, d)
}
// Cache caches the session with the default expiration duration.
@ -167,10 +178,9 @@ func (m *Session) Save() error {
return nil
}
// Delete removes a session.
// Delete permanently deletes a session.
func (m *Session) Delete() error {
DeleteFromSessionCache(m.ID)
return UnscopedDb().Delete(m).Error
return DeleteSession(m)
}
// Updates multiple properties in the database.
@ -237,12 +247,58 @@ func (m *Session) SetUser(u *User) *Session {
m.UserName = u.UserName
}
if u.DownloadToken != "" {
m.DownloadToken = u.DownloadToken
m.SetPreviewToken(u.PreviewToken)
m.SetDownloadToken(u.DownloadToken)
return m
}
// ChangePassword changes the password of the current user.
func (m *Session) ChangePassword(newPw string) (err error) {
u := m.User()
if u == nil {
return fmt.Errorf("unknown user")
}
if u.PreviewToken != "" {
m.PreviewToken = u.PreviewToken
// Change password.
err = u.SetPassword(newPw)
m.SetPreviewToken(u.PreviewToken)
m.SetDownloadToken(u.DownloadToken)
return nil
}
// SetPreviewToken updates the preview token if not empty.
func (m *Session) SetPreviewToken(token string) *Session {
if m.ID == "" {
return m
}
if token != "" {
m.PreviewToken = token
PreviewToken.Set(token, m.ID)
} else if m.PreviewToken == "" {
m.PreviewToken = GenerateToken()
PreviewToken.Set(token, m.ID)
}
return m
}
// SetDownloadToken updates the download token if not empty.
func (m *Session) SetDownloadToken(token string) *Session {
if m.ID == "" {
return m
}
if token != "" {
m.DownloadToken = token
DownloadToken.Set(token, m.ID)
} else if m.DownloadToken == "" {
m.DownloadToken = GenerateToken()
DownloadToken.Set(token, m.ID)
}
return m

View file

@ -40,7 +40,7 @@ func FindSession(id string) (*Session, error) {
return found, fmt.Errorf("has invalid id %s", clean.LogQuote(found.ID))
} else if !found.Expired() {
found.UpdateLastActive()
sessionCache.SetDefault(found.ID, found)
CacheSession(found, sessionCacheExpiration)
return found, nil
} else if err := found.Delete(); err != nil {
event.AuditErr([]string{found.IP(), "session %s", "failed to delete after expiration", "%s"}, found.RefID, err)
@ -54,6 +54,50 @@ func FlushSessionCache() {
sessionCache.Flush()
}
// CacheSession adds a session to the cache if its ID is valid.
func CacheSession(s *Session, d time.Duration) {
if s == nil {
return
} else if !rnd.IsSessionID(s.ID) {
return
}
if d == 0 {
d = sessionCacheExpiration
}
if s.PreviewToken != "" {
PreviewToken.Set(s.PreviewToken, s.ID)
}
if s.DownloadToken != "" {
DownloadToken.Set(s.DownloadToken, s.ID)
}
sessionCache.Set(s.ID, s, d)
}
// DeleteSession permanently deletes a session.
func DeleteSession(s *Session) error {
if s == nil {
return nil
} else if !rnd.IsSessionID(s.ID) {
return fmt.Errorf("invalid session id")
}
DeleteFromSessionCache(s.ID)
if s.PreviewToken != "" {
PreviewToken.Set(s.PreviewToken, s.ID)
}
if s.DownloadToken != "" {
DownloadToken.Set(s.DownloadToken, s.ID)
}
return UnscopedDb().Delete(s).Error
}
// DeleteFromSessionCache deletes a session from the cache.
func DeleteFromSessionCache(id string) {
if id == "" {

View file

@ -9,7 +9,7 @@ import (
)
func TestFlushSessionCache(t *testing.T) {
t.Run("Success", func(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
FlushSessionCache()
})
}

View file

@ -2,6 +2,9 @@ package entity
import (
"net/http"
"time"
"github.com/photoprism/photoprism/pkg/txt"
"github.com/gin-gonic/gin"
@ -38,7 +41,7 @@ func (m *Session) LogIn(f form.Login, c *gin.Context) (err error) {
}
// Login allowed?
if !user.LoginAllowed() {
if !user.CanLogIn() {
message := "account disabled"
event.AuditWarn([]string{m.IP(), "session %s", "login as %s", message}, m.RefID, clean.LogQuote(name))
event.LoginError(m.IP(), "api", name, m.UserAgent, message)
@ -94,6 +97,9 @@ func (m *Session) LogIn(f form.Login, c *gin.Context) (err error) {
if user.IsUnknown() {
user = &Visitor
event.AuditDebug([]string{m.IP(), "session %s", "role upgraded to %s"}, m.RefID, user.AclRole().String())
expires := UTC().Add(time.Hour * 24)
m.Expires(expires)
event.AuditDebug([]string{m.IP(), "session %s", "expires at %s"}, m.RefID, txt.TimeStamp(&expires))
}
m.SetUser(user)

View file

@ -0,0 +1,27 @@
package entity
import (
"github.com/photoprism/photoprism/pkg/rnd"
)
const TokenConfig = "__config__"
const TokenPublic = "public"
var PreviewToken = NewStringMap(Strings{})
var DownloadToken = NewStringMap(Strings{})
var CheckTokens = true
// GenerateToken returns a random string token.
func GenerateToken() string {
return rnd.GenerateToken(8)
}
// InvalidDownloadToken checks if the token is unknown.
func InvalidDownloadToken(t string) bool {
return CheckTokens && DownloadToken.Missing(t)
}
// InvalidPreviewToken checks if the preview token is unknown.
func InvalidPreviewToken(t string) bool {
return CheckTokens && PreviewToken.Missing(t) && DownloadToken.Missing(t)
}

View file

@ -44,15 +44,15 @@ type User struct {
DisplayName string `gorm:"size:200;" json:"DisplayName" yaml:"DisplayName,omitempty"`
UserEmail string `gorm:"size:255;index;" json:"Email" yaml:"Email,omitempty"`
BackupEmail string `gorm:"size:255;" json:"BackupEmail,omitempty" yaml:"BackupEmail,omitempty"`
UserRole string `gorm:"size:64;default:'restricted';" json:"Role,omitempty" yaml:"Role,omitempty"`
UserRole string `gorm:"size:64;default:'';" json:"Role,omitempty" yaml:"Role,omitempty"`
UserAttr string `gorm:"size:1024;" json:"Attr,omitempty" yaml:"Attr,omitempty"`
SuperAdmin bool `json:"SuperAdmin,omitempty" yaml:"SuperAdmin,omitempty"`
CanLogin bool `json:"CanLogin,omitempty" yaml:"CanLogin,omitempty"`
LoginAt *time.Time `json:"LoginAt,omitempty" yaml:"LoginAt,omitempty"`
ExpiresAt *time.Time `sql:"index" json:"ExpiresAt,omitempty" yaml:"ExpiresAt,omitempty"`
WebDAV bool `gorm:"column:webdav;" json:"WebDAV,omitempty" yaml:"WebDAV,omitempty"`
BasePath string `gorm:"type:VARBINARY(1024);" json:"BasePath,omitempty" yaml:"BasePath,omitempty"`
UploadPath string `gorm:"type:VARBINARY(1024);" json:"UploadPath,omitempty" yaml:"UploadPath,omitempty"`
CanSync bool `json:"CanSync,omitempty" yaml:"CanSync,omitempty"`
CanInvite bool `json:"CanInvite,omitempty" yaml:"CanInvite,omitempty"`
InviteToken string `gorm:"type:VARBINARY(64);index;" json:"-" yaml:"-"`
InvitedBy string `gorm:"size:64;" json:"-" yaml:"-"`
@ -84,60 +84,75 @@ func NewUser() (m *User) {
uid := rnd.GenerateUID(UserUID)
return &User{
UserUID: uid,
UserDetails: NewUserDetails(uid),
UserSettings: NewUserSettings(uid),
RefID: rnd.RefID(UserPrefix),
UserUID: uid,
UserDetails: NewUserDetails(uid),
UserSettings: NewUserSettings(uid),
PreviewToken: GenerateToken(),
DownloadToken: GenerateToken(),
RefID: rnd.RefID(UserPrefix),
}
}
// FindUser returns the matching user or nil if it was not found.
func FindUser(find User) *User {
m := &User{}
// Build query.
stmt := UnscopedDb()
if find.ID != 0 {
stmt = stmt.Where("id = ?", find.ID)
} else if rnd.IsUID(find.UserUID, UserUID) {
stmt = stmt.Where("user_uid = ?", find.UserUID)
} else if find.UserName != "" {
stmt = stmt.Where("user_name = ?", find.UserName)
} else if find.UserEmail != "" {
stmt = stmt.Where("user_email = ?", find.UserEmail)
} else {
return nil
}
// Find matching record.
if err := stmt.First(m).Error; err != nil {
return nil
}
// Fetch related records.
return m.LoadRelated()
}
// FirstOrCreateUser returns an existing record, inserts a new record, or returns nil in case of an error.
func FirstOrCreateUser(m *User) *User {
result := User{}
if err := Db().Where("id = ? OR (user_uid = ? AND user_uid <> '') OR (user_name = ? AND user_name <> '')", m.ID, m.UserUID, m.UserName).First(&result).Error; err == nil {
return &result
} else if err = m.Create(); err != nil {
event.AuditErr([]string{"user", "failed to create", "%s"}, err)
if m == nil {
return nil
}
return m
if found := FindUser(*m); found != nil {
return found
} else if err := m.Create(); err != nil {
event.AuditErr([]string{"user", "failed to create", "%s"}, err)
return nil
} else {
return m
}
}
// FindUserByName finds a user by its username or returns nil if it was not found.
// FindUserByName returns the matching user or nil if it was not found.
func FindUserByName(name string) *User {
name = clean.Username(name)
if name == "" {
return nil
}
m := &User{}
// Find matching record.
if Db().First(m, "user_name = ?", name).RecordNotFound() {
return nil
}
// Fetch related settings and details.
return m.LoadRelated()
return FindUser(User{UserName: name})
}
// FindUserByUID returns an existing user or nil if not found.
// FindUserByUID returns the matching user or nil if it was not found.
func FindUserByUID(uid string) *User {
if rnd.InvalidUID(uid, UserUID) {
return nil
}
m := &User{}
// Find matching record.
if UnscopedDb().First(m, "user_uid = ?", uid).RecordNotFound() {
return nil
}
// Fetch related settings and details.
return m.LoadRelated()
return FindUser(User{UserUID: uid})
}
// UID returns the unique id as string.
@ -161,34 +176,36 @@ func (m *User) SameUID(uid string) bool {
}
// InitAccount sets the name and password of the initial admin account.
func (m *User) InitAccount(login, password string) (updated bool) {
if !m.IsRegistered() {
log.Warn("only registered users can change their password")
func (m *User) InitAccount(initName, initPasswd string) (updated bool) {
// User must exist and the password must not be empty.
initPasswd = strings.TrimSpace(initPasswd)
if rnd.InvalidUID(m.UserUID, UserUID) || initPasswd == "" {
return false
} else if !m.CanLogIn() {
log.Warnf("users: %s account is not allowed to log in", m.String())
}
// Abort if user has a password.
existingPasswd := FindPassword(m.UserUID)
if existingPasswd != nil {
return false
}
// Password must not be empty.
if password == "" {
return false
}
existing := FindPassword(m.UserUID)
if existing != nil {
return false
}
pw := NewPassword(m.UserUID, password)
// Set initial password.
initialPasswd := NewPassword(m.UserUID, initPasswd)
// Save password.
if err := pw.Save(); err != nil {
if err := initialPasswd.Save(); err != nil {
event.AuditErr([]string{"user %s", "failed to change password", "%s"}, m.RefID, err)
return false
}
// Change username.
if err := m.UpdateName(login); err != nil {
event.AuditErr([]string{"user %s", "failed to change username to %s", "%s"}, m.RefID, clean.Log(login), err)
// Change username if needed.
if initName != "" && initName != m.UserName {
if err := m.UpdateName(initName); err != nil {
event.AuditErr([]string{"user %s", "failed to change username to %s", "%s"}, m.RefID, clean.Log(initName), err)
}
}
return true
@ -207,6 +224,8 @@ func (m *User) Create() (err error) {
// Save updates the record in the database or inserts a new record if it does not already exist.
func (m *User) Save() (err error) {
m.GenerateTokens(false)
err = Db().Save(m).Error
if err == nil {
@ -269,6 +288,8 @@ func (m *User) BeforeCreate(scope *gorm.Scope) error {
m.UserDetails.UserUID = m.UserUID
}
m.GenerateTokens(false)
if rnd.InvalidRefID(m.RefID) {
m.RefID = rnd.RefID(UserPrefix)
Log("user", "set ref id", scope.SetColumn("RefID", m.RefID))
@ -293,12 +314,14 @@ func (m *User) Expired() bool {
// Disabled checks if the user account has been deleted or has expired.
func (m *User) Disabled() bool {
return m.Deleted() || m.Expired()
return m.Deleted() || m.Expired() && !m.SuperAdmin
}
// LoginAllowed checks if the user is allowed to log in and use the web UI.
func (m *User) LoginAllowed() bool {
if role := m.AclRole(); m.Disabled() || !m.CanLogin || m.ID <= 0 || m.UserName == "" || role == acl.RoleUnauthorized {
// CanLogIn checks if the user is allowed to log in and use the web UI.
func (m *User) CanLogIn() bool {
if !m.CanLogin && !m.SuperAdmin || m.ID <= 0 || m.UserName == "" {
return false
} else if role := m.AclRole(); m.Disabled() || role == acl.RoleUnknown {
return false
} else {
return acl.Resources.Allow(acl.ResourceConfig, role, acl.AccessOwn)
@ -306,18 +329,18 @@ func (m *User) LoginAllowed() bool {
}
// SyncAllowed checks whether the user is allowed to use WebDAV to synchronize files.
func (m *User) SyncAllowed() bool {
if role := m.AclRole(); m.Disabled() || !m.CanSync || m.ID <= 0 || m.UserName == "" || role == acl.RoleUnauthorized {
// CanUseWebDAV checks whether the user is allowed to use WebDAV to synchronize files.
func (m *User) CanUseWebDAV() bool {
if role := m.AclRole(); m.Disabled() || !m.WebDAV || m.ID <= 0 || m.UserName == "" || role == acl.RoleUnknown {
return false
} else {
return acl.Resources.Allow(acl.ResourcePhotos, role, acl.ActionUpload)
}
}
// UploadAllowed checks if the user is allowed to upload files.
func (m *User) UploadAllowed() bool {
if role := m.AclRole(); m.Disabled() || role == acl.RoleUnauthorized {
// CanUpload checks if the user is allowed to upload files.
func (m *User) CanUpload() bool {
if role := m.AclRole(); m.Disabled() || role == acl.RoleUnknown {
return false
} else {
return acl.Resources.Allow(acl.ResourcePhotos, role, acl.ActionUpload)
@ -414,7 +437,7 @@ func (m *User) AclRole() acl.Role {
case m.SuperAdmin:
return acl.RoleAdmin
case role == "":
return acl.RoleUnauthorized
return acl.RoleUnknown
case m.UserName == "":
return acl.RoleVisitor
default:
@ -531,7 +554,11 @@ func (m *User) SetPassword(password string) error {
pw := NewPassword(m.UserUID, password)
return pw.Save()
if err := pw.Save(); err != nil {
return err
}
return m.RegenerateTokens()
}
// HasPassword checks if the user has the specified password and the account is registered.
@ -629,7 +656,7 @@ func (m *User) SetFormValues(frm form.User) *User {
m.DisplayName = frm.DisplayName
m.SuperAdmin = frm.SuperAdmin
m.CanLogin = frm.CanLogin
m.CanSync = frm.CanSync
m.WebDAV = frm.WebDAV
m.UserRole = frm.Role()
m.UserAttr = frm.Attr()
m.SetBasePath(frm.BasePath)
@ -638,6 +665,34 @@ func (m *User) SetFormValues(frm form.User) *User {
return m
}
// GenerateTokens generates preview and download tokens as needed.
func (m *User) GenerateTokens(force bool) *User {
if m.ID < 0 {
return m
}
if m.PreviewToken == "" || force {
m.PreviewToken = GenerateToken()
}
if m.DownloadToken == "" || force {
m.DownloadToken = GenerateToken()
}
return m
}
// RegenerateTokens replaces the existing preview and download tokens.
func (m *User) RegenerateTokens() error {
if m.ID < 0 {
return nil
}
m.GenerateTokens(true)
return m.Updates(Values{"PreviewToken": m.PreviewToken, "DownloadToken": m.DownloadToken})
}
// RefreshShares updates the list of shares.
func (m *User) RefreshShares() *User {
m.UserShares = FindUserShares(m.UID())

View file

@ -31,17 +31,17 @@ func (m *User) SetValuesFromCli(ctx *cli.Context) error {
m.SuperAdmin = frm.SuperAdmin
}
// Disable Web UI?
// Disable login (Web UI)?
if ctx.IsSet("no-login") {
m.CanLogin = frm.CanLogin
}
// Can use WebDAV.
if ctx.IsSet("can-sync") {
m.CanSync = frm.CanSync
// Allow the use of WebDAV?
if ctx.IsSet("webdav") {
m.WebDAV = frm.WebDAV
}
// Custom attributes.
// Set custom attributes?
if ctx.IsSet("attr") {
m.UserAttr = frm.Attr()
}

View file

@ -2,7 +2,7 @@ package entity
import (
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/internal/event"
)
// Role defaults.
@ -10,53 +10,83 @@ const (
AdminUserName = "admin"
AdminDisplayName = "Admin"
VisitorDisplayName = "Visitor"
UnknownDisplayName = "Unknown"
)
// Admin is the default admin user.
var Admin = User{
ID: 1,
UserName: AdminUserName,
UserRole: acl.RoleAdmin.String(),
DisplayName: AdminDisplayName,
SuperAdmin: true,
CanLogin: true,
CanSync: true,
CanInvite: true,
InviteToken: rnd.GenerateToken(8),
ID: 1,
UserName: AdminUserName,
UserRole: acl.RoleAdmin.String(),
DisplayName: AdminDisplayName,
SuperAdmin: true,
CanLogin: true,
WebDAV: true,
CanInvite: true,
InviteToken: GenerateToken(),
PreviewToken: GenerateToken(),
DownloadToken: GenerateToken(),
}
// UnknownUser is an anonymous, public user without own account.
var UnknownUser = User{
ID: -1,
UserUID: "u000000000000001",
UserRole: acl.RoleUnauthorized.String(),
UserName: "",
DisplayName: "",
SuperAdmin: false,
CanLogin: false,
CanSync: false,
CanInvite: false,
InviteToken: "",
ID: -1,
UserUID: "u000000000000001",
UserName: "",
UserRole: acl.RoleUnknown.String(),
CanLogin: false,
WebDAV: false,
CanInvite: false,
DisplayName: UnknownDisplayName,
InviteToken: "",
PreviewToken: "",
DownloadToken: "",
}
// Visitor is a user without own account e.g. for link sharing.
var Visitor = User{
ID: -2,
UserUID: "u000000000000002",
UserRole: acl.RoleVisitor.String(),
UserName: "",
DisplayName: VisitorDisplayName,
SuperAdmin: false,
CanLogin: false,
CanSync: false,
CanInvite: false,
InviteToken: "",
ID: -2,
UserUID: "u000000000000002",
UserName: "",
UserRole: acl.RoleVisitor.String(),
DisplayName: VisitorDisplayName,
CanLogin: false,
WebDAV: false,
CanInvite: false,
InviteToken: "",
PreviewToken: "",
DownloadToken: "",
}
// CreateDefaultUsers initializes the database with default user accounts.
func CreateDefaultUsers() {
if user := FirstOrCreateUser(&Admin); user != nil {
Admin = *user
if admin := FindUser(Admin); admin != nil {
Admin = *admin
} else {
// Set legacy values.
if leg := FindLegacyUser(Admin); leg != nil {
Admin.UserUID = leg.UserUID
Admin.UserName = leg.UserName
Admin.UserEmail = leg.PrimaryEmail
Admin.DisplayName = leg.FullName
Admin.LoginAt = leg.LoginAt
log.Infof("users: migrating %s account", Admin.UserName)
}
// Set default values.
Admin.SuperAdmin = true
Admin.CanLogin = true
Admin.WebDAV = true
// Username is required.
if Admin.UserName == "" {
Admin.UserName = "admin"
}
// Add initial admin account.
if err := Admin.Create(); err != nil {
event.AuditErr([]string{"user", "failed to create", "%s"}, err)
}
}
if user := FirstOrCreateUser(&UnknownUser); user != nil {

View file

@ -2,7 +2,6 @@ package entity
import (
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/pkg/rnd"
)
type UserMap map[string]User
@ -36,9 +35,9 @@ var UserFixtures = UserMap{
UserRole: acl.RoleAdmin.String(),
SuperAdmin: true,
CanLogin: true,
CanSync: true,
WebDAV: true,
CanInvite: true,
InviteToken: rnd.GenerateToken(8),
InviteToken: GenerateToken(),
UserSettings: &UserSettings{
UITheme: "",
MapsStyle: "",
@ -60,7 +59,7 @@ var UserFixtures = UserMap{
UserRole: acl.RoleAdmin.String(),
SuperAdmin: false,
CanLogin: true,
CanSync: true,
WebDAV: true,
CanInvite: false,
UserSettings: &UserSettings{
UITheme: "grayscale",
@ -86,7 +85,7 @@ var UserFixtures = UserMap{
SuperAdmin: false,
DisplayName: "Guy Friend",
CanLogin: true,
CanSync: false,
WebDAV: false,
CanInvite: false,
UserSettings: &UserSettings{
UITheme: "gemstone",
@ -108,7 +107,7 @@ var UserFixtures = UserMap{
SuperAdmin: false,
UserRole: acl.RoleVisitor.String(),
CanLogin: false,
CanSync: true,
WebDAV: true,
CanInvite: false,
DeletedAt: TimePointer(),
UserSettings: &UserSettings{
@ -124,11 +123,11 @@ var UserFixtures = UserMap{
UserUID: "uriku0138hqql4bz",
UserName: "jens.mander",
UserEmail: "jens.mander@microsoft.com",
UserRole: acl.RoleUnauthorized.String(),
UserRole: acl.RoleUnknown.String(),
SuperAdmin: false,
DisplayName: "Jens Mander",
CanLogin: true,
CanSync: true,
WebDAV: true,
CanInvite: false,
UserSettings: &UserSettings{
UITheme: "",
@ -150,9 +149,9 @@ var UserFixtures = UserMap{
UserRole: acl.RoleAdmin.String(),
SuperAdmin: false,
CanLogin: true,
CanSync: true,
WebDAV: true,
CanInvite: true,
InviteToken: rnd.GenerateToken(8),
InviteToken: GenerateToken(),
UserSettings: &UserSettings{
UITheme: "custom",
MapsStyle: "invalid",

View file

@ -39,7 +39,7 @@ func TestUserMap_Pointer(t *testing.T) {
r := UserFixtures.Pointer("monstera")
assert.Equal(t, "", r.UserName)
assert.Equal(t, "", r.Email())
assert.Equal(t, acl.RoleUnauthorized, r.AclRole())
assert.Equal(t, acl.RoleUnknown, r.AclRole())
assert.IsType(t, &User{}, r)
})
}

View file

@ -0,0 +1,38 @@
package entity
import "github.com/photoprism/photoprism/internal/entity/legacy"
// FindLegacyUser returns the matching legacy user or nil if it was not found.
func FindLegacyUser(find User) *legacy.User {
m := &legacy.User{}
// Build query.
stmt := Db()
if find.ID != 0 {
stmt = stmt.Where("id = ?", find.ID)
} else if find.UserUID != "" {
stmt = stmt.Where("user_uid = ?", find.UserUID)
} else if find.UserName != "" {
stmt = stmt.Where("user_name = ?", find.UserName)
} else if find.UserEmail != "" {
stmt = stmt.Where("primary_email = ?", find.UserEmail)
} else {
return nil
}
// Find matching record.
if err := stmt.First(m).Error; err != nil {
return nil
}
return m
}
// FindLegacyUsers finds registered legacy users.
func FindLegacyUsers() legacy.Users {
result := make(legacy.Users, 0, 1)
Db().Where("id > 0").Find(&result)
return result
}

View file

@ -0,0 +1,40 @@
package entity
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/entity/legacy"
)
func TestFindLegacyUser(t *testing.T) {
notFound := FindLegacyUser(Admin)
assert.Nil(t, notFound)
t.Logf("Legacy Admin: %#v", notFound)
if err := Db().AutoMigrate(legacy.User{}).Error; err != nil {
log.Debugf("TestFindLegacyUser: %s (waiting 1s)", err.Error())
time.Sleep(time.Second)
if err = Db().AutoMigrate(legacy.User{}).Error; err != nil {
log.Errorf("TestFindLegacyUser: failed migrating legacy.User")
t.Error(err)
}
}
Db().Save(legacy.Admin)
found := FindLegacyUser(Admin)
assert.NotNil(t, found)
t.Logf("Legacy Admin: %#v", found)
if err := Db().DropTable(legacy.User{}).Error; err != nil {
log.Errorf("TestFindLegacyUser: failed dropping legacy.User")
t.Error(err)
}
}

View file

@ -190,7 +190,7 @@ func TestUser_WrongPassword(t *testing.T) {
}
func TestUser_Save(t *testing.T) {
t.Run("Success", func(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
p := User{}
err := p.Save()
@ -236,6 +236,63 @@ func TestFirstOrCreateUser(t *testing.T) {
})
}
func TestFindUser(t *testing.T) {
t.Run("ID", func(t *testing.T) {
m := FindUser(User{ID: 1})
if m == nil {
t.Fatal("result should not be nil")
}
assert.Equal(t, 1, m.ID)
assert.NotEmpty(t, m.UserUID)
assert.Equal(t, "admin", m.UserName)
assert.NotEmpty(t, m.CreatedAt)
assert.NotEmpty(t, m.UpdatedAt)
})
t.Run("UserUID", func(t *testing.T) {
m := FindUser(User{UserUID: "u000000000000002"})
if m == nil {
t.Fatal("result should not be nil")
}
assert.Equal(t, -2, m.ID)
assert.NotEmpty(t, m.UserUID)
assert.Equal(t, "", m.UserName)
assert.Equal(t, "Visitor", m.DisplayName)
assert.NotEmpty(t, m.CreatedAt)
assert.NotEmpty(t, m.UpdatedAt)
})
t.Run("UserName", func(t *testing.T) {
m := FindUser(User{UserName: "admin"})
if m == nil {
t.Fatal("result should not be nil")
}
assert.Equal(t, 1, m.ID)
assert.NotEmpty(t, m.UserUID)
assert.Equal(t, "admin", m.UserName)
assert.NotEmpty(t, m.CreatedAt)
assert.NotEmpty(t, m.UpdatedAt)
})
t.Run("Unknown", func(t *testing.T) {
m := FindUser(User{})
if m != nil {
t.Fatal("result should be nil")
}
})
t.Run("NotFound", func(t *testing.T) {
m := FindUser(User{UserUID: "xxx"})
if m != nil {
t.Fatal("result should be nil")
}
})
}
func TestFindUserByUID(t *testing.T) {
t.Run("Visitor", func(t *testing.T) {
m := FindUserByUID("u000000000000002")
@ -251,7 +308,6 @@ func TestFindUserByUID(t *testing.T) {
assert.NotEmpty(t, m.CreatedAt)
assert.NotEmpty(t, m.UpdatedAt)
})
t.Run("Unknown", func(t *testing.T) {
m := FindUserByUID("")
@ -259,7 +315,6 @@ func TestFindUserByUID(t *testing.T) {
t.Fatal("result should be nil")
}
})
t.Run("NotFound", func(t *testing.T) {
m := FindUserByUID("xxx")
@ -267,7 +322,6 @@ func TestFindUserByUID(t *testing.T) {
t.Fatal("result should be nil")
}
})
t.Run("Alice", func(t *testing.T) {
m := FindUserByUID("uqxetse3cy5eo9z2")
@ -287,7 +341,6 @@ func TestFindUserByUID(t *testing.T) {
assert.NotEmpty(t, m.CreatedAt)
assert.NotEmpty(t, m.UpdatedAt)
})
t.Run("Bob", func(t *testing.T) {
m := FindUserByUID("uqxc08w3d0ej2283")
@ -323,7 +376,6 @@ func TestFindUserByUID(t *testing.T) {
assert.NotEmpty(t, m.CreatedAt)
assert.NotEmpty(t, m.UpdatedAt)
})
}
func TestUser_String(t *testing.T) {
@ -379,7 +431,7 @@ func TestUser_Guest(t *testing.T) {
}
func TestUser_SetPassword(t *testing.T) {
t.Run("Success", func(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
p := User{UserUID: "u000000000000008", UserName: "Hanna", DisplayName: ""}
if err := p.SetPassword("insecure"); err != nil {
t.Fatal(err)
@ -397,7 +449,7 @@ func TestUser_SetPassword(t *testing.T) {
}
func TestUser_InitLogin(t *testing.T) {
t.Run("Success", func(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
p := User{UserUID: "u000000000000009", UserName: "Hanna", DisplayName: ""}
assert.Nil(t, FindPassword("u000000000000009"))
p.InitAccount("admin", "insecure")
@ -461,7 +513,7 @@ func TestUser_AclRole(t *testing.T) {
})
t.Run("Unauthorized", func(t *testing.T) {
p := User{UserUID: "u000000000000008", UserName: "Hanna", DisplayName: ""}
assert.Equal(t, acl.RoleUnauthorized, p.AclRole())
assert.Equal(t, acl.RoleUnknown, p.AclRole())
assert.False(t, p.IsAdmin())
assert.False(t, p.IsVisitor())
})
@ -615,7 +667,7 @@ func TestAddUser(t *testing.T) {
}
func TestDeleteUser(t *testing.T) {
t.Run("Success", func(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
u := &User{
UserName: "thomasdel",
UserEmail: "thomasdel@example.com",
@ -632,7 +684,7 @@ func TestDeleteUser(t *testing.T) {
UserName: "thomasdel2",
UserEmail: "thomasdel2@example.com",
DisplayName: "Thomas Delete 2",
UserRole: acl.RoleUnauthorized.String(),
UserRole: acl.RoleUnknown.String(),
}
err := u.Delete()
@ -655,21 +707,21 @@ func TestUser_Disabled(t *testing.T) {
assert.True(t, UserFixtures.Pointer("deleted").Disabled())
}
func TestUser_LoginAllowed(t *testing.T) {
assert.True(t, UserFixtures.Pointer("alice").LoginAllowed())
assert.False(t, UserFixtures.Pointer("deleted").LoginAllowed())
func TestUser_CanUseAPI(t *testing.T) {
assert.True(t, UserFixtures.Pointer("alice").CanLogIn())
assert.False(t, UserFixtures.Pointer("deleted").CanLogIn())
}
func TestUser_SyncAllowed(t *testing.T) {
assert.True(t, UserFixtures.Pointer("alice").SyncAllowed())
assert.False(t, UserFixtures.Pointer("deleted").SyncAllowed())
assert.False(t, UserFixtures.Pointer("friend").SyncAllowed())
func TestUser_CanUseWebDAV(t *testing.T) {
assert.True(t, UserFixtures.Pointer("alice").CanUseWebDAV())
assert.False(t, UserFixtures.Pointer("deleted").CanUseWebDAV())
assert.False(t, UserFixtures.Pointer("friend").CanUseWebDAV())
}
func TestUser_UploadAllowed(t *testing.T) {
assert.True(t, UserFixtures.Pointer("alice").UploadAllowed())
assert.False(t, UserFixtures.Pointer("deleted").UploadAllowed())
assert.True(t, UserFixtures.Pointer("friend").UploadAllowed())
func TestUser_CanUpdate(t *testing.T) {
assert.True(t, UserFixtures.Pointer("alice").CanUpload())
assert.False(t, UserFixtures.Pointer("deleted").CanUpload())
assert.True(t, UserFixtures.Pointer("friend").CanUpload())
}
func TestUser_SharedUIDs(t *testing.T) {

View file

@ -1,5 +1,5 @@
/*
Package entity provides models for data storage and business logic based on the GORM library.
Package entity provides entity models based on the GORM library.
Copyright (c) 2018 - 2022 PhotoPrism UG. All rights reserved.

View file

@ -23,7 +23,7 @@ func TestFaces_IDs(t *testing.T) {
}
func TestDeleteOrphanFaces(t *testing.T) {
t.Run("Success", func(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
if count, err := DeleteOrphanFaces(); err != nil {
t.Fatal(err)
} else {

View file

@ -660,7 +660,7 @@ func TestFile_ReplaceHash(t *testing.T) {
}
func TestFile_SetHDR(t *testing.T) {
t.Run("Success", func(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
m := FileFixtures.Get("exampleFileName.jpg")
assert.Equal(t, false, m.IsHDR())

View file

@ -0,0 +1,25 @@
/*
Package legacy provides legacy entity models to be used in migrations.
Copyright (c) 2018 - 2022 PhotoPrism UG. All rights reserved.
This program is free software: you can redistribute it and/or modify
it under Version 3 of the GNU Affero General Public License (the "AGPL"):
<https://docs.photoprism.app/license/agpl>
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
The AGPL is supplemented by our Trademark and Brand Guidelines,
which describe how our Brand Assets may be used:
<https://photoprism.app/trademark>
Feel free to send an email to hello@photoprism.app if you have questions,
want to support our work, or just want to say hello.
Additional information can be found in our Developer Guide:
<https://docs.photoprism.app/developer-guide/>
*/
package legacy

View file

@ -0,0 +1,155 @@
package legacy
import (
"time"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/rnd"
)
type Users []User
// User represents a person that may optionally log in as user.
type User struct {
ID int `gorm:"primary_key" json:"-" yaml:"-"`
AddressID int `gorm:"default:1" json:"-" yaml:"-"`
UserUID string `gorm:"type:VARBINARY(42);unique_index;" json:"UID" yaml:"UID"`
MotherUID string `gorm:"type:VARBINARY(42);" json:"MotherUID" yaml:"MotherUID,omitempty"`
FatherUID string `gorm:"type:VARBINARY(42);" json:"FatherUID" yaml:"FatherUID,omitempty"`
GlobalUID string `gorm:"type:VARBINARY(42);index;" json:"GlobalUID" yaml:"GlobalUID,omitempty"`
FullName string `gorm:"size:128;" json:"FullName" yaml:"FullName,omitempty"`
NickName string `gorm:"size:64;" json:"NickName" yaml:"NickName,omitempty"`
MaidenName string `gorm:"size:64;" json:"MaidenName" yaml:"MaidenName,omitempty"`
ArtistName string `gorm:"size:64;" json:"ArtistName" yaml:"ArtistName,omitempty"`
UserName string `gorm:"size:64;" json:"UserName" yaml:"UserName,omitempty"`
UserStatus string `gorm:"size:32;" json:"UserStatus" yaml:"UserStatus,omitempty"`
UserDisabled bool `json:"UserDisabled" yaml:"UserDisabled,omitempty"`
UserSettings string `gorm:"type:LONGTEXT;" json:"-" yaml:"-"`
PrimaryEmail string `gorm:"size:255;index;" json:"PrimaryEmail" yaml:"PrimaryEmail,omitempty"`
EmailConfirmed bool `json:"EmailConfirmed" yaml:"EmailConfirmed,omitempty"`
BackupEmail string `gorm:"size:255;" json:"BackupEmail" yaml:"BackupEmail,omitempty"`
PersonURL string `gorm:"type:VARBINARY(255);" json:"PersonURL" yaml:"PersonURL,omitempty"`
PersonPhone string `gorm:"size:32;" json:"PersonPhone" yaml:"PersonPhone,omitempty"`
PersonStatus string `gorm:"size:32;" json:"PersonStatus" yaml:"PersonStatus,omitempty"`
PersonAvatar string `gorm:"type:VARBINARY(255);" json:"PersonAvatar" yaml:"PersonAvatar,omitempty"`
PersonLocation string `gorm:"size:128;" json:"PersonLocation" yaml:"PersonLocation,omitempty"`
PersonBio string `gorm:"type:TEXT;" json:"PersonBio" yaml:"PersonBio,omitempty"`
PersonAccounts string `gorm:"type:LONGTEXT;" json:"-" yaml:"-"`
BusinessURL string `gorm:"type:VARBINARY(255);" json:"BusinessURL" yaml:"BusinessURL,omitempty"`
BusinessPhone string `gorm:"size:32;" json:"BusinessPhone" yaml:"BusinessPhone,omitempty"`
BusinessEmail string `gorm:"size:255;" json:"BusinessEmail" yaml:"BusinessEmail,omitempty"`
CompanyName string `gorm:"size:128;" json:"CompanyName" yaml:"CompanyName,omitempty"`
DepartmentName string `gorm:"size:128;" json:"DepartmentName" yaml:"DepartmentName,omitempty"`
JobTitle string `gorm:"size:64;" json:"JobTitle" yaml:"JobTitle,omitempty"`
BirthYear int `json:"BirthYear" yaml:"BirthYear,omitempty"`
BirthMonth int `json:"BirthMonth" yaml:"BirthMonth,omitempty"`
BirthDay int `json:"BirthDay" yaml:"BirthDay,omitempty"`
TermsAccepted bool `json:"TermsAccepted" yaml:"TermsAccepted,omitempty"`
IsArtist bool `json:"IsArtist" yaml:"IsArtist,omitempty"`
IsSubject bool `json:"IsSubject" yaml:"IsSubject,omitempty"`
RoleAdmin bool `json:"RoleAdmin" yaml:"RoleAdmin,omitempty"`
RoleGuest bool `json:"RoleGuest" yaml:"RoleGuest,omitempty"`
RoleChild bool `json:"RoleChild" yaml:"RoleChild,omitempty"`
RoleFamily bool `json:"RoleFamily" yaml:"RoleFamily,omitempty"`
RoleFriend bool `json:"RoleFriend" yaml:"RoleFriend,omitempty"`
WebDAV bool `gorm:"column:webdav" json:"WebDAV" yaml:"WebDAV,omitempty"`
StoragePath string `gorm:"column:storage_path;type:VARBINARY(500);" json:"StoragePath" yaml:"StoragePath,omitempty"`
CanInvite bool `json:"CanInvite" yaml:"CanInvite,omitempty"`
InviteToken string `gorm:"type:VARBINARY(32);" json:"-" yaml:"-"`
InvitedBy string `gorm:"type:VARBINARY(32);" json:"-" yaml:"-"`
ConfirmToken string `gorm:"type:VARBINARY(64);" json:"-" yaml:"-"`
ResetToken string `gorm:"type:VARBINARY(64);" json:"-" yaml:"-"`
ApiToken string `gorm:"column:api_token;type:VARBINARY(128);" json:"-" yaml:"-"`
ApiSecret string `gorm:"column:api_secret;type:VARBINARY(128);" json:"-" yaml:"-"`
LoginAttempts int `json:"-" yaml:"-"`
LoginAt *time.Time `json:"-" yaml:"-"`
CreatedAt time.Time `json:"CreatedAt" yaml:"-"`
UpdatedAt time.Time `json:"UpdatedAt" yaml:"-"`
DeletedAt *time.Time `sql:"index" json:"DeletedAt,omitempty" yaml:"-"`
}
// TableName returns the entity database table name.
func (User) TableName() string {
return "users"
}
// Admin is the default admin user.
var Admin = User{
ID: 1,
AddressID: 1,
UserName: "admin",
FullName: "Admin",
RoleAdmin: true,
UserDisabled: false,
}
// UnknownUser is an anonymous, public user without own account.
var UnknownUser = User{
ID: -1,
AddressID: 1,
UserUID: "u000000000000001",
UserName: "",
FullName: "Anonymous",
RoleAdmin: false,
RoleGuest: false,
UserDisabled: true,
}
// Guest is a user without own account e.g. for link sharing.
var Guest = User{
ID: -2,
AddressID: 1,
UserUID: "u000000000000002",
UserName: "",
FullName: "Guest",
RoleAdmin: false,
RoleGuest: true,
UserDisabled: true,
}
// Deleted tests if the entity is marked as deleted.
func (m *User) Deleted() bool {
if m.DeletedAt == nil {
return false
}
return !m.DeletedAt.IsZero()
}
// String returns an identifier that can be used in logs.
func (m *User) String() string {
if n := m.Username(); n != "" {
return clean.Log(n)
}
if m.FullName != "" {
return clean.Log(m.FullName)
}
return clean.Log(m.UserUID)
}
// Username returns the normalized username.
func (m *User) Username() string {
return clean.Username(m.UserName)
}
// Registered tests if the user is registered e.g. has a username.
func (m *User) Registered() bool {
return m.Username() != "" && rnd.IsUID(m.UserUID, 'u')
}
// Admin returns true if the user is an admin with user name.
func (m *User) Admin() bool {
return m.Registered() && m.RoleAdmin
}
// Anonymous returns true if the user is unknown.
func (m *User) Anonymous() bool {
return !rnd.IsUID(m.UserUID, 'u') || m.ID == UnknownUser.ID || m.UserUID == UnknownUser.UserUID
}
// Guest returns true if the user is a guest.
func (m *User) Guest() bool {
return m.RoleGuest
}

View file

@ -165,7 +165,7 @@ func TestMarkers_Labels(t *testing.T) {
}
func TestMarkers_AppendWithEmbedding(t *testing.T) {
t.Run("Success", func(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
m1 := *NewMarker(FileFixtures.Get("exampleFileName.jpg"), cropArea1, "lt9k3pw1wowuy1c1", SrcImage, MarkerFace, 100, 65)
m2 := *NewMarker(FileFixtures.Get("exampleFileName.jpg"), cropArea3, "lt9k3pw1wowuy1c3", SrcImage, MarkerFace, 100, 65)

View file

@ -11,7 +11,7 @@ import (
)
func TestSavePhotoForm(t *testing.T) {
t.Run("Success", func(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
f := form.Photo{
TakenAt: time.Date(2008, 1, 1, 2, 0, 0, 0, time.UTC),
TakenAtLocal: time.Date(2008, 1, 1, 2, 0, 0, 0, time.UTC),
@ -143,7 +143,7 @@ func TestPhoto_ClassifyLabels(t *testing.T) {
}
func TestPhoto_PreloadFiles(t *testing.T) {
t.Run("Success", func(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
m := PhotoFixtures.Get("Photo01")
assert.Empty(t, m.Files)
m.PreloadFiles()
@ -152,7 +152,7 @@ func TestPhoto_PreloadFiles(t *testing.T) {
}
func TestPhoto_PreloadKeywords(t *testing.T) {
t.Run("Success", func(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
m := PhotoFixtures.Get("Photo01")
assert.Empty(t, m.Keywords)
m.PreloadKeywords()
@ -161,7 +161,7 @@ func TestPhoto_PreloadKeywords(t *testing.T) {
}
func TestPhoto_PreloadAlbums(t *testing.T) {
t.Run("Success", func(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
m := PhotoFixtures.Get("Photo01")
assert.Empty(t, m.Albums)
m.PreloadAlbums()
@ -170,7 +170,7 @@ func TestPhoto_PreloadAlbums(t *testing.T) {
}
func TestPhoto_PreloadMany(t *testing.T) {
t.Run("Success", func(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
m := PhotoFixtures.Get("Photo01")
assert.Empty(t, m.Albums)
assert.Empty(t, m.Files)
@ -266,7 +266,7 @@ func TestPhoto_SetDescription(t *testing.T) {
m.SetDescription("new photo description", SrcName)
assert.Equal(t, "photo description blacklist", m.PhotoDescription)
})
t.Run("Success", func(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
m := PhotoFixtures.Get("Photo15")
assert.Equal(t, "photo description blacklist", m.PhotoDescription)
m.SetDescription("new photo description", SrcMeta)
@ -294,7 +294,7 @@ func TestPhoto_Delete(t *testing.T) {
}
func TestPhotos_UIDs(t *testing.T) {
t.Run("Success", func(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
photo1 := Photo{PhotoUID: "abc123"}
photo2 := Photo{PhotoUID: "abc456"}
photos := Photos{photo1, photo2}
@ -314,7 +314,7 @@ func TestPhoto_String(t *testing.T) {
}
func TestPhoto_Create(t *testing.T) {
t.Run("Success", func(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
photo := Photo{PhotoUID: "567", PhotoName: "Holiday", OriginalName: "holidayOriginal2"}
err := photo.Create()
if err != nil {
@ -324,7 +324,7 @@ func TestPhoto_Create(t *testing.T) {
}
func TestPhoto_Save(t *testing.T) {
t.Run("Success", func(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
photo := Photo{PhotoUID: "567", PhotoName: "Holiday", OriginalName: "holidayOriginal2"}
err := photo.Save()
if err != nil {
@ -370,7 +370,7 @@ func TestFindPhoto(t *testing.T) {
}
func TestPhoto_RemoveKeyword(t *testing.T) {
t.Run("Success", func(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
keyword := Keyword{Keyword: "snake"}
keyword2 := Keyword{Keyword: "otter"}
keywords := []Keyword{keyword, keyword2}
@ -389,7 +389,7 @@ func TestPhoto_RemoveKeyword(t *testing.T) {
}
func TestPhoto_SyncKeywordLabels(t *testing.T) {
t.Run("Success", func(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
keyword := Keyword{Keyword: "snake"}
keyword2 := Keyword{Keyword: "otter"}
keywords := []Keyword{keyword, keyword2}
@ -430,7 +430,7 @@ func TestPhoto_LocationLoaded(t *testing.T) {
}
func TestPhoto_LoadLocation(t *testing.T) {
t.Run("Success", func(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
photo := PhotoFixtures.Get("Photo03")
if err := photo.LoadLocation(); err != nil {
t.Fatal(err)
@ -456,7 +456,7 @@ func TestPhoto_PlaceLoaded(t *testing.T) {
}
func TestPhoto_LoadPlace(t *testing.T) {
t.Run("Success", func(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
photo := PhotoFixtures.Get("Photo03")
err := photo.LoadPlace()
if err != nil {
@ -500,7 +500,7 @@ func TestPhoto_AllFilesMissing(t *testing.T) {
}
func TestPhoto_Updates(t *testing.T) {
t.Run("Success", func(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
photo := Photo{PhotoDescription: "bcss", PhotoName: "InitialName"}
if err := photo.Save(); err != nil {
@ -604,7 +604,7 @@ func TestPhoto_Links(t *testing.T) {
}
func TestPhoto_SetPrimary(t *testing.T) {
t.Run("Success", func(t *testing.T) {
t.Run("Ok", func(t *testing.T) {
m := PhotoFixtures.Get("19800101_000002_D640C559")
if err := m.SetPrimary(""); err != nil {

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