From 2a75896274dc30c0cfd35e108f0a31996e6a79f2 Mon Sep 17 00:00:00 2001 From: Claudio Date: Mon, 18 May 2026 20:25:19 +0000 Subject: [PATCH] sidecar: declarative USER_BASEPATHS reconciler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PhotoPrism's OSS edition has no way to map OIDC claims to BasePath, so every freshly-registered OIDC user lands with BasePath="" and either sees the whole library (admin) or nothing (guest) — never their own subfolder. Introduces a sidecar-driven reconciler with a single env knob the admin sets in docker-compose / .env.photoprism: USER_BASEPATHS="test:test, alice:family/alice, bob:bob" (`user:originals-relative-path` pairs, comma-separated.) On boot and every 60s thereafter the sidecar: - mkdir -p's the target subdirectory under ORIGINALS_ROOT so PhotoPrism's path: ACL filter has somewhere real to point; - UPDATEs photoprism.auth_users.base_path for the matching row where it differs (idempotent, missing users skipped — they materialise on first OIDC login and the next pass catches them). The reconciler uses a separate gorm connection scoped to the `photoprism` schema with PhotoPrism's own DB user, since the existing `sidecar` user only has grants on `mule_sidecar.*`. Connection stays dormant when PP_DB_PASSWORD is empty — the feature is opt-in via env. Compose changes: thread PP_DB_* + USER_BASEPATHS through to the sidecar service. New users.go file isolates the reconciler logic; main.go calls startUserBasepathReconciler() during boot. --- docker-compose.photoprism.yml | 15 ++++ sidecar/config.go | 30 ++++++++ sidecar/main.go | 6 ++ sidecar/users.go | 124 ++++++++++++++++++++++++++++++++++ 4 files changed, 175 insertions(+) create mode 100644 sidecar/users.go diff --git a/docker-compose.photoprism.yml b/docker-compose.photoprism.yml index 11260b2..31f795f 100644 --- a/docker-compose.photoprism.yml +++ b/docker-compose.photoprism.yml @@ -177,6 +177,21 @@ services: # mariadb/init/01-sidecar.sql on first boot of the mariadb volume. SIDECAR_DB_PASSWORD: ${SIDECAR_DB_PASSWORD:-replace-at-m4-bringup} SIDECAR_DB_NAME: mule_sidecar + # Second DB connection for poking PhotoPrism's own schema (only + # used by the user-basepath reconciler today). Stays inert if + # PP_DB_PASSWORD is empty — the reconciler then silently no-ops. + PP_DB_HOST: mariadb + PP_DB_PORT: "3306" + PP_DB_USER: ${PP_DB_USER:-photoprism} + PP_DB_PASSWORD: ${PP_DB_PASSWORD:-} + PP_DB_NAME: ${PP_DB_NAME:-photoprism} + # Declarative username → originals-relative BasePath mapping. + # Format: comma-separated `user:path` pairs. Sidecar applies it + # to auth_users on boot and every 60s, and `mkdir -p`s each + # target subdirectory so PhotoPrism's ACL filter has somewhere to + # point. Leave empty to disable. + # USER_BASEPATHS="test:test, alice:family/alice" + USER_BASEPATHS: ${USER_BASEPATHS:-} volumes: # Sidecar mutates originals (rename, folder mutations, heap # convert) — always rw regardless of PhotoPrism's mount mode. diff --git a/sidecar/config.go b/sidecar/config.go index fdb9939..c32a795 100644 --- a/sidecar/config.go +++ b/sidecar/config.go @@ -15,6 +15,19 @@ type Config struct { ListenAddr string // bind interface — 127.0.0.1 for host mode, 0.0.0.0 in containers Port int // HTTP listen port DSN string // GORM/MySQL connection string for mule_sidecar + // PpDSN is a second DB connection string pointed at PhotoPrism's own + // schema (`photoprism.*`). Sidecar code that needs to mutate + // PhotoPrism-managed rows (e.g. auth_users.base_path) opens its own + // connection with these creds rather than asking for grants on the + // mule_sidecar user. Empty if PP_DB_PASSWORD isn't provided, in + // which case PP-touching features (user-basepath reconciler) stay + // dormant. + PpDSN string + // UserBasepaths is the parsed `USER_BASEPATHS` env. Maps PhotoPrism + // usernames to originals-relative base paths so OIDC-provisioned + // users land with the right library scope without any admin + // touching `photoprism users mod`. + UserBasepaths map[string]string } func loadConfig() (*Config, error) { @@ -45,12 +58,29 @@ func loadConfig() (*Config, error) { "?charset=utf8mb4&parseTime=true&loc=Local" } + // PhotoPrism schema connection — only used by the user-basepath + // reconciler. Stays empty if PP_DB_PASSWORD isn't set, and callers + // gate behaviour on that. We use PhotoPrism's own DB user rather + // than the sidecar's because `mule_sidecar` has no grants on + // `photoprism.*` (see mariadb/init/01-sidecar.sql). + ppDSN := "" + if ppPass := os.Getenv("PP_DB_PASSWORD"); ppPass != "" { + ppUser := envOr("PP_DB_USER", "photoprism") + ppHost := envOr("PP_DB_HOST", envOr("SIDECAR_DB_HOST", "mariadb")) + ppPort := envOr("PP_DB_PORT", envOr("SIDECAR_DB_PORT", "3306")) + ppName := envOr("PP_DB_NAME", "photoprism") + ppDSN = ppUser + ":" + ppPass + "@tcp(" + ppHost + ":" + ppPort + ")/" + ppName + + "?charset=utf8mb4&parseTime=true&loc=Local" + } + return &Config{ OriginalsRoot: abs, PhotoprismBaseURL: envOr("PHOTOPRISM_BASE_URL", "http://localhost:2342"), ListenAddr: envOr("SIDECAR_LISTEN_ADDR", "127.0.0.1"), Port: port, DSN: dsn, + PpDSN: ppDSN, + UserBasepaths: parseUserBasepaths(os.Getenv("USER_BASEPATHS")), }, nil } diff --git a/sidecar/main.go b/sidecar/main.go index e0114f5..2f4a0fb 100644 --- a/sidecar/main.go +++ b/sidecar/main.go @@ -40,6 +40,12 @@ func main() { } pp := newPPClient(cfg.PhotoprismBaseURL) + // Apply any declared username→BasePath mapping to PhotoPrism's + // auth_users table. Runs immediately + every 60s thereafter so a + // user who logs in after the sidecar booted still gets their + // BasePath wired without an admin restart. + startUserBasepathReconciler(cfg) + gin.SetMode(gin.ReleaseMode) r := gin.New() // Keep `%2F` literal in path params so callers can pass URL-encoded diff --git a/sidecar/users.go b/sidecar/users.go new file mode 100644 index 0000000..99af5be --- /dev/null +++ b/sidecar/users.go @@ -0,0 +1,124 @@ +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. + } + + res := db.Exec(`UPDATE auth_users + SET base_path = ? + WHERE user_name = ? + AND COALESCE(base_path, '') <> ? + AND deleted_at IS NULL`, + path, username, 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() + } + }() +}