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() } }() }