package main import ( "encoding/json" "errors" "log/slog" "net/http" "os" "path/filepath" "strings" "github.com/gin-gonic/gin" ) // renameBody mirrors the Node prototype's wire contract — a single // `newName` field carrying the bare basename (no slashes). type renameBody struct { NewName string `json:"newName"` } // ppPhoto is the partial PhotoPrism photo shape we need to find the // primary file's on-disk location. Anything we don't read stays // unspecified so version drift across PhotoPrism builds doesn't break // JSON unmarshalling. type ppPhoto struct { Files []ppFile `json:"Files"` } type ppFile struct { Name string `json:"Name"` Root string `json:"Root"` Primary bool `json:"Primary"` } func primaryFileOf(p *ppPhoto) (ppFile, bool) { if p == nil { return ppFile{}, false } for _, f := range p.Files { if f.Primary { return f, true } } if len(p.Files) > 0 { return p.Files[0], true } return ppFile{}, false } func handleRename(cfg *Config, pp *ppClient) gin.HandlerFunc { return func(c *gin.Context) { token := ctxToken(c) photoUID := c.Param("uid") var body renameBody if err := c.ShouldBindJSON(&body); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid json"}) return } newName, ok := sanitizeFilename(body.NewName) if !ok { c.JSON(http.StatusBadRequest, gin.H{"error": "newName must be a plain filename"}) return } // Fetch the photo so we can resolve Files[0].Root + Name into a // concrete on-disk path. PhotoPrism has no "file by UID" endpoint // in this build, so the single-photo lookup is the cheapest path. resp, err := pp.call(c.Request.Context(), http.MethodGet, "/api/v1/photos/"+photoUID, token, nil) if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) return } if !resp.OK { c.JSON(resp.Status, gin.H{"error": "photo not found"}) return } var photo ppPhoto if err := json.Unmarshal(resp.Body, &photo); err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": "decode photo"}) return } file, ok := primaryFileOf(&photo) if !ok { c.JSON(http.StatusNotFound, gin.H{"error": "no files on this photo"}) return } root := strings.TrimPrefix(file.Root, "/") if root == "" || root == "/" { root = "" } relPath := filepath.Join(root, file.Name) oldAbs := filepath.Join(cfg.OriginalsRoot, relPath) if !ensureWithinOriginals(cfg.OriginalsRoot, oldAbs) { c.JSON(http.StatusBadRequest, gin.H{"error": "path escapes originals root"}) return } if !requireUserScope(c, cfg, oldAbs, false) { return } st, err := os.Stat(oldAbs) if err != nil || !st.Mode().IsRegular() { c.JSON(http.StatusNotFound, gin.H{"error": "file missing on disk"}) return } newAbs := filepath.Join(filepath.Dir(oldAbs), newName) if !ensureWithinOriginals(cfg.OriginalsRoot, newAbs) { c.JSON(http.StatusBadRequest, gin.H{"error": "new path escapes originals root"}) return } if _, err := os.Stat(newAbs); err == nil { c.JSON(http.StatusConflict, gin.H{"error": "target filename already exists"}) return } else if !errors.Is(err, os.ErrNotExist) { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } newRel := filepath.Join(root, newName) slog.Info("rename", "from", relPath, "to", newRel) if err := os.Rename(oldAbs, newAbs); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Trigger reindex on the parent so PhotoPrism picks up the new // filename and drops the orphan row for the old name. Best-effort. reindexPath := "/" if root != "" { reindexPath = "/" + root } if err := pp.reindex(c.Request.Context(), token, reindexPath); err != nil { slog.Warn("rename reindex failed", "err", err) } c.JSON(http.StatusOK, gin.H{ "ok": true, "oldName": file.Name, "newName": newName, "oldRelPath": relPath, "newRelPath": newRel, }) } }