diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9d176571f..3e375967d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -2033,9 +2033,9 @@ } }, "node_modules/@humanwhocodes/config-array": { - "version": "0.10.5", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.10.5.tgz", - "integrity": "sha512-XVVDtp+dVvRxMoxSiSfasYaG02VEe1qH5cKgMQJWhol6HwzbcqoCMJi8dAGoYAO57jhUyhI6cWuRiTcRaDaYug==", + "version": "0.10.7", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.10.7.tgz", + "integrity": "sha512-MDl6D6sBsaV452/QSdX+4CXIjZhIcI0PELsxUjk4U828yd58vk3bTIvk/6w5FY+4hIy9sLW0sfrV7K7Kc++j/w==", "dependencies": { "@humanwhocodes/object-schema": "^1.2.1", "debug": "^4.1.1", @@ -3502,9 +3502,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001412", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001412.tgz", - "integrity": "sha512-+TeEIee1gS5bYOiuf+PS/kp2mrXic37Hl66VY6EAfxasIk5fELTktK2oOezYed12H8w7jt3s512PpulQidPjwA==", + "version": "1.0.30001414", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001414.tgz", + "integrity": "sha512-t55jfSaWjCdocnFdKQoO+d2ct9C59UZg4dY3OnUlSZ447r8pUtIKdp0hpAzrGFultmTC+Us+KpKi4GZl/LXlFg==", "funding": [ { "type": "opencollective", @@ -4661,9 +4661,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.4.266", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.266.tgz", - "integrity": "sha512-saJTYECxUSv7eSpnXw0XIEvUkP9x4s/x2mm3TVX7k4rIFS6f5TjBih1B5h437WzIhHQjid+d8ouQzPQskMervQ==" + "version": "1.4.268", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.268.tgz", + "integrity": "sha512-PO90Bv++vEzdln+eA9qLg1IRnh0rKETus6QkTzcFm5P3Wg3EQBZud5dcnzkpYXuIKWBjKe5CO8zjz02cicvn1g==" }, "node_modules/emoji-regex": { "version": "8.0.0", @@ -6410,19 +6410,19 @@ "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==" }, "node_modules/flow-parser": { - "version": "0.187.1", - "resolved": "https://registry.npmjs.org/flow-parser/-/flow-parser-0.187.1.tgz", - "integrity": "sha512-ZvlTeakTTMmYGukt4EIQtLEp4ie45W+jK325uukGgiqFg2Rl7TdpOJQbOLUN2xMeGS+WvXaK0uIJ3coPGDXFGQ==", + "version": "0.188.0", + "resolved": "https://registry.npmjs.org/flow-parser/-/flow-parser-0.188.0.tgz", + "integrity": "sha512-yu/Tc3UOhE0lXZk02MO/69N8RLdZsBtz3R6pBS+GaHiaopAxRcfUHPkqOZnI2BtotaylYTLrZGkIqFC8KqVxXQ==", "engines": { "node": ">=0.4.0" } }, "node_modules/flow-remove-types": { - "version": "2.187.1", - "resolved": "https://registry.npmjs.org/flow-remove-types/-/flow-remove-types-2.187.1.tgz", - "integrity": "sha512-kfNwss9K0A76VeeYZgNQ2pTeNPGmVNOh9mBDDJOzueejctZL/Tuiwa8lT10LAWRxTQIzdQcWFrUEzGSDSzZNdg==", + "version": "2.188.0", + "resolved": "https://registry.npmjs.org/flow-remove-types/-/flow-remove-types-2.188.0.tgz", + "integrity": "sha512-bvNLQtjIFWJ4bkzZmLpIr4iZ83TsKv+S5Er9x/5OQMlNXvqGV0q/d/NIs/oZ7v9ZVnj0CObP2XgcfdduzKW4Dw==", "dependencies": { - "flow-parser": "^0.187.1", + "flow-parser": "^0.188.0", "pirates": "^3.0.2", "vlq": "^0.2.1" }, @@ -14667,9 +14667,9 @@ } }, "@humanwhocodes/config-array": { - "version": "0.10.5", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.10.5.tgz", - "integrity": "sha512-XVVDtp+dVvRxMoxSiSfasYaG02VEe1qH5cKgMQJWhol6HwzbcqoCMJi8dAGoYAO57jhUyhI6cWuRiTcRaDaYug==", + "version": "0.10.7", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.10.7.tgz", + "integrity": "sha512-MDl6D6sBsaV452/QSdX+4CXIjZhIcI0PELsxUjk4U828yd58vk3bTIvk/6w5FY+4hIy9sLW0sfrV7K7Kc++j/w==", "requires": { "@humanwhocodes/object-schema": "^1.2.1", "debug": "^4.1.1", @@ -15836,9 +15836,9 @@ } }, "caniuse-lite": { - "version": "1.0.30001412", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001412.tgz", - "integrity": "sha512-+TeEIee1gS5bYOiuf+PS/kp2mrXic37Hl66VY6EAfxasIk5fELTktK2oOezYed12H8w7jt3s512PpulQidPjwA==" + "version": "1.0.30001414", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001414.tgz", + "integrity": "sha512-t55jfSaWjCdocnFdKQoO+d2ct9C59UZg4dY3OnUlSZ447r8pUtIKdp0hpAzrGFultmTC+Us+KpKi4GZl/LXlFg==" }, "chai": { "version": "4.3.6", @@ -16662,9 +16662,9 @@ } }, "electron-to-chromium": { - "version": "1.4.266", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.266.tgz", - "integrity": "sha512-saJTYECxUSv7eSpnXw0XIEvUkP9x4s/x2mm3TVX7k4rIFS6f5TjBih1B5h437WzIhHQjid+d8ouQzPQskMervQ==" + "version": "1.4.268", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.268.tgz", + "integrity": "sha512-PO90Bv++vEzdln+eA9qLg1IRnh0rKETus6QkTzcFm5P3Wg3EQBZud5dcnzkpYXuIKWBjKe5CO8zjz02cicvn1g==" }, "emoji-regex": { "version": "8.0.0", @@ -17931,16 +17931,16 @@ "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==" }, "flow-parser": { - "version": "0.187.1", - "resolved": "https://registry.npmjs.org/flow-parser/-/flow-parser-0.187.1.tgz", - "integrity": "sha512-ZvlTeakTTMmYGukt4EIQtLEp4ie45W+jK325uukGgiqFg2Rl7TdpOJQbOLUN2xMeGS+WvXaK0uIJ3coPGDXFGQ==" + "version": "0.188.0", + "resolved": "https://registry.npmjs.org/flow-parser/-/flow-parser-0.188.0.tgz", + "integrity": "sha512-yu/Tc3UOhE0lXZk02MO/69N8RLdZsBtz3R6pBS+GaHiaopAxRcfUHPkqOZnI2BtotaylYTLrZGkIqFC8KqVxXQ==" }, "flow-remove-types": { - "version": "2.187.1", - "resolved": "https://registry.npmjs.org/flow-remove-types/-/flow-remove-types-2.187.1.tgz", - "integrity": "sha512-kfNwss9K0A76VeeYZgNQ2pTeNPGmVNOh9mBDDJOzueejctZL/Tuiwa8lT10LAWRxTQIzdQcWFrUEzGSDSzZNdg==", + "version": "2.188.0", + "resolved": "https://registry.npmjs.org/flow-remove-types/-/flow-remove-types-2.188.0.tgz", + "integrity": "sha512-bvNLQtjIFWJ4bkzZmLpIr4iZ83TsKv+S5Er9x/5OQMlNXvqGV0q/d/NIs/oZ7v9ZVnj0CObP2XgcfdduzKW4Dw==", "requires": { - "flow-parser": "^0.187.1", + "flow-parser": "^0.188.0", "pirates": "^3.0.2", "vlq": "^0.2.1" }, diff --git a/frontend/src/app/routes.js b/frontend/src/app/routes.js index 4e1102c5f..1cb9b2112 100644 --- a/frontend/src/app/routes.js +++ b/frontend/src/app/routes.js @@ -228,14 +228,14 @@ export default [ meta: { title: $gettext("Places"), auth: true }, }, { - name: "place", + name: "places_query", path: "/places/:q", component: Places, meta: { title: $gettext("Places"), auth: true }, }, { - name: "album_place", - path: "/places/:album/:q", + name: "places_scope", + path: "/places/:s/:q", component: Places, meta: { title: $gettext("Places"), auth: true }, }, diff --git a/frontend/src/pages/album/photos.vue b/frontend/src/pages/album/photos.vue index f54570f3f..2a9963f9a 100644 --- a/frontend/src/pages/album/photos.vue +++ b/frontend/src/pages/album/photos.vue @@ -174,12 +174,12 @@ export default { if (photo && photo.CellID && photo.CellID !== "zz") { if (this.canSearchPlaces) { - this.$router.push({name: "place", params: {q: photo.CellID}}); + this.$router.push({name: "places_query", params: {q: photo.CellID}}); } else { - this.$router.push({name: "album_place", params: {album: this.uid, q: photo.CellID}}); + this.$router.push({name: "places_scope", params: {s: this.uid, q: photo.CellID}}); } } else { - this.$router.push({name: "album_place", params: {album: this.uid, q: ""}}); + this.$router.push({name: "places_scope", params: {s: this.uid, q: ""}}); } }, editPhoto(index) { @@ -250,8 +250,7 @@ export default { const params = { count: count, offset: offset, - album: this.uid, - filter: this.model.Filter ? this.model.Filter : "", + s: this.uid, merged: true, }; @@ -365,8 +364,7 @@ export default { const params = { count: this.batchSize, offset: this.offset, - album: this.uid, - filter: this.model.Filter ? this.model.Filter : "", + s: this.uid, merged: true, }; diff --git a/frontend/src/pages/photos.vue b/frontend/src/pages/photos.vue index 4e38418d1..1a6ba861c 100644 --- a/frontend/src/pages/photos.vue +++ b/frontend/src/pages/photos.vue @@ -241,9 +241,9 @@ export default { const photo = this.results[index]; if (photo.CellID && photo.CellID !== "zz") { - this.$router.push({name: "place", params: {q: photo.CellID}}); + this.$router.push({name: "places_query", params: {q: photo.CellID}}); } else if (photo.Country && photo.Country !== "zz") { - this.$router.push({name: "place", params: {q: "country:" + photo.Country}}); + this.$router.push({name: "places_query", params: {q: "country:" + photo.Country}}); } else { this.$notify.warn("unknown location"); } diff --git a/frontend/src/pages/places.vue b/frontend/src/pages/places.vue index 9076436f5..d6e92c557 100644 --- a/frontend/src/pages/places.vue +++ b/frontend/src/pages/places.vue @@ -49,7 +49,7 @@ export default { options: {}, mapFont: ["Open Sans Regular"], result: {}, - filter: {q: this.query(), album: this.album()}, + filter: {q: this.query(), s: this.scope()}, lastFilter: {}, config: this.$config.values, settings: this.$config.values.settings.maps, @@ -58,7 +58,7 @@ export default { watch: { '$route'() { this.filter.q = this.query(); - this.filter.album = this.album(); + this.filter.s = this.scope(); this.lastFilter = {}; this.search(); @@ -77,7 +77,7 @@ export default { const s = this.$config.values.settings.maps; const filter = { q: this.query(), - album: this.album(), + s: this.scope(), }; let mapKey = ""; @@ -208,8 +208,8 @@ export default { query: function () { return this.$route.params.q ? this.$route.params.q : ''; }, - album: function () { - return this.$route.params.album ? this.$route.params.album : ''; + scope: function () { + return this.$route.params.s ? this.$route.params.s : ''; }, openPhoto(uid) { // Abort if uid is empty or results aren't loaded. @@ -225,8 +225,8 @@ export default { }, }; - if (this.filter.album) { - options.params.album = this.filter.album; + if (this.filter.s) { + options.params.s = this.filter.s; } this.loading = true; @@ -256,10 +256,10 @@ export default { if (this.loading) return; if (this.query() !== this.filter.q) { - if (this.filter.album) { - this.$router.replace({name: "album_place", params: {album: this.filter.album, q: this.filter.q}}); + if (this.filter.s) { + this.$router.replace({name: "places_scope", params: {s: this.filter.s, q: this.filter.q}}); } else if (this.filter.q) { - this.$router.replace({name: "place", params: {q: this.filter.q}}); + this.$router.replace({name: "places_query", params: {q: this.filter.q}}); } else { this.$router.replace({name: "places"}); } diff --git a/frontend/src/share.js b/frontend/src/share.js index 16d3180d1..100f3cd93 100644 --- a/frontend/src/share.js +++ b/frontend/src/share.js @@ -50,7 +50,7 @@ import VueFilters from "vue2-filters"; import VueFullscreen from "vue-fullscreen"; import VueInfiniteScroll from "vue-infinite-scroll"; import Hls from "hls.js"; -import { $gettext, Mount } from "common/vm"; +import { Mount, T } from "common/vm"; import * as options from "./options/options"; config.load().finally(() => { @@ -160,12 +160,28 @@ config.load().finally(() => { }); router.afterEach((to) => { - if (to.meta.title && config.values.siteTitle !== to.meta.title) { - config.page.title = $gettext(to.meta.title); - window.document.title = config.page.title; + const t = to.meta["title"] ? to.meta["title"] : ""; + + if (t !== "" && config.values.siteTitle !== t && config.values.name !== t) { + config.page.title = T(t); + + if (config.page.title.startsWith(config.values.siteTitle)) { + window.document.title = config.page.title; + } else if (config.page.title === "") { + window.document.title = config.values.siteTitle; + } else { + window.document.title = config.page.title + " – " + config.values.siteTitle; + } } else { - config.page.title = config.values.siteTitle; - window.document.title = config.values.siteTitle; + config.page.title = config.values.name; + + if (!config.values.sponsor) { + window.document.title = config.values.name; + } else if (config.values.siteCaption === "") { + window.document.title = config.values.siteTitle; + } else { + window.document.title = config.values.siteCaption; + } } }); diff --git a/frontend/src/share/album/clipboard.vue b/frontend/src/share/album/clipboard.vue index 56bab4133..d516b634b 100644 --- a/frontend/src/share/album/clipboard.vue +++ b/frontend/src/share/album/clipboard.vue @@ -55,9 +55,18 @@ export default { type: Array, default: () => [], }, - refresh: Function, - clearSelection: Function, - context: String, + refresh: { + type: Function, + default: () => {}, + }, + clearSelection: { + type: Function, + default: () => {}, + }, + context: { + type: String, + default: "", + }, }, data() { return { diff --git a/frontend/src/share/photo/clipboard.vue b/frontend/src/share/photo/clipboard.vue index d89591fbd..7a025bb0d 100644 --- a/frontend/src/share/photo/clipboard.vue +++ b/frontend/src/share/photo/clipboard.vue @@ -58,12 +58,18 @@ export default { type: Array, default: () => [], }, - refresh: Function, + refresh: { + type: Function, + default: () => {}, + }, album: { type: Object, default: () => {}, }, - context: String, + context: { + type: String, + default: "", + }, }, data() { return { diff --git a/frontend/src/share/photos.vue b/frontend/src/share/photos.vue index 64ddb3e2b..c34e7822d 100644 --- a/frontend/src/share/photos.vue +++ b/frontend/src/share/photos.vue @@ -228,9 +228,9 @@ export default { const photo = this.results[index]; if (photo && photo.CellID && photo.CellID !== "zz") { - this.$router.push({name: "album_place", params: {album: this.uid, q: photo.CellID}}); + this.$router.push({name: "places_scope", params: {s: this.uid, q: photo.CellID}}); } else { - this.$router.push({name: "album_place", params: {album: this.uid, q: ""}}); + this.$router.push({name: "places_scope", params: {s: this.uid, q: ""}}); } }, editPhoto(index) { @@ -297,7 +297,7 @@ export default { const params = { count: count, offset: offset, - album: this.uid, + s: this.uid, filter: this.model.Filter ? this.model.Filter : "", merged: true, }; @@ -405,7 +405,7 @@ export default { const params = { count: this.batchSize, offset: this.offset, - album: this.uid, + s: this.uid, filter: this.model.Filter ? this.model.Filter : "", merged: true, }; diff --git a/frontend/src/share/places.vue b/frontend/src/share/places.vue index 14c327bcc..9666e58cb 100644 --- a/frontend/src/share/places.vue +++ b/frontend/src/share/places.vue @@ -247,7 +247,7 @@ export default { if (this.query() !== this.filter.q) { if (this.filter.q) { - this.$router.replace({name: "place", params: {q: this.filter.q}}); + this.$router.replace({name: "places_query", params: {q: this.filter.q}}); } else { this.$router.replace({name: "places"}); } diff --git a/frontend/src/share/routes.js b/frontend/src/share/routes.js index ada7290ac..bc25ded39 100644 --- a/frontend/src/share/routes.js +++ b/frontend/src/share/routes.js @@ -26,8 +26,8 @@ export default [ meta: { title: shareTitle, auth: true, hideNav: true }, }, { - name: "album_place", - path: "/places/:album/:q", + name: "places_scope", + path: "/places/:s/:q", component: Places, meta: { title: shareTitle, auth: true, hideNav: true }, }, diff --git a/internal/acl/acl_resources.go b/internal/acl/acl_resources.go index 24368d438..2cb0ecb60 100644 --- a/internal/acl/acl_resources.go +++ b/internal/acl/acl_resources.go @@ -2,61 +2,18 @@ package acl // Resources specifies granted permissions by Resource and Role. var Resources = ACL{ - ResourceDefault: Roles{ - RoleAdmin: GrantFullAccess, - }, - ResourceConfig: Roles{ - RoleAdmin: GrantFullAccess, - }, - ResourceSettings: Roles{ - RoleAdmin: GrantFullAccess, - }, - ResourceCalendar: Roles{ - RoleAdmin: GrantFullAccess, - }, - ResourceMoments: Roles{ - RoleAdmin: GrantFullAccess, - }, ResourceFiles: Roles{ RoleAdmin: GrantFullAccess, }, - ResourcePeople: Roles{ - RoleAdmin: GrantFullAccess, - }, - ResourceFavorites: Roles{ - RoleAdmin: GrantFullAccess, - }, - ResourceFeedback: Roles{ - RoleAdmin: GrantFullAccess, - }, - ResourceShares: Roles{ - RoleAdmin: GrantFullAccess, - }, - ResourcePassword: Roles{ - RoleAdmin: GrantFullAccess, - }, - ResourceAccounts: Roles{ - RoleAdmin: GrantFullAccess, - }, - ResourceLogs: Roles{ - RoleAdmin: GrantFullAccess, - }, - ResourceLabels: Roles{ - RoleAdmin: GrantFullAccess, - }, ResourcePhotos: Roles{ RoleAdmin: GrantFullAccess, RoleVisitor: Grant{AccessShared: true, ActionView: true, ActionDownload: true}, }, - ResourceAlbums: Roles{ - RoleAdmin: GrantFullAccess, - RoleVisitor: Grant{AccessShared: true, ActionView: true, ActionDownload: true}, - }, ResourceVideos: Roles{ RoleAdmin: GrantFullAccess, RoleVisitor: Grant{AccessShared: true, ActionView: true, ActionDownload: true}, }, - ResourcePlaces: Roles{ + ResourceAlbums: Roles{ RoleAdmin: GrantFullAccess, RoleVisitor: Grant{AccessShared: true, ActionView: true, ActionDownload: true}, }, @@ -64,4 +21,52 @@ var Resources = ACL{ RoleAdmin: GrantFullAccess, RoleVisitor: Grant{AccessShared: true, ActionView: true, ActionDownload: true}, }, + ResourcePlaces: Roles{ + RoleAdmin: GrantFullAccess, + RoleVisitor: Grant{AccessShared: true, ActionView: true, ActionDownload: true}, + }, + ResourceCalendar: Roles{ + RoleAdmin: GrantFullAccess, + RoleVisitor: Grant{AccessShared: true, ActionView: true, ActionDownload: true}, + }, + ResourceMoments: Roles{ + RoleAdmin: GrantFullAccess, + RoleVisitor: Grant{AccessShared: true, ActionView: true, ActionDownload: true}, + }, + ResourcePeople: Roles{ + RoleAdmin: GrantFullAccess, + }, + ResourceFavorites: Roles{ + RoleAdmin: GrantFullAccess, + }, + ResourceLabels: Roles{ + RoleAdmin: GrantFullAccess, + }, + ResourceLogs: Roles{ + RoleAdmin: GrantFullAccess, + }, + ResourceSettings: Roles{ + RoleAdmin: GrantFullAccess, + }, + ResourceFeedback: Roles{ + RoleAdmin: GrantFullAccess, + }, + ResourcePassword: Roles{ + RoleAdmin: GrantFullAccess, + }, + ResourceShares: Roles{ + RoleAdmin: GrantFullAccess, + }, + ResourceAccounts: Roles{ + RoleAdmin: GrantFullAccess, + }, + ResourceUsers: Roles{ + RoleAdmin: GrantFullAccess, + }, + ResourceConfig: Roles{ + RoleAdmin: GrantFullAccess, + }, + ResourceDefault: Roles{ + RoleAdmin: GrantFullAccess, + }, } diff --git a/internal/api/albums.go b/internal/api/albums.go index 716122680..8173e573e 100644 --- a/internal/api/albums.go +++ b/internal/api/albums.go @@ -15,7 +15,6 @@ import ( "github.com/photoprism/photoprism/internal/query" "github.com/photoprism/photoprism/internal/search" "github.com/photoprism/photoprism/internal/service" - "github.com/photoprism/photoprism/pkg/clean" ) @@ -83,7 +82,7 @@ func CreateAlbum(router *gin.RouterGroup) { albumMutex.Lock() defer albumMutex.Unlock() - a := entity.NewAlbum(f.AlbumTitle, entity.AlbumDefault) + a := entity.NewUserAlbum(f.AlbumTitle, entity.AlbumDefault, s.UserUID) a.AlbumFavorite = f.AlbumFavorite // Existing album? diff --git a/internal/api/albums_search.go b/internal/api/albums_search.go index 0d10bcb26..5072283c8 100644 --- a/internal/api/albums_search.go +++ b/internal/api/albums_search.go @@ -26,12 +26,11 @@ func SearchAlbums(router *gin.RouterGroup) { return } + var err error var f form.SearchAlbums - err := c.MustBindWith(&f, binding.Form) - // Abort if request params are invalid. - if err != nil { + if err = c.MustBindWith(&f, binding.Form); err != nil { event.AuditWarn([]string{ClientIP(c), "session %s", "albums", "search", "form invalid", "%s"}, s.RefID, err) AbortBadRequest(c) return @@ -39,21 +38,15 @@ func SearchAlbums(router *gin.RouterGroup) { conf := service.Config() - // Sharing link visitors permissions are limited to shared albums. - if s.IsVisitor() { - f.UID = s.SharedUIDs().Join(txt.Or) - f.Public = true - event.AuditDebug([]string{ClientIP(c), "session %s", "albums", "search", "shared", "%s"}, s.RefID, f.UID) - } else if conf.Settings().Features.Private { - f.Public = true - event.AuditDebug([]string{ClientIP(c), "session %s", "albums", "search", "all public"}, s.RefID) - } else { + // Ignore private flag if feature is disabled. + if !conf.Settings().Features.Private { f.Public = false - event.AuditDebug([]string{ClientIP(c), "session %s", "albums", "search", "all public and private"}, s.RefID) } - result, err := search.Albums(f) + // Find matching albums. + result, err := search.UserAlbums(f, s) + // Ok? if err != nil { event.AuditWarn([]string{ClientIP(c), "session %s", "albums", "search", "%s"}, s.RefID, err) c.AbortWithStatusJSON(400, gin.H{"error": txt.UpperFirst(err.Error())}) @@ -65,6 +58,7 @@ func SearchAlbums(router *gin.RouterGroup) { AddOffsetHeader(c, f.Offset) AddTokenHeaders(c) + // Return as JSON. c.JSON(http.StatusOK, result) }) } diff --git a/internal/api/config_settings.go b/internal/api/config_settings.go index 3a332b1f7..abfd79a54 100644 --- a/internal/api/config_settings.go +++ b/internal/api/config_settings.go @@ -3,11 +3,10 @@ package api import ( "net/http" - "github.com/photoprism/photoprism/internal/customize" - "github.com/gin-gonic/gin" "github.com/photoprism/photoprism/internal/acl" + "github.com/photoprism/photoprism/internal/customize" "github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/i18n" "github.com/photoprism/photoprism/internal/service" @@ -57,7 +56,8 @@ func SaveSettings(router *gin.RouterGroup) { var settings *customize.Settings - if acl.Resources.Allow(acl.ResourceSettings, s.User().AclRole(), acl.ActionManage) { + // Only admins can change the global config. + if s.User().IsAdmin() { settings = conf.Settings() if err := c.BindJSON(settings); err != nil { diff --git a/internal/api/import.go b/internal/api/import.go index 51326841f..01d51555c 100644 --- a/internal/api/import.go +++ b/internal/api/import.go @@ -103,6 +103,11 @@ func StartImport(router *gin.RouterGroup) { opt.Albums = f.Albums } + // Set user UID if known. + if s.UserUID != "" { + opt.OwnerUID = s.UserUID + } + // Start import. imported := imp.Start(opt) diff --git a/internal/api/links.go b/internal/api/links.go index acba99824..3b6f3ebc5 100644 --- a/internal/api/links.go +++ b/internal/api/links.go @@ -16,6 +16,8 @@ import ( "github.com/photoprism/photoprism/pkg/txt" ) +// UpdateLink updates a share link and return it as JSON. +// // PUT /api/v1/:entity/:uid/links/:link func UpdateLink(c *gin.Context) { s := Auth(c, acl.ResourceShares, acl.ActionUpdate) @@ -28,6 +30,7 @@ func UpdateLink(c *gin.Context) { var f form.Link if err := c.BindJSON(&f); err != nil { + log.Debugf("share: %s", err) AbortBadRequest(c) return } @@ -63,6 +66,8 @@ func UpdateLink(c *gin.Context) { c.JSON(http.StatusOK, link) } +// DeleteLink deletes a share link. +// // DELETE /api/v1/:entity/:uid/links/:link func DeleteLink(c *gin.Context) { s := Auth(c, acl.ResourceShares, acl.ActionDelete) @@ -88,23 +93,32 @@ func DeleteLink(c *gin.Context) { c.JSON(http.StatusOK, link) } -// CreateLink returns a new link entity initialized with request data +// CreateLink adds a new share link and return it as JSON. +// +// POST /api/v1/:entity/:uid/links func CreateLink(c *gin.Context) { s := Auth(c, acl.ResourceShares, acl.ActionCreate) - if s.Invalid() { - AbortForbidden(c) + if s.Abort(c) { + return + } + + uid := clean.UID(c.Param("uid")) + + if uid == "" { + AbortBadRequest(c) return } var f form.Link if err := c.BindJSON(&f); err != nil { + log.Debugf("share: %s", err) AbortBadRequest(c) return } - link := entity.NewLink(clean.UID(c.Param("uid")), f.CanComment, f.CanEdit) + link := entity.NewUserLink(uid, f.CanComment, f.CanEdit, s.UserUID) link.SetSlug(f.ShareSlug) link.MaxViews = f.MaxViews @@ -131,9 +145,17 @@ func CreateLink(c *gin.Context) { c.JSON(http.StatusOK, link) } +// CreateAlbumLink adds a new album share link and return it as JSON. +// // POST /api/v1/albums/:uid/links func CreateAlbumLink(router *gin.RouterGroup) { router.POST("/albums/:uid/links", func(c *gin.Context) { + s := Auth(c, acl.ResourceAlbums, acl.ActionShare) + + if s.Abort(c) { + return + } + if _, err := query.AlbumByUID(clean.UID(c.Param("uid"))); err != nil { AbortAlbumNotFound(c) return @@ -143,23 +165,47 @@ func CreateAlbumLink(router *gin.RouterGroup) { }) } +// UpdateAlbumLink updates an album share link and return it as JSON. +// // PUT /api/v1/albums/:uid/links/:link func UpdateAlbumLink(router *gin.RouterGroup) { router.PUT("/albums/:uid/links/:link", func(c *gin.Context) { + s := Auth(c, acl.ResourceAlbums, acl.ActionShare) + + if s.Abort(c) { + return + } + UpdateLink(c) }) } +// DeleteAlbumLink deletes an album share link. +// // DELETE /api/v1/albums/:uid/links/:link func DeleteAlbumLink(router *gin.RouterGroup) { router.DELETE("/albums/:uid/links/:link", func(c *gin.Context) { + s := Auth(c, acl.ResourceAlbums, acl.ActionShare) + + if s.Abort(c) { + return + } + DeleteLink(c) }) } +// GetAlbumLinks returns all share links for the given UID as JSON. +// // GET /api/v1/albums/:uid/links func GetAlbumLinks(router *gin.RouterGroup) { router.GET("/albums/:uid/links", func(c *gin.Context) { + s := Auth(c, acl.ResourceAlbums, acl.ActionShare) + + if s.Abort(c) { + return + } + m, err := query.AlbumByUID(clean.UID(c.Param("uid"))) if err != nil { @@ -171,9 +217,19 @@ func GetAlbumLinks(router *gin.RouterGroup) { }) } +/* + +// CreatePhotoLink adds a new photo share link and return it as JSON. +// // POST /api/v1/photos/:uid/links func CreatePhotoLink(router *gin.RouterGroup) { router.POST("/photos/:uid/links", func(c *gin.Context) { + s := Auth(c, acl.ResourcePhotos, acl.ActionShare) + + if s.Abort(c) { + return + } + if _, err := query.PhotoByUID(clean.UID(c.Param("uid"))); err != nil { AbortEntityNotFound(c) return @@ -183,23 +239,47 @@ func CreatePhotoLink(router *gin.RouterGroup) { }) } +// UpdatePhotoLink updates an existing photo sharing link. +// // PUT /api/v1/photos/:uid/links/:link func UpdatePhotoLink(router *gin.RouterGroup) { router.PUT("/photos/:uid/links/:link", func(c *gin.Context) { + s := Auth(c, acl.ResourcePhotos, acl.ActionShare) + + if s.Abort(c) { + return + } + UpdateLink(c) }) } +// DeletePhotoLink deletes a photo sharing link. +// // DELETE /api/v1/photos/:uid/links/:link func DeletePhotoLink(router *gin.RouterGroup) { router.DELETE("/photos/:uid/links/:link", func(c *gin.Context) { + s := Auth(c, acl.ResourcePhotos, acl.ActionShare) + + if s.Abort(c) { + return + } + DeleteLink(c) }) } +// GetPhotoLinks returns all share links for the given UID as JSON. +// // GET /api/v1/photos/:uid/links func GetPhotoLinks(router *gin.RouterGroup) { router.GET("/photos/:uid/links", func(c *gin.Context) { + s := Auth(c, acl.ResourcePhotos, acl.ActionShare) + + if s.Abort(c) { + return + } + m, err := query.PhotoByUID(clean.UID(c.Param("uid"))) if err != nil { @@ -211,9 +291,17 @@ func GetPhotoLinks(router *gin.RouterGroup) { }) } +// CreateLabelLink adds a new label share link and return it as JSON. +// // POST /api/v1/labels/:uid/links func CreateLabelLink(router *gin.RouterGroup) { router.POST("/labels/:uid/links", func(c *gin.Context) { + s := Auth(c, acl.ResourceLabels, acl.ActionShare) + + if s.Abort(c) { + return + } + if _, err := query.LabelByUID(clean.UID(c.Param("uid"))); err != nil { Abort(c, http.StatusNotFound, i18n.ErrLabelNotFound) return @@ -223,23 +311,47 @@ func CreateLabelLink(router *gin.RouterGroup) { }) } +// UpdateLabelLink updates a label share link and return it as JSON. +// // PUT /api/v1/labels/:uid/links/:link func UpdateLabelLink(router *gin.RouterGroup) { router.PUT("/labels/:uid/links/:link", func(c *gin.Context) { + s := Auth(c, acl.ResourceLabels, acl.ActionShare) + + if s.Abort(c) { + return + } + UpdateLink(c) }) } +// DeleteLabelLink deletes a label share link. +// // DELETE /api/v1/labels/:uid/links/:link func DeleteLabelLink(router *gin.RouterGroup) { router.DELETE("/labels/:uid/links/:link", func(c *gin.Context) { + s := Auth(c, acl.ResourceLabels, acl.ActionShare) + + if s.Abort(c) { + return + } + DeleteLink(c) }) } +// GetLabelLinks returns all share links for the given UID as JSON. +// // GET /api/v1/labels/:uid/links func GetLabelLinks(router *gin.RouterGroup) { router.GET("/labels/:uid/links", func(c *gin.Context) { + s := Auth(c, acl.ResourceLabels, acl.ActionShare) + + if s.Abort(c) { + return + } + m, err := query.LabelByUID(clean.UID(c.Param("uid"))) if err != nil { @@ -250,3 +362,4 @@ func GetLabelLinks(router *gin.RouterGroup) { c.JSON(http.StatusOK, m.Links()) }) } +*/ diff --git a/internal/api/links_test.go b/internal/api/links_test.go index f496d2c76..821e56ef0 100644 --- a/internal/api/links_test.go +++ b/internal/api/links_test.go @@ -149,6 +149,7 @@ func TestGetAlbumLinks(t *testing.T) { }) } +/* func TestCreatePhotoLink(t *testing.T) { t.Run("create share link", func(t *testing.T) { app, router, _ := NewApiTest() @@ -157,7 +158,8 @@ func TestCreatePhotoLink(t *testing.T) { CreatePhotoLink(router) - resp := PerformRequestWithBody(app, "POST", "/api/v1/photos/pt9jtdre2lvl0yh7/links", `{"Password": "foobar", "Expires": 0, "CanEdit": true}`) + resp := PerformRequestWithBody(app, "POST", "/api/v1/photos/pt9jtdre2lvl0yh7/links", `{"Password":"foobar","Expires":0,"CanEdit":true}`) + log.Debugf("BODY: %s", resp.Body.String()) assert.Equal(t, http.StatusOK, resp.Code) if err := json.Unmarshal(resp.Body.Bytes(), &link); err != nil { @@ -403,3 +405,4 @@ func TestGetLabelLinks(t *testing.T) { assert.Equal(t, http.StatusNotFound, r.Code) }) } +*/ diff --git a/internal/api/photo_unstack.go b/internal/api/photo_unstack.go index d1ac73aa2..a8a3df77f 100644 --- a/internal/api/photo_unstack.go +++ b/internal/api/photo_unstack.go @@ -73,6 +73,7 @@ func PhotoUnstack(router *gin.RouterGroup) { } stackPhoto := *file.Photo + ownerUID := stackPhoto.OwnerUID stackPrimary, err := stackPhoto.PrimaryFile() if err != nil { @@ -125,7 +126,7 @@ func PhotoUnstack(router *gin.RouterGroup) { } // Create new photo, also flagged as unstacked / not stackable. - newPhoto := entity.NewPhoto(false) + newPhoto := entity.NewUserPhoto(false, ownerUID) newPhoto.PhotoPath = unstackFile.RootRelPath() newPhoto.PhotoName = unstackFile.BasePrefix(false) diff --git a/internal/api/photos_search.go b/internal/api/photos_search.go index 62c3bab8d..8d9a8064f 100644 --- a/internal/api/photos_search.go +++ b/internal/api/photos_search.go @@ -53,8 +53,10 @@ func SearchPhotos(router *gin.RouterGroup) { return } + // Find matching pictures. result, count, err := search.UserPhotos(f, s) + // Ok? if err != nil { event.AuditWarn([]string{ClientIP(c), "session %s", string(acl.ResourcePhotos), "search", "%s"}, s.RefID, err) AbortBadRequest(c) @@ -67,7 +69,7 @@ func SearchPhotos(router *gin.RouterGroup) { AddOffsetHeader(c, f.Offset) AddTokenHeaders(c) - // Render as JSON. + // Return as JSON. c.JSON(http.StatusOK, result) } @@ -96,7 +98,7 @@ func SearchPhotos(router *gin.RouterGroup) { AddOffsetHeader(c, f.Offset) AddTokenHeaders(c) - // Render as JSON. + // Return as JSON. c.JSON(http.StatusOK, result) } diff --git a/internal/api/photos_search_geojson.go b/internal/api/photos_search_geojson.go index f73ae6b0a..8c691e83f 100644 --- a/internal/api/photos_search_geojson.go +++ b/internal/api/photos_search_geojson.go @@ -28,8 +28,8 @@ func SearchGeo(router *gin.RouterGroup) { return } - var f form.SearchPhotosGeo var err error + var f form.SearchPhotosGeo // Abort if request params are invalid. if err = c.MustBindWith(&f, binding.Form); err != nil { @@ -48,6 +48,7 @@ func SearchGeo(router *gin.RouterGroup) { // Find matching pictures. photos, err := search.UserPhotosGeo(f, s) + // Ok? if err != nil { event.AuditWarn([]string{ClientIP(c), "session %s", string(acl.ResourcePlaces), "search", "%s"}, s.RefID, err) AbortBadRequest(c) diff --git a/internal/entity/album.go b/internal/entity/album.go index 030744a1f..c35ad2092 100644 --- a/internal/entity/album.go +++ b/internal/entity/album.go @@ -55,6 +55,7 @@ type Album struct { AlbumPrivate bool `json:"Private" yaml:"Private,omitempty"` Thumb string `gorm:"type:VARBINARY(128);index;default:'';" json:"Thumb" yaml:"Thumb,omitempty"` ThumbSrc string `gorm:"type:VARBINARY(8);default:'';" json:"ThumbSrc,omitempty" yaml:"ThumbSrc,omitempty"` + OwnerUID string `gorm:"type:VARBINARY(64);index" json:"OwnerUID,omitempty" yaml:"OwnerUID,omitempty"` CreatedAt time.Time `json:"CreatedAt" yaml:"CreatedAt,omitempty"` UpdatedAt time.Time `json:"UpdatedAt" yaml:"UpdatedAt,omitempty"` DeletedAt *time.Time `sql:"index" json:"DeletedAt" yaml:"DeletedAt,omitempty"` @@ -79,43 +80,48 @@ func (Album) TableName() string { } // AddPhotoToAlbums adds a photo UID to multiple albums and automatically creates them if needed. -func AddPhotoToAlbums(photo string, albums []string) (err error) { - if photo == "" || len(albums) == 0 { +func AddPhotoToAlbums(uid string, albums []string) (err error) { + return AddPhotoToUserAlbums(uid, albums, OwnerUnknown) +} + +// AddPhotoToUserAlbums adds a photo UID to multiple albums and automatically creates them as a user if needed. +func AddPhotoToUserAlbums(uid string, albums []string, userUID string) (err error) { + if uid == "" || len(albums) == 0 { // Do nothing. return nil } - if !rnd.IsUID(photo, PhotoUID) { - return fmt.Errorf("album: invalid photo uid %s", photo) + if !rnd.IsUID(uid, PhotoUID) { + return fmt.Errorf("album: invalid photo uid %s", uid) } for _, album := range albums { var aUID string if album == "" { - log.Debugf("album: empty album identifier while adding photo %s", photo) + log.Debugf("album: empty album identifier while adding photo %s", uid) continue } if rnd.IsUID(album, AlbumUID) { aUID = album } else { - a := NewAlbum(album, AlbumDefault) + a := NewUserAlbum(album, AlbumDefault, userUID) if found := a.Find(); found != nil { aUID = found.AlbumUID } else if err = a.Create(); err == nil { aUID = a.AlbumUID } else { - log.Errorf("album: %s (add photo %s to albums)", err.Error(), photo) + log.Errorf("album: %s (add photo %s to albums)", err.Error(), uid) } } if aUID != "" { - entry := PhotoAlbum{AlbumUID: aUID, PhotoUID: photo, Hidden: false} + entry := PhotoAlbum{AlbumUID: aUID, PhotoUID: uid, Hidden: false} if err = entry.Save(); err != nil { - log.Errorf("album: %s (add photo %s to albums)", err.Error(), photo) + log.Errorf("album: %s (add photo %s to albums)", err.Error(), uid) } } } @@ -123,21 +129,30 @@ func AddPhotoToAlbums(photo string, albums []string) (err error) { return err } -// NewAlbum creates a new album; default name is current month and year +// NewAlbum creates a new album of the given type. func NewAlbum(albumTitle, albumType string) *Album { + return NewUserAlbum(albumTitle, albumType, OwnerUnknown) +} + +// NewUserAlbum creates a new album owned by a user. +func NewUserAlbum(albumTitle, albumType, userUID string) *Album { now := TimeStamp() + // Set default type. if albumType == "" { albumType = AlbumDefault } + // Set default values. result := &Album{ + OwnerUID: userUID, AlbumOrder: SortOrderOldest, AlbumType: albumType, CreatedAt: now, UpdatedAt: now, } + // Set album title. result.SetTitle(albumTitle) return result diff --git a/internal/entity/auth_user.go b/internal/entity/auth_user.go index e53a9ccd5..cae1a9326 100644 --- a/internal/entity/auth_user.go +++ b/internal/entity/auth_user.go @@ -19,8 +19,9 @@ import ( // User identifier prefixes. const ( - UserUID = byte('u') - UserPrefix = "user" + UserUID = byte('u') + UserPrefix = "user" + OwnerUnknown = "" ) // LenNameMin specifies the minimum length of the username in characters. @@ -35,8 +36,8 @@ type Users []User // User represents a person that may optionally log in as user. type User struct { ID int `gorm:"primary_key" json:"-" yaml:"-"` + UUID string `gorm:"type:VARBINARY(64);column:user_uuid;index;" json:"UUID,omitempty" yaml:"UUID,omitempty"` UserUID string `gorm:"type:VARBINARY(64);column:user_uid;unique_index;" json:"UID" yaml:"UID"` - UserUUID string `gorm:"type:VARBINARY(128);column:user_uuid;index;" json:"UUID,omitempty" yaml:"UUID,omitempty"` AuthProvider string `gorm:"type:VARBINARY(128);default:'';" json:"AuthProvider,omitempty" yaml:"AuthProvider,omitempty"` AuthID string `gorm:"type:VARBINARY(128);index;default:'';" json:"AuthID,omitempty" yaml:"AuthID,omitempty"` UserName string `gorm:"size:64;index;" json:"Name" yaml:"Name,omitempty"` @@ -449,7 +450,7 @@ func (m *User) IsRegistered() bool { // IsAdmin checks if the user is an admin with username. func (m *User) IsAdmin() bool { - return m.IsRegistered() && m.AclRole() == acl.RoleAdmin + return m.IsRegistered() && (m.SuperAdmin || m.AclRole() == acl.RoleAdmin) } // IsVisitor checks if the user is a sharing link visitor. diff --git a/internal/entity/link.go b/internal/entity/link.go index 4bce0b90e..de2d6e680 100644 --- a/internal/entity/link.go +++ b/internal/entity/link.go @@ -29,6 +29,7 @@ type Link struct { HasPassword bool `json:"HasPassword" yaml:"HasPassword,omitempty"` CanComment bool `json:"CanComment" yaml:"CanComment,omitempty"` CanEdit bool `json:"CanEdit" yaml:"CanEdit,omitempty"` + OwnerUID string `gorm:"type:VARBINARY(64);index" json:"OwnerUID,omitempty" yaml:"OwnerUID,omitempty"` CreatedAt time.Time `deepcopier:"skip" json:"CreatedAt" yaml:"CreatedAt"` ModifiedAt time.Time `deepcopier:"skip" json:"ModifiedAt" yaml:"ModifiedAt"` } @@ -49,9 +50,15 @@ func (m *Link) BeforeCreate(scope *gorm.Scope) error { // NewLink creates a sharing link. func NewLink(shareUID string, canComment, canEdit bool) Link { + return NewUserLink(shareUID, canComment, canEdit, OwnerUnknown) +} + +// NewUserLink creates a sharing link owned by a user. +func NewUserLink(shareUID string, canComment, canEdit bool, userUID string) Link { now := TimeStamp() result := Link{ + OwnerUID: userUID, LinkUID: rnd.GenerateUID(LinkUID), ShareUID: shareUID, LinkToken: rnd.GenerateToken(10), diff --git a/internal/entity/photo.go b/internal/entity/photo.go index f5c699bed..d91b2953c 100644 --- a/internal/entity/photo.go +++ b/internal/entity/photo.go @@ -9,8 +9,6 @@ import ( "sync" "time" - "github.com/photoprism/photoprism/pkg/react" - "github.com/jinzhu/gorm" "github.com/ulule/deepcopier" @@ -18,6 +16,7 @@ import ( "github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/form" "github.com/photoprism/photoprism/pkg/clean" + "github.com/photoprism/photoprism/pkg/react" "github.com/photoprism/photoprism/pkg/rnd" "github.com/photoprism/photoprism/pkg/txt" ) @@ -104,6 +103,7 @@ type Photo struct { Albums []Album `json:"-" yaml:"-"` Files []File `yaml:"-"` Labels []PhotoLabel `yaml:"-"` + OwnerUID string `gorm:"type:VARBINARY(64);index" json:"OwnerUID,omitempty" yaml:"OwnerUID,omitempty"` CreatedAt time.Time `yaml:"CreatedAt,omitempty"` UpdatedAt time.Time `yaml:"UpdatedAt,omitempty"` EditedAt *time.Time `yaml:"EditedAt,omitempty"` @@ -117,9 +117,15 @@ func (Photo) TableName() string { return "photos" } -// NewPhoto creates a photo entity. +// NewPhoto creates a new photo with default values. func NewPhoto(stackable bool) Photo { + return NewUserPhoto(stackable, "") +} + +// NewUserPhoto creates a photo owned by a user. +func NewUserPhoto(stackable bool, userUID string) Photo { m := Photo{ + OwnerUID: userUID, PhotoTitle: UnknownTitle, PhotoType: MediaImage, PhotoCountry: UnknownCountry.ID, diff --git a/internal/form/search_photos.go b/internal/form/search_photos.go index 0e258778e..eed1835a1 100644 --- a/internal/form/search_photos.go +++ b/internal/form/search_photos.go @@ -9,7 +9,7 @@ import ( // SearchPhotos represents search form fields for "/api/v1/photos". type SearchPhotos struct { Query string `form:"q"` - In string `form:"in" serialize:"-" example:"in:ariqwb43p5dh9h13" notes:"Limits results to the album UID specified"` + Scope string `form:"s" serialize:"-" example:"s:ariqwb43p5dh9h13" notes:"Limits the results to one album or another scope, if specified"` Filter string `form:"filter" serialize:"-" notes:"-"` UID string `form:"uid" example:"uid:pqbcf5j446s0futy" notes:"Search for specific files or photos, only exact matches"` Type string `form:"type" example:"type:raw" notes:"Media Type (image, video, raw, live, animated); OR search with |"` @@ -141,9 +141,9 @@ func (f *SearchPhotos) SerializeAll() string { // FindUidOnly checks if search filters other than UID may be skipped to improve performance. func (f *SearchPhotos) FindUidOnly() bool { - return f.UID != "" && f.Query == "" && f.In == "" && f.Filter == "" && f.Album == "" && f.Albums == "" + return f.UID != "" && f.Query == "" && f.Scope == "" && f.Filter == "" && f.Album == "" && f.Albums == "" } -func NewPhotoSearch(query string) SearchPhotos { +func NewSearchPhotos(query string) SearchPhotos { return SearchPhotos{Query: query} } diff --git a/internal/form/search_photos_geo.go b/internal/form/search_photos_geo.go index 8a12820d2..2d123b58e 100644 --- a/internal/form/search_photos_geo.go +++ b/internal/form/search_photos_geo.go @@ -9,7 +9,7 @@ import ( // SearchPhotosGeo represents search form fields for "/api/v1/geo". type SearchPhotosGeo struct { Query string `form:"q"` - In string `form:"in" serialize:"-" example:"in:ariqwb43p5dh9h13" notes:"Limits results to the album UID specified"` + Scope string `form:"s" serialize:"-" example:"s:ariqwb43p5dh9h13" notes:"Limits the results to one album or another scope, if specified"` Filter string `form:"filter" serialize:"-" notes:"-"` UID string `form:"uid" example:"uid:pqbcf5j446s0futy" notes:"Search for specific files or photos, only exact matches"` Near string `form:"near"` @@ -126,9 +126,9 @@ func (f *SearchPhotosGeo) SerializeAll() string { // FindByIdOnly checks if search filters other than UID may be skipped to improve performance. func (f *SearchPhotosGeo) FindByIdOnly() bool { - return f.UID != "" && f.Query == "" && f.In == "" && f.Filter == "" && f.Album == "" && f.Albums == "" + return f.UID != "" && f.Query == "" && f.Scope == "" && f.Filter == "" && f.Album == "" && f.Albums == "" } -func NewGeoSearch(query string) SearchPhotosGeo { +func NewSearchPhotosGeo(query string) SearchPhotosGeo { return SearchPhotosGeo{Query: query} } diff --git a/internal/form/search_photos_geo_test.go b/internal/form/search_photos_geo_test.go index ea05b3660..1f5ba7558 100644 --- a/internal/form/search_photos_geo_test.go +++ b/internal/form/search_photos_geo_test.go @@ -7,7 +7,7 @@ import ( "github.com/stretchr/testify/assert" ) -func TestGeoSearch(t *testing.T) { +func TestSearchPhotosGeo(t *testing.T) { t.Run("subjects", func(t *testing.T) { form := &SearchPhotosGeo{Query: "subjects:\"Jens Mander\""} @@ -177,19 +177,19 @@ func TestGeoSearch(t *testing.T) { }) } -func TestGeoSearch_Serialize(t *testing.T) { +func TestSearchPhotosGeo_Serialize(t *testing.T) { form := &SearchPhotosGeo{Query: "q:\"fooBar baz\"", Favorite: true} assert.Equal(t, "q:\"q:fooBar baz\" favorite:true", form.Serialize()) } -func TestGeoSearch_SerializeAll(t *testing.T) { +func TestSearchPhotosGeo_SerializeAll(t *testing.T) { form := &SearchPhotosGeo{Query: "q:\"fooBar baz\"", Favorite: true} assert.Equal(t, "q:\"q:fooBar baz\" favorite:true", form.SerializeAll()) } -func TestNewGeoSearch(t *testing.T) { - r := NewGeoSearch("Berlin") +func TestNewSearchPhotosGeo(t *testing.T) { + r := NewSearchPhotosGeo("Berlin") assert.IsType(t, SearchPhotosGeo{}, r) } diff --git a/internal/form/search_photos_test.go b/internal/form/search_photos_test.go index 4decd06f0..5c636482a 100644 --- a/internal/form/search_photos_test.go +++ b/internal/form/search_photos_test.go @@ -7,7 +7,7 @@ import ( "github.com/stretchr/testify/assert" ) -func TestPhotoSearchForm(t *testing.T) { +func TestSearchPhotosForm(t *testing.T) { form := &SearchPhotos{} assert.IsType(t, new(SearchPhotos), form) @@ -664,12 +664,12 @@ func TestParseQueryString(t *testing.T) { }) } -func TestNewPhotoSearch(t *testing.T) { - r := NewPhotoSearch("cat") +func TestNewSearchPhotos(t *testing.T) { + r := NewSearchPhotos("cat") assert.IsType(t, SearchPhotos{}, r) } -func TestPhotoSearch_Serialize(t *testing.T) { +func TestSearchPhotos_Serialize(t *testing.T) { form := SearchPhotos{ Query: "foo BAR", Private: true, @@ -689,7 +689,7 @@ func TestPhotoSearch_Serialize(t *testing.T) { assert.IsType(t, "string", result) } -func TestPhotoSearch_SerializeAll(t *testing.T) { +func TestSearchPhotos_SerializeAll(t *testing.T) { form := SearchPhotos{ Query: "foo BAR", Private: true, @@ -708,3 +708,70 @@ func TestPhotoSearch_SerializeAll(t *testing.T) { assert.IsType(t, "string", result) } + +func TestSearchPhotos_Filter(t *testing.T) { + t.Run("WithScope", func(t *testing.T) { + f := &SearchPhotos{Query: "album:cat filter:\"name:foo.jpg\" s:ariqwb43p5dh9h13 search-string", Scope: "ariqwb43p5dh2244", Filter: "name:foo.jpg album:ariqwb43p5dh5555 q:foo uid:priqwb43p5dh7777"} + + err := f.ParseQueryString() + + t.Logf("WithScope: %+v\n", f) + + assert.ErrorContains(t, err, "unknown filter: s") + assert.Equal(t, "search-string", f.Query) + assert.Equal(t, "ariqwb43p5dh2244", f.Scope) + assert.Equal(t, "name:foo.jpg album:ariqwb43p5dh5555 q:foo uid:priqwb43p5dh7777", f.Filter) + assert.Equal(t, "", f.Name) + assert.Equal(t, "", f.UID) + assert.Equal(t, "cat", f.Album) + }) + t.Run("ScopeInQuery", func(t *testing.T) { + f := &SearchPhotos{Query: "album:cat filter:\"name:foo.jpg\" s:ariqwb43p5dh9h13 search-string", Filter: "name:foo.jpg album:ariqwb43p5dh5555 q:foo uid:priqwb43p5dh7777"} + + err := f.ParseQueryString() + + t.Logf("ScopeInQuery: %+v\n", f) + + assert.ErrorContains(t, err, "unknown filter: s") + assert.Equal(t, "search-string", f.Query) + assert.Equal(t, "", f.Scope) + assert.Equal(t, "name:foo.jpg album:ariqwb43p5dh5555 q:foo uid:priqwb43p5dh7777", f.Filter) + assert.Equal(t, "", f.Name) + assert.Equal(t, "", f.UID) + assert.Equal(t, "cat", f.Album) + }) + t.Run("NoScope", func(t *testing.T) { + f := &SearchPhotos{Query: "album:cat search-string", Filter: "name:foo.jpg album:ariqwb43p5dh5555 q:foo uid:priqwb43p5dh7777"} + + err := f.ParseQueryString() + + t.Logf("ScopeInQuery: %+v\n", f) + + assert.NoError(t, err) + assert.Equal(t, "foo", f.Query) + assert.Equal(t, "", f.Scope) + assert.Equal(t, "name:foo.jpg album:ariqwb43p5dh5555 q:foo uid:priqwb43p5dh7777", f.Filter) + assert.Equal(t, "foo", f.Name) + assert.Equal(t, "priqwb43p5dh7777", f.UID) + assert.Equal(t, "ariqwb43p5dh5555", f.Album) + }) +} + +func TestSearchPhotos_Unserialize(t *testing.T) { + t.Run("Filter", func(t *testing.T) { + f := &SearchPhotos{Query: "bar album:ariqwb43p5dh9999 uid:priqwb43p5dh4321 albums:baz s:ariqwb43p5dh1122 search-string", Scope: "ariqwb43p5dh2244"} + + if err := Unserialize(f, "name:foo.jpg album:ariqwb43p5dh5555 q:foo uid:priqwb43p5dh7777"); err != nil { + t.Fatal(err) + } + + t.Logf("UnserializeFilter: %+v\n", f) + + assert.Equal(t, "foo", f.Query) + assert.Equal(t, "ariqwb43p5dh2244", f.Scope) + assert.Equal(t, "", f.Filter) + assert.Equal(t, "foo.jpg", f.Name) + assert.Equal(t, "priqwb43p5dh7777", f.UID) + assert.Equal(t, "ariqwb43p5dh5555", f.Album) + }) +} diff --git a/internal/photoprism/import_options.go b/internal/photoprism/import_options.go index d147f8574..3bd543636 100644 --- a/internal/photoprism/import_options.go +++ b/internal/photoprism/import_options.go @@ -5,6 +5,7 @@ type ImportOptions struct { Albums []string Path string Move bool + OwnerUID string DestFolder string RemoveDotFiles bool RemoveExistingFiles bool diff --git a/internal/photoprism/import_worker.go b/internal/photoprism/import_worker.go index e9e449f6b..aea9ec221 100644 --- a/internal/photoprism/import_worker.go +++ b/internal/photoprism/import_worker.go @@ -111,7 +111,7 @@ func ImportWorker(jobs <-chan ImportJob) { // Do nothing. } else if file, err := entity.FirstFileByHash(fileHash); err != nil { // Do nothing. - } else if err := entity.AddPhotoToAlbums(file.PhotoUID, opt.Albums); err != nil { + } else if err := entity.AddPhotoToUserAlbums(file.PhotoUID, opt.Albums, opt.OwnerUID); err != nil { log.Warn(err) } @@ -191,7 +191,7 @@ func ImportWorker(jobs <-chan ImportJob) { } // Index main MediaFile. - res := ind.MediaFile(f, o, originalName, "") + res := ind.UserMediaFile(f, o, originalName, "", opt.OwnerUID) // Log result. log.Infof("import: %s main %s file %s", res, f.FileType(), clean.Log(f.RootRelName())) @@ -204,7 +204,7 @@ func ImportWorker(jobs <-chan ImportJob) { photoUID = res.PhotoUID // Add photo to album if a list of albums was provided when importing. - if err := entity.AddPhotoToAlbums(photoUID, opt.Albums); err != nil { + if err := entity.AddPhotoToUserAlbums(photoUID, opt.Albums, opt.OwnerUID); err != nil { log.Warn(err) } } @@ -241,7 +241,7 @@ func ImportWorker(jobs <-chan ImportJob) { } // Index related media file including its original filename. - res := ind.MediaFile(f, o, relatedOriginalNames[f.FileName()], photoUID) + res := ind.UserMediaFile(f, o, relatedOriginalNames[f.FileName()], photoUID, opt.OwnerUID) // Save file error. if fileUid, err := res.FileError(); err != nil { diff --git a/internal/photoprism/index_mediafile.go b/internal/photoprism/index_mediafile.go index f8ac18071..0640ec39a 100644 --- a/internal/photoprism/index_mediafile.go +++ b/internal/photoprism/index_mediafile.go @@ -14,7 +14,6 @@ import ( "github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/meta" "github.com/photoprism/photoprism/internal/query" - "github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/fs" "github.com/photoprism/photoprism/pkg/txt" @@ -22,6 +21,11 @@ import ( // MediaFile indexes a single media file. func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName, photoUID string) (result IndexResult) { + return ind.UserMediaFile(m, o, originalName, photoUID, entity.OwnerUnknown) +} + +// UserMediaFile indexes a single media file owned by a user. +func (ind *Index) UserMediaFile(m *MediaFile, o IndexOptions, originalName, photoUID, userUID string) (result IndexResult) { if m == nil { result.Status = IndexFailed result.Err = errors.New("index: media file is nil - possible bug") @@ -46,7 +50,7 @@ func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName, photoUID file, primaryFile := entity.File{}, entity.File{} - photo := entity.NewPhoto(o.Stack) + photo := entity.NewUserPhoto(o.Stack, userUID) metaData := meta.New() labels := classify.Labels{} stripSequence := Config().Settings().StackSequences() && o.Stack diff --git a/internal/photoprism/index_mediafile_test.go b/internal/photoprism/index_mediafile_test.go index f835c6c9c..836c2d10b 100644 --- a/internal/photoprism/index_mediafile_test.go +++ b/internal/photoprism/index_mediafile_test.go @@ -3,6 +3,8 @@ package photoprism import ( "testing" + "github.com/photoprism/photoprism/internal/entity" + "github.com/stretchr/testify/assert" "github.com/photoprism/photoprism/internal/classify" @@ -71,7 +73,8 @@ func TestIndex_MediaFile(t *testing.T) { } assert.Equal(t, "", mediaFile.metaData.Title) - result := ind.MediaFile(mediaFile, indexOpt, "blue-go-video.mp4", "") + result := ind.UserMediaFile(mediaFile, indexOpt, "blue-go-video.mp4", "", entity.Admin.UID()) + assert.Equal(t, "Blue Gopher", mediaFile.metaData.Title) assert.Equal(t, IndexStatus("added"), result.Status) }) diff --git a/internal/photoprism/index_related.go b/internal/photoprism/index_related.go index 3cd89821a..a80ae42c1 100644 --- a/internal/photoprism/index_related.go +++ b/internal/photoprism/index_related.go @@ -7,7 +7,6 @@ import ( "github.com/dustin/go-humanize/english" "github.com/photoprism/photoprism/internal/query" - "github.com/photoprism/photoprism/pkg/clean" ) diff --git a/internal/search/albums.go b/internal/search/albums.go index 4aec4bd05..310885298 100644 --- a/internal/search/albums.go +++ b/internal/search/albums.go @@ -2,16 +2,30 @@ package search import ( "strings" + "time" + "github.com/dustin/go-humanize/english" + "github.com/photoprism/photoprism/pkg/rnd" + + "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/pkg/txt" ) -// Albums searches albums based on their name. +// Albums finds AlbumResults based on the search form without checking rights or permissions. func Albums(f form.SearchAlbums) (results AlbumResults, err error) { - if err := f.ParseQueryString(); err != nil { - return results, err + return UserAlbums(f, nil) +} + +// UserAlbums finds AlbumResults based on the search form and user session. +func UserAlbums(f form.SearchAlbums, sess *entity.Session) (results AlbumResults, err error) { + start := time.Now() + + if err = f.ParseQueryString(); err != nil { + log.Debugf("albums: %s", err) + return AlbumResults{}, err } // Base query. @@ -21,47 +35,54 @@ func Albums(f form.SearchAlbums) (results AlbumResults, err error) { Joins("LEFT JOIN (SELECT share_uid, count(share_uid) AS link_count FROM links GROUP BY share_uid) AS cl ON cl.share_uid = albums.album_uid"). Where("albums.deleted_at IS NULL") - // Albums with public pictures only? - if f.Public { - s = s.Where("albums.album_type <> 'folder' OR albums.album_path IN (SELECT photo_path FROM photos WHERE photo_private = 0 AND photo_quality > -1 AND deleted_at IS NULL)") - } else { - s = s.Where("albums.album_type <> 'folder' OR albums.album_path IN (SELECT photo_path FROM photos WHERE photo_quality > -1 AND deleted_at IS NULL)") - } + // Check session permissions and apply as needed. + if sess != nil { + user := sess.User() + aclRole := user.AclRole() - // Limit result count. - if f.Count > 0 && f.Count <= MaxResults { - s = s.Limit(f.Count).Offset(f.Offset) - } else { - s = s.Limit(MaxResults).Offset(f.Offset) - } - - // Filter by storage path? - if f.Query != "" && f.Type == entity.AlbumFolder { - f.Order = entity.SortOrderPath - - p := f.Query - - if strings.HasPrefix(p, "/") { - p = p[1:] + // Determine resource to check. + var aclResource acl.Resource + switch f.Type { + case entity.AlbumDefault: + aclResource = acl.ResourceAlbums + case entity.AlbumFolder: + aclResource = acl.ResourceFolders + case entity.AlbumMoment: + aclResource = acl.ResourceMoments + case entity.AlbumMonth: + aclResource = acl.ResourceCalendar + case entity.AlbumState: + aclResource = acl.ResourcePlaces } - if strings.HasSuffix(p, "/") { - s = s.Where("albums.album_path = ?", p[:len(p)-1]) - } else { - p = p + "*" + // Check user rights. + if acl.Resources.DenyAll(aclResource, aclRole, acl.Permissions{acl.AccessAll, acl.AccessLibrary, acl.AccessShared, acl.AccessOwn}) { + return AlbumResults{}, ErrForbidden + } - where, values := OrLike("albums.album_path", p) + // Visitors and other restricted users can only access shared content. + if sess.IsVisitor() && sess.NoShares() { + event.AuditErr([]string{sess.IP(), "session %s", "%s %s as %s", "denied"}, sess.RefID, acl.AccessShared.String(), string(aclResource), aclRole) + return AlbumResults{}, ErrForbidden + } - if w, v := OrLike("albums.album_title", p); len(v) > 0 { - where = where + " OR " + w - values = append(values, v...) + // Limit results by UID, owner and path. + if sess.IsVisitor() { + s = s.Where("albums.album_uid IN (?)", sess.SharedUIDs()) + } else if acl.Resources.DenyAll(aclResource, aclRole, acl.Permissions{acl.AccessAll, acl.AccessLibrary}) { + if user.BasePath == "" { + s = s.Where("albums.album_uid IN (?) OR albums.owner_uid = ?", sess.SharedUIDs(), user.UserUID) + } else { + s = s.Where("albums.album_uid IN (?) OR albums.owner_uid = ? OR (albums.album_type = ? AND (albums.album_path = ? OR albums.album_path LIKE ?))", + sess.SharedUIDs(), user.UserUID, entity.AlbumFolder, user.BasePath, user.BasePath+"/%") } - - s = s.Where(where, values...) } - } else if f.Query != "" { - likeString := "%" + f.Query + "%" - s = s.Where("albums.album_title LIKE ? OR albums.album_location LIKE ?", likeString, likeString) + + // Exclude private content? + if acl.Resources.Deny(acl.ResourcePhotos, aclRole, acl.AccessPrivate) || acl.Resources.Deny(aclResource, aclRole, acl.AccessPrivate) { + f.Public = true + f.Private = false + } } // Set sort order. @@ -92,29 +113,66 @@ func Albums(f form.SearchAlbums) (results AlbumResults, err error) { s = s.Order("albums.album_favorite DESC, albums.album_title ASC, albums.album_uid DESC") } - if f.UID != "" { - s = s.Where("albums.album_uid IN (?)", strings.Split(strings.ToLower(f.UID), txt.Or)) + // Find specific UIDs only? + if txt.NotEmpty(f.UID) { + ids := SplitOr(strings.ToLower(f.UID)) - if result := s.Scan(&results); result.Error != nil { - return results, result.Error + if rnd.ContainsUID(ids, entity.AlbumUID) { + s = s.Where("albums.album_uid IN (?)", ids) } - - return results, nil } - if f.Type != "" { + // Filter by title or path? + if txt.NotEmpty(f.Query) { + if f.Type != entity.AlbumFolder { + likeString := "%" + f.Query + "%" + s = s.Where("albums.album_title LIKE ? OR albums.album_location LIKE ?", likeString, likeString) + } else { + f.Order = entity.SortOrderPath + + p := f.Query + + if strings.HasPrefix(p, "/") { + p = p[1:] + } + + if strings.HasSuffix(p, "/") { + s = s.Where("albums.album_path = ?", p[:len(p)-1]) + } else { + p = p + "*" + + where, values := OrLike("albums.album_path", p) + + if w, v := OrLike("albums.album_title", p); len(v) > 0 { + where = where + " OR " + w + values = append(values, v...) + } + + s = s.Where(where, values...) + } + } + } + + // Albums with public pictures only? + if f.Public { + s = s.Where("albums.album_type <> 'folder' OR albums.album_path IN (SELECT photo_path FROM photos WHERE photo_private = 0 AND photo_quality > -1 AND deleted_at IS NULL)") + } else { + s = s.Where("albums.album_type <> 'folder' OR albums.album_path IN (SELECT photo_path FROM photos WHERE photo_quality > -1 AND deleted_at IS NULL)") + } + + if txt.NotEmpty(f.Type) { s = s.Where("albums.album_type IN (?)", strings.Split(f.Type, txt.Or)) } - if f.Category != "" { + if txt.NotEmpty(f.Category) { s = s.Where("albums.album_category IN (?)", strings.Split(f.Category, txt.Or)) } - if f.Location != "" { + if txt.NotEmpty(f.Location) { s = s.Where("albums.album_location IN (?)", strings.Split(f.Location, txt.Or)) } - if f.Country != "" { + if txt.NotEmpty(f.Country) { s = s.Where("albums.album_country IN (?)", strings.Split(f.Country, txt.Or)) } @@ -134,9 +192,20 @@ func Albums(f form.SearchAlbums) (results AlbumResults, err error) { s = s.Where("albums.album_day = ?", f.Day) } + // Limit result count. + if f.Count > 0 && f.Count <= MaxResults { + s = s.Limit(f.Count).Offset(f.Offset) + } else { + s = s.Limit(MaxResults).Offset(f.Offset) + } + + // Query database. if result := s.Scan(&results); result.Error != nil { return results, result.Error } + // Log number of results. + log.Debugf("albums: found %s [%s]", english.Plural(len(results), "result", "results"), time.Since(start)) + return results, nil } diff --git a/internal/search/errors.go b/internal/search/errors.go index 025e30ab7..b64e35599 100644 --- a/internal/search/errors.go +++ b/internal/search/errors.go @@ -9,7 +9,7 @@ import ( var ( ErrForbidden = i18n.Error(i18n.ErrForbidden) ErrBadRequest = i18n.Error(i18n.ErrBadRequest) - ErrBadSortOrder = fmt.Errorf("iinvalid sort order") - ErrBadFilter = fmt.Errorf("search filter is invalid") + ErrBadSortOrder = fmt.Errorf("invalid sort order") + ErrBadFilter = fmt.Errorf("invalid search filter") ErrInvalidId = fmt.Errorf("invalid ID specified") ) diff --git a/internal/search/photos.go b/internal/search/photos.go index 8820515da..6b38aaf7f 100644 --- a/internal/search/photos.go +++ b/internal/search/photos.go @@ -27,12 +27,12 @@ var PhotosColsView = SelectString(Photo{}, SelectCols(GeoResult{}, []string{"*"} // FileTypes contains a list of browser-compatible file formats returned by search queries. var FileTypes = []string{fs.ImageJPEG.String(), fs.ImagePNG.String(), fs.ImageGIF.String(), fs.ImageWebP.String()} -// Photos finds photos based on the search form provided and returns them as PhotoResults. +// Photos finds PhotoResults based on the search form without checking rights or permissions. func Photos(f form.SearchPhotos) (results PhotoResults, count int, err error) { return searchPhotos(f, nil, PhotosColsAll) } -// UserPhotos finds photos based on the search form and user session then returns them as PhotoResults. +// UserPhotos finds PhotoResults based on the search form and user session. func UserPhotos(f form.SearchPhotos, sess *entity.Session) (results PhotoResults, count int, err error) { return searchPhotos(f, sess, PhotosColsAll) } @@ -61,11 +61,74 @@ func searchPhotos(f form.SearchPhotos, sess *entity.Session, resultCols string) Joins("LEFT JOIN lenses ON photos.lens_id = lenses.id"). Joins("LEFT JOIN places ON photos.place_id = places.id") - // Limit offset and count. - if f.Count > 0 && f.Count <= MaxResults { - s = s.Limit(f.Count).Offset(f.Offset) + // Accept the album UID as scope for backward compatibility. + if rnd.IsUID(f.Album, 'a') { + if txt.Empty(f.Scope) { + f.Scope = f.Album + } + + f.Album = "" + } + + // Limit search results to a specific UID scope, e.g. when sharing. + if txt.NotEmpty(f.Scope) { + f.Scope = strings.ToLower(f.Scope) + + if idType, idPrefix := rnd.IdType(f.Scope); idType != rnd.TypeUID || idPrefix != entity.AlbumUID { + return PhotoResults{}, 0, ErrInvalidId + } else if a, err := entity.CachedAlbumByUID(f.Scope); err != nil || a.AlbumUID == "" { + return PhotoResults{}, 0, ErrInvalidId + } else if a.AlbumFilter == "" { + s = s.Joins("JOIN photos_albums ON photos_albums.photo_uid = files.photo_uid"). + Where("photos_albums.hidden = 0 AND photos_albums.album_uid = ?", a.AlbumUID) + } else if err = form.Unserialize(&f, a.AlbumFilter); err != nil { + return PhotoResults{}, 0, ErrBadFilter + } else { + f.Filter = a.AlbumFilter + s = s.Where("files.photo_uid NOT IN (SELECT photo_uid FROM photos_albums pa WHERE pa.hidden = 1 AND pa.album_uid = ?)", a.AlbumUID) + } } else { - s = s.Limit(MaxResults).Offset(f.Offset) + f.Scope = "" + } + + // Check session permissions and apply as needed. + if sess != nil { + user := sess.User() + aclRole := user.AclRole() + + // Visitors and other restricted users can only access shared content. + if !sess.HasShare(f.Scope) && (sess.IsVisitor() || sess.Unregistered()) || + f.Scope == "" && acl.Resources.Deny(acl.ResourcePhotos, aclRole, acl.ActionSearch) { + event.AuditErr([]string{sess.IP(), "session %s", "%s %s as %s", "denied"}, sess.RefID, acl.ActionSearch.String(), string(acl.ResourcePhotos), aclRole) + return PhotoResults{}, 0, ErrForbidden + } + + // Exclude private content? + if acl.Resources.Deny(acl.ResourcePhotos, aclRole, acl.AccessPrivate) { + f.Public = true + f.Private = false + } + + // Exclude archived content? + if acl.Resources.Deny(acl.ResourcePhotos, aclRole, acl.ActionDelete) { + f.Archived = false + f.Review = false + } + + // Exclude hidden files? + if acl.Resources.Deny(acl.ResourceFiles, aclRole, acl.AccessAll) { + f.Hidden = false + } + + // Limit results by owner and path? + if f.Scope == "" && acl.Resources.DenyAll(acl.ResourcePhotos, aclRole, acl.Permissions{acl.AccessAll, acl.AccessLibrary}) { + if user.BasePath == "" { + s = s.Where("photos.owner_uid = ?", user.UserUID) + } else { + s = s.Where("photos.owner_uid = ? OR photos.photo_path = ? OR photos.photo_path LIKE ?", + user.UserUID, user.BasePath, user.BasePath+"/%") + } + } } // Set sort order. @@ -93,60 +156,6 @@ func searchPhotos(f form.SearchPhotos, sess *entity.Session, resultCols string) return PhotoResults{}, 0, ErrBadSortOrder } - // Limit search results to a specific UID scope, e.g. when sharing. - if txt.NotEmpty(f.In) { - f.In = strings.ToLower(f.In) - if idType, idPrefix := rnd.IdType(f.In); idType != rnd.TypeUID || idPrefix != entity.AlbumUID { - return PhotoResults{}, 0, ErrInvalidId - } else if a, err := entity.CachedAlbumByUID(f.In); err != nil || a.AlbumUID == "" { - return PhotoResults{}, 0, ErrInvalidId - } else if a.AlbumFilter == "" { - s = s.Joins("JOIN photos_albums ON photos_albums.photo_uid = files.photo_uid"). - Where("photos_albums.hidden = 0 AND photos_albums.album_uid = ?", a.AlbumUID) - } else if err = form.Unserialize(&f, a.AlbumFilter); err != nil { - return PhotoResults{}, 0, ErrBadFilter - } else { - f.Filter = a.AlbumFilter - s = s.Where("files.photo_uid NOT IN (SELECT photo_uid FROM photos_albums pa WHERE pa.hidden = 1 AND pa.album_uid = ?)", a.AlbumUID) - } - } else { - f.In = "" - } - - // Check session permissions and apply as needed. - if sess != nil { - aclRole := sess.User().AclRole() - - // Visitors and other restricted users can only access shared content. - if !sess.HasShare(f.In) && (sess.IsVisitor() || sess.Unregistered()) || - f.In == "" && acl.Resources.Deny(acl.ResourcePhotos, aclRole, acl.ActionSearch) { - event.AuditErr([]string{sess.IP(), "session %s", "%s %s as %s", "denied"}, sess.RefID, acl.ActionSearch.String(), string(acl.ResourcePhotos), aclRole) - return PhotoResults{}, 0, ErrForbidden - } - - // Exclude private content? - if acl.Resources.Deny(acl.ResourcePhotos, aclRole, acl.AccessPrivate) { - f.Public = true - f.Private = false - } - - // Exclude archived content? - if acl.Resources.Deny(acl.ResourcePhotos, aclRole, acl.ActionDelete) { - f.Archived = false - f.Review = false - } - - // Exclude hidden files? - if acl.Resources.Deny(acl.ResourceFiles, aclRole, acl.AccessAll) { - f.Hidden = false - } - - // Restrict the search to a sub-folder, if specified. - if dir := sess.User().BasePath; f.In == "" && dir != "" { - s = s.Where("photos.photo_path LIKE ?", dir+"%") - } - } - // Limit the result file types if hidden images/videos should not be found. if !f.Hidden { s = s.Where("files.file_type IN (?) OR files.file_video = 1", FileTypes) @@ -163,7 +172,7 @@ func searchPhotos(f form.SearchPhotos, sess *entity.Session, resultCols string) s = s.Where("files.file_primary = 1") } - // Find only certain unique IDs? + // Find specific UIDs only? if txt.NotEmpty(f.UID) { ids := SplitOr(strings.ToLower(f.UID)) @@ -174,20 +183,14 @@ func searchPhotos(f form.SearchPhotos, sess *entity.Session, resultCols string) case entity.FileUID: s = s.Where("files.file_uid IN (?)", ids) default: - log.Debugf("(1) idType: %s, prefix: %s", idType, prefix) return PhotoResults{}, 0, fmt.Errorf("invalid %s specified", idType) } } else if idType.SHA() { s = s.Where("files.file_hash IN (?)", ids) - } else { - log.Debugf("(2) idType: %s, prefix: %s", idType, prefix) - return PhotoResults{}, 0, ErrInvalidId } // Find UIDs only to improve performance? if sess == nil && f.FindUidOnly() { - s = s.Order("files.media_id") - if result := s.Scan(&results); result.Error != nil { return results, 0, result.Error } @@ -200,8 +203,6 @@ func searchPhotos(f form.SearchPhotos, sess *entity.Session, resultCols string) return results, len(results), nil } - } else { - f.UID = "" } // Filter by label, label category and keywords. @@ -612,25 +613,27 @@ func searchPhotos(f form.SearchPhotos, sess *entity.Session, resultCols string) s = s.Where("photos.id IN (SELECT a.photo_id FROM files a JOIN files b ON a.id != b.id AND a.photo_id = b.photo_id AND a.file_type = b.file_type WHERE a.file_type='jpg')") } - // Filter by album? - if rnd.IsUID(f.Album, 'a') { - if f.Filter != "" { - s = s.Where("files.photo_uid NOT IN (SELECT photo_uid FROM photos_albums pa WHERE pa.hidden = 1 AND pa.album_uid = ?)", f.Album) - } else { - s = s.Joins("JOIN photos_albums ON photos_albums.photo_uid = files.photo_uid"). - Where("photos_albums.hidden = 0 AND photos_albums.album_uid = ?", f.Album) - } - } else if f.Unsorted && f.Filter == "" { - s = s.Where("files.photo_uid NOT IN (SELECT photo_uid FROM photos_albums pa WHERE pa.hidden = 0)") - } else if txt.NotEmpty(f.Album) { - v := strings.Trim(f.Album, "*%") + "%" - s = s.Where("files.photo_uid IN (SELECT pa.photo_uid FROM photos_albums pa JOIN albums a ON a.album_uid = pa.album_uid AND pa.hidden = 0 WHERE (a.album_title LIKE ? OR a.album_slug LIKE ?))", v, v) - } else if txt.NotEmpty(f.Albums) { - for _, where := range LikeAnyWord("a.album_title", f.Albums) { - s = s.Where("files.photo_uid IN (SELECT pa.photo_uid FROM photos_albums pa JOIN albums a ON a.album_uid = pa.album_uid AND pa.hidden = 0 WHERE (?))", gorm.Expr(where)) + // Find photos in albums or not in an album, unless search results are limited to a scope. + if f.Scope == "" { + if f.Unsorted { + s = s.Where("photos.photo_uid NOT IN (SELECT photo_uid FROM photos_albums pa WHERE pa.hidden = 0)") + } else if txt.NotEmpty(f.Album) { + v := strings.Trim(f.Album, "*%") + "%" + s = s.Where("photos.photo_uid IN (SELECT pa.photo_uid FROM photos_albums pa JOIN albums a ON a.album_uid = pa.album_uid AND pa.hidden = 0 WHERE (a.album_title LIKE ? OR a.album_slug LIKE ?))", v, v) + } else if txt.NotEmpty(f.Albums) { + for _, where := range LikeAnyWord("a.album_title", f.Albums) { + s = s.Where("photos.photo_uid IN (SELECT pa.photo_uid FROM photos_albums pa JOIN albums a ON a.album_uid = pa.album_uid AND pa.hidden = 0 WHERE (?))", gorm.Expr(where)) + } } } + // Limit offset and count. + if f.Count > 0 && f.Count <= MaxResults { + s = s.Limit(f.Count).Offset(f.Offset) + } else { + s = s.Limit(MaxResults).Offset(f.Offset) + } + // Query database. if err = s.Scan(&results).Error; err != nil { return results, 0, err diff --git a/internal/search/photos_geo.go b/internal/search/photos_geo.go index a88081dbe..421bf702f 100644 --- a/internal/search/photos_geo.go +++ b/internal/search/photos_geo.go @@ -23,7 +23,7 @@ import ( // GeoCols specifies the UserPhotosGeo result column names. var GeoCols = SelectString(GeoResult{}, []string{"*"}) -// PhotosGeo finds photos based on the search form and returns them as GeoResults. +// PhotosGeo finds GeoResults based on the search form without checking rights or permissions. func PhotosGeo(f form.SearchPhotosGeo) (results GeoResults, err error) { return UserPhotosGeo(f, nil) } @@ -63,19 +63,22 @@ func UserPhotosGeo(f form.SearchPhotosGeo, sess *entity.Session) (results GeoRes Where("photos.deleted_at IS NULL"). Where("photos.photo_lat <> 0") - // Limit offset and count. - if f.Count > 0 { - s = s.Limit(f.Count).Offset(f.Offset) - } else { - s = s.Limit(1000000).Offset(f.Offset) + // Accept the album UID as scope for backward compatibility. + if rnd.IsUID(f.Album, 'a') { + if txt.Empty(f.Scope) { + f.Scope = f.Album + } + + f.Album = "" } // Limit search results to a specific UID scope, e.g. when sharing. - if txt.NotEmpty(f.In) { - f.In = strings.ToLower(f.In) - if idType, idPrefix := rnd.IdType(f.In); idType != rnd.TypeUID || idPrefix != entity.AlbumUID { + if txt.NotEmpty(f.Scope) { + f.Scope = strings.ToLower(f.Scope) + + if idType, idPrefix := rnd.IdType(f.Scope); idType != rnd.TypeUID || idPrefix != entity.AlbumUID { return GeoResults{}, ErrInvalidId - } else if a, err := entity.CachedAlbumByUID(f.In); err != nil || a.AlbumUID == "" { + } else if a, err := entity.CachedAlbumByUID(f.Scope); err != nil || a.AlbumUID == "" { return GeoResults{}, ErrInvalidId } else if a.AlbumFilter == "" { s = s.Joins("JOIN photos_albums ON photos_albums.photo_uid = files.photo_uid"). @@ -86,18 +89,21 @@ func UserPhotosGeo(f form.SearchPhotosGeo, sess *entity.Session) (results GeoRes f.Filter = a.AlbumFilter s = s.Where("files.photo_uid NOT IN (SELECT photo_uid FROM photos_albums pa WHERE pa.hidden = 1 AND pa.album_uid = ?)", a.AlbumUID) } + + S2Levels = 15 } else { - f.In = "" + f.Scope = "" } // Check session permissions and apply as needed. if sess != nil { - aclRole := sess.User().AclRole() + user := sess.User() + aclRole := user.AclRole() // Visitors and other restricted users can only access shared content. - if !sess.HasShare(f.In) && (sess.IsVisitor() || sess.Unregistered()) || - f.In == "" && acl.Resources.Deny(acl.ResourcePhotos, aclRole, acl.ActionSearch) || - f.In == "" && acl.Resources.Deny(acl.ResourcePlaces, aclRole, acl.ActionSearch) { + if !sess.HasShare(f.Scope) && (sess.IsVisitor() || sess.Unregistered()) || + f.Scope == "" && acl.Resources.Deny(acl.ResourcePhotos, aclRole, acl.ActionSearch) || + f.Scope == "" && acl.Resources.Deny(acl.ResourcePlaces, aclRole, acl.ActionSearch) { event.AuditErr([]string{sess.IP(), "session %s", "%s %s as %s", "denied"}, sess.RefID, acl.ActionSearch.String(), string(acl.ResourcePlaces), aclRole) return GeoResults{}, ErrForbidden } @@ -114,9 +120,14 @@ func UserPhotosGeo(f form.SearchPhotosGeo, sess *entity.Session) (results GeoRes f.Review = false } - // Restrict the search to a sub-folder, if specified. - if dir := sess.User().BasePath; f.In == "" && dir != "" { - s = s.Where("photos.photo_path LIKE ?", dir+"%") + // Limit results by owner and path? + if f.Scope == "" && acl.Resources.DenyAll(acl.ResourcePhotos, aclRole, acl.Permissions{acl.AccessAll, acl.AccessLibrary}) { + if user.BasePath == "" { + s = s.Where("photos.owner_uid = ?", user.UserUID) + } else { + s = s.Where("photos.owner_uid = ? OR photos.photo_path = ? OR photos.photo_path LIKE ?", + user.UserUID, user.BasePath, user.BasePath+"/%") + } } } @@ -128,7 +139,7 @@ func UserPhotosGeo(f form.SearchPhotosGeo, sess *entity.Session) (results GeoRes s = s.Order(gorm.Expr("(photos.photo_uid = ?) DESC, ABS(? - photos.photo_lat)+ABS(? - photos.photo_lng)", f.Near, f.Lat, f.Lng)) } - // Find only certain unique IDs? + // Find specific UIDs only? if txt.NotEmpty(f.UID) { ids := SplitOr(strings.ToLower(f.UID)) @@ -156,8 +167,6 @@ func UserPhotosGeo(f form.SearchPhotosGeo, sess *entity.Session) (results GeoRes return results, nil } - } else { - f.UID = "" } // Set search filters based on search terms. @@ -302,23 +311,17 @@ func UserPhotosGeo(f form.SearchPhotosGeo, sess *entity.Session) (results GeoRes } } - // Filter by album? - if rnd.IsUID(f.Album, 'a') { - S2Levels = 15 - if f.Filter != "" { - s = s.Where("photos.photo_uid NOT IN (SELECT photo_uid FROM photos_albums pa WHERE pa.hidden = 1 AND pa.album_uid = ?)", f.Album) - } else { - s = s.Joins("JOIN photos_albums ON photos_albums.photo_uid = photos.photo_uid"). - Where("photos_albums.hidden = 0 AND photos_albums.album_uid = ?", f.Album) - } - } else if f.Unsorted && f.Filter == "" { - s = s.Where("photos.photo_uid NOT IN (SELECT photo_uid FROM photos_albums pa WHERE pa.hidden = 0)") - } else if txt.NotEmpty(f.Album) { - v := strings.Trim(f.Album, "*%") + "%" - s = s.Where("photos.photo_uid IN (SELECT pa.photo_uid FROM photos_albums pa JOIN albums a ON a.album_uid = pa.album_uid AND pa.hidden = 0 WHERE (a.album_title LIKE ? OR a.album_slug LIKE ?))", v, v) - } else if txt.NotEmpty(f.Albums) { - for _, where := range LikeAnyWord("a.album_title", f.Albums) { - s = s.Where("photos.photo_uid IN (SELECT pa.photo_uid FROM photos_albums pa JOIN albums a ON a.album_uid = pa.album_uid AND pa.hidden = 0 WHERE (?))", gorm.Expr(where)) + // Find photos in albums or not in an album, unless search results are limited to a scope. + if f.Scope == "" { + if f.Unsorted { + s = s.Where("photos.photo_uid NOT IN (SELECT photo_uid FROM photos_albums pa WHERE pa.hidden = 0)") + } else if txt.NotEmpty(f.Album) { + v := strings.Trim(f.Album, "*%") + "%" + s = s.Where("photos.photo_uid IN (SELECT pa.photo_uid FROM photos_albums pa JOIN albums a ON a.album_uid = pa.album_uid AND pa.hidden = 0 WHERE (a.album_title LIKE ? OR a.album_slug LIKE ?))", v, v) + } else if txt.NotEmpty(f.Albums) { + for _, where := range LikeAnyWord("a.album_title", f.Albums) { + s = s.Where("photos.photo_uid IN (SELECT pa.photo_uid FROM photos_albums pa JOIN albums a ON a.album_uid = pa.album_uid AND pa.hidden = 0 WHERE (?))", gorm.Expr(where)) + } } } @@ -492,6 +495,13 @@ func UserPhotosGeo(f form.SearchPhotosGeo, sess *entity.Session) (results GeoRes s = s.Where("photos.taken_at >= ?", f.After.Format("2006-01-02")) } + // Limit offset and count. + if f.Count > 0 { + s = s.Limit(f.Count).Offset(f.Offset) + } else { + s = s.Limit(1000000).Offset(f.Offset) + } + // Fetch results. if result := s.Scan(&results); result.Error != nil { return results, result.Error diff --git a/internal/search/photos_geo_test.go b/internal/search/photos_geo_test.go index 978ad9883..44ea0c66e 100644 --- a/internal/search/photos_geo_test.go +++ b/internal/search/photos_geo_test.go @@ -12,7 +12,7 @@ import ( func TestGeo(t *testing.T) { t.Run("Near", func(t *testing.T) { - query := form.NewGeoSearch("near:pt9jtdre2lvl0y43") + query := form.NewSearchPhotosGeo("near:pt9jtdre2lvl0y43") // Parse query string and filter. if err := query.ParseQueryString(); err != nil { @@ -27,7 +27,7 @@ func TestGeo(t *testing.T) { } }) t.Run("UnknownFaces", func(t *testing.T) { - query := form.NewGeoSearch("face:none") + query := form.NewSearchPhotosGeo("face:none") // Parse query string and filter. if err := query.ParseQueryString(); err != nil { @@ -41,7 +41,7 @@ func TestGeo(t *testing.T) { } }) t.Run("form.keywords", func(t *testing.T) { - query := form.NewGeoSearch("keywords:bridge") + query := form.NewSearchPhotosGeo("keywords:bridge") // Parse query string and filter. if err := query.ParseQueryString(); err != nil { @@ -55,7 +55,7 @@ func TestGeo(t *testing.T) { } }) t.Run("form.subjects", func(t *testing.T) { - query := form.NewGeoSearch("subjects:John") + query := form.NewSearchPhotosGeo("subjects:John") // Parse query string and filter. if err := query.ParseQueryString(); err != nil { @@ -69,7 +69,7 @@ func TestGeo(t *testing.T) { } }) t.Run("find_all", func(t *testing.T) { - query := form.NewGeoSearch("") + query := form.NewSearchPhotosGeo("") // Parse query string and filter. if err := query.ParseQueryString(); err != nil { @@ -84,7 +84,7 @@ func TestGeo(t *testing.T) { }) t.Run("search for bridge", func(t *testing.T) { - query := form.NewGeoSearch("q:bridge Before:3006-01-02") + query := form.NewSearchPhotosGeo("q:bridge Before:3006-01-02") // Parse query string and filter. if err := query.ParseQueryString(); err != nil { @@ -103,7 +103,7 @@ func TestGeo(t *testing.T) { }) t.Run("search for date range", func(t *testing.T) { - query := form.NewGeoSearch("After:2014-12-02 Before:3006-01-02") + query := form.NewSearchPhotosGeo("After:2014-12-02 Before:3006-01-02") // Parse query string and filter. if err := query.ParseQueryString(); err != nil { diff --git a/internal/server/routes.go b/internal/server/routes.go index 269e04488..7d5409136 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -103,10 +103,10 @@ func registerRoutes(router *gin.Engine, conf *config.Config) { api.GetPhotoYaml(v1) api.UpdatePhoto(v1) api.GetPhotoDownload(v1) - api.GetPhotoLinks(v1) - api.CreatePhotoLink(v1) - api.UpdatePhotoLink(v1) - api.DeletePhotoLink(v1) + // api.GetPhotoLinks(v1) + // api.CreatePhotoLink(v1) + // api.UpdatePhotoLink(v1) + // api.DeletePhotoLink(v1) api.ApprovePhoto(v1) api.LikePhoto(v1) api.DislikePhoto(v1) @@ -143,10 +143,10 @@ func registerRoutes(router *gin.Engine, conf *config.Config) { api.SearchLabels(v1) api.LabelCover(v1) api.UpdateLabel(v1) - api.GetLabelLinks(v1) - api.CreateLabelLink(v1) - api.UpdateLabelLink(v1) - api.DeleteLabelLink(v1) + // api.GetLabelLinks(v1) + // api.CreateLabelLink(v1) + // api.UpdateLabelLink(v1) + // api.DeleteLabelLink(v1) api.LikeLabel(v1) api.DislikeLabel(v1) diff --git a/pkg/rnd/contains.go b/pkg/rnd/contains.go index 80401b48d..3aaad8257 100644 --- a/pkg/rnd/contains.go +++ b/pkg/rnd/contains.go @@ -25,7 +25,7 @@ func ContainsUID(s []string, prefix byte) bool { // ContainsType checks if a slice of strings contains only random IDs of a given type and returns it. func ContainsType(ids []string) (idType Type, idPrefix byte) { if len(ids) < 1 { - return TypeNone, PrefixNone + return TypeEmpty, PrefixNone } idType = TypeUnknown diff --git a/pkg/rnd/contains_test.go b/pkg/rnd/contains_test.go index 7dcd212de..4df65bc2f 100644 --- a/pkg/rnd/contains_test.go +++ b/pkg/rnd/contains_test.go @@ -19,7 +19,7 @@ func TestContainsUID(t *testing.T) { func TestContainsType(t *testing.T) { t.Run("None", func(t *testing.T) { result, prefix := ContainsType([]string{}) - assert.Equal(t, TypeNone, result) + assert.Equal(t, TypeEmpty, result) assert.Equal(t, PrefixNone, prefix) }) t.Run("Unknown", func(t *testing.T) { diff --git a/pkg/rnd/type.go b/pkg/rnd/type.go index a4ac88b3f..9b370637b 100644 --- a/pkg/rnd/type.go +++ b/pkg/rnd/type.go @@ -5,6 +5,8 @@ import ( ) const ( + TypeEmpty Type = "empty" + TypeMixed Type = "mixed" TypeUUID Type = "UUID" TypeUID Type = "UID" TypeRefID Type = "RID" @@ -16,8 +18,6 @@ const ( TypeSHA256 Type = "SHA256" TypeSHA384 Type = "SHA384" TypeSHA512 Type = "SHA512" - TypeNone Type = "none" - TypeMixed Type = "mixed" TypeUnknown Type = "unknown" ) @@ -25,7 +25,7 @@ const ( // and returns it along with the id prefix, if any. func IdType(id string) (Type, byte) { if l := len(id); l == 0 { - return TypeNone, PrefixNone + return TypeEmpty, PrefixNone } else if l < 14 || l > 128 { return TypeUnknown, PrefixNone } diff --git a/pkg/rnd/type_test.go b/pkg/rnd/type_test.go index cb76919b2..a16ebd384 100644 --- a/pkg/rnd/type_test.go +++ b/pkg/rnd/type_test.go @@ -9,7 +9,7 @@ import ( func TestUidType(t *testing.T) { t.Run("None", func(t *testing.T) { result, prefix := IdType("") - assert.Equal(t, TypeNone, result) + assert.Equal(t, TypeEmpty, result) assert.Equal(t, PrefixNone, prefix) }) t.Run("Unknown", func(t *testing.T) { diff --git a/pkg/txt/empty.go b/pkg/txt/empty.go index acd5d2758..91c06f6b7 100644 --- a/pkg/txt/empty.go +++ b/pkg/txt/empty.go @@ -4,17 +4,17 @@ import ( "strings" ) -// Empty tests if a string represents an empty/invalid value. +// Empty checks whether a string represents an empty, unset, or undefined value. func Empty(s string) bool { - s = strings.Trim(strings.TrimSpace(s), "%*") - - if s == "" || s == "0" || s == "-1" || EmptyTime(s) { + if s == "" { + return true + } else if s = strings.Trim(s, "%* "); s == "" || s == "0" || s == "-1" || EmptyTime(s) { + return true + } else if s = strings.ToLower(s); s == "nil" || s == "null" || s == "nan" { return true } - s = strings.ToLower(s) - - return s == "nil" || s == "null" || s == "nan" + return false } // NotEmpty tests if a string does not represent an empty/invalid value.