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>
118 lines
3.9 KiB
Go
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
|
|
}
|