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:
@@ -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
|
||||
|
||||
@@ -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
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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user