Files
mule-image/sidecar/handlers_prefs.go
dtoro 634abc2a95 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>
2026-06-30 22:41:33 +02:00

118 lines
3.9 KiB
Go

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
}