API: Scope photo reads and updates to the session

GetPhoto, GetPhotoYaml, and UpdatePhoto now limit access to pictures within the session's shared scope via the shared search helpers, with no change for full-access sessions. GetAlbum reports out-of-scope albums as not found for consistency with photos and files. In preparation for upcoming sharing features.
This commit is contained in:
Michael Mayer 2026-05-29 04:58:45 +00:00
parent 763757cc4b
commit 5f9f1bc55b
4 changed files with 68 additions and 5 deletions

View file

@ -90,9 +90,10 @@ func GetAlbum(router *gin.RouterGroup) {
// Get sanitized album UID from request path.
uid := clean.UID(c.Param("uid"))
// Visitors can only access shared content.
// Limit access to albums within the session's shared scope; albums outside it are reported
// as not found, consistent with how photos and files are read.
if (s.NotRegistered()) && !s.HasShare(uid) {
AbortForbidden(c)
AbortAlbumNotFound(c)
return
}
@ -106,7 +107,7 @@ func GetAlbum(router *gin.RouterGroup) {
// Other restricted users can only access their own or shared content.
if s.GetUser().HasSharedAccessOnly(acl.ResourceAlbums) && album.CreatedBy != s.UserUID && !s.HasShare(uid) {
AbortForbidden(c)
AbortAlbumNotFound(c)
return
}

View file

@ -5,6 +5,7 @@ import (
"strings"
"testing"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity/query"
"github.com/stretchr/testify/assert"
@ -30,6 +31,17 @@ func TestGetAlbum(t *testing.T) {
assert.Equal(t, "Album not found", val.String())
assert.Equal(t, http.StatusNotFound, r.Code)
})
t.Run("GuestDeniedNotShared", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
defer conf.SetAuthMode(config.AuthModePublic)
GetAlbum(router)
sessId := AuthenticateUser(app, router, "gandalf", "Gandalf123!")
// An album outside the guest's shared scope is reported as not found.
r := AuthenticatedRequest(app, "GET", "/api/v1/albums/as6sg6bxpogaaba8", sessId)
assert.Equal(t, http.StatusNotFound, r.Code)
})
}
func TestCreateAlbum(t *testing.T) {

View file

@ -8,6 +8,7 @@ import (
"github.com/photoprism/photoprism/internal/auth/acl"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/entity/query"
"github.com/photoprism/photoprism/internal/entity/search"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/photoprism"
@ -56,7 +57,16 @@ func GetPhoto(router *gin.RouterGroup) {
return
}
p, err := query.PhotoPreloadByUID(clean.UID(c.Param("uid")))
uid := clean.UID(c.Param("uid"))
// Limit access to pictures within the session's shared scope, consistent with how photo
// search filters results. Pictures outside the scope are reported as not found.
if visible, err := search.PhotoVisibleToSession(uid, s); err != nil || !visible {
AbortEntityNotFound(c)
return
}
p, err := query.PhotoPreloadByUID(uid)
if err != nil {
AbortEntityNotFound(c)
@ -88,6 +98,17 @@ func UpdatePhoto(router *gin.RouterGroup) {
}
uid := clean.UID(c.Param("uid"))
// Restricted sessions may only modify pictures within their shared scope, mirroring album
// updates. This runs before the lookup and form binding so out-of-scope requests have no
// side effects.
if s.GetUser().HasSharedAccessOnly(acl.ResourcePhotos) || s.NotRegistered() {
if visible, vErr := search.PhotoVisibleToSession(uid, s); vErr != nil || !visible {
AbortForbidden(c)
return
}
}
m, err := query.PhotoByUID(uid)
if err != nil {
@ -201,7 +222,15 @@ func GetPhotoYaml(router *gin.RouterGroup) {
return
}
p, err := query.PhotoPreloadByUID(clean.UID(c.Param("uid")))
uid := clean.UID(c.Param("uid"))
// Limit access to pictures within the session's shared scope, consistent with photo search.
if visible, err := search.PhotoVisibleToSession(uid, s); err != nil || !visible {
c.AbortWithStatus(http.StatusNotFound)
return
}
p, err := query.PhotoPreloadByUID(uid)
if err != nil {
c.AbortWithStatus(http.StatusNotFound)

View file

@ -70,6 +70,27 @@ func TestGetPhoto(t *testing.T) {
r := PerformRequest(app, "GET", "/api/v1/photos/xxx")
assert.Equal(t, http.StatusNotFound, r.Code)
})
t.Run("GuestDeniedPrivate", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
defer conf.SetAuthMode(config.AuthModePublic)
GetPhoto(router)
sessId := AuthenticateUser(app, router, "gandalf", "Gandalf123!")
// A private picture outside the guest's shared scope is reported as not found.
r := AuthenticatedRequest(app, "GET", "/api/v1/photos/ps6sg6be2lvl0y13", sessId)
assert.Equal(t, http.StatusNotFound, r.Code)
})
t.Run("AdminSeesPrivate", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.SetAuthMode(config.AuthModePasswd)
defer conf.SetAuthMode(config.AuthModePublic)
GetPhoto(router)
sessId := AuthenticateUser(app, router, "alice", "Alice123!")
r := AuthenticatedRequest(app, "GET", "/api/v1/photos/ps6sg6be2lvl0y13", sessId)
assert.Equal(t, http.StatusOK, r.Code)
})
}
func TestUpdatePhoto(t *testing.T) {