Auth: Remember ownership of uploaded photos and albums #98 #782

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer 2022-09-30 19:15:10 +02:00
parent 94de0598d2
commit 4f425790ab
45 changed files with 722 additions and 385 deletions

View file

@ -2033,9 +2033,9 @@
} }
}, },
"node_modules/@humanwhocodes/config-array": { "node_modules/@humanwhocodes/config-array": {
"version": "0.10.5", "version": "0.10.7",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.10.5.tgz", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.10.7.tgz",
"integrity": "sha512-XVVDtp+dVvRxMoxSiSfasYaG02VEe1qH5cKgMQJWhol6HwzbcqoCMJi8dAGoYAO57jhUyhI6cWuRiTcRaDaYug==", "integrity": "sha512-MDl6D6sBsaV452/QSdX+4CXIjZhIcI0PELsxUjk4U828yd58vk3bTIvk/6w5FY+4hIy9sLW0sfrV7K7Kc++j/w==",
"dependencies": { "dependencies": {
"@humanwhocodes/object-schema": "^1.2.1", "@humanwhocodes/object-schema": "^1.2.1",
"debug": "^4.1.1", "debug": "^4.1.1",
@ -3502,9 +3502,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001412", "version": "1.0.30001414",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001412.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001414.tgz",
"integrity": "sha512-+TeEIee1gS5bYOiuf+PS/kp2mrXic37Hl66VY6EAfxasIk5fELTktK2oOezYed12H8w7jt3s512PpulQidPjwA==", "integrity": "sha512-t55jfSaWjCdocnFdKQoO+d2ct9C59UZg4dY3OnUlSZ447r8pUtIKdp0hpAzrGFultmTC+Us+KpKi4GZl/LXlFg==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@ -4661,9 +4661,9 @@
} }
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.4.266", "version": "1.4.268",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.266.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.268.tgz",
"integrity": "sha512-saJTYECxUSv7eSpnXw0XIEvUkP9x4s/x2mm3TVX7k4rIFS6f5TjBih1B5h437WzIhHQjid+d8ouQzPQskMervQ==" "integrity": "sha512-PO90Bv++vEzdln+eA9qLg1IRnh0rKETus6QkTzcFm5P3Wg3EQBZud5dcnzkpYXuIKWBjKe5CO8zjz02cicvn1g=="
}, },
"node_modules/emoji-regex": { "node_modules/emoji-regex": {
"version": "8.0.0", "version": "8.0.0",
@ -6410,19 +6410,19 @@
"integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==" "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ=="
}, },
"node_modules/flow-parser": { "node_modules/flow-parser": {
"version": "0.187.1", "version": "0.188.0",
"resolved": "https://registry.npmjs.org/flow-parser/-/flow-parser-0.187.1.tgz", "resolved": "https://registry.npmjs.org/flow-parser/-/flow-parser-0.188.0.tgz",
"integrity": "sha512-ZvlTeakTTMmYGukt4EIQtLEp4ie45W+jK325uukGgiqFg2Rl7TdpOJQbOLUN2xMeGS+WvXaK0uIJ3coPGDXFGQ==", "integrity": "sha512-yu/Tc3UOhE0lXZk02MO/69N8RLdZsBtz3R6pBS+GaHiaopAxRcfUHPkqOZnI2BtotaylYTLrZGkIqFC8KqVxXQ==",
"engines": { "engines": {
"node": ">=0.4.0" "node": ">=0.4.0"
} }
}, },
"node_modules/flow-remove-types": { "node_modules/flow-remove-types": {
"version": "2.187.1", "version": "2.188.0",
"resolved": "https://registry.npmjs.org/flow-remove-types/-/flow-remove-types-2.187.1.tgz", "resolved": "https://registry.npmjs.org/flow-remove-types/-/flow-remove-types-2.188.0.tgz",
"integrity": "sha512-kfNwss9K0A76VeeYZgNQ2pTeNPGmVNOh9mBDDJOzueejctZL/Tuiwa8lT10LAWRxTQIzdQcWFrUEzGSDSzZNdg==", "integrity": "sha512-bvNLQtjIFWJ4bkzZmLpIr4iZ83TsKv+S5Er9x/5OQMlNXvqGV0q/d/NIs/oZ7v9ZVnj0CObP2XgcfdduzKW4Dw==",
"dependencies": { "dependencies": {
"flow-parser": "^0.187.1", "flow-parser": "^0.188.0",
"pirates": "^3.0.2", "pirates": "^3.0.2",
"vlq": "^0.2.1" "vlq": "^0.2.1"
}, },
@ -14667,9 +14667,9 @@
} }
}, },
"@humanwhocodes/config-array": { "@humanwhocodes/config-array": {
"version": "0.10.5", "version": "0.10.7",
"resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.10.5.tgz", "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.10.7.tgz",
"integrity": "sha512-XVVDtp+dVvRxMoxSiSfasYaG02VEe1qH5cKgMQJWhol6HwzbcqoCMJi8dAGoYAO57jhUyhI6cWuRiTcRaDaYug==", "integrity": "sha512-MDl6D6sBsaV452/QSdX+4CXIjZhIcI0PELsxUjk4U828yd58vk3bTIvk/6w5FY+4hIy9sLW0sfrV7K7Kc++j/w==",
"requires": { "requires": {
"@humanwhocodes/object-schema": "^1.2.1", "@humanwhocodes/object-schema": "^1.2.1",
"debug": "^4.1.1", "debug": "^4.1.1",
@ -15836,9 +15836,9 @@
} }
}, },
"caniuse-lite": { "caniuse-lite": {
"version": "1.0.30001412", "version": "1.0.30001414",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001412.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001414.tgz",
"integrity": "sha512-+TeEIee1gS5bYOiuf+PS/kp2mrXic37Hl66VY6EAfxasIk5fELTktK2oOezYed12H8w7jt3s512PpulQidPjwA==" "integrity": "sha512-t55jfSaWjCdocnFdKQoO+d2ct9C59UZg4dY3OnUlSZ447r8pUtIKdp0hpAzrGFultmTC+Us+KpKi4GZl/LXlFg=="
}, },
"chai": { "chai": {
"version": "4.3.6", "version": "4.3.6",
@ -16662,9 +16662,9 @@
} }
}, },
"electron-to-chromium": { "electron-to-chromium": {
"version": "1.4.266", "version": "1.4.268",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.266.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.268.tgz",
"integrity": "sha512-saJTYECxUSv7eSpnXw0XIEvUkP9x4s/x2mm3TVX7k4rIFS6f5TjBih1B5h437WzIhHQjid+d8ouQzPQskMervQ==" "integrity": "sha512-PO90Bv++vEzdln+eA9qLg1IRnh0rKETus6QkTzcFm5P3Wg3EQBZud5dcnzkpYXuIKWBjKe5CO8zjz02cicvn1g=="
}, },
"emoji-regex": { "emoji-regex": {
"version": "8.0.0", "version": "8.0.0",
@ -17931,16 +17931,16 @@
"integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==" "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ=="
}, },
"flow-parser": { "flow-parser": {
"version": "0.187.1", "version": "0.188.0",
"resolved": "https://registry.npmjs.org/flow-parser/-/flow-parser-0.187.1.tgz", "resolved": "https://registry.npmjs.org/flow-parser/-/flow-parser-0.188.0.tgz",
"integrity": "sha512-ZvlTeakTTMmYGukt4EIQtLEp4ie45W+jK325uukGgiqFg2Rl7TdpOJQbOLUN2xMeGS+WvXaK0uIJ3coPGDXFGQ==" "integrity": "sha512-yu/Tc3UOhE0lXZk02MO/69N8RLdZsBtz3R6pBS+GaHiaopAxRcfUHPkqOZnI2BtotaylYTLrZGkIqFC8KqVxXQ=="
}, },
"flow-remove-types": { "flow-remove-types": {
"version": "2.187.1", "version": "2.188.0",
"resolved": "https://registry.npmjs.org/flow-remove-types/-/flow-remove-types-2.187.1.tgz", "resolved": "https://registry.npmjs.org/flow-remove-types/-/flow-remove-types-2.188.0.tgz",
"integrity": "sha512-kfNwss9K0A76VeeYZgNQ2pTeNPGmVNOh9mBDDJOzueejctZL/Tuiwa8lT10LAWRxTQIzdQcWFrUEzGSDSzZNdg==", "integrity": "sha512-bvNLQtjIFWJ4bkzZmLpIr4iZ83TsKv+S5Er9x/5OQMlNXvqGV0q/d/NIs/oZ7v9ZVnj0CObP2XgcfdduzKW4Dw==",
"requires": { "requires": {
"flow-parser": "^0.187.1", "flow-parser": "^0.188.0",
"pirates": "^3.0.2", "pirates": "^3.0.2",
"vlq": "^0.2.1" "vlq": "^0.2.1"
}, },

View file

@ -228,14 +228,14 @@ export default [
meta: { title: $gettext("Places"), auth: true }, meta: { title: $gettext("Places"), auth: true },
}, },
{ {
name: "place", name: "places_query",
path: "/places/:q", path: "/places/:q",
component: Places, component: Places,
meta: { title: $gettext("Places"), auth: true }, meta: { title: $gettext("Places"), auth: true },
}, },
{ {
name: "album_place", name: "places_scope",
path: "/places/:album/:q", path: "/places/:s/:q",
component: Places, component: Places,
meta: { title: $gettext("Places"), auth: true }, meta: { title: $gettext("Places"), auth: true },
}, },

View file

@ -174,12 +174,12 @@ export default {
if (photo && photo.CellID && photo.CellID !== "zz") { if (photo && photo.CellID && photo.CellID !== "zz") {
if (this.canSearchPlaces) { if (this.canSearchPlaces) {
this.$router.push({name: "place", params: {q: photo.CellID}}); this.$router.push({name: "places_query", params: {q: photo.CellID}});
} else { } 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 { } 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) { editPhoto(index) {
@ -250,8 +250,7 @@ export default {
const params = { const params = {
count: count, count: count,
offset: offset, offset: offset,
album: this.uid, s: this.uid,
filter: this.model.Filter ? this.model.Filter : "",
merged: true, merged: true,
}; };
@ -365,8 +364,7 @@ export default {
const params = { const params = {
count: this.batchSize, count: this.batchSize,
offset: this.offset, offset: this.offset,
album: this.uid, s: this.uid,
filter: this.model.Filter ? this.model.Filter : "",
merged: true, merged: true,
}; };

View file

@ -241,9 +241,9 @@ export default {
const photo = this.results[index]; const photo = this.results[index];
if (photo.CellID && photo.CellID !== "zz") { 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") { } 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 { } else {
this.$notify.warn("unknown location"); this.$notify.warn("unknown location");
} }

View file

@ -49,7 +49,7 @@ export default {
options: {}, options: {},
mapFont: ["Open Sans Regular"], mapFont: ["Open Sans Regular"],
result: {}, result: {},
filter: {q: this.query(), album: this.album()}, filter: {q: this.query(), s: this.scope()},
lastFilter: {}, lastFilter: {},
config: this.$config.values, config: this.$config.values,
settings: this.$config.values.settings.maps, settings: this.$config.values.settings.maps,
@ -58,7 +58,7 @@ export default {
watch: { watch: {
'$route'() { '$route'() {
this.filter.q = this.query(); this.filter.q = this.query();
this.filter.album = this.album(); this.filter.s = this.scope();
this.lastFilter = {}; this.lastFilter = {};
this.search(); this.search();
@ -77,7 +77,7 @@ export default {
const s = this.$config.values.settings.maps; const s = this.$config.values.settings.maps;
const filter = { const filter = {
q: this.query(), q: this.query(),
album: this.album(), s: this.scope(),
}; };
let mapKey = ""; let mapKey = "";
@ -208,8 +208,8 @@ export default {
query: function () { query: function () {
return this.$route.params.q ? this.$route.params.q : ''; return this.$route.params.q ? this.$route.params.q : '';
}, },
album: function () { scope: function () {
return this.$route.params.album ? this.$route.params.album : ''; return this.$route.params.s ? this.$route.params.s : '';
}, },
openPhoto(uid) { openPhoto(uid) {
// Abort if uid is empty or results aren't loaded. // Abort if uid is empty or results aren't loaded.
@ -225,8 +225,8 @@ export default {
}, },
}; };
if (this.filter.album) { if (this.filter.s) {
options.params.album = this.filter.album; options.params.s = this.filter.s;
} }
this.loading = true; this.loading = true;
@ -256,10 +256,10 @@ export default {
if (this.loading) return; if (this.loading) return;
if (this.query() !== this.filter.q) { if (this.query() !== this.filter.q) {
if (this.filter.album) { if (this.filter.s) {
this.$router.replace({name: "album_place", params: {album: this.filter.album, q: this.filter.q}}); this.$router.replace({name: "places_scope", params: {s: this.filter.s, q: this.filter.q}});
} else if (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 { } else {
this.$router.replace({name: "places"}); this.$router.replace({name: "places"});
} }

View file

@ -50,7 +50,7 @@ import VueFilters from "vue2-filters";
import VueFullscreen from "vue-fullscreen"; import VueFullscreen from "vue-fullscreen";
import VueInfiniteScroll from "vue-infinite-scroll"; import VueInfiniteScroll from "vue-infinite-scroll";
import Hls from "hls.js"; import Hls from "hls.js";
import { $gettext, Mount } from "common/vm"; import { Mount, T } from "common/vm";
import * as options from "./options/options"; import * as options from "./options/options";
config.load().finally(() => { config.load().finally(() => {
@ -160,12 +160,28 @@ config.load().finally(() => {
}); });
router.afterEach((to) => { router.afterEach((to) => {
if (to.meta.title && config.values.siteTitle !== to.meta.title) { const t = to.meta["title"] ? to.meta["title"] : "";
config.page.title = $gettext(to.meta.title);
window.document.title = config.page.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 { } else {
config.page.title = config.values.siteTitle; config.page.title = config.values.name;
window.document.title = config.values.siteTitle;
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;
}
} }
}); });

View file

@ -55,9 +55,18 @@ export default {
type: Array, type: Array,
default: () => [], default: () => [],
}, },
refresh: Function, refresh: {
clearSelection: Function, type: Function,
context: String, default: () => {},
},
clearSelection: {
type: Function,
default: () => {},
},
context: {
type: String,
default: "",
},
}, },
data() { data() {
return { return {

View file

@ -58,12 +58,18 @@ export default {
type: Array, type: Array,
default: () => [], default: () => [],
}, },
refresh: Function, refresh: {
type: Function,
default: () => {},
},
album: { album: {
type: Object, type: Object,
default: () => {}, default: () => {},
}, },
context: String, context: {
type: String,
default: "",
},
}, },
data() { data() {
return { return {

View file

@ -228,9 +228,9 @@ export default {
const photo = this.results[index]; const photo = this.results[index];
if (photo && photo.CellID && photo.CellID !== "zz") { 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 { } 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) { editPhoto(index) {
@ -297,7 +297,7 @@ export default {
const params = { const params = {
count: count, count: count,
offset: offset, offset: offset,
album: this.uid, s: this.uid,
filter: this.model.Filter ? this.model.Filter : "", filter: this.model.Filter ? this.model.Filter : "",
merged: true, merged: true,
}; };
@ -405,7 +405,7 @@ export default {
const params = { const params = {
count: this.batchSize, count: this.batchSize,
offset: this.offset, offset: this.offset,
album: this.uid, s: this.uid,
filter: this.model.Filter ? this.model.Filter : "", filter: this.model.Filter ? this.model.Filter : "",
merged: true, merged: true,
}; };

View file

@ -247,7 +247,7 @@ export default {
if (this.query() !== this.filter.q) { if (this.query() !== this.filter.q) {
if (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 { } else {
this.$router.replace({name: "places"}); this.$router.replace({name: "places"});
} }

View file

@ -26,8 +26,8 @@ export default [
meta: { title: shareTitle, auth: true, hideNav: true }, meta: { title: shareTitle, auth: true, hideNav: true },
}, },
{ {
name: "album_place", name: "places_scope",
path: "/places/:album/:q", path: "/places/:s/:q",
component: Places, component: Places,
meta: { title: shareTitle, auth: true, hideNav: true }, meta: { title: shareTitle, auth: true, hideNav: true },
}, },

View file

@ -2,61 +2,18 @@ package acl
// Resources specifies granted permissions by Resource and Role. // Resources specifies granted permissions by Resource and Role.
var Resources = ACL{ 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{ ResourceFiles: Roles{
RoleAdmin: GrantFullAccess, 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{ ResourcePhotos: Roles{
RoleAdmin: GrantFullAccess, RoleAdmin: GrantFullAccess,
RoleVisitor: Grant{AccessShared: true, ActionView: true, ActionDownload: true}, RoleVisitor: Grant{AccessShared: true, ActionView: true, ActionDownload: true},
}, },
ResourceAlbums: Roles{
RoleAdmin: GrantFullAccess,
RoleVisitor: Grant{AccessShared: true, ActionView: true, ActionDownload: true},
},
ResourceVideos: Roles{ ResourceVideos: Roles{
RoleAdmin: GrantFullAccess, RoleAdmin: GrantFullAccess,
RoleVisitor: Grant{AccessShared: true, ActionView: true, ActionDownload: true}, RoleVisitor: Grant{AccessShared: true, ActionView: true, ActionDownload: true},
}, },
ResourcePlaces: Roles{ ResourceAlbums: Roles{
RoleAdmin: GrantFullAccess, RoleAdmin: GrantFullAccess,
RoleVisitor: Grant{AccessShared: true, ActionView: true, ActionDownload: true}, RoleVisitor: Grant{AccessShared: true, ActionView: true, ActionDownload: true},
}, },
@ -64,4 +21,52 @@ var Resources = ACL{
RoleAdmin: GrantFullAccess, RoleAdmin: GrantFullAccess,
RoleVisitor: Grant{AccessShared: true, ActionView: true, ActionDownload: true}, 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,
},
} }

View file

@ -15,7 +15,6 @@ import (
"github.com/photoprism/photoprism/internal/query" "github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/internal/search" "github.com/photoprism/photoprism/internal/search"
"github.com/photoprism/photoprism/internal/service" "github.com/photoprism/photoprism/internal/service"
"github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/clean"
) )
@ -83,7 +82,7 @@ func CreateAlbum(router *gin.RouterGroup) {
albumMutex.Lock() albumMutex.Lock()
defer albumMutex.Unlock() defer albumMutex.Unlock()
a := entity.NewAlbum(f.AlbumTitle, entity.AlbumDefault) a := entity.NewUserAlbum(f.AlbumTitle, entity.AlbumDefault, s.UserUID)
a.AlbumFavorite = f.AlbumFavorite a.AlbumFavorite = f.AlbumFavorite
// Existing album? // Existing album?

View file

@ -26,12 +26,11 @@ func SearchAlbums(router *gin.RouterGroup) {
return return
} }
var err error
var f form.SearchAlbums var f form.SearchAlbums
err := c.MustBindWith(&f, binding.Form)
// Abort if request params are invalid. // 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) event.AuditWarn([]string{ClientIP(c), "session %s", "albums", "search", "form invalid", "%s"}, s.RefID, err)
AbortBadRequest(c) AbortBadRequest(c)
return return
@ -39,21 +38,15 @@ func SearchAlbums(router *gin.RouterGroup) {
conf := service.Config() conf := service.Config()
// Sharing link visitors permissions are limited to shared albums. // Ignore private flag if feature is disabled.
if s.IsVisitor() { if !conf.Settings().Features.Private {
f.UID = s.SharedUIDs().Join(txt.Or)
f.Public = true
event.AuditDebug([]string{ClientIP(c), "session %s", "albums", "search", "shared", "%s"}, s.RefID, f.UID)
} else if conf.Settings().Features.Private {
f.Public = true
event.AuditDebug([]string{ClientIP(c), "session %s", "albums", "search", "all public"}, s.RefID)
} else {
f.Public = false 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 { if err != nil {
event.AuditWarn([]string{ClientIP(c), "session %s", "albums", "search", "%s"}, s.RefID, err) event.AuditWarn([]string{ClientIP(c), "session %s", "albums", "search", "%s"}, s.RefID, err)
c.AbortWithStatusJSON(400, gin.H{"error": txt.UpperFirst(err.Error())}) c.AbortWithStatusJSON(400, gin.H{"error": txt.UpperFirst(err.Error())})
@ -65,6 +58,7 @@ func SearchAlbums(router *gin.RouterGroup) {
AddOffsetHeader(c, f.Offset) AddOffsetHeader(c, f.Offset)
AddTokenHeaders(c) AddTokenHeaders(c)
// Return as JSON.
c.JSON(http.StatusOK, result) c.JSON(http.StatusOK, result)
}) })
} }

View file

@ -3,11 +3,10 @@ package api
import ( import (
"net/http" "net/http"
"github.com/photoprism/photoprism/internal/customize"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/acl" "github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/customize"
"github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/i18n" "github.com/photoprism/photoprism/internal/i18n"
"github.com/photoprism/photoprism/internal/service" "github.com/photoprism/photoprism/internal/service"
@ -57,7 +56,8 @@ func SaveSettings(router *gin.RouterGroup) {
var settings *customize.Settings 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() settings = conf.Settings()
if err := c.BindJSON(settings); err != nil { if err := c.BindJSON(settings); err != nil {

View file

@ -103,6 +103,11 @@ func StartImport(router *gin.RouterGroup) {
opt.Albums = f.Albums opt.Albums = f.Albums
} }
// Set user UID if known.
if s.UserUID != "" {
opt.OwnerUID = s.UserUID
}
// Start import. // Start import.
imported := imp.Start(opt) imported := imp.Start(opt)

View file

@ -16,6 +16,8 @@ import (
"github.com/photoprism/photoprism/pkg/txt" "github.com/photoprism/photoprism/pkg/txt"
) )
// UpdateLink updates a share link and return it as JSON.
//
// PUT /api/v1/:entity/:uid/links/:link // PUT /api/v1/:entity/:uid/links/:link
func UpdateLink(c *gin.Context) { func UpdateLink(c *gin.Context) {
s := Auth(c, acl.ResourceShares, acl.ActionUpdate) s := Auth(c, acl.ResourceShares, acl.ActionUpdate)
@ -28,6 +30,7 @@ func UpdateLink(c *gin.Context) {
var f form.Link var f form.Link
if err := c.BindJSON(&f); err != nil { if err := c.BindJSON(&f); err != nil {
log.Debugf("share: %s", err)
AbortBadRequest(c) AbortBadRequest(c)
return return
} }
@ -63,6 +66,8 @@ func UpdateLink(c *gin.Context) {
c.JSON(http.StatusOK, link) c.JSON(http.StatusOK, link)
} }
// DeleteLink deletes a share link.
//
// DELETE /api/v1/:entity/:uid/links/:link // DELETE /api/v1/:entity/:uid/links/:link
func DeleteLink(c *gin.Context) { func DeleteLink(c *gin.Context) {
s := Auth(c, acl.ResourceShares, acl.ActionDelete) s := Auth(c, acl.ResourceShares, acl.ActionDelete)
@ -88,23 +93,32 @@ func DeleteLink(c *gin.Context) {
c.JSON(http.StatusOK, link) 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) { func CreateLink(c *gin.Context) {
s := Auth(c, acl.ResourceShares, acl.ActionCreate) s := Auth(c, acl.ResourceShares, acl.ActionCreate)
if s.Invalid() { if s.Abort(c) {
AbortForbidden(c) return
}
uid := clean.UID(c.Param("uid"))
if uid == "" {
AbortBadRequest(c)
return return
} }
var f form.Link var f form.Link
if err := c.BindJSON(&f); err != nil { if err := c.BindJSON(&f); err != nil {
log.Debugf("share: %s", err)
AbortBadRequest(c) AbortBadRequest(c)
return 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.SetSlug(f.ShareSlug)
link.MaxViews = f.MaxViews link.MaxViews = f.MaxViews
@ -131,9 +145,17 @@ func CreateLink(c *gin.Context) {
c.JSON(http.StatusOK, link) c.JSON(http.StatusOK, link)
} }
// CreateAlbumLink adds a new album share link and return it as JSON.
//
// POST /api/v1/albums/:uid/links // POST /api/v1/albums/:uid/links
func CreateAlbumLink(router *gin.RouterGroup) { func CreateAlbumLink(router *gin.RouterGroup) {
router.POST("/albums/:uid/links", func(c *gin.Context) { 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 { if _, err := query.AlbumByUID(clean.UID(c.Param("uid"))); err != nil {
AbortAlbumNotFound(c) AbortAlbumNotFound(c)
return 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 // PUT /api/v1/albums/:uid/links/:link
func UpdateAlbumLink(router *gin.RouterGroup) { func UpdateAlbumLink(router *gin.RouterGroup) {
router.PUT("/albums/:uid/links/:link", func(c *gin.Context) { router.PUT("/albums/:uid/links/:link", func(c *gin.Context) {
s := Auth(c, acl.ResourceAlbums, acl.ActionShare)
if s.Abort(c) {
return
}
UpdateLink(c) UpdateLink(c)
}) })
} }
// DeleteAlbumLink deletes an album share link.
//
// DELETE /api/v1/albums/:uid/links/:link // DELETE /api/v1/albums/:uid/links/:link
func DeleteAlbumLink(router *gin.RouterGroup) { func DeleteAlbumLink(router *gin.RouterGroup) {
router.DELETE("/albums/:uid/links/:link", func(c *gin.Context) { router.DELETE("/albums/:uid/links/:link", func(c *gin.Context) {
s := Auth(c, acl.ResourceAlbums, acl.ActionShare)
if s.Abort(c) {
return
}
DeleteLink(c) DeleteLink(c)
}) })
} }
// GetAlbumLinks returns all share links for the given UID as JSON.
//
// GET /api/v1/albums/:uid/links // GET /api/v1/albums/:uid/links
func GetAlbumLinks(router *gin.RouterGroup) { func GetAlbumLinks(router *gin.RouterGroup) {
router.GET("/albums/:uid/links", func(c *gin.Context) { 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"))) m, err := query.AlbumByUID(clean.UID(c.Param("uid")))
if err != nil { 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 // POST /api/v1/photos/:uid/links
func CreatePhotoLink(router *gin.RouterGroup) { func CreatePhotoLink(router *gin.RouterGroup) {
router.POST("/photos/:uid/links", func(c *gin.Context) { 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 { if _, err := query.PhotoByUID(clean.UID(c.Param("uid"))); err != nil {
AbortEntityNotFound(c) AbortEntityNotFound(c)
return return
@ -183,23 +239,47 @@ func CreatePhotoLink(router *gin.RouterGroup) {
}) })
} }
// UpdatePhotoLink updates an existing photo sharing link.
//
// PUT /api/v1/photos/:uid/links/:link // PUT /api/v1/photos/:uid/links/:link
func UpdatePhotoLink(router *gin.RouterGroup) { func UpdatePhotoLink(router *gin.RouterGroup) {
router.PUT("/photos/:uid/links/:link", func(c *gin.Context) { router.PUT("/photos/:uid/links/:link", func(c *gin.Context) {
s := Auth(c, acl.ResourcePhotos, acl.ActionShare)
if s.Abort(c) {
return
}
UpdateLink(c) UpdateLink(c)
}) })
} }
// DeletePhotoLink deletes a photo sharing link.
//
// DELETE /api/v1/photos/:uid/links/:link // DELETE /api/v1/photos/:uid/links/:link
func DeletePhotoLink(router *gin.RouterGroup) { func DeletePhotoLink(router *gin.RouterGroup) {
router.DELETE("/photos/:uid/links/:link", func(c *gin.Context) { router.DELETE("/photos/:uid/links/:link", func(c *gin.Context) {
s := Auth(c, acl.ResourcePhotos, acl.ActionShare)
if s.Abort(c) {
return
}
DeleteLink(c) DeleteLink(c)
}) })
} }
// GetPhotoLinks returns all share links for the given UID as JSON.
//
// GET /api/v1/photos/:uid/links // GET /api/v1/photos/:uid/links
func GetPhotoLinks(router *gin.RouterGroup) { func GetPhotoLinks(router *gin.RouterGroup) {
router.GET("/photos/:uid/links", func(c *gin.Context) { 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"))) m, err := query.PhotoByUID(clean.UID(c.Param("uid")))
if err != nil { 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 // POST /api/v1/labels/:uid/links
func CreateLabelLink(router *gin.RouterGroup) { func CreateLabelLink(router *gin.RouterGroup) {
router.POST("/labels/:uid/links", func(c *gin.Context) { 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 { if _, err := query.LabelByUID(clean.UID(c.Param("uid"))); err != nil {
Abort(c, http.StatusNotFound, i18n.ErrLabelNotFound) Abort(c, http.StatusNotFound, i18n.ErrLabelNotFound)
return 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 // PUT /api/v1/labels/:uid/links/:link
func UpdateLabelLink(router *gin.RouterGroup) { func UpdateLabelLink(router *gin.RouterGroup) {
router.PUT("/labels/:uid/links/:link", func(c *gin.Context) { router.PUT("/labels/:uid/links/:link", func(c *gin.Context) {
s := Auth(c, acl.ResourceLabels, acl.ActionShare)
if s.Abort(c) {
return
}
UpdateLink(c) UpdateLink(c)
}) })
} }
// DeleteLabelLink deletes a label share link.
//
// DELETE /api/v1/labels/:uid/links/:link // DELETE /api/v1/labels/:uid/links/:link
func DeleteLabelLink(router *gin.RouterGroup) { func DeleteLabelLink(router *gin.RouterGroup) {
router.DELETE("/labels/:uid/links/:link", func(c *gin.Context) { router.DELETE("/labels/:uid/links/:link", func(c *gin.Context) {
s := Auth(c, acl.ResourceLabels, acl.ActionShare)
if s.Abort(c) {
return
}
DeleteLink(c) DeleteLink(c)
}) })
} }
// GetLabelLinks returns all share links for the given UID as JSON.
//
// GET /api/v1/labels/:uid/links // GET /api/v1/labels/:uid/links
func GetLabelLinks(router *gin.RouterGroup) { func GetLabelLinks(router *gin.RouterGroup) {
router.GET("/labels/:uid/links", func(c *gin.Context) { 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"))) m, err := query.LabelByUID(clean.UID(c.Param("uid")))
if err != nil { if err != nil {
@ -250,3 +362,4 @@ func GetLabelLinks(router *gin.RouterGroup) {
c.JSON(http.StatusOK, m.Links()) c.JSON(http.StatusOK, m.Links())
}) })
} }
*/

View file

@ -149,6 +149,7 @@ func TestGetAlbumLinks(t *testing.T) {
}) })
} }
/*
func TestCreatePhotoLink(t *testing.T) { func TestCreatePhotoLink(t *testing.T) {
t.Run("create share link", func(t *testing.T) { t.Run("create share link", func(t *testing.T) {
app, router, _ := NewApiTest() app, router, _ := NewApiTest()
@ -157,7 +158,8 @@ func TestCreatePhotoLink(t *testing.T) {
CreatePhotoLink(router) 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) assert.Equal(t, http.StatusOK, resp.Code)
if err := json.Unmarshal(resp.Body.Bytes(), &link); err != nil { 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) assert.Equal(t, http.StatusNotFound, r.Code)
}) })
} }
*/

View file

@ -73,6 +73,7 @@ func PhotoUnstack(router *gin.RouterGroup) {
} }
stackPhoto := *file.Photo stackPhoto := *file.Photo
ownerUID := stackPhoto.OwnerUID
stackPrimary, err := stackPhoto.PrimaryFile() stackPrimary, err := stackPhoto.PrimaryFile()
if err != nil { if err != nil {
@ -125,7 +126,7 @@ func PhotoUnstack(router *gin.RouterGroup) {
} }
// Create new photo, also flagged as unstacked / not stackable. // Create new photo, also flagged as unstacked / not stackable.
newPhoto := entity.NewPhoto(false) newPhoto := entity.NewUserPhoto(false, ownerUID)
newPhoto.PhotoPath = unstackFile.RootRelPath() newPhoto.PhotoPath = unstackFile.RootRelPath()
newPhoto.PhotoName = unstackFile.BasePrefix(false) newPhoto.PhotoName = unstackFile.BasePrefix(false)

View file

@ -53,8 +53,10 @@ func SearchPhotos(router *gin.RouterGroup) {
return return
} }
// Find matching pictures.
result, count, err := search.UserPhotos(f, s) result, count, err := search.UserPhotos(f, s)
// Ok?
if err != nil { if err != nil {
event.AuditWarn([]string{ClientIP(c), "session %s", string(acl.ResourcePhotos), "search", "%s"}, s.RefID, err) event.AuditWarn([]string{ClientIP(c), "session %s", string(acl.ResourcePhotos), "search", "%s"}, s.RefID, err)
AbortBadRequest(c) AbortBadRequest(c)
@ -67,7 +69,7 @@ func SearchPhotos(router *gin.RouterGroup) {
AddOffsetHeader(c, f.Offset) AddOffsetHeader(c, f.Offset)
AddTokenHeaders(c) AddTokenHeaders(c)
// Render as JSON. // Return as JSON.
c.JSON(http.StatusOK, result) c.JSON(http.StatusOK, result)
} }
@ -96,7 +98,7 @@ func SearchPhotos(router *gin.RouterGroup) {
AddOffsetHeader(c, f.Offset) AddOffsetHeader(c, f.Offset)
AddTokenHeaders(c) AddTokenHeaders(c)
// Render as JSON. // Return as JSON.
c.JSON(http.StatusOK, result) c.JSON(http.StatusOK, result)
} }

View file

@ -28,8 +28,8 @@ func SearchGeo(router *gin.RouterGroup) {
return return
} }
var f form.SearchPhotosGeo
var err error var err error
var f form.SearchPhotosGeo
// Abort if request params are invalid. // Abort if request params are invalid.
if err = c.MustBindWith(&f, binding.Form); err != nil { if err = c.MustBindWith(&f, binding.Form); err != nil {
@ -48,6 +48,7 @@ func SearchGeo(router *gin.RouterGroup) {
// Find matching pictures. // Find matching pictures.
photos, err := search.UserPhotosGeo(f, s) photos, err := search.UserPhotosGeo(f, s)
// Ok?
if err != nil { if err != nil {
event.AuditWarn([]string{ClientIP(c), "session %s", string(acl.ResourcePlaces), "search", "%s"}, s.RefID, err) event.AuditWarn([]string{ClientIP(c), "session %s", string(acl.ResourcePlaces), "search", "%s"}, s.RefID, err)
AbortBadRequest(c) AbortBadRequest(c)

View file

@ -55,6 +55,7 @@ type Album struct {
AlbumPrivate bool `json:"Private" yaml:"Private,omitempty"` AlbumPrivate bool `json:"Private" yaml:"Private,omitempty"`
Thumb string `gorm:"type:VARBINARY(128);index;default:'';" json:"Thumb" yaml:"Thumb,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"` 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"` CreatedAt time.Time `json:"CreatedAt" yaml:"CreatedAt,omitempty"`
UpdatedAt time.Time `json:"UpdatedAt" yaml:"UpdatedAt,omitempty"` UpdatedAt time.Time `json:"UpdatedAt" yaml:"UpdatedAt,omitempty"`
DeletedAt *time.Time `sql:"index" json:"DeletedAt" yaml:"DeletedAt,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. // AddPhotoToAlbums adds a photo UID to multiple albums and automatically creates them if needed.
func AddPhotoToAlbums(photo string, albums []string) (err error) { func AddPhotoToAlbums(uid string, albums []string) (err error) {
if photo == "" || len(albums) == 0 { 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. // Do nothing.
return nil return nil
} }
if !rnd.IsUID(photo, PhotoUID) { if !rnd.IsUID(uid, PhotoUID) {
return fmt.Errorf("album: invalid photo uid %s", photo) return fmt.Errorf("album: invalid photo uid %s", uid)
} }
for _, album := range albums { for _, album := range albums {
var aUID string var aUID string
if album == "" { 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 continue
} }
if rnd.IsUID(album, AlbumUID) { if rnd.IsUID(album, AlbumUID) {
aUID = album aUID = album
} else { } else {
a := NewAlbum(album, AlbumDefault) a := NewUserAlbum(album, AlbumDefault, userUID)
if found := a.Find(); found != nil { if found := a.Find(); found != nil {
aUID = found.AlbumUID aUID = found.AlbumUID
} else if err = a.Create(); err == nil { } else if err = a.Create(); err == nil {
aUID = a.AlbumUID aUID = a.AlbumUID
} else { } 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 != "" { if aUID != "" {
entry := PhotoAlbum{AlbumUID: aUID, PhotoUID: photo, Hidden: false} entry := PhotoAlbum{AlbumUID: aUID, PhotoUID: uid, Hidden: false}
if err = entry.Save(); err != nil { 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 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 { 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() now := TimeStamp()
// Set default type.
if albumType == "" { if albumType == "" {
albumType = AlbumDefault albumType = AlbumDefault
} }
// Set default values.
result := &Album{ result := &Album{
OwnerUID: userUID,
AlbumOrder: SortOrderOldest, AlbumOrder: SortOrderOldest,
AlbumType: albumType, AlbumType: albumType,
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
} }
// Set album title.
result.SetTitle(albumTitle) result.SetTitle(albumTitle)
return result return result

View file

@ -19,8 +19,9 @@ import (
// User identifier prefixes. // User identifier prefixes.
const ( const (
UserUID = byte('u') UserUID = byte('u')
UserPrefix = "user" UserPrefix = "user"
OwnerUnknown = ""
) )
// LenNameMin specifies the minimum length of the username in characters. // 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. // User represents a person that may optionally log in as user.
type User struct { type User struct {
ID int `gorm:"primary_key" json:"-" yaml:"-"` 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"` 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"` 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"` 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"` 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. // IsAdmin checks if the user is an admin with username.
func (m *User) IsAdmin() bool { 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. // IsVisitor checks if the user is a sharing link visitor.

View file

@ -29,6 +29,7 @@ type Link struct {
HasPassword bool `json:"HasPassword" yaml:"HasPassword,omitempty"` HasPassword bool `json:"HasPassword" yaml:"HasPassword,omitempty"`
CanComment bool `json:"CanComment" yaml:"CanComment,omitempty"` CanComment bool `json:"CanComment" yaml:"CanComment,omitempty"`
CanEdit bool `json:"CanEdit" yaml:"CanEdit,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"` CreatedAt time.Time `deepcopier:"skip" json:"CreatedAt" yaml:"CreatedAt"`
ModifiedAt time.Time `deepcopier:"skip" json:"ModifiedAt" yaml:"ModifiedAt"` 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. // NewLink creates a sharing link.
func NewLink(shareUID string, canComment, canEdit bool) 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() now := TimeStamp()
result := Link{ result := Link{
OwnerUID: userUID,
LinkUID: rnd.GenerateUID(LinkUID), LinkUID: rnd.GenerateUID(LinkUID),
ShareUID: shareUID, ShareUID: shareUID,
LinkToken: rnd.GenerateToken(10), LinkToken: rnd.GenerateToken(10),

View file

@ -9,8 +9,6 @@ import (
"sync" "sync"
"time" "time"
"github.com/photoprism/photoprism/pkg/react"
"github.com/jinzhu/gorm" "github.com/jinzhu/gorm"
"github.com/ulule/deepcopier" "github.com/ulule/deepcopier"
@ -18,6 +16,7 @@ import (
"github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form" "github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/react"
"github.com/photoprism/photoprism/pkg/rnd" "github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/txt" "github.com/photoprism/photoprism/pkg/txt"
) )
@ -104,6 +103,7 @@ type Photo struct {
Albums []Album `json:"-" yaml:"-"` Albums []Album `json:"-" yaml:"-"`
Files []File `yaml:"-"` Files []File `yaml:"-"`
Labels []PhotoLabel `yaml:"-"` Labels []PhotoLabel `yaml:"-"`
OwnerUID string `gorm:"type:VARBINARY(64);index" json:"OwnerUID,omitempty" yaml:"OwnerUID,omitempty"`
CreatedAt time.Time `yaml:"CreatedAt,omitempty"` CreatedAt time.Time `yaml:"CreatedAt,omitempty"`
UpdatedAt time.Time `yaml:"UpdatedAt,omitempty"` UpdatedAt time.Time `yaml:"UpdatedAt,omitempty"`
EditedAt *time.Time `yaml:"EditedAt,omitempty"` EditedAt *time.Time `yaml:"EditedAt,omitempty"`
@ -117,9 +117,15 @@ func (Photo) TableName() string {
return "photos" return "photos"
} }
// NewPhoto creates a photo entity. // NewPhoto creates a new photo with default values.
func NewPhoto(stackable bool) Photo { 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{ m := Photo{
OwnerUID: userUID,
PhotoTitle: UnknownTitle, PhotoTitle: UnknownTitle,
PhotoType: MediaImage, PhotoType: MediaImage,
PhotoCountry: UnknownCountry.ID, PhotoCountry: UnknownCountry.ID,

View file

@ -9,7 +9,7 @@ import (
// SearchPhotos represents search form fields for "/api/v1/photos". // SearchPhotos represents search form fields for "/api/v1/photos".
type SearchPhotos struct { type SearchPhotos struct {
Query string `form:"q"` 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:"-"` Filter string `form:"filter" serialize:"-" notes:"-"`
UID string `form:"uid" example:"uid:pqbcf5j446s0futy" notes:"Search for specific files or photos, only exact matches"` 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 |"` 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. // FindUidOnly checks if search filters other than UID may be skipped to improve performance.
func (f *SearchPhotos) FindUidOnly() bool { 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} return SearchPhotos{Query: query}
} }

View file

@ -9,7 +9,7 @@ import (
// SearchPhotosGeo represents search form fields for "/api/v1/geo". // SearchPhotosGeo represents search form fields for "/api/v1/geo".
type SearchPhotosGeo struct { type SearchPhotosGeo struct {
Query string `form:"q"` 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:"-"` Filter string `form:"filter" serialize:"-" notes:"-"`
UID string `form:"uid" example:"uid:pqbcf5j446s0futy" notes:"Search for specific files or photos, only exact matches"` UID string `form:"uid" example:"uid:pqbcf5j446s0futy" notes:"Search for specific files or photos, only exact matches"`
Near string `form:"near"` 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. // FindByIdOnly checks if search filters other than UID may be skipped to improve performance.
func (f *SearchPhotosGeo) FindByIdOnly() bool { 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} return SearchPhotosGeo{Query: query}
} }

View file

@ -7,7 +7,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func TestGeoSearch(t *testing.T) { func TestSearchPhotosGeo(t *testing.T) {
t.Run("subjects", func(t *testing.T) { t.Run("subjects", func(t *testing.T) {
form := &SearchPhotosGeo{Query: "subjects:\"Jens Mander\""} 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} form := &SearchPhotosGeo{Query: "q:\"fooBar baz\"", Favorite: true}
assert.Equal(t, "q:\"q:fooBar baz\" favorite:true", form.Serialize()) 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} form := &SearchPhotosGeo{Query: "q:\"fooBar baz\"", Favorite: true}
assert.Equal(t, "q:\"q:fooBar baz\" favorite:true", form.SerializeAll()) assert.Equal(t, "q:\"q:fooBar baz\" favorite:true", form.SerializeAll())
} }
func TestNewGeoSearch(t *testing.T) { func TestNewSearchPhotosGeo(t *testing.T) {
r := NewGeoSearch("Berlin") r := NewSearchPhotosGeo("Berlin")
assert.IsType(t, SearchPhotosGeo{}, r) assert.IsType(t, SearchPhotosGeo{}, r)
} }

View file

@ -7,7 +7,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
func TestPhotoSearchForm(t *testing.T) { func TestSearchPhotosForm(t *testing.T) {
form := &SearchPhotos{} form := &SearchPhotos{}
assert.IsType(t, new(SearchPhotos), form) assert.IsType(t, new(SearchPhotos), form)
@ -664,12 +664,12 @@ func TestParseQueryString(t *testing.T) {
}) })
} }
func TestNewPhotoSearch(t *testing.T) { func TestNewSearchPhotos(t *testing.T) {
r := NewPhotoSearch("cat") r := NewSearchPhotos("cat")
assert.IsType(t, SearchPhotos{}, r) assert.IsType(t, SearchPhotos{}, r)
} }
func TestPhotoSearch_Serialize(t *testing.T) { func TestSearchPhotos_Serialize(t *testing.T) {
form := SearchPhotos{ form := SearchPhotos{
Query: "foo BAR", Query: "foo BAR",
Private: true, Private: true,
@ -689,7 +689,7 @@ func TestPhotoSearch_Serialize(t *testing.T) {
assert.IsType(t, "string", result) assert.IsType(t, "string", result)
} }
func TestPhotoSearch_SerializeAll(t *testing.T) { func TestSearchPhotos_SerializeAll(t *testing.T) {
form := SearchPhotos{ form := SearchPhotos{
Query: "foo BAR", Query: "foo BAR",
Private: true, Private: true,
@ -708,3 +708,70 @@ func TestPhotoSearch_SerializeAll(t *testing.T) {
assert.IsType(t, "string", result) 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)
})
}

View file

@ -5,6 +5,7 @@ type ImportOptions struct {
Albums []string Albums []string
Path string Path string
Move bool Move bool
OwnerUID string
DestFolder string DestFolder string
RemoveDotFiles bool RemoveDotFiles bool
RemoveExistingFiles bool RemoveExistingFiles bool

View file

@ -111,7 +111,7 @@ func ImportWorker(jobs <-chan ImportJob) {
// Do nothing. // Do nothing.
} else if file, err := entity.FirstFileByHash(fileHash); err != nil { } else if file, err := entity.FirstFileByHash(fileHash); err != nil {
// Do nothing. // 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) log.Warn(err)
} }
@ -191,7 +191,7 @@ func ImportWorker(jobs <-chan ImportJob) {
} }
// Index main MediaFile. // Index main MediaFile.
res := ind.MediaFile(f, o, originalName, "") res := ind.UserMediaFile(f, o, originalName, "", opt.OwnerUID)
// Log result. // Log result.
log.Infof("import: %s main %s file %s", res, f.FileType(), clean.Log(f.RootRelName())) 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 photoUID = res.PhotoUID
// Add photo to album if a list of albums was provided when importing. // 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) log.Warn(err)
} }
} }
@ -241,7 +241,7 @@ func ImportWorker(jobs <-chan ImportJob) {
} }
// Index related media file including its original filename. // 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. // Save file error.
if fileUid, err := res.FileError(); err != nil { if fileUid, err := res.FileError(); err != nil {

View file

@ -14,7 +14,6 @@ import (
"github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/meta" "github.com/photoprism/photoprism/internal/meta"
"github.com/photoprism/photoprism/internal/query" "github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs" "github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/txt" "github.com/photoprism/photoprism/pkg/txt"
@ -22,6 +21,11 @@ import (
// MediaFile indexes a single media file. // MediaFile indexes a single media file.
func (ind *Index) MediaFile(m *MediaFile, o IndexOptions, originalName, photoUID string) (result IndexResult) { 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 { if m == nil {
result.Status = IndexFailed result.Status = IndexFailed
result.Err = errors.New("index: media file is nil - possible bug") 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{} file, primaryFile := entity.File{}, entity.File{}
photo := entity.NewPhoto(o.Stack) photo := entity.NewUserPhoto(o.Stack, userUID)
metaData := meta.New() metaData := meta.New()
labels := classify.Labels{} labels := classify.Labels{}
stripSequence := Config().Settings().StackSequences() && o.Stack stripSequence := Config().Settings().StackSequences() && o.Stack

View file

@ -3,6 +3,8 @@ package photoprism
import ( import (
"testing" "testing"
"github.com/photoprism/photoprism/internal/entity"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/classify" "github.com/photoprism/photoprism/internal/classify"
@ -71,7 +73,8 @@ func TestIndex_MediaFile(t *testing.T) {
} }
assert.Equal(t, "", mediaFile.metaData.Title) 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, "Blue Gopher", mediaFile.metaData.Title)
assert.Equal(t, IndexStatus("added"), result.Status) assert.Equal(t, IndexStatus("added"), result.Status)
}) })

View file

@ -7,7 +7,6 @@ import (
"github.com/dustin/go-humanize/english" "github.com/dustin/go-humanize/english"
"github.com/photoprism/photoprism/internal/query" "github.com/photoprism/photoprism/internal/query"
"github.com/photoprism/photoprism/pkg/clean" "github.com/photoprism/photoprism/pkg/clean"
) )

View file

@ -2,16 +2,30 @@ package search
import ( import (
"strings" "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/entity"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form" "github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/pkg/txt" "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) { func Albums(f form.SearchAlbums) (results AlbumResults, err error) {
if err := f.ParseQueryString(); err != nil { return UserAlbums(f, nil)
return results, err }
// 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. // 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"). 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") Where("albums.deleted_at IS NULL")
// Albums with public pictures only? // Check session permissions and apply as needed.
if f.Public { if sess != nil {
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)") user := sess.User()
} else { aclRole := user.AclRole()
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)")
}
// Limit result count. // Determine resource to check.
if f.Count > 0 && f.Count <= MaxResults { var aclResource acl.Resource
s = s.Limit(f.Count).Offset(f.Offset) switch f.Type {
} else { case entity.AlbumDefault:
s = s.Limit(MaxResults).Offset(f.Offset) aclResource = acl.ResourceAlbums
} case entity.AlbumFolder:
aclResource = acl.ResourceFolders
// Filter by storage path? case entity.AlbumMoment:
if f.Query != "" && f.Type == entity.AlbumFolder { aclResource = acl.ResourceMoments
f.Order = entity.SortOrderPath case entity.AlbumMonth:
aclResource = acl.ResourceCalendar
p := f.Query case entity.AlbumState:
aclResource = acl.ResourcePlaces
if strings.HasPrefix(p, "/") {
p = p[1:]
} }
if strings.HasSuffix(p, "/") { // Check user rights.
s = s.Where("albums.album_path = ?", p[:len(p)-1]) if acl.Resources.DenyAll(aclResource, aclRole, acl.Permissions{acl.AccessAll, acl.AccessLibrary, acl.AccessShared, acl.AccessOwn}) {
} else { return AlbumResults{}, ErrForbidden
p = p + "*" }
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 { // Limit results by UID, owner and path.
where = where + " OR " + w if sess.IsVisitor() {
values = append(values, v...) 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 + "%" // Exclude private content?
s = s.Where("albums.album_title LIKE ? OR albums.album_location LIKE ?", likeString, likeString) 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. // 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") s = s.Order("albums.album_favorite DESC, albums.album_title ASC, albums.album_uid DESC")
} }
if f.UID != "" { // Find specific UIDs only?
s = s.Where("albums.album_uid IN (?)", strings.Split(strings.ToLower(f.UID), txt.Or)) if txt.NotEmpty(f.UID) {
ids := SplitOr(strings.ToLower(f.UID))
if result := s.Scan(&results); result.Error != nil { if rnd.ContainsUID(ids, entity.AlbumUID) {
return results, result.Error 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)) 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)) 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)) 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)) 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) 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 { if result := s.Scan(&results); result.Error != nil {
return results, result.Error 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 return results, nil
} }

View file

@ -9,7 +9,7 @@ import (
var ( var (
ErrForbidden = i18n.Error(i18n.ErrForbidden) ErrForbidden = i18n.Error(i18n.ErrForbidden)
ErrBadRequest = i18n.Error(i18n.ErrBadRequest) ErrBadRequest = i18n.Error(i18n.ErrBadRequest)
ErrBadSortOrder = fmt.Errorf("iinvalid sort order") ErrBadSortOrder = fmt.Errorf("invalid sort order")
ErrBadFilter = fmt.Errorf("search filter is invalid") ErrBadFilter = fmt.Errorf("invalid search filter")
ErrInvalidId = fmt.Errorf("invalid ID specified") ErrInvalidId = fmt.Errorf("invalid ID specified")
) )

View file

@ -27,12 +27,12 @@ var PhotosColsView = SelectString(Photo{}, SelectCols(GeoResult{}, []string{"*"}
// FileTypes contains a list of browser-compatible file formats returned by search queries. // 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()} 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) { func Photos(f form.SearchPhotos) (results PhotoResults, count int, err error) {
return searchPhotos(f, nil, PhotosColsAll) 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) { func UserPhotos(f form.SearchPhotos, sess *entity.Session) (results PhotoResults, count int, err error) {
return searchPhotos(f, sess, PhotosColsAll) 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 lenses ON photos.lens_id = lenses.id").
Joins("LEFT JOIN places ON photos.place_id = places.id") Joins("LEFT JOIN places ON photos.place_id = places.id")
// Limit offset and count. // Accept the album UID as scope for backward compatibility.
if f.Count > 0 && f.Count <= MaxResults { if rnd.IsUID(f.Album, 'a') {
s = s.Limit(f.Count).Offset(f.Offset) 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 { } 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. // Set sort order.
@ -93,60 +156,6 @@ func searchPhotos(f form.SearchPhotos, sess *entity.Session, resultCols string)
return PhotoResults{}, 0, ErrBadSortOrder 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. // Limit the result file types if hidden images/videos should not be found.
if !f.Hidden { if !f.Hidden {
s = s.Where("files.file_type IN (?) OR files.file_video = 1", FileTypes) 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") s = s.Where("files.file_primary = 1")
} }
// Find only certain unique IDs? // Find specific UIDs only?
if txt.NotEmpty(f.UID) { if txt.NotEmpty(f.UID) {
ids := SplitOr(strings.ToLower(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: case entity.FileUID:
s = s.Where("files.file_uid IN (?)", ids) s = s.Where("files.file_uid IN (?)", ids)
default: default:
log.Debugf("(1) idType: %s, prefix: %s", idType, prefix)
return PhotoResults{}, 0, fmt.Errorf("invalid %s specified", idType) return PhotoResults{}, 0, fmt.Errorf("invalid %s specified", idType)
} }
} else if idType.SHA() { } else if idType.SHA() {
s = s.Where("files.file_hash IN (?)", ids) 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? // Find UIDs only to improve performance?
if sess == nil && f.FindUidOnly() { if sess == nil && f.FindUidOnly() {
s = s.Order("files.media_id")
if result := s.Scan(&results); result.Error != nil { if result := s.Scan(&results); result.Error != nil {
return results, 0, result.Error return results, 0, result.Error
} }
@ -200,8 +203,6 @@ func searchPhotos(f form.SearchPhotos, sess *entity.Session, resultCols string)
return results, len(results), nil return results, len(results), nil
} }
} else {
f.UID = ""
} }
// Filter by label, label category and keywords. // 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')") 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? // Find photos in albums or not in an album, unless search results are limited to a scope.
if rnd.IsUID(f.Album, 'a') { if f.Scope == "" {
if f.Filter != "" { if f.Unsorted {
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) s = s.Where("photos.photo_uid NOT IN (SELECT photo_uid FROM photos_albums pa WHERE pa.hidden = 0)")
} else { } else if txt.NotEmpty(f.Album) {
s = s.Joins("JOIN photos_albums ON photos_albums.photo_uid = files.photo_uid"). v := strings.Trim(f.Album, "*%") + "%"
Where("photos_albums.hidden = 0 AND photos_albums.album_uid = ?", 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) {
} else if f.Unsorted && f.Filter == "" { for _, where := range LikeAnyWord("a.album_title", f.Albums) {
s = s.Where("files.photo_uid NOT IN (SELECT photo_uid FROM photos_albums pa WHERE pa.hidden = 0)") 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))
} 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))
} }
} }
// 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. // Query database.
if err = s.Scan(&results).Error; err != nil { if err = s.Scan(&results).Error; err != nil {
return results, 0, err return results, 0, err

View file

@ -23,7 +23,7 @@ import (
// GeoCols specifies the UserPhotosGeo result column names. // GeoCols specifies the UserPhotosGeo result column names.
var GeoCols = SelectString(GeoResult{}, []string{"*"}) 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) { func PhotosGeo(f form.SearchPhotosGeo) (results GeoResults, err error) {
return UserPhotosGeo(f, nil) 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.deleted_at IS NULL").
Where("photos.photo_lat <> 0") Where("photos.photo_lat <> 0")
// Limit offset and count. // Accept the album UID as scope for backward compatibility.
if f.Count > 0 { if rnd.IsUID(f.Album, 'a') {
s = s.Limit(f.Count).Offset(f.Offset) if txt.Empty(f.Scope) {
} else { f.Scope = f.Album
s = s.Limit(1000000).Offset(f.Offset) }
f.Album = ""
} }
// Limit search results to a specific UID scope, e.g. when sharing. // Limit search results to a specific UID scope, e.g. when sharing.
if txt.NotEmpty(f.In) { if txt.NotEmpty(f.Scope) {
f.In = strings.ToLower(f.In) f.Scope = strings.ToLower(f.Scope)
if idType, idPrefix := rnd.IdType(f.In); idType != rnd.TypeUID || idPrefix != entity.AlbumUID {
if idType, idPrefix := rnd.IdType(f.Scope); idType != rnd.TypeUID || idPrefix != entity.AlbumUID {
return GeoResults{}, ErrInvalidId 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 return GeoResults{}, ErrInvalidId
} else if a.AlbumFilter == "" { } else if a.AlbumFilter == "" {
s = s.Joins("JOIN photos_albums ON photos_albums.photo_uid = files.photo_uid"). 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 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) 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 { } else {
f.In = "" f.Scope = ""
} }
// Check session permissions and apply as needed. // Check session permissions and apply as needed.
if sess != nil { if sess != nil {
aclRole := sess.User().AclRole() user := sess.User()
aclRole := user.AclRole()
// Visitors and other restricted users can only access shared content. // Visitors and other restricted users can only access shared content.
if !sess.HasShare(f.In) && (sess.IsVisitor() || sess.Unregistered()) || if !sess.HasShare(f.Scope) && (sess.IsVisitor() || sess.Unregistered()) ||
f.In == "" && acl.Resources.Deny(acl.ResourcePhotos, aclRole, acl.ActionSearch) || f.Scope == "" && acl.Resources.Deny(acl.ResourcePhotos, aclRole, acl.ActionSearch) ||
f.In == "" && acl.Resources.Deny(acl.ResourcePlaces, 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) event.AuditErr([]string{sess.IP(), "session %s", "%s %s as %s", "denied"}, sess.RefID, acl.ActionSearch.String(), string(acl.ResourcePlaces), aclRole)
return GeoResults{}, ErrForbidden return GeoResults{}, ErrForbidden
} }
@ -114,9 +120,14 @@ func UserPhotosGeo(f form.SearchPhotosGeo, sess *entity.Session) (results GeoRes
f.Review = false f.Review = false
} }
// Restrict the search to a sub-folder, if specified. // Limit results by owner and path?
if dir := sess.User().BasePath; f.In == "" && dir != "" { if f.Scope == "" && acl.Resources.DenyAll(acl.ResourcePhotos, aclRole, acl.Permissions{acl.AccessAll, acl.AccessLibrary}) {
s = s.Where("photos.photo_path LIKE ?", dir+"%") 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)) 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) { if txt.NotEmpty(f.UID) {
ids := SplitOr(strings.ToLower(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 return results, nil
} }
} else {
f.UID = ""
} }
// Set search filters based on search terms. // Set search filters based on search terms.
@ -302,23 +311,17 @@ func UserPhotosGeo(f form.SearchPhotosGeo, sess *entity.Session) (results GeoRes
} }
} }
// Filter by album? // Find photos in albums or not in an album, unless search results are limited to a scope.
if rnd.IsUID(f.Album, 'a') { if f.Scope == "" {
S2Levels = 15 if f.Unsorted {
if f.Filter != "" { s = s.Where("photos.photo_uid NOT IN (SELECT photo_uid FROM photos_albums pa WHERE pa.hidden = 0)")
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 if txt.NotEmpty(f.Album) {
} else { v := strings.Trim(f.Album, "*%") + "%"
s = s.Joins("JOIN photos_albums ON photos_albums.photo_uid = photos.photo_uid"). 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)
Where("photos_albums.hidden = 0 AND photos_albums.album_uid = ?", f.Album) } else if txt.NotEmpty(f.Albums) {
} for _, where := range LikeAnyWord("a.album_title", f.Albums) {
} else if f.Unsorted && f.Filter == "" { 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))
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")) 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. // Fetch results.
if result := s.Scan(&results); result.Error != nil { if result := s.Scan(&results); result.Error != nil {
return results, result.Error return results, result.Error

View file

@ -12,7 +12,7 @@ import (
func TestGeo(t *testing.T) { func TestGeo(t *testing.T) {
t.Run("Near", func(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. // Parse query string and filter.
if err := query.ParseQueryString(); err != nil { if err := query.ParseQueryString(); err != nil {
@ -27,7 +27,7 @@ func TestGeo(t *testing.T) {
} }
}) })
t.Run("UnknownFaces", func(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. // Parse query string and filter.
if err := query.ParseQueryString(); err != nil { if err := query.ParseQueryString(); err != nil {
@ -41,7 +41,7 @@ func TestGeo(t *testing.T) {
} }
}) })
t.Run("form.keywords", func(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. // Parse query string and filter.
if err := query.ParseQueryString(); err != nil { if err := query.ParseQueryString(); err != nil {
@ -55,7 +55,7 @@ func TestGeo(t *testing.T) {
} }
}) })
t.Run("form.subjects", func(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. // Parse query string and filter.
if err := query.ParseQueryString(); err != nil { if err := query.ParseQueryString(); err != nil {
@ -69,7 +69,7 @@ func TestGeo(t *testing.T) {
} }
}) })
t.Run("find_all", func(t *testing.T) { t.Run("find_all", func(t *testing.T) {
query := form.NewGeoSearch("") query := form.NewSearchPhotosGeo("")
// Parse query string and filter. // Parse query string and filter.
if err := query.ParseQueryString(); err != nil { if err := query.ParseQueryString(); err != nil {
@ -84,7 +84,7 @@ func TestGeo(t *testing.T) {
}) })
t.Run("search for bridge", func(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. // Parse query string and filter.
if err := query.ParseQueryString(); err != nil { 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) { 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. // Parse query string and filter.
if err := query.ParseQueryString(); err != nil { if err := query.ParseQueryString(); err != nil {

View file

@ -103,10 +103,10 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
api.GetPhotoYaml(v1) api.GetPhotoYaml(v1)
api.UpdatePhoto(v1) api.UpdatePhoto(v1)
api.GetPhotoDownload(v1) api.GetPhotoDownload(v1)
api.GetPhotoLinks(v1) // api.GetPhotoLinks(v1)
api.CreatePhotoLink(v1) // api.CreatePhotoLink(v1)
api.UpdatePhotoLink(v1) // api.UpdatePhotoLink(v1)
api.DeletePhotoLink(v1) // api.DeletePhotoLink(v1)
api.ApprovePhoto(v1) api.ApprovePhoto(v1)
api.LikePhoto(v1) api.LikePhoto(v1)
api.DislikePhoto(v1) api.DislikePhoto(v1)
@ -143,10 +143,10 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
api.SearchLabels(v1) api.SearchLabels(v1)
api.LabelCover(v1) api.LabelCover(v1)
api.UpdateLabel(v1) api.UpdateLabel(v1)
api.GetLabelLinks(v1) // api.GetLabelLinks(v1)
api.CreateLabelLink(v1) // api.CreateLabelLink(v1)
api.UpdateLabelLink(v1) // api.UpdateLabelLink(v1)
api.DeleteLabelLink(v1) // api.DeleteLabelLink(v1)
api.LikeLabel(v1) api.LikeLabel(v1)
api.DislikeLabel(v1) api.DislikeLabel(v1)

View file

@ -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. // 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) { func ContainsType(ids []string) (idType Type, idPrefix byte) {
if len(ids) < 1 { if len(ids) < 1 {
return TypeNone, PrefixNone return TypeEmpty, PrefixNone
} }
idType = TypeUnknown idType = TypeUnknown

View file

@ -19,7 +19,7 @@ func TestContainsUID(t *testing.T) {
func TestContainsType(t *testing.T) { func TestContainsType(t *testing.T) {
t.Run("None", func(t *testing.T) { t.Run("None", func(t *testing.T) {
result, prefix := ContainsType([]string{}) result, prefix := ContainsType([]string{})
assert.Equal(t, TypeNone, result) assert.Equal(t, TypeEmpty, result)
assert.Equal(t, PrefixNone, prefix) assert.Equal(t, PrefixNone, prefix)
}) })
t.Run("Unknown", func(t *testing.T) { t.Run("Unknown", func(t *testing.T) {

View file

@ -5,6 +5,8 @@ import (
) )
const ( const (
TypeEmpty Type = "empty"
TypeMixed Type = "mixed"
TypeUUID Type = "UUID" TypeUUID Type = "UUID"
TypeUID Type = "UID" TypeUID Type = "UID"
TypeRefID Type = "RID" TypeRefID Type = "RID"
@ -16,8 +18,6 @@ const (
TypeSHA256 Type = "SHA256" TypeSHA256 Type = "SHA256"
TypeSHA384 Type = "SHA384" TypeSHA384 Type = "SHA384"
TypeSHA512 Type = "SHA512" TypeSHA512 Type = "SHA512"
TypeNone Type = "none"
TypeMixed Type = "mixed"
TypeUnknown Type = "unknown" TypeUnknown Type = "unknown"
) )
@ -25,7 +25,7 @@ const (
// and returns it along with the id prefix, if any. // and returns it along with the id prefix, if any.
func IdType(id string) (Type, byte) { func IdType(id string) (Type, byte) {
if l := len(id); l == 0 { if l := len(id); l == 0 {
return TypeNone, PrefixNone return TypeEmpty, PrefixNone
} else if l < 14 || l > 128 { } else if l < 14 || l > 128 {
return TypeUnknown, PrefixNone return TypeUnknown, PrefixNone
} }

View file

@ -9,7 +9,7 @@ import (
func TestUidType(t *testing.T) { func TestUidType(t *testing.T) {
t.Run("None", func(t *testing.T) { t.Run("None", func(t *testing.T) {
result, prefix := IdType("") result, prefix := IdType("")
assert.Equal(t, TypeNone, result) assert.Equal(t, TypeEmpty, result)
assert.Equal(t, PrefixNone, prefix) assert.Equal(t, PrefixNone, prefix)
}) })
t.Run("Unknown", func(t *testing.T) { t.Run("Unknown", func(t *testing.T) {

View file

@ -4,17 +4,17 @@ import (
"strings" "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 { func Empty(s string) bool {
s = strings.Trim(strings.TrimSpace(s), "%*") if s == "" {
return true
if s == "" || s == "0" || s == "-1" || EmptyTime(s) { } 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 return true
} }
s = strings.ToLower(s) return false
return s == "nil" || s == "null" || s == "nan"
} }
// NotEmpty tests if a string does not represent an empty/invalid value. // NotEmpty tests if a string does not represent an empty/invalid value.