Folder create/rename/delete/move, photo move, heap convert, and file rename now reject paths outside the caller's BasePath (403). Sources resolved via PhotoPrism UIDs are re-checked in movePhotoFiles. The USER_BASEPATHS reconciler also sets upload_path so client-app uploads land inside the user's subtree. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
122 lines
3.4 KiB
Go
122 lines
3.4 KiB
Go
package main
|
|
|
|
import (
|
|
"net/http"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
// requireSession is the standard auth shim every mutating handler wears.
|
|
// We don't store a shared service credential — the caller's X-Auth-Token
|
|
// is the only authority, and we probe PhotoPrism with it before doing any
|
|
// destructive work. The handler reads the validated token off the context
|
|
// via ctxToken so it can keep forwarding it to PhotoPrism for the actual
|
|
// operation. The resolved username is available via ctxUserName.
|
|
func requireSession(pp *ppClient) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
token := c.GetHeader("X-Auth-Token")
|
|
if token == "" {
|
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "no token"})
|
|
return
|
|
}
|
|
user := pp.resolveSession(c.Request.Context(), token)
|
|
if user == nil {
|
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid session"})
|
|
return
|
|
}
|
|
c.Set("token", token)
|
|
c.Set("userName", user.UserName)
|
|
c.Set("userUID", user.UserUID)
|
|
c.Set("basePath", user.BasePath)
|
|
c.Next()
|
|
}
|
|
}
|
|
|
|
// ctxToken returns the validated X-Auth-Token a previous requireSession
|
|
// middleware stored on the request. Handlers MUST run behind that
|
|
// middleware; otherwise this returns the empty string.
|
|
func ctxToken(c *gin.Context) string {
|
|
v, ok := c.Get("token")
|
|
if !ok {
|
|
return ""
|
|
}
|
|
s, ok := v.(string)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
return s
|
|
}
|
|
|
|
// ctxUserName returns the PhotoPrism username resolved by requireSession.
|
|
func ctxUserName(c *gin.Context) string {
|
|
v, ok := c.Get("userName")
|
|
if !ok {
|
|
return ""
|
|
}
|
|
s, ok := v.(string)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
return s
|
|
}
|
|
|
|
// ctxUserUID returns the PhotoPrism user UID resolved by requireSession.
|
|
func ctxUserUID(c *gin.Context) string {
|
|
v, ok := c.Get("userUID")
|
|
if !ok {
|
|
return ""
|
|
}
|
|
s, ok := v.(string)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
return s
|
|
}
|
|
|
|
// ctxBasePath returns the PhotoPrism user BasePath resolved by requireSession.
|
|
func ctxBasePath(c *gin.Context) string {
|
|
v, ok := c.Get("basePath")
|
|
if !ok {
|
|
return ""
|
|
}
|
|
s, ok := v.(string)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
return s
|
|
}
|
|
|
|
// userScopeRoot returns the absolute directory the session may mutate:
|
|
// ORIGINALS_ROOT/<BasePath> for scoped users, the whole originals root for
|
|
// admins (empty BasePath). Lexical join only — callers compare it against
|
|
// paths built the same way from cfg.OriginalsRoot.
|
|
func userScopeRoot(c *gin.Context, cfg *Config) string {
|
|
base := strings.Trim(ctxBasePath(c), "/")
|
|
if base == "" {
|
|
return cfg.OriginalsRoot
|
|
}
|
|
return filepath.Join(cfg.OriginalsRoot, base)
|
|
}
|
|
|
|
// requireUserScope guards an already-root-resolved absolute path against
|
|
// the caller's BasePath. PhotoPrism scopes what a session can *see* by
|
|
// BasePath, but the sidecar's filesystem endpoints accept raw paths, so
|
|
// every mutation must re-check that boundary here. `strict` additionally
|
|
// rejects the scope root itself — renaming/deleting/moving the user's own
|
|
// base folder would detach their library from auth_users.base_path.
|
|
// Writes the 403 response and returns false when out of bounds.
|
|
func requireUserScope(c *gin.Context, cfg *Config, abs string, strict bool) bool {
|
|
scope := userScopeRoot(c, cfg)
|
|
if !sameOrUnder(abs, scope) {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "path outside your library"})
|
|
return false
|
|
}
|
|
if strict && abs == scope {
|
|
c.JSON(http.StatusForbidden, gin.H{"error": "cannot modify your library root"})
|
|
return false
|
|
}
|
|
return true
|
|
}
|