package main import ( "errors" "net/http" "os" "strings" "time" "github.com/gin-gonic/gin" "gorm.io/gorm" ) // Per-user preferences the PhotoPrism account model can't hold. Currently a // single field — the index sub-path the web client re-roots the Library tree // to and scopes the reindex to. Stored in the sidecar's own DB keyed by // username (see UserPref in db.go); never touches PhotoPrism's auth_users. // prefsBody is the wire shape for GET responses and PUT requests alike. type prefsBody struct { IndexPath string `json:"indexPath"` } // loadUserPref reads the row for a user, returning a zero-value pref (empty // IndexPath) when none exists yet — the "whole folder" default. func loadUserPref(db *gorm.DB, userName string) (UserPref, error) { var p UserPref err := db.Where("user_name = ?", userName).First(&p).Error if errors.Is(err, gorm.ErrRecordNotFound) { return UserPref{UserName: userName}, nil } return p, err } func handlePrefsGet(db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { p, err := loadUserPref(db, ctxUserName(c)) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, prefsBody{IndexPath: p.IndexPath}) } } // handlePrefsPut validates the requested index sub-path lives under the user's // BasePath (an existing directory, no traversal) and upserts it. An empty // string clears the sub-path back to "whole folder". func handlePrefsPut(cfg *Config, db *gorm.DB) gin.HandlerFunc { return func(c *gin.Context) { var body prefsBody if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"}) return } // Normalise to originals-relative, no leading/trailing slashes — // the same shape the web client and auth_users.base_path use. sub := strings.Trim(strings.TrimSpace(body.IndexPath), "/") if sub != "" { // The sub-path is relative to the user's BasePath; resolve the // combined originals-relative path and require it to be an // existing directory inside the originals root. resolveUnderRoot // already rejects traversal and symlink escapes. base := strings.Trim(ctxBasePath(c), "/") combined := sub if base != "" { combined = base + "/" + sub } abs, err := resolveUnderRoot(cfg.OriginalsRoot, combined, true) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid index path: " + err.Error()}) return } info, err := os.Stat(abs) if err != nil || !info.IsDir() { c.JSON(http.StatusBadRequest, gin.H{"error": "index path is not a folder"}) return } } userName := ctxUserName(c) p := UserPref{UserName: userName, IndexPath: sub, UpdatedAt: time.Now().UTC()} // Upsert: a clear (sub == "") persists an empty string rather than // deleting the row, so the GET path stays a single code branch. if err := db.Save(&p).Error; err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, prefsBody{IndexPath: sub}) } } // effectiveLibraryRoot returns the requesting user's working library root, // originals-relative with no leading/trailing slash: their BasePath narrowed // by their chosen index sub-path (if any). Mirrors the web client's // `userLibraryBase()` — handlers that walk the filesystem on a user's behalf // (duplicate scan/archive) should scope to this instead of cfg.OriginalsRoot // so a narrowed root also narrows what those handlers can see or touch. // Returns "" for "whole library" (no BasePath and no sub-path set — today's // admin default). func effectiveLibraryRoot(c *gin.Context, db *gorm.DB) string { base := strings.Trim(ctxBasePath(c), "/") pref, err := loadUserPref(db, ctxUserName(c)) sub := "" if err == nil { sub = strings.Trim(pref.IndexPath, "/") } if sub == "" { return base } if base == "" { return sub } return base + "/" + sub }