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