Folder create/rename/delete/move, photo move, heap convert, and file rename now reject paths outside the caller's BasePath (403). Sources resolved via PhotoPrism UIDs are re-checked in movePhotoFiles. The USER_BASEPATHS reconciler also sets upload_path so client-app uploads land inside the user's subtree. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
128 lines
3.9 KiB
Go
128 lines
3.9 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"gorm.io/driver/mysql"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/logger"
|
|
)
|
|
|
|
// PhotoPrism's open-source edition doesn't expose any way to map OIDC
|
|
// claims to a per-user BasePath — every newly-registered OIDC user
|
|
// lands with `BasePath = ""`, which means PhotoPrism's ACL filter
|
|
// shows them nothing (non-admin) or everything (admin). Neither is the
|
|
// per-user library scope a homelab admin typically wants.
|
|
//
|
|
// This reconciler reads a declarative `USER_BASEPATHS` env at sidecar
|
|
// startup, formatted as `username:originals-relative-path` pairs
|
|
// separated by commas (whitespace tolerated), e.g.:
|
|
//
|
|
// USER_BASEPATHS="test:test, alice:family/alice, bob:bob"
|
|
//
|
|
// For every entry the sidecar:
|
|
// 1. Ensures the originals subdirectory exists (so PhotoPrism's path:
|
|
// filter has somewhere to point — empty dirs are fine).
|
|
// 2. UPDATEs `photoprism.auth_users.base_path` for the matching user
|
|
// row if the current value differs. Idempotent: rows already
|
|
// matching are skipped, and missing users are no-ops (they'll
|
|
// materialise when they log in via OIDC; the periodic ticker
|
|
// catches them on the next pass).
|
|
//
|
|
// A 60-second ticker keeps the mapping in lockstep with new OIDC
|
|
// registrations without needing a webhook.
|
|
|
|
func parseUserBasepaths(raw string) map[string]string {
|
|
out := map[string]string{}
|
|
for _, p := range strings.Split(raw, ",") {
|
|
p = strings.TrimSpace(p)
|
|
if p == "" {
|
|
continue
|
|
}
|
|
bits := strings.SplitN(p, ":", 2)
|
|
if len(bits) != 2 {
|
|
continue
|
|
}
|
|
u := strings.TrimSpace(bits[0])
|
|
// Strip any leading/trailing slash so the value lands in
|
|
// auth_users.base_path the same way PhotoPrism's own user-edit
|
|
// UI persists it (relative, no slashes).
|
|
path := strings.Trim(strings.TrimSpace(bits[1]), "/")
|
|
if u == "" || path == "" {
|
|
continue
|
|
}
|
|
out[u] = path
|
|
}
|
|
return out
|
|
}
|
|
|
|
func reconcileUserBasepaths(ppDSN, originalsRoot string, mapping map[string]string) error {
|
|
if len(mapping) == 0 {
|
|
return nil
|
|
}
|
|
if ppDSN == "" {
|
|
return fmt.Errorf("USER_BASEPATHS set but PP_DB_PASSWORD missing — can't reach photoprism schema")
|
|
}
|
|
db, err := gorm.Open(mysql.Open(ppDSN), &gorm.Config{
|
|
Logger: logger.Default.LogMode(logger.Warn),
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("open pp db: %w", err)
|
|
}
|
|
if sqlDB, derr := db.DB(); derr == nil {
|
|
defer sqlDB.Close()
|
|
}
|
|
|
|
for username, path := range mapping {
|
|
target := filepath.Join(originalsRoot, path)
|
|
if err := os.MkdirAll(target, 0o775); err != nil {
|
|
slog.Warn("user-basepath: mkdir failed", "user", username, "path", target, "err", err)
|
|
// Continue — the DB update is still useful so PhotoPrism's
|
|
// ACL kicks in even if the directory is created later.
|
|
}
|
|
|
|
// upload_path rides along with base_path so anything a client app
|
|
// uploads (WebDAV sync apps, PhotoPrism's own UI) lands inside the
|
|
// user's library subtree instead of the shared originals root.
|
|
res := db.Exec(`UPDATE auth_users
|
|
SET base_path = ?, upload_path = ?
|
|
WHERE user_name = ?
|
|
AND (COALESCE(base_path, '') <> ? OR COALESCE(upload_path, '') <> ?)
|
|
AND deleted_at IS NULL`,
|
|
path, path, username, path, path)
|
|
if res.Error != nil {
|
|
slog.Error("user-basepath: update failed", "user", username, "err", res.Error)
|
|
continue
|
|
}
|
|
if res.RowsAffected > 0 {
|
|
slog.Info("user-basepath: set", "user", username, "path", path)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func startUserBasepathReconciler(cfg *Config) {
|
|
if len(cfg.UserBasepaths) == 0 {
|
|
return
|
|
}
|
|
slog.Info("user-basepath: starting reconciler", "entries", len(cfg.UserBasepaths))
|
|
apply := func() {
|
|
if err := reconcileUserBasepaths(cfg.PpDSN, cfg.OriginalsRoot, cfg.UserBasepaths); err != nil {
|
|
slog.Warn("user-basepath: reconciliation error", "err", err)
|
|
}
|
|
}
|
|
apply()
|
|
go func() {
|
|
t := time.NewTicker(60 * time.Second)
|
|
defer t.Stop()
|
|
for range t.C {
|
|
apply()
|
|
}
|
|
}()
|
|
}
|