Folder create/rename/delete/move, photo move, heap convert, and file rename now reject paths outside the caller's BasePath (403). Sources resolved via PhotoPrism UIDs are re-checked in movePhotoFiles. The USER_BASEPATHS reconciler also sets upload_path so client-app uploads land inside the user's subtree. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
147 lines
3.9 KiB
Go
147 lines
3.9 KiB
Go
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,
|
|
})
|
|
}
|
|
}
|