fix: scope marks, labels, and subjects to the authenticated user
Marks (ratings/color labels) were stored without a user column — every user saw every other user's marks. Labels and subjects from PhotoPrism's global endpoints leaked across users because those endpoints ignore BasePath ACL. Sidecar: - Add UserName as composite primary key on Mark (photo_uid, user_name) - Replace validateSession with resolveSession that fetches the user identity from PhotoPrism's session endpoint - Filter all mark queries by user_name Frontend: - Filter listLabels/listSubjects through a BasePath-aware existence check — each label/subject is kept only if the user has at least one matching photo (single count=1 probe per item, batched at concurrency 8) - Skip filtering for admin users with empty BasePath (single-user compat) Also documents USER_BASEPATHS in .env.example — the env var that drives per-user library isolation via PhotoPrism's auth_users.base_path. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
13
.env.example
13
.env.example
@@ -65,6 +65,19 @@ PP_GID=1000
|
|||||||
# OIDC_ROLE=user
|
# OIDC_ROLE=user
|
||||||
|
|
||||||
|
|
||||||
|
# ── USER LIBRARY ISOLATION ───────────────────────────────────────────────────
|
||||||
|
# Maps PhotoPrism usernames to originals-relative subdirectories so each
|
||||||
|
# user only sees their own photos. Format: comma-separated user:path pairs.
|
||||||
|
# The sidecar reconciler applies this to auth_users.base_path on boot and
|
||||||
|
# every 60s. Leave empty for single-user deployments.
|
||||||
|
#
|
||||||
|
# USER_BASEPATHS="alice:alice, bob:bob"
|
||||||
|
|
||||||
|
# Sidecar DB password — provisioned by mariadb/init/01-sidecar.sql on first
|
||||||
|
# boot. Rotate before any non-local deployment.
|
||||||
|
# SIDECAR_DB_PASSWORD=replace-at-m4-bringup
|
||||||
|
|
||||||
|
|
||||||
# ── LOGGING ──────────────────────────────────────────────────────────────────
|
# ── LOGGING ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
PP_LOG_LEVEL=info
|
PP_LOG_LEVEL=info
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
// is the only authority, and we probe PhotoPrism with it before doing any
|
// is the only authority, and we probe PhotoPrism with it before doing any
|
||||||
// destructive work. The handler reads the validated token off the context
|
// destructive work. The handler reads the validated token off the context
|
||||||
// via ctxToken so it can keep forwarding it to PhotoPrism for the actual
|
// via ctxToken so it can keep forwarding it to PhotoPrism for the actual
|
||||||
// operation.
|
// operation. The resolved username is available via ctxUserName.
|
||||||
func requireSession(pp *ppClient) gin.HandlerFunc {
|
func requireSession(pp *ppClient) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
token := c.GetHeader("X-Auth-Token")
|
token := c.GetHeader("X-Auth-Token")
|
||||||
@@ -19,11 +19,13 @@ func requireSession(pp *ppClient) gin.HandlerFunc {
|
|||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "no token"})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "no token"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !pp.validateSession(c.Request.Context(), token) {
|
user := pp.resolveSession(c.Request.Context(), token)
|
||||||
|
if user == nil {
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid session"})
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid session"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.Set("token", token)
|
c.Set("token", token)
|
||||||
|
c.Set("userName", user.UserName)
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -42,3 +44,16 @@ func ctxToken(c *gin.Context) string {
|
|||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ctxUserName returns the PhotoPrism username resolved by requireSession.
|
||||||
|
func ctxUserName(c *gin.Context) string {
|
||||||
|
v, ok := c.Get("userName")
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
s, ok := v.(string)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Mark mirrors the per-photo extras the web client stores via the marks
|
// Mark mirrors the per-photo extras the web client stores via the marks
|
||||||
// endpoints — rating + four-colour label. PhotoUID is the row key; both
|
// endpoints — rating + four-colour label. Composite primary key
|
||||||
// payload fields are nullable so the sparse "no rating / no colour" state
|
// (photo_uid, user_name) so each user has independent marks. Both payload
|
||||||
// round-trips cleanly. The Node prototype kept this in a JSON file; we
|
// fields are nullable so the sparse "no rating / no colour" state
|
||||||
// migrate to MariaDB here so the M4 sharing work has a real table to
|
// round-trips cleanly.
|
||||||
// extend.
|
|
||||||
type Mark struct {
|
type Mark struct {
|
||||||
PhotoUID string `gorm:"primaryKey;size:64;column:photo_uid" json:"-"`
|
PhotoUID string `gorm:"primaryKey;size:64;column:photo_uid" json:"-"`
|
||||||
|
UserName string `gorm:"primaryKey;size:128;column:user_name" json:"-"`
|
||||||
Rating *int `gorm:"column:rating" json:"rating,omitempty"`
|
Rating *int `gorm:"column:rating" json:"rating,omitempty"`
|
||||||
Color *string `gorm:"size:16;column:color" json:"color,omitempty"`
|
Color *string `gorm:"size:16;column:color" json:"color,omitempty"`
|
||||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
|
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
|
||||||
|
|||||||
@@ -70,12 +70,12 @@ func (p *markPatch) apply(m *Mark) bool {
|
|||||||
return m.Rating != nil || (m.Color != nil && *m.Color != "")
|
return m.Rating != nil || (m.Color != nil && *m.Color != "")
|
||||||
}
|
}
|
||||||
|
|
||||||
// allMarksJSON renders the entire `marks` table as the wire shape
|
// allMarksJSON renders the current user's marks as the wire shape
|
||||||
// `{"<uid>": {"rating": …, "color": …, "updatedAt": …}, …}`. Used by
|
// `{"<uid>": {"rating": …, "color": …, "updatedAt": …}, …}`. Used by
|
||||||
// GET /photos/marks which the web client calls once on session start.
|
// GET /photos/marks which the web client calls once on session start.
|
||||||
func allMarksJSON(db *gorm.DB) (map[string]map[string]any, error) {
|
func allMarksJSON(db *gorm.DB, userName string) (map[string]map[string]any, error) {
|
||||||
var rows []Mark
|
var rows []Mark
|
||||||
if err := db.Find(&rows).Error; err != nil {
|
if err := db.Where("user_name = ?", userName).Find(&rows).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
out := make(map[string]map[string]any, len(rows))
|
out := make(map[string]map[string]any, len(rows))
|
||||||
@@ -87,7 +87,7 @@ func allMarksJSON(db *gorm.DB) (map[string]map[string]any, error) {
|
|||||||
|
|
||||||
func handleMarksAll(db *gorm.DB) gin.HandlerFunc {
|
func handleMarksAll(db *gorm.DB) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
marks, err := allMarksJSON(db)
|
marks, err := allMarksJSON(db, ctxUserName(c))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -100,7 +100,7 @@ func handleMarkGet(db *gorm.DB) gin.HandlerFunc {
|
|||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
uid := c.Param("uid")
|
uid := c.Param("uid")
|
||||||
var m Mark
|
var m Mark
|
||||||
err := db.Where("photo_uid = ?", uid).First(&m).Error
|
err := db.Where("photo_uid = ? AND user_name = ?", uid, ctxUserName(c)).First(&m).Error
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
c.JSON(http.StatusOK, gin.H{})
|
c.JSON(http.StatusOK, gin.H{})
|
||||||
return
|
return
|
||||||
@@ -115,18 +115,18 @@ func handleMarkGet(db *gorm.DB) gin.HandlerFunc {
|
|||||||
|
|
||||||
// upsert applies the patch and writes back. Returns the resulting JSON
|
// upsert applies the patch and writes back. Returns the resulting JSON
|
||||||
// shape (empty map if the row was deleted).
|
// shape (empty map if the row was deleted).
|
||||||
func upsert(db *gorm.DB, uid string, patch *markPatch) (map[string]any, error) {
|
func upsert(db *gorm.DB, uid, userName string, patch *markPatch) (map[string]any, error) {
|
||||||
var m Mark
|
var m Mark
|
||||||
err := db.Where("photo_uid = ?", uid).First(&m).Error
|
err := db.Where("photo_uid = ? AND user_name = ?", uid, userName).First(&m).Error
|
||||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
m.PhotoUID = uid
|
m.PhotoUID = uid
|
||||||
|
m.UserName = userName
|
||||||
keep := patch.apply(&m)
|
keep := patch.apply(&m)
|
||||||
m.UpdatedAt = time.Now().UTC()
|
m.UpdatedAt = time.Now().UTC()
|
||||||
if !keep {
|
if !keep {
|
||||||
// Drop the row entirely so a re-fetch returns {}.
|
if err := db.Where("photo_uid = ? AND user_name = ?", uid, userName).Delete(&Mark{}).Error; err != nil {
|
||||||
if err := db.Where("photo_uid = ?", uid).Delete(&Mark{}).Error; err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return map[string]any{}, nil
|
return map[string]any{}, nil
|
||||||
@@ -149,7 +149,7 @@ func handleMarkPut(db *gorm.DB) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out, err := upsert(db, uid, &patch)
|
out, err := upsert(db, uid, ctxUserName(c), &patch)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -178,6 +178,7 @@ func handleMarkBulk(db *gorm.DB) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
userName := ctxUserName(c)
|
||||||
applied := make(map[string]map[string]any, len(body.IDs))
|
applied := make(map[string]map[string]any, len(body.IDs))
|
||||||
// Single transaction so a partial failure rolls back. The client
|
// Single transaction so a partial failure rolls back. The client
|
||||||
// expects atomic semantics for a bulk star/colour stamp.
|
// expects atomic semantics for a bulk star/colour stamp.
|
||||||
@@ -186,7 +187,7 @@ func handleMarkBulk(db *gorm.DB) gin.HandlerFunc {
|
|||||||
if uid == "" {
|
if uid == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out, err := upsert(tx, uid, &body.Patch)
|
out, err := upsert(tx, uid, userName, &body.Patch)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,37 @@ func (c *ppClient) call(ctx context.Context, method, urlPath, token string, body
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ppSessionUser is the subset of PhotoPrism's session response we need.
|
||||||
|
type ppSessionUser struct {
|
||||||
|
UserName string `json:"UserName"`
|
||||||
|
UserUID string `json:"UserUID"`
|
||||||
|
BasePath string `json:"BasePath"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ppSessionResponse struct {
|
||||||
|
User ppSessionUser `json:"user"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveSession validates the token AND returns the authenticated user.
|
||||||
|
// Returns nil when the token is invalid or the response can't be parsed.
|
||||||
|
func (c *ppClient) resolveSession(ctx context.Context, token string) *ppSessionUser {
|
||||||
|
if token == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
r, err := c.call(ctx, http.MethodGet, "/api/v1/session/"+token, token, nil)
|
||||||
|
if err != nil || !r.OK {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var resp ppSessionResponse
|
||||||
|
if err := json.Unmarshal(r.Body, &resp); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if resp.User.UserName == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &resp.User
|
||||||
|
}
|
||||||
|
|
||||||
// validateSession is the cheapest probe that the supplied token is live:
|
// validateSession is the cheapest probe that the supplied token is live:
|
||||||
// list one photo. 401 → bad/expired token. We never read the payload.
|
// list one photo. 401 → bad/expired token. We never read the payload.
|
||||||
func (c *ppClient) validateSession(ctx context.Context, token string) bool {
|
func (c *ppClient) validateSession(ctx context.Context, token string) bool {
|
||||||
|
|||||||
@@ -635,6 +635,35 @@ export async function aggregateKeywords(): Promise<AggregatedKeyword[]> {
|
|||||||
return Array.from(buckets.values()).sort((a, b) => b.count - a.count);
|
return Array.from(buckets.values()).sort((a, b) => b.count - a.count);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function hasPhotosMatching(q: string): Promise<boolean> {
|
||||||
|
const resp = await http.get<PpPhoto[]>('/photos', {
|
||||||
|
params: { count: 1, offset: 0, q }
|
||||||
|
});
|
||||||
|
return Array.isArray(resp.data) && resp.data.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function filterByUserPhotos<T>(
|
||||||
|
items: T[],
|
||||||
|
queryFor: (item: T) => string
|
||||||
|
): Promise<T[]> {
|
||||||
|
if (userBasePath() === '') return items;
|
||||||
|
const CONCURRENCY = 8;
|
||||||
|
const out: T[] = [];
|
||||||
|
for (let i = 0; i < items.length; i += CONCURRENCY) {
|
||||||
|
const batch = items.slice(i, i + CONCURRENCY);
|
||||||
|
const checks = await Promise.all(
|
||||||
|
batch.map(async (item) => ({
|
||||||
|
item,
|
||||||
|
has: await hasPhotosMatching(queryFor(item))
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
for (const { item, has } of checks) {
|
||||||
|
if (has) out.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
export async function listLabels(): Promise<PpLabel[]> {
|
export async function listLabels(): Promise<PpLabel[]> {
|
||||||
// `all=true` includes labels PhotoPrism has soft-deleted (auto-hidden
|
// `all=true` includes labels PhotoPrism has soft-deleted (auto-hidden
|
||||||
// low-confidence classifier hits, manually-removed labels). They're
|
// low-confidence classifier hits, manually-removed labels). They're
|
||||||
@@ -646,7 +675,7 @@ export async function listLabels(): Promise<PpLabel[]> {
|
|||||||
const { data } = await http.get<PpLabel[]>('/labels', {
|
const { data } = await http.get<PpLabel[]>('/labels', {
|
||||||
params: { count: 1000, order: 'count', all: true }
|
params: { count: 1000, order: 'count', all: true }
|
||||||
});
|
});
|
||||||
return data;
|
return filterByUserPhotos(data, (l) => `label:${l.Slug}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Subjects (people / face recognition) ────────────────────────────────────
|
// ── Subjects (people / face recognition) ────────────────────────────────────
|
||||||
@@ -670,7 +699,7 @@ export async function listSubjects(): Promise<PpSubject[]> {
|
|||||||
const { data } = await http.get<PpSubject[]>('/subjects', {
|
const { data } = await http.get<PpSubject[]>('/subjects', {
|
||||||
params: { count: 1000, order: 'count' }
|
params: { count: 1000, order: 'count' }
|
||||||
});
|
});
|
||||||
return data ?? [];
|
return filterByUserPhotos(data ?? [], (s) => `person:${s.Slug}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateSubject(uid: string, patch: Partial<PpSubject>): Promise<PpSubject> {
|
export async function updateSubject(uid: string, patch: Partial<PpSubject>): Promise<PpSubject> {
|
||||||
|
|||||||
Reference in New Issue
Block a user