From 884dea17defa68bc466fa7747d726bafee1d7863 Mon Sep 17 00:00:00 2001 From: Michael Mayer Date: Thu, 13 Oct 2022 22:11:02 +0200 Subject: [PATCH] Security: Use individual preview tokens for each user account #98 Signed-off-by: Michael Mayer --- Makefile | 18 +- docker-compose.latest.yml | 6 +- frontend/package-lock.json | 128 ++++++------ frontend/src/common/api.js | 6 +- frontend/src/common/config.js | 19 +- frontend/src/component/album/clipboard.vue | 2 +- frontend/src/component/album/toolbar.vue | 2 +- frontend/src/component/file/clipboard.vue | 2 +- frontend/src/component/label/clipboard.vue | 2 +- frontend/src/component/photo/cards.vue | 2 +- frontend/src/component/photo/clipboard.vue | 2 +- frontend/src/component/photo/list.vue | 2 +- frontend/src/component/subject/clipboard.vue | 2 +- frontend/src/model/album.js | 4 +- frontend/src/model/face.js | 2 +- frontend/src/model/file.js | 4 +- frontend/src/model/folder.js | 2 +- frontend/src/model/label.js | 4 +- frontend/src/model/marker.js | 2 +- frontend/src/model/photo.js | 10 +- frontend/src/model/subject.js | 2 +- frontend/src/model/thumb.js | 4 +- frontend/src/model/user.js | 4 +- frontend/src/pages/library/files.vue | 2 +- frontend/src/pages/places.vue | 2 +- internal/acl/role.go | 4 - internal/acl/roles.go | 15 +- internal/api/albums_search.go | 2 +- internal/api/albums_search_test.go | 4 +- internal/api/api_client_config.go | 6 +- internal/api/api_ws.go | 12 +- ...th_change_password.go => auth_password.go} | 17 +- ...password_test.go => auth_password_test.go} | 2 +- internal/api/auth_session_create.go | 32 +-- internal/api/auth_session_get.go | 23 +-- internal/api/auth_session_test.go | 2 +- internal/api/auth_share_test.go | 4 +- internal/api/auth_tokens.go | 6 +- internal/api/config_settings_test.go | 4 +- internal/api/covers_test.go | 18 +- internal/api/download_file_test.go | 15 +- internal/api/faces_search.go | 2 +- internal/api/faces_search_test.go | 2 +- internal/api/faces_test.go | 4 +- internal/api/folders_cover_test.go | 14 +- internal/api/folders_search.go | 2 +- internal/api/headers.go | 12 +- internal/api/labels_search.go | 2 +- internal/api/photos_search.go | 6 +- internal/api/photos_search_geo.go | 4 +- internal/api/photos_search_test.go | 2 +- internal/api/photos_test.go | 41 ++-- internal/api/share_preview_test.go | 14 +- internal/api/subjects_search.go | 2 +- internal/api/subjects_search_test.go | 2 +- internal/api/subjects_test.go | 2 +- internal/api/thumbnails_test.go | 37 ++-- internal/api/video_test.go | 21 +- internal/commands/backup.go | 2 +- internal/commands/convert.go | 2 +- internal/commands/copy.go | 2 +- internal/commands/import.go | 2 +- internal/commands/migrations.go | 2 +- internal/commands/reset.go | 2 +- internal/commands/users.go | 7 +- internal/commands/users_legacy.go | 53 +++++ internal/commands/users_list.go | 4 +- internal/commands/users_mod.go | 30 +-- internal/commands/users_remove.go | 32 ++- internal/commands/users_show.go | 24 ++- internal/config/client_assets_test.go | 2 +- internal/config/client_config.go | 32 ++- internal/config/client_config_test.go | 45 ++--- internal/config/config.go | 5 + internal/config/config_auth.go | 32 +-- internal/config/config_db.go | 1 + internal/config/options.go | 2 +- internal/config/test.go | 14 +- internal/customize/customize.go | 25 +++ internal/entity/album_cache_test.go | 2 +- internal/entity/auth_session.go | 72 ++++++- internal/entity/auth_session_cache.go | 46 ++++- internal/entity/auth_session_cache_test.go | 2 +- internal/entity/auth_session_login.go | 8 +- internal/entity/auth_tokens.go | 27 +++ internal/entity/auth_user.go | 185 ++++++++++++------ internal/entity/auth_user_cli.go | 10 +- internal/entity/auth_user_default.go | 94 ++++++--- internal/entity/auth_user_fixtures.go | 19 +- internal/entity/auth_user_fixtures_test.go | 2 +- internal/entity/auth_user_legacy.go | 38 ++++ internal/entity/auth_user_legacy_test.go | 40 ++++ internal/entity/auth_user_test.go | 96 ++++++--- internal/entity/entity.go | 2 +- internal/entity/faces_test.go | 2 +- internal/entity/file_test.go | 2 +- internal/entity/legacy/legacy.go | 25 +++ internal/entity/legacy/user.go | 155 +++++++++++++++ internal/entity/markers_test.go | 2 +- internal/entity/photo_test.go | 30 +-- internal/entity/string_map.go | 78 ++++++-- internal/entity/string_map_test.go | 37 +++- internal/entity/subject_test.go | 2 +- internal/entity/subjects_test.go | 2 +- internal/face/embedding_test.go | 2 +- internal/face/embeddings_test.go | 2 +- internal/form/user.go | 4 +- internal/migrate/dialect_mysql.go | 5 - internal/migrate/dialect_sqlite3.go | 5 - internal/migrate/mysql/20220927-000200.sql | 1 - internal/migrate/sqlite3/20220927-000200.sql | 1 - internal/query/moments_test.go | 2 +- internal/query/users.go | 2 +- internal/server/basicauth.go | 2 +- pkg/list/add.go | 14 ++ pkg/list/add_test.go | 19 ++ pkg/list/remove.go | 21 ++ pkg/list/remove_test.go | 19 ++ pkg/report/bool.go | 8 +- 119 files changed, 1394 insertions(+), 581 deletions(-) rename internal/api/{auth_change_password.go => auth_password.go} (86%) rename internal/api/{auth_change_password_test.go => auth_password_test.go} (99%) create mode 100644 internal/commands/users_legacy.go create mode 100644 internal/customize/customize.go create mode 100644 internal/entity/auth_tokens.go create mode 100644 internal/entity/auth_user_legacy.go create mode 100644 internal/entity/auth_user_legacy_test.go create mode 100644 internal/entity/legacy/legacy.go create mode 100644 internal/entity/legacy/user.go delete mode 100644 internal/migrate/mysql/20220927-000200.sql delete mode 100644 internal/migrate/sqlite3/20220927-000200.sql create mode 100644 pkg/list/add.go create mode 100644 pkg/list/add_test.go create mode 100644 pkg/list/remove.go create mode 100644 pkg/list/remove_test.go diff --git a/Makefile b/Makefile index d660b6563..428c893eb 100644 --- a/Makefile +++ b/Makefile @@ -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: diff --git a/docker-compose.latest.yml b/docker-compose.latest.yml index aa2bc2f8b..dde8be54b 100644 --- a/docker-compose.latest.yml +++ b/docker-compose.latest.yml @@ -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 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 7139131a4..2d0e43dbf 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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" diff --git a/frontend/src/common/api.js b/frontend/src/common/api.js index 76e20cb0c..722d3bb9f 100644 --- a/frontend/src/common/api.js +++ b/frontend/src/common/api.js @@ -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 } }); } } diff --git a/frontend/src/common/config.js b/frontend/src/common/config.js index cc25b5ed9..cf91acf86 100644 --- a/frontend/src/common/config.js +++ b/frontend/src/common/config.js @@ -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() { diff --git a/frontend/src/component/album/clipboard.vue b/frontend/src/component/album/clipboard.vue index 55662e17b..e17f088e1 100644 --- a/frontend/src/component/album/clipboard.vue +++ b/frontend/src/component/album/clipboard.vue @@ -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; }, diff --git a/frontend/src/component/album/toolbar.vue b/frontend/src/component/album/toolbar.vue index edc67d831..7e1c855b8 100644 --- a/frontend/src/component/album/toolbar.vue +++ b/frontend/src/component/album/toolbar.vue @@ -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…")); diff --git a/frontend/src/component/file/clipboard.vue b/frontend/src/component/file/clipboard.vue index 9e6911b67..29300cb33 100644 --- a/frontend/src/component/file/clipboard.vue +++ b/frontend/src/component/file/clipboard.vue @@ -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; diff --git a/frontend/src/component/label/clipboard.vue b/frontend/src/component/label/clipboard.vue index 206113519..080762b75 100644 --- a/frontend/src/component/label/clipboard.vue +++ b/frontend/src/component/label/clipboard.vue @@ -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; }, diff --git a/frontend/src/component/photo/cards.vue b/frontend/src/component/photo/cards.vue index 2ed41872a..c0160ba11 100644 --- a/frontend/src/component/photo/cards.vue +++ b/frontend/src/component/photo/cards.vue @@ -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); diff --git a/frontend/src/component/photo/clipboard.vue b/frontend/src/component/photo/clipboard.vue index 906857e71..1cdfed2d4 100644 --- a/frontend/src/component/photo/clipboard.vue +++ b/frontend/src/component/photo/clipboard.vue @@ -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; diff --git a/frontend/src/component/photo/list.vue b/frontend/src/component/photo/list.vue index 7d300e0b1..78f53d7a4 100644 --- a/frontend/src/component/photo/list.vue +++ b/frontend/src/component/photo/list.vue @@ -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) { diff --git a/frontend/src/component/subject/clipboard.vue b/frontend/src/component/subject/clipboard.vue index e329fa209..86d471bea 100644 --- a/frontend/src/component/subject/clipboard.vue +++ b/frontend/src/component/subject/clipboard.vue @@ -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; diff --git a/frontend/src/model/album.js b/frontend/src/model/album.js index c8c4174aa..923eb4a52 100644 --- a/frontend/src/model/album.js +++ b/frontend/src/model/album.js @@ -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`; } diff --git a/frontend/src/model/face.js b/frontend/src/model/face.js index cdcba6698..8d6b42f27 100644 --- a/frontend/src/model/face.js +++ b/frontend/src/model/face.js @@ -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`; } diff --git a/frontend/src/model/file.js b/frontend/src/model/file.js index e69f3870d..ff18abeb5 100644 --- a/frontend/src/model/file.js +++ b/frontend/src/model/file.js @@ -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() { diff --git a/frontend/src/model/folder.js b/frontend/src/model/folder.js index b08b6fe69..eb8976365 100644 --- a/frontend/src/model/folder.js +++ b/frontend/src/model/folder.js @@ -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() { diff --git a/frontend/src/model/label.js b/frontend/src/model/label.js index cb0ccaa3a..0247ed874 100644 --- a/frontend/src/model/label.js +++ b/frontend/src/model/label.js @@ -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`; } diff --git a/frontend/src/model/marker.js b/frontend/src/model/marker.js index b4c410d0b..97e64e7cf 100644 --- a/frontend/src/model/marker.js +++ b/frontend/src/model/marker.js @@ -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`; } diff --git a/frontend/src/model/photo.js b/frontend/src/model/photo.js index 478acf741..0949f4f63 100644 --- a/frontend/src/model/photo.js +++ b/frontend/src/model/photo.js @@ -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(); diff --git a/frontend/src/model/subject.js b/frontend/src/model/subject.js index 724b04c5f..ace94ba2a 100644 --- a/frontend/src/model/subject.js +++ b/frontend/src/model/subject.js @@ -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() { diff --git a/frontend/src/model/thumb.js b/frontend/src/model/thumb.js index 37f8fee86..e0cf7828f 100644 --- a/frontend/src/model/thumb.js +++ b/frontend/src/model/thumb.js @@ -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}`; } } diff --git a/frontend/src/model/user.js b/frontend/src/model/user.js index 4e25bce1a..b952c18c6 100644 --- a/frontend/src/model/user.js +++ b/frontend/src/model/user.js @@ -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: { diff --git a/frontend/src/pages/library/files.vue b/frontend/src/pages/library/files.vue index f890439fa..b5c55c432 100644 --- a/frontend/src/pages/library/files.vue +++ b/frontend/src/pages/library/files.vue @@ -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)) { diff --git a/frontend/src/pages/places.vue b/frontend/src/pages/places.vue index d6e92c557..fd6c7244b 100644 --- a/frontend/src/pages/places.vue +++ b/frontend/src/pages/places.vue @@ -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'; diff --git a/internal/acl/role.go b/internal/acl/role.go index 98d3af9a4..991a8076e 100644 --- a/internal/acl/role.go +++ b/internal/acl/role.go @@ -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) } diff --git a/internal/acl/roles.go b/internal/acl/roles.go index b18ddb49b..ba0a3c424 100644 --- a/internal/acl/roles.go +++ b/internal/acl/roles.go @@ -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. diff --git a/internal/api/albums_search.go b/internal/api/albums_search.go index 5072283c8..43b158088 100644 --- a/internal/api/albums_search.go +++ b/internal/api/albums_search.go @@ -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) diff --git a/internal/api/albums_search_test.go b/internal/api/albums_search_test.go index c99582790..b972b20ff 100644 --- a/internal/api/albums_search_test.go +++ b/internal/api/albums_search_test.go @@ -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") diff --git a/internal/api/api_client_config.go b/internal/api/api_client_config.go index 84262b831..1de3f00f5 100644 --- a/internal/api/api_client_config.go +++ b/internal/api/api_client_config.go @@ -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)) } }) } diff --git a/internal/api/api_ws.go b/internal/api/api_ws.go index 7e0ea6a04..26b0e3adc 100644 --- a/internal/api/api_ws.go +++ b/internal/api/api_ws.go @@ -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) } } } diff --git a/internal/api/auth_change_password.go b/internal/api/auth_password.go similarity index 86% rename from internal/api/auth_change_password.go rename to internal/api/auth_password.go index 396dc4305..980ce6d21 100644 --- a/internal/api/auth_change_password.go +++ b/internal/api/auth_password.go @@ -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)) }) } diff --git a/internal/api/auth_change_password_test.go b/internal/api/auth_password_test.go similarity index 99% rename from internal/api/auth_change_password_test.go rename to internal/api/auth_password_test.go index 503dc43b9..529ccf884 100644 --- a/internal/api/auth_change_password_test.go +++ b/internal/api/auth_password_test.go @@ -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) diff --git a/internal/api/auth_session_create.go b/internal/api/auth_session_create.go index adc23a485..adf27fc5d 100644 --- a/internal/api/auth_session_create.go +++ b/internal/api/auth_session_create.go @@ -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) }) } diff --git a/internal/api/auth_session_get.go b/internal/api/auth_session_get.go index f544daa62..dce6a4fe9 100644 --- a/internal/api/auth_session_get.go +++ b/internal/api/auth_session_get.go @@ -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) }) } diff --git a/internal/api/auth_session_test.go b/internal/api/auth_session_test.go index d2797074e..61f7a6c6f 100644 --- a/internal/api/auth_session_test.go +++ b/internal/api/auth_session_test.go @@ -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) diff --git a/internal/api/auth_share_test.go b/internal/api/auth_share_test.go index 332a54949..902e17b47 100644 --- a/internal/api/auth_share_test.go +++ b/internal/api/auth_share_test.go @@ -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") diff --git a/internal/api/auth_tokens.go b/internal/api/auth_tokens.go index 5ffadea04..f75958742 100644 --- a/internal/api/auth_tokens.go +++ b/internal/api/auth_tokens.go @@ -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"))) } diff --git a/internal/api/config_settings_test.go b/internal/api/config_settings_test.go index ce8349080..c7c4a12cb 100644 --- a/internal/api/config_settings_test.go +++ b/internal/api/config_settings_test.go @@ -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") diff --git a/internal/api/covers_test.go b/internal/api/covers_test.go index 82561d50f..84fc5fef4 100644 --- a/internal/api/covers_test.go +++ b/internal/api/covers_test.go @@ -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) diff --git a/internal/api/download_file_test.go b/internal/api/download_file_test.go index 6c2e9205e..ae03f6123 100644 --- a/internal/api/download_file_test.go +++ b/internal/api/download_file_test.go @@ -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) diff --git a/internal/api/faces_search.go b/internal/api/faces_search.go index fcb4cde7d..44908bc01 100644 --- a/internal/api/faces_search.go +++ b/internal/api/faces_search.go @@ -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) }) diff --git a/internal/api/faces_search_test.go b/internal/api/faces_search_test.go index 77b8a01be..6d8183268 100644 --- a/internal/api/faces_search_test.go +++ b/internal/api/faces_search_test.go @@ -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") diff --git a/internal/api/faces_test.go b/internal/api/faces_test.go index d2d16fade..c67800413 100644 --- a/internal/api/faces_test.go +++ b/internal/api/faces_test.go @@ -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"}`) diff --git a/internal/api/folders_cover_test.go b/internal/api/folders_cover_test.go index d368698c8..d95496e6c 100644 --- a/internal/api/folders_cover_test.go +++ b/internal/api/folders_cover_test.go @@ -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") diff --git a/internal/api/folders_search.go b/internal/api/folders_search.go index a0bd25f2c..ce19b0744 100644 --- a/internal/api/folders_search.go +++ b/internal/api/folders_search.go @@ -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) } diff --git a/internal/api/headers.go b/internal/api/headers.go index c87b6ba92..885dac375 100644 --- a/internal/api/headers.go +++ b/internal/api/headers.go @@ -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) + } } diff --git a/internal/api/labels_search.go b/internal/api/labels_search.go index 6c6be2ca9..5abda4962 100644 --- a/internal/api/labels_search.go +++ b/internal/api/labels_search.go @@ -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) }) diff --git a/internal/api/photos_search.go b/internal/api/photos_search.go index 5acf4f267..ee9c02a08 100644 --- a/internal/api/photos_search.go +++ b/internal/api/photos_search.go @@ -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) diff --git a/internal/api/photos_search_geo.go b/internal/api/photos_search_geo.go index b91ccfeaa..0534f22f5 100644 --- a/internal/api/photos_search_geo.go +++ b/internal/api/photos_search_geo.go @@ -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() } diff --git a/internal/api/photos_search_test.go b/internal/api/photos_search_test.go index b8bebe46c..d7c43d991 100644 --- a/internal/api/photos_search_test.go +++ b/internal/api/photos_search_test.go @@ -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") diff --git a/internal/api/photos_test.go b/internal/api/photos_test.go index ee3df3f94..2ccc58f95 100644 --- a/internal/api/photos_test.go +++ b/internal/api/photos_test.go @@ -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") diff --git a/internal/api/share_preview_test.go b/internal/api/share_preview_test.go index 229dc11c4..899efa736 100644 --- a/internal/api/share_preview_test.go +++ b/internal/api/share_preview_test.go @@ -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) diff --git a/internal/api/subjects_search.go b/internal/api/subjects_search.go index 318c535fa..93d923241 100644 --- a/internal/api/subjects_search.go +++ b/internal/api/subjects_search.go @@ -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) }) diff --git a/internal/api/subjects_search_test.go b/internal/api/subjects_search_test.go index 01d2fea35..4f3f020f5 100644 --- a/internal/api/subjects_search_test.go +++ b/internal/api/subjects_search_test.go @@ -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") diff --git a/internal/api/subjects_test.go b/internal/api/subjects_test.go index b88172c0b..a8173f515 100644 --- a/internal/api/subjects_test.go +++ b/internal/api/subjects_test.go @@ -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") diff --git a/internal/api/thumbnails_test.go b/internal/api/thumbnails_test.go index 8f6108cf5..e6225d36f 100644 --- a/internal/api/thumbnails_test.go +++ b/internal/api/thumbnails_test.go @@ -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) - }) } diff --git a/internal/api/video_test.go b/internal/api/video_test.go index c1a8070e8..8f1751ac1 100644 --- a/internal/api/video_test.go +++ b/internal/api/video_test.go @@ -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") diff --git a/internal/commands/backup.go b/internal/commands/backup.go index e35687ddf..625ff0315 100644 --- a/internal/commands/backup.go +++ b/internal/commands/backup.go @@ -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 } diff --git a/internal/commands/convert.go b/internal/commands/convert.go index e3053a0cf..0d22cc871 100644 --- a/internal/commands/convert.go +++ b/internal/commands/convert.go @@ -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 } diff --git a/internal/commands/copy.go b/internal/commands/copy.go index a3ebc463f..1c325e784 100644 --- a/internal/commands/copy.go +++ b/internal/commands/copy.go @@ -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 } diff --git a/internal/commands/import.go b/internal/commands/import.go index 17e923954..4d834c138 100644 --- a/internal/commands/import.go +++ b/internal/commands/import.go @@ -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 } diff --git a/internal/commands/migrations.go b/internal/commands/migrations.go index 1117df90b..2d477f3f1 100644 --- a/internal/commands/migrations.go +++ b/internal/commands/migrations.go @@ -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 } diff --git a/internal/commands/reset.go b/internal/commands/reset.go index 26d4f190d..bf1e963a7 100644 --- a/internal/commands/reset.go +++ b/internal/commands/reset.go @@ -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. diff --git a/internal/commands/users.go b/internal/commands/users.go index b5f331b73..793d40ae7 100644 --- a/internal/commands/users.go +++ b/internal/commands/users.go @@ -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, }, } diff --git a/internal/commands/users_legacy.go b/internal/commands/users_legacy.go new file mode 100644 index 000000000..e6dfdfd5a --- /dev/null +++ b/internal/commands/users_legacy.go @@ -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 + }) +} diff --git a/internal/commands/users_list.go b/internal/commands/users_list.go index f52c8d98e..37d327ddb 100644 --- a/internal/commands/users_list.go +++ b/internal/commands/users_list.go @@ -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(), } } diff --git a/internal/commands/users_mod.go b/internal/commands/users_mod.go index fc1fab9e5..3aa8cd86c 100644 --- a/internal/commands/users_mod.go +++ b/internal/commands/users_mod.go @@ -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 }) diff --git a/internal/commands/users_remove.go b/internal/commands/users_remove.go index 0d354096e..32e4ac4a3 100644 --- a/internal/commands/users_remove.go +++ b/internal/commands/users_remove.go @@ -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 diff --git a/internal/commands/users_show.go b/internal/commands/users_show.go index 5be388b74..d0f463535 100644 --- a/internal/commands/users_show.go +++ b/internal/commands/users_show.go @@ -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) diff --git a/internal/config/client_assets_test.go b/internal/config/client_assets_test.go index e429b882a..b1e235271 100644 --- a/internal/config/client_assets_test.go +++ b/internal/config/client_assets_test.go @@ -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") diff --git a/internal/config/client_config.go b/internal/config/client_config.go index 67d13180b..eb29301e2 100644 --- a/internal/config/client_config.go +++ b/internal/config/client_config.go @@ -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 } diff --git a/internal/config/client_config_test.go b/internal/config/client_config_test.go index c1e9d3433..c16f583e2 100644 --- a/internal/config/client_config_test.go +++ b/internal/config/client_config_test.go @@ -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) diff --git a/internal/config/config.go b/internal/config/config.go index 2ff68cc0c..ee400192c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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() diff --git a/internal/config/config_auth.go b/internal/config/config_auth.go index e80ea0e9e..eeb83b3ee 100644 --- a/internal/config/config_auth.go +++ b/internal/config/config_auth.go @@ -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) +} diff --git a/internal/config/config_db.go b/internal/config/config_db.go index fcf6bbfad..bff0a060f 100644 --- a/internal/config/config_db.go +++ b/internal/config/config_db.go @@ -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? diff --git a/internal/config/options.go b/internal/config/options.go index 7f42ec8a5..d469a4503 100644 --- a/internal/config/options.go +++ b/internal/config/options.go @@ -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"` diff --git a/internal/config/test.go b/internal/config/test.go index d4c3fe98d..2a9277cc9 100644 --- a/internal/config/test.go +++ b/internal/config/test.go @@ -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, "") diff --git a/internal/customize/customize.go b/internal/customize/customize.go new file mode 100644 index 000000000..4677e2ddb --- /dev/null +++ b/internal/customize/customize.go @@ -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"): + + + 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: + + +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: + +*/ +package customize diff --git a/internal/entity/album_cache_test.go b/internal/entity/album_cache_test.go index 2e911d87e..8d9cd4fbc 100644 --- a/internal/entity/album_cache_test.go +++ b/internal/entity/album_cache_test.go @@ -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() }) } diff --git a/internal/entity/auth_session.go b/internal/entity/auth_session.go index 01624a5c3..6faa2a94f 100644 --- a/internal/entity/auth_session.go +++ b/internal/entity/auth_session.go @@ -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 diff --git a/internal/entity/auth_session_cache.go b/internal/entity/auth_session_cache.go index 75f3ab6f0..32d3d25c1 100644 --- a/internal/entity/auth_session_cache.go +++ b/internal/entity/auth_session_cache.go @@ -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 == "" { diff --git a/internal/entity/auth_session_cache_test.go b/internal/entity/auth_session_cache_test.go index 0881d2deb..31da82ace 100644 --- a/internal/entity/auth_session_cache_test.go +++ b/internal/entity/auth_session_cache_test.go @@ -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() }) } diff --git a/internal/entity/auth_session_login.go b/internal/entity/auth_session_login.go index e1f30ad84..f1fa613ee 100644 --- a/internal/entity/auth_session_login.go +++ b/internal/entity/auth_session_login.go @@ -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) diff --git a/internal/entity/auth_tokens.go b/internal/entity/auth_tokens.go new file mode 100644 index 000000000..669a6bcd1 --- /dev/null +++ b/internal/entity/auth_tokens.go @@ -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) +} diff --git a/internal/entity/auth_user.go b/internal/entity/auth_user.go index 966656a06..23a57758b 100644 --- a/internal/entity/auth_user.go +++ b/internal/entity/auth_user.go @@ -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()) diff --git a/internal/entity/auth_user_cli.go b/internal/entity/auth_user_cli.go index f78497457..f0e42cb85 100644 --- a/internal/entity/auth_user_cli.go +++ b/internal/entity/auth_user_cli.go @@ -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() } diff --git a/internal/entity/auth_user_default.go b/internal/entity/auth_user_default.go index 00053006e..2cbfb003a 100644 --- a/internal/entity/auth_user_default.go +++ b/internal/entity/auth_user_default.go @@ -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 { diff --git a/internal/entity/auth_user_fixtures.go b/internal/entity/auth_user_fixtures.go index bef758abf..5ae5bcedc 100644 --- a/internal/entity/auth_user_fixtures.go +++ b/internal/entity/auth_user_fixtures.go @@ -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", diff --git a/internal/entity/auth_user_fixtures_test.go b/internal/entity/auth_user_fixtures_test.go index cab0c8564..4ae64a327 100644 --- a/internal/entity/auth_user_fixtures_test.go +++ b/internal/entity/auth_user_fixtures_test.go @@ -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) }) } diff --git a/internal/entity/auth_user_legacy.go b/internal/entity/auth_user_legacy.go new file mode 100644 index 000000000..6b4c51b70 --- /dev/null +++ b/internal/entity/auth_user_legacy.go @@ -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 +} diff --git a/internal/entity/auth_user_legacy_test.go b/internal/entity/auth_user_legacy_test.go new file mode 100644 index 000000000..09f670b07 --- /dev/null +++ b/internal/entity/auth_user_legacy_test.go @@ -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) + } +} diff --git a/internal/entity/auth_user_test.go b/internal/entity/auth_user_test.go index 7688df39d..db8d07ac5 100644 --- a/internal/entity/auth_user_test.go +++ b/internal/entity/auth_user_test.go @@ -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) { diff --git a/internal/entity/entity.go b/internal/entity/entity.go index b61e51d04..15ec25620 100644 --- a/internal/entity/entity.go +++ b/internal/entity/entity.go @@ -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. diff --git a/internal/entity/faces_test.go b/internal/entity/faces_test.go index f0344c221..e119e3d23 100644 --- a/internal/entity/faces_test.go +++ b/internal/entity/faces_test.go @@ -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 { diff --git a/internal/entity/file_test.go b/internal/entity/file_test.go index 04f82dbf3..543a512c3 100644 --- a/internal/entity/file_test.go +++ b/internal/entity/file_test.go @@ -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()) diff --git a/internal/entity/legacy/legacy.go b/internal/entity/legacy/legacy.go new file mode 100644 index 000000000..eec1882d1 --- /dev/null +++ b/internal/entity/legacy/legacy.go @@ -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"): + + + 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: + + +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: + +*/ +package legacy diff --git a/internal/entity/legacy/user.go b/internal/entity/legacy/user.go new file mode 100644 index 000000000..3bc154533 --- /dev/null +++ b/internal/entity/legacy/user.go @@ -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 +} diff --git a/internal/entity/markers_test.go b/internal/entity/markers_test.go index 97c9a1d70..07b6152fc 100644 --- a/internal/entity/markers_test.go +++ b/internal/entity/markers_test.go @@ -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) diff --git a/internal/entity/photo_test.go b/internal/entity/photo_test.go index 9a65d6e80..553987d77 100644 --- a/internal/entity/photo_test.go +++ b/internal/entity/photo_test.go @@ -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 { diff --git a/internal/entity/string_map.go b/internal/entity/string_map.go index 4990c5fca..5deba1e4e 100644 --- a/internal/entity/string_map.go +++ b/internal/entity/string_map.go @@ -5,34 +5,37 @@ import ( "sync" "github.com/photoprism/photoprism/pkg/clean" + "github.com/photoprism/photoprism/pkg/list" ) // Strings is a simple string map that should not be accessed by multiple goroutines. type Strings map[string]string +type MultiStrings map[string][]string + // StringMap is a string (reverse) lookup map that can be accessed by multiple goroutines. type StringMap struct { sync.RWMutex m Strings - r Strings + r MultiStrings } // NewStringMap creates a new string (reverse) lookup map. func NewStringMap(s Strings) *StringMap { if s == nil { - return &StringMap{m: make(Strings, 64), r: make(Strings, 64)} + return &StringMap{m: make(Strings, 64), r: make(MultiStrings, 64)} } else { - m := &StringMap{m: s, r: make(Strings, len(s))} + m := &StringMap{m: s, r: make(MultiStrings, len(s))} for k := range s { - m.r[strings.ToLower(s[k])] = k + m.r[strings.ToLower(s[k])] = []string{k} } return m } } -// Get returns a string from the map, empty if not found. +// Get returns the matching value or an empty string if it was not found. func (s *StringMap) Get(key string) string { if key == "" { return "" @@ -44,10 +47,40 @@ func (s *StringMap) Get(key string) string { return s.m[key] } -// Key returns a string from the map, empty if not found. +// Has checks whether a value has been set for the specified key. +func (s *StringMap) Has(key string) bool { + if key == "" { + return false + } + + s.RLock() + defer s.RUnlock() + + _, ok := s.m[key] + + return ok +} + +// Missing checks if the key is unknown. +func (s *StringMap) Missing(key string) bool { + return !s.Has(key) +} + +// Key returns the last added key that matches the specified value. func (s *StringMap) Key(val string) string { + keys := s.Keys(val) + + if l := len(keys); l > 0 { + return keys[l-1] + } + + return "" +} + +// Keys returns all keys that match the specified value. +func (s *StringMap) Keys(val string) []string { if val == "" { - return "" + return []string{} } s.RLock() @@ -56,6 +89,20 @@ func (s *StringMap) Key(val string) string { return s.r[strings.ToLower(val)] } +// HasValue checks if the specified value exists for any key. +func (s *StringMap) HasValue(val string) bool { + if val == "" { + return false + } + + s.RLock() + defer s.RUnlock() + + _, ok := s.r[strings.ToLower(val)] + + return ok +} + // Log returns a string sanitized for logging and using the key as fallback value. func (s *StringMap) Log(key string) (val string) { if key == "" { @@ -78,7 +125,7 @@ func (s *StringMap) Unchanged(key string, val string) bool { s.RLock() defer s.RUnlock() - return s.m[key] == val && s.r[strings.ToLower(val)] == key + return s.m[key] == val && list.Contains(s.r[strings.ToLower(val)], key) } // Set adds a string to the map. @@ -94,7 +141,9 @@ func (s *StringMap) Set(key string, val string) { defer s.Unlock() s.m[key] = val - s.r[strings.ToLower(val)] = key + + // Update reverse lookup map. + s.r[strings.ToLower(val)] = list.Add(s.r[strings.ToLower(val)], key) } // Unset removes a string from the map. @@ -106,10 +155,13 @@ func (s *StringMap) Unset(key string) { s.Lock() defer s.Unlock() - if v := s.m[key]; v == "" { - // Should never happen. - } else if v = strings.ToLower(v); s.r[v] == key { - delete(s.r, v) + // Update reverse lookup map. + if v := strings.ToLower(s.m[key]); v != "" { + if keys := list.Remove(s.r[v], key); len(keys) == 0 { + delete(s.r, v) + } else { + s.r[v] = keys + } } delete(s.m, key) diff --git a/internal/entity/string_map_test.go b/internal/entity/string_map_test.go index cea13820f..6d5a8b58b 100644 --- a/internal/entity/string_map_test.go +++ b/internal/entity/string_map_test.go @@ -87,7 +87,7 @@ func TestStringMap_Key(t *testing.T) { m.Unset("Dog") - assert.Equal(t, "", m.Key("WINDOWS")) + assert.Equal(t, "dog", m.Key("WINDOWS")) assert.Equal(t, "foo", m.Key("bar")) assert.Equal(t, "", m.Key("Dog")) }) @@ -109,7 +109,40 @@ func TestStringMap_Key(t *testing.T) { m.Set("My", "") assert.Equal(t, "", m.Get("My")) - assert.Equal(t, "", m.Key("bar")) + assert.Equal(t, "Foo", m.Key("bar")) + }) +} + +func TestStringMap_KeyExists(t *testing.T) { + t.Run("True", func(t *testing.T) { + assert.True(t, NewStringMap(Strings{"foo": "bar"}).Has("foo")) + assert.True(t, NewStringMap(Strings{"foo": "bar", "zzz": "bar"}).Has("zzz")) + }) + t.Run("False", func(t *testing.T) { + assert.False(t, NewStringMap(Strings{"foo": "bar"}).Has("")) + assert.False(t, NewStringMap(Strings{"foo": "bar"}).Has("zzz")) + }) +} + +func TestStringMap_Missing(t *testing.T) { + t.Run("False", func(t *testing.T) { + assert.False(t, NewStringMap(Strings{"foo": "bar"}).Missing("foo")) + assert.False(t, NewStringMap(Strings{"foo": "bar", "zzz": "bar"}).Missing("zzz")) + }) + t.Run("True", func(t *testing.T) { + assert.True(t, NewStringMap(Strings{"foo": "bar"}).Missing("")) + assert.True(t, NewStringMap(Strings{"foo": "bar"}).Missing("zzz")) + }) +} + +func TestStringMap_ValueExists(t *testing.T) { + t.Run("True", func(t *testing.T) { + assert.True(t, NewStringMap(Strings{"foo": "bar"}).HasValue("bar")) + assert.True(t, NewStringMap(Strings{"foo": "bar", "zzz": "bar"}).HasValue("bar")) + }) + t.Run("False", func(t *testing.T) { + assert.False(t, NewStringMap(Strings{"foo": "bar"}).HasValue("")) + assert.False(t, NewStringMap(Strings{"foo": "bar"}).HasValue("zzz")) }) } diff --git a/internal/entity/subject_test.go b/internal/entity/subject_test.go index e90c8a745..024b874f9 100644 --- a/internal/entity/subject_test.go +++ b/internal/entity/subject_test.go @@ -34,7 +34,7 @@ func TestNewSubject(t *testing.T) { } func TestSubject_SetName(t *testing.T) { - t.Run("Success", func(t *testing.T) { + t.Run("Ok", func(t *testing.T) { m := NewSubject("Jens Mander", SubjPerson, SrcAuto) assert.Equal(t, "Jens Mander", m.SubjName) diff --git a/internal/entity/subjects_test.go b/internal/entity/subjects_test.go index cd14e27fb..1d8ad677d 100644 --- a/internal/entity/subjects_test.go +++ b/internal/entity/subjects_test.go @@ -5,7 +5,7 @@ import ( ) func TestDeleteOrphanPeople(t *testing.T) { - t.Run("Success", func(t *testing.T) { + t.Run("Ok", func(t *testing.T) { if count, err := DeleteOrphanPeople(); err != nil { t.Fatal(err) } else { diff --git a/internal/face/embedding_test.go b/internal/face/embedding_test.go index 8dd135ca2..d6730feca 100644 --- a/internal/face/embedding_test.go +++ b/internal/face/embedding_test.go @@ -66,7 +66,7 @@ func TestEmbedding_SkipMatching(t *testing.T) { } func TestUnmarshalEmbedding(t *testing.T) { - t.Run("Success", func(t *testing.T) { + t.Run("Ok", func(t *testing.T) { emb, err := UnmarshalEmbedding("[-0.013,-0.031]") assert.NoError(t, err) diff --git a/internal/face/embeddings_test.go b/internal/face/embeddings_test.go index 5dd29707c..ab8760dca 100644 --- a/internal/face/embeddings_test.go +++ b/internal/face/embeddings_test.go @@ -84,7 +84,7 @@ func TestEmbeddingsMidpoint(t *testing.T) { } func TestUnmarshalEmbeddings(t *testing.T) { - t.Run("Success", func(t *testing.T) { + t.Run("Ok", func(t *testing.T) { emb, err := UnmarshalEmbeddings("[[-0.013,-0.031]]") assert.NoError(t, err) diff --git a/internal/form/user.go b/internal/form/user.go index e34ad6e6f..242cba83a 100644 --- a/internal/form/user.go +++ b/internal/form/user.go @@ -14,7 +14,7 @@ type User struct { UserRole string `json:"Role,omitempty" yaml:"Role,omitempty"` SuperAdmin bool `json:"SuperAdmin,omitempty" yaml:"SuperAdmin,omitempty"` CanLogin bool `json:"CanLogin,omitempty" yaml:"CanLogin,omitempty"` - CanSync bool `json:"CanSync,omitempty" yaml:"CanSync,omitempty"` + WebDAV bool `json:"WebDAV,omitempty" yaml:"WebDAV,omitempty"` UserAttr string `json:"Attr,omitempty" yaml:"Attr,omitempty"` BasePath string `json:"BasePath,omitempty" yaml:"BasePath,omitempty"` UploadPath string `json:"UploadPath,omitempty" yaml:"UploadPath,omitempty"` @@ -30,7 +30,7 @@ func NewUserFromCli(ctx *cli.Context) User { UserRole: clean.Role(ctx.String("role")), SuperAdmin: ctx.Bool("superadmin"), CanLogin: !ctx.Bool("no-login"), - CanSync: ctx.Bool("can-sync"), + WebDAV: ctx.Bool("webdav"), UserAttr: clean.Attr(ctx.String("attr")), BasePath: clean.Path(ctx.String("base-path")), UploadPath: clean.Path(ctx.String("upload-path")), diff --git a/internal/migrate/dialect_mysql.go b/internal/migrate/dialect_mysql.go index cd4fe592d..46c26e3d0 100644 --- a/internal/migrate/dialect_mysql.go +++ b/internal/migrate/dialect_mysql.go @@ -103,11 +103,6 @@ var DialectMySQL = Migrations{ Dialect: "mysql", Statements: []string{"ALTER TABLE files MODIFY time_index VARBINARY(64);"}, }, - { - ID: "20220927-000200", - Dialect: "mysql", - Statements: []string{"REPLACE INTO auth_users (id, user_uid, super_admin, can_login, can_sync, user_role, display_name, user_name, user_email, login_at, created_at, updated_at) SELECT id, user_uid, role_admin, 1, 1, 'admin', full_name, user_name, primary_email, login_at, created_at, updated_at FROM users WHERE role_admin = 1 AND user_name NOT IN (SELECT user_name FROM auth_users) AND user_name <> '' AND user_name IS NOT NULL;"}, - }, { ID: "20221002-000100", Dialect: "mysql", diff --git a/internal/migrate/dialect_sqlite3.go b/internal/migrate/dialect_sqlite3.go index d39531666..2424d33a0 100644 --- a/internal/migrate/dialect_sqlite3.go +++ b/internal/migrate/dialect_sqlite3.go @@ -58,9 +58,4 @@ var DialectSQLite3 = Migrations{ Dialect: "sqlite3", Statements: []string{"CREATE INDEX IF NOT EXISTS idx_files_missing_root ON files (file_missing, file_root);"}, }, - { - ID: "20220927-000200", - Dialect: "sqlite3", - Statements: []string{"REPLACE INTO auth_users (id, user_uid, super_admin, can_login, can_sync, user_role, display_name, user_name, user_email, login_at, created_at, updated_at) SELECT id, user_uid, 1, 1, 1 'admin', full_name, user_name, primary_email, login_at, created_at, updated_at FROM users WHERE user_name <> '' AND user_name IS NOT NULL AND user_uid <> '' AND user_uid IS NOT NULL AND role_admin = 1 AND user_disabled = 0;"}, - }, } diff --git a/internal/migrate/mysql/20220927-000200.sql b/internal/migrate/mysql/20220927-000200.sql deleted file mode 100644 index 2d8f930d0..000000000 --- a/internal/migrate/mysql/20220927-000200.sql +++ /dev/null @@ -1 +0,0 @@ -REPLACE INTO auth_users (id, user_uid, super_admin, can_login, can_sync, user_role, display_name, user_name, user_email, login_at, created_at, updated_at) SELECT id, user_uid, role_admin, 1, 1, 'admin', full_name, user_name, primary_email, login_at, created_at, updated_at FROM users WHERE role_admin = 1 AND user_name NOT IN (SELECT user_name FROM auth_users) AND user_name <> '' AND user_name IS NOT NULL; diff --git a/internal/migrate/sqlite3/20220927-000200.sql b/internal/migrate/sqlite3/20220927-000200.sql deleted file mode 100644 index ff9a687b2..000000000 --- a/internal/migrate/sqlite3/20220927-000200.sql +++ /dev/null @@ -1 +0,0 @@ -REPLACE INTO auth_users (id, user_uid, super_admin, can_login, can_sync, user_role, display_name, user_name, user_email, login_at, created_at, updated_at) SELECT id, user_uid, 1, 1, 1 'admin', full_name, user_name, primary_email, login_at, created_at, updated_at FROM users WHERE user_name <> '' AND user_name IS NOT NULL AND user_uid <> '' AND user_uid IS NOT NULL AND role_admin = 1 AND user_disabled = 0; \ No newline at end of file diff --git a/internal/query/moments_test.go b/internal/query/moments_test.go index 69b824e3f..b673cf300 100644 --- a/internal/query/moments_test.go +++ b/internal/query/moments_test.go @@ -322,7 +322,7 @@ func TestMoment_Title(t *testing.T) { } func TestRemoveDuplicateMoments(t *testing.T) { - t.Run("Success", func(t *testing.T) { + t.Run("Ok", func(t *testing.T) { if removed, err := RemoveDuplicateMoments(); err != nil { t.Fatal(err) } else { diff --git a/internal/query/users.go b/internal/query/users.go index 0d659e2f9..713c6f179 100644 --- a/internal/query/users.go +++ b/internal/query/users.go @@ -25,7 +25,7 @@ func Users(limit, offset int, sortOrder, search string) (result entity.Users, er search = strings.TrimSpace(search) if search == "all" { - stmt = stmt.Where("sess_expires > 0 AND sess_expires < ?", entity.UnixTime()) + // Don't filter. } else if id := txt.Int(search); id != 0 { stmt = stmt.Where("id = ?", id) } else if rnd.IsUID(search, entity.UserUID) { diff --git a/internal/server/basicauth.go b/internal/server/basicauth.go index a2c740c36..5146a4606 100644 --- a/internal/server/basicauth.go +++ b/internal/server/basicauth.go @@ -92,7 +92,7 @@ func BasicAuth() gin.HandlerFunc { limiter.Auth.Reserve(clientIp) event.AuditWarn([]string{clientIp, "webdav login as %s", message}, clean.LogQuote(name)) event.LoginError(clientIp, "webdav", name, api.UserAgent(c), message) - } else if !user.SyncAllowed() { + } else if !user.CanUseWebDAV() { // Sync disabled for this account. message := "sync disabled" diff --git a/pkg/list/add.go b/pkg/list/add.go new file mode 100644 index 000000000..d609d1d15 --- /dev/null +++ b/pkg/list/add.go @@ -0,0 +1,14 @@ +package list + +// Add adds a string to the list if it does not exist yet. +func Add(list []string, s string) []string { + if s == "" { + return list + } else if len(list) == 0 { + return []string{s} + } else if Contains(list, s) { + return list + } + + return append(list, s) +} diff --git a/pkg/list/add_test.go b/pkg/list/add_test.go new file mode 100644 index 000000000..ee7f650bd --- /dev/null +++ b/pkg/list/add_test.go @@ -0,0 +1,19 @@ +package list + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAdd(t *testing.T) { + assert.Equal(t, []string{}, Add([]string{}, "")) + assert.Equal(t, []string{"bar"}, Add([]string{}, "bar")) + assert.Equal(t, []string{"foo", "bar"}, Add([]string{"foo", "bar"}, "")) + assert.Equal(t, []string{"foo", "bar"}, Add([]string{"foo", "bar"}, "foo")) + assert.Equal(t, []string{"foo", "bar", "zzz"}, Add([]string{"foo", "bar"}, "zzz")) + assert.Equal(t, []string{"foo", "bar", " "}, Add([]string{"foo", "bar"}, " ")) + assert.Equal(t, []string{"foo", "bar", "645656"}, Add([]string{"foo", "bar"}, "645656")) + assert.Equal(t, []string{"foo", "bar ", "foo ", "baz", "bar"}, Add([]string{"foo", "bar ", "foo ", "baz"}, "bar")) + assert.Equal(t, []string{"foo", "bar", "foo ", "baz", "bar "}, Add([]string{"foo", "bar", "foo ", "baz"}, "bar ")) +} diff --git a/pkg/list/remove.go b/pkg/list/remove.go new file mode 100644 index 000000000..fab41e43c --- /dev/null +++ b/pkg/list/remove.go @@ -0,0 +1,21 @@ +package list + +// Remove removes a string from a list and returns it. +func Remove(list []string, s string) []string { + if len(list) == 0 || s == "" { + return list + } else if s == All { + return []string{} + } + + result := make([]string, 0, len(list)) + + // Find matches. + for i := range list { + if s != list[i] { + result = append(result, list[i]) + } + } + + return result +} diff --git a/pkg/list/remove_test.go b/pkg/list/remove_test.go new file mode 100644 index 000000000..69213622c --- /dev/null +++ b/pkg/list/remove_test.go @@ -0,0 +1,19 @@ +package list + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRemove(t *testing.T) { + assert.Equal(t, []string{}, Remove([]string{}, "")) + assert.Equal(t, []string{}, Remove([]string{}, "bar")) + assert.Equal(t, []string{"foo", "bar"}, Remove([]string{"foo", "bar"}, "")) + assert.Equal(t, []string{"bar"}, Remove([]string{"foo", "bar"}, "foo")) + assert.Equal(t, []string{"foo", "bar"}, Remove([]string{"foo", "bar"}, "zzz")) + assert.Equal(t, []string{"foo", "bar"}, Remove([]string{"foo", "bar"}, " ")) + assert.Equal(t, []string{"foo", "bar"}, Remove([]string{"foo", "bar"}, "645656")) + assert.Equal(t, []string{"foo", "bar ", "foo ", "baz"}, Remove([]string{"foo", "bar ", "foo ", "baz"}, "bar")) + assert.Equal(t, []string{"foo", "bar", "foo ", "baz"}, Remove([]string{"foo", "bar", "foo ", "baz"}, "bar ")) +} diff --git a/pkg/report/bool.go b/pkg/report/bool.go index d01ebc658..9452ea2df 100644 --- a/pkg/report/bool.go +++ b/pkg/report/bool.go @@ -8,10 +8,10 @@ const ( ) // Bool returns t or f, depending on the value of b. -func Bool(b bool, t, f string) string { - if b { - return t +func Bool(value bool, yes, no string) string { + if value { + return yes } - return f + return no }