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": {
"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"
},

View file

@ -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 },
},

View file

@ -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,
};

View file

@ -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");
}

View file

@ -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"});
}

View file

@ -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;
}
}
});

View file

@ -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 {

View file

@ -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 {

View file

@ -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,
};

View file

@ -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"});
}

View file

@ -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 },
},

View file

@ -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,
},
}

View file

@ -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?

View file

@ -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)
})
}

View file

@ -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 {

View file

@ -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)

View file

@ -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())
})
}
*/

View file

@ -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)
})
}
*/

View file

@ -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)

View file

@ -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)
}

View file

@ -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)

View file

@ -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

View file

@ -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.

View file

@ -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),

View file

@ -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,

View file

@ -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}
}

View file

@ -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}
}

View file

@ -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)
}

View file

@ -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)
})
}

View file

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

View file

@ -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 {

View file

@ -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

View file

@ -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)
})

View file

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

View file

@ -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
}

View file

@ -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")
)

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.
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

View file

@ -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

View file

@ -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 {

View file

@ -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)

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

View file

@ -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) {

View file

@ -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
}

View file

@ -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) {

View file

@ -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.