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:
2026-06-30 22:41:12 +02:00
parent ba5684d120
commit 634abc2a95
12 changed files with 513 additions and 542 deletions

View File

@@ -26,6 +26,21 @@ type Mark struct {
// nothing surprising lands in the schema.
func (Mark) TableName() string { return "marks" }
// UserPref holds the per-user, server-side preferences PhotoPrism's account
// model has no slot for. Today that's just `IndexPath` — the originals-
// relative sub-folder (under the user's BasePath) the web client re-roots the
// Library tree to and scopes the reindex to. Empty string = "whole folder".
// Keyed by username so each user has independent prefs, matching `Mark`.
type UserPref struct {
UserName string `gorm:"primaryKey;size:128;column:user_name" json:"-"`
IndexPath string `gorm:"size:1024;column:index_path" json:"indexPath"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"-"`
}
// TableName pins the table name (GORM would pluralise to `user_prefs` anyway,
// but pin it explicitly to stay consistent with Mark).
func (UserPref) TableName() string { return "user_prefs" }
// asJSON returns the wire shape clients expect — same flat object the
// Node prototype emitted. An empty Mark (rating=nil, color=nil) renders
// as `{}` which the client treats as "no mark on this photo".
@@ -56,7 +71,7 @@ func openDB(dsn string) (*gorm.DB, error) {
if err != nil {
return nil, err
}
if err := db.AutoMigrate(&Mark{}); err != nil {
if err := db.AutoMigrate(&Mark{}, &UserPref{}); err != nil {
return nil, err
}
return db, nil

View File

@@ -9,10 +9,12 @@ import (
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
const quarantineDir = ".duplicates"
@@ -36,17 +38,41 @@ type dupListPhoto struct {
Files []ppFile `json:"Files"`
}
func handleDupScan(cfg *Config, pp *ppClient) gin.HandlerFunc {
func handleDupScan(cfg *Config, pp *ppClient, db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
token := ctxToken(c)
start := time.Now()
slog.Info("dup.scan starting", "root", cfg.OriginalsRoot)
all, err := walkFiles(cfg.OriginalsRoot)
// Scope the walk to the user's effective library root (BasePath +
// chosen index sub-path), same as the folders/timeline/reindex scope —
// otherwise a narrowed root would still surface every other user's
// files in the cross-folder duplicate scan. "" means whole library
// (today's admin-without-BasePath default).
root := effectiveLibraryRoot(c, db)
scanRoot := cfg.OriginalsRoot
if root != "" {
abs, err := resolveUnderRoot(cfg.OriginalsRoot, root, true)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid library root"})
return
}
scanRoot = abs
}
slog.Info("dup.scan starting", "root", scanRoot)
all, err := walkFiles(scanRoot)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// walkFiles computes RelPath relative to scanRoot; re-prefix with the
// scoped sub-path so RelPath stays originals-root-relative, matching
// what handleDupArchive (and the rest of the API) expects.
if root != "" {
for i := range all {
all[i].RelPath = root + "/" + all[i].RelPath
}
}
// Group by size first: byte-identical files necessarily share size,
// so size-collision is a cheap O(N) prefilter that lets us skip
@@ -149,7 +175,7 @@ type dupArchiveErr struct {
Error string `json:"error"`
}
func handleDupArchive(cfg *Config, pp *ppClient) gin.HandlerFunc {
func handleDupArchive(cfg *Config, pp *ppClient, db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
token := ctxToken(c)
var body dupArchiveBody
@@ -158,6 +184,23 @@ func handleDupArchive(cfg *Config, pp *ppClient) gin.HandlerFunc {
return
}
// Authz: every path must live under the caller's effective library
// root. The scan above already only ever returns paths from there,
// but this endpoint takes paths straight from the request body, so a
// scoped (non-admin, or admin-with-sub-path) user could otherwise
// pass an arbitrary originals-relative path and archive (move) files
// outside their own folder.
root := effectiveLibraryRoot(c, db)
if root != "" {
for _, p := range body.Paths {
clean := strings.Trim(p, "/")
if clean != root && !strings.HasPrefix(clean, root+"/") {
c.JSON(http.StatusForbidden, gin.H{"error": "path outside your library root"})
return
}
}
}
// Each archive batch lands in its own timestamped subdir so the
// user can browse what was quarantined when (and recover by hand
// if they change their mind).

117
sidecar/handlers_prefs.go Normal file
View 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
}

View File

@@ -81,6 +81,9 @@ func main() {
// under one group keeps the middleware wiring obvious.
auth := r.Group("/api/sidecar", requireSession(pp))
{
auth.GET("/prefs", handlePrefsGet(db))
auth.PUT("/prefs", handlePrefsPut(cfg, db))
auth.GET("/photos/marks", handleMarksAll(db))
auth.GET("/photos/:uid/marks", handleMarkGet(db))
auth.PUT("/photos/:uid/marks", handleMarkPut(db))
@@ -97,8 +100,8 @@ func main() {
auth.POST("/albums/:uid/convert", handleHeapConvert(cfg, pp))
auth.POST("/photos/move", handlePhotosMove(cfg, pp))
auth.GET("/duplicates/scan", handleDupScan(cfg, pp))
auth.POST("/duplicates/archive", handleDupArchive(cfg, pp))
auth.GET("/duplicates/scan", handleDupScan(cfg, pp, db))
auth.POST("/duplicates/archive", handleDupArchive(cfg, pp, db))
// User-scoped proxies — require PpDSN connection.
if ppDb != nil {