feat(settings,index): per-user index sub-path, strip dead PP settings, scope duplicates
Lets a user pick a sub-folder under their library as a working index root, stored server-side (new sidecar user_prefs table). The Library tree, reindex, and both duplicate views (stacks + cross-folder scan) now re-root to it via a single userLibraryBase() helper. Also fixes the cross-folder scan/archive endpoints, which previously walked/touched the whole originals root instead of being scoped per-user (archive now rejects out-of-scope paths, 403). Removes PhotoPrism settings (Search/Maps/Server-UI/Features/Import) that only steered PhotoPrism's own bundled SPA and were never read by mulimage's UI. Also fixes the Library tree occasionally getting stuck on "Loading folders…" by dropping gcTime:0 and gating the spinner on isLoading instead of isPending. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
117
sidecar/handlers_prefs.go
Normal file
117
sidecar/handlers_prefs.go
Normal file
@@ -0,0 +1,117 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user