feat(metadata,people): richer metadata editing + face-naming flow
Metadata (sidebar):
- New editable fields: Title, Credits section (Artist/Copyright/
License via Details), GPS Altitude.
- Video facts in the File section: Duration, FPS, Codec — required
fixing videoFile(): PhotoPrism serializes MediaType as the bare word
"video", so the old startsWith('video/') check never matched and the
helper always fell back to the JPEG poster.
- Timezone correctness: buildTakenAtPatch no longer forces
TakenAtLocal=UTC; it preserves the photo's existing UTC↔local offset
(per-photo in bulk edits) so PhotoPrism can't clobber manual date
edits when recomputing from TimeZone, and Year/Month/Day now derive
from local wall-clock time.
People (was "disabled" — really: zero subjects because naming is what
creates a person, and the UI had no naming flow; prod has 40k face
markers in 790 unnamed clusters):
- Sidecar GET /api/sidecar/subjects — scoped people list via one
markers→files→photos SQL pass (labels pattern), replacing the
client-side probe-per-subject N+1 filter.
- Sidecar GET /api/sidecar/faces/unnamed — the caller's unnamed face
clusters with count, crop thumb, and a representative marker UID.
- "Name new faces" panel on /tags/people: face-crop cards with inline
name input; naming uses PhotoPrism's own flow (PUT /markers/:uid
{Name, SubjSrc:manual}, verified against PP source) which creates
the Subject and propagates across the cluster.
- Scoped proxy: marker PUT / subject-clear DELETE now allowed with
per-marker ownership checks (was blanket-forbidden, which would have
blocked naming for scoped users).
- Per-photo People chips in the sidebar from named Files[].Markers,
linking to the person's page.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -185,6 +185,27 @@ func proxyToken(r *http.Request) string {
|
||||
return r.Header.Get("X-Session-ID")
|
||||
}
|
||||
|
||||
// markerWithinBase reports whether a marker's underlying photo lives under
|
||||
// base. Fails closed: no DB handle or unknown marker → false.
|
||||
func markerWithinBase(ppDb *gorm.DB, markerUID, base string) bool {
|
||||
if ppDb == nil || markerUID == "" {
|
||||
return false
|
||||
}
|
||||
var n int64
|
||||
err := ppDb.Table("markers m").
|
||||
Joins("JOIN files f ON f.file_uid = m.file_uid").
|
||||
Joins("JOIN photos p ON p.photo_uid = f.photo_uid").
|
||||
Where("m.marker_uid = ?", markerUID).
|
||||
Where("p.deleted_at IS NULL").
|
||||
Where("p.photo_path = ? OR p.photo_path LIKE ?", base, base+"/%").
|
||||
Count(&n).Error
|
||||
if err != nil {
|
||||
slog.Warn("pp-proxy: marker ownership query failed", "err", err)
|
||||
return false
|
||||
}
|
||||
return n > 0
|
||||
}
|
||||
|
||||
// batchUIDsWithinBase validates that every photo UID in a batch body lives
|
||||
// under base, using one SQL query against PhotoPrism's photos table. Fails
|
||||
// closed: no DB handle, unknown UIDs, or any path outside base → false.
|
||||
@@ -344,13 +365,28 @@ func handlePPProxy(cfg *Config, ppDb *gorm.DB) gin.HandlerFunc {
|
||||
|
||||
// Albums (heaps), labels, subjects, faces: shared across users by
|
||||
// design in CE — reads and mutations pass through; the photos inside
|
||||
// any of them stay path-scoped by the rules above.
|
||||
// any of them stay path-scoped by the rules above. Note that naming
|
||||
// a face (marker PUT below) creates/updates a shared Subject the
|
||||
// same way album/label edits are shared.
|
||||
case rest == "albums" || strings.HasPrefix(rest, "albums/") ||
|
||||
rest == "labels" || strings.HasPrefix(rest, "labels/") ||
|
||||
rest == "subjects" || strings.HasPrefix(rest, "subjects/") ||
|
||||
rest == "faces" || strings.HasPrefix(rest, "faces/"):
|
||||
proxy.ServeHTTP(c.Writer, c.Request)
|
||||
|
||||
// Marker mutations (face naming / clearing): ownership-checked —
|
||||
// the marker's file must belong to a photo under the caller's
|
||||
// BasePath. This is how the web client names people (PhotoPrism's
|
||||
// own naming flow is PUT /markers/:uid {Name, SubjSrc:"manual"}).
|
||||
case (method == http.MethodPut && strings.HasPrefix(rest, "markers/") && strings.Count(rest, "/") == 1) ||
|
||||
(method == http.MethodDelete && strings.HasPrefix(rest, "markers/") && strings.HasSuffix(rest, "/subject")):
|
||||
markerUID := strings.TrimSuffix(strings.TrimPrefix(rest, "markers/"), "/subject")
|
||||
if !markerWithinBase(ppDb, markerUID, base) {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": "marker not found"})
|
||||
return
|
||||
}
|
||||
proxy.ServeHTTP(c.Writer, c.Request)
|
||||
|
||||
default:
|
||||
slog.Info("pp-proxy: blocked", "user", user.UserName, "method", method, "path", rest)
|
||||
forbid(c)
|
||||
|
||||
Reference in New Issue
Block a user