package main import ( "context" "errors" "log/slog" "net/http" "net/url" "os" "path/filepath" "github.com/gin-gonic/gin" ) // pathParam pulls the URL-encoded :rel out of the Gin context and // unescapes it. UseRawPath is on at the router level (see main.go) so the // raw value still carries `%2F` for nested paths; we decode here. func pathParam(c *gin.Context, key string) (string, bool) { raw := c.Param(key) if raw == "" { return "", false } dec, err := url.PathUnescape(raw) if err != nil { return "", false } return dec, true } type folderCreateBody struct { Path string `json:"path"` } func handleFolderCreate(cfg *Config, pp *ppClient) gin.HandlerFunc { return func(c *gin.Context) { token := ctxToken(c) var body folderCreateBody if err := c.ShouldBindJSON(&body); err != nil || body.Path == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "path required"}) return } abs, err := resolveUnderRoot(cfg.OriginalsRoot, body.Path, false) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"}) return } if _, err := os.Stat(abs); err == nil { c.JSON(http.StatusConflict, gin.H{"error": "already exists"}) return } else if !errors.Is(err, os.ErrNotExist) { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if err := os.Mkdir(abs, 0o755); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } rel, _ := filepath.Rel(cfg.OriginalsRoot, abs) slog.Info("folder.create", "path", rel) go fireReindex(cfg, pp, token, "/"+filepath.Dir(rel)) c.JSON(http.StatusOK, gin.H{"ok": true, "path": rel}) } } type folderRenameBody struct { NewName string `json:"newName"` } func handleFolderRename(cfg *Config, pp *ppClient) gin.HandlerFunc { return func(c *gin.Context) { token := ctxToken(c) rel, ok := pathParam(c, "rel") if !ok { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"}) return } var body folderRenameBody 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 dirname"}) return } oldAbs, err := resolveUnderRoot(cfg.OriginalsRoot, rel, true) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"}) return } st, err := os.Stat(oldAbs) if err != nil || !st.IsDir() { c.JSON(http.StatusBadRequest, gin.H{"error": "not a directory"}) return } newAbs := filepath.Join(filepath.Dir(oldAbs), newName) if _, err := os.Stat(newAbs); err == nil { c.JSON(http.StatusConflict, gin.H{"error": "target already exists"}) return } else if !errors.Is(err, os.ErrNotExist) { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if !sameOrUnder(newAbs, cfg.OriginalsRoot) { c.JSON(http.StatusBadRequest, gin.H{"error": "target escapes root"}) return } if err := os.Rename(oldAbs, newAbs); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } oldRel, _ := filepath.Rel(cfg.OriginalsRoot, oldAbs) newRel, _ := filepath.Rel(cfg.OriginalsRoot, newAbs) slog.Info("folder.rename", "from", oldRel, "to", newRel) go fireReindex(cfg, pp, token, "/"+filepath.Dir(oldRel)) c.JSON(http.StatusOK, gin.H{ "ok": true, "oldPath": oldRel, "newPath": newRel, }) } } func handleFolderDelete(cfg *Config, pp *ppClient) gin.HandlerFunc { return func(c *gin.Context) { token := ctxToken(c) rel, ok := pathParam(c, "rel") if !ok { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"}) return } abs, err := resolveUnderRoot(cfg.OriginalsRoot, rel, true) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid path"}) return } if abs == cfg.OriginalsRoot { c.JSON(http.StatusBadRequest, gin.H{"error": "refuse to delete root"}) return } st, err := os.Stat(abs) if err != nil || !st.IsDir() { c.JSON(http.StatusBadRequest, gin.H{"error": "not a directory"}) return } entries, err := os.ReadDir(abs) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if len(entries) > 0 { c.JSON(http.StatusConflict, gin.H{"error": "directory not empty"}) return } if err := os.Remove(abs); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } slog.Info("folder.delete", "path", rel) go fireReindex(cfg, pp, token, "/"+filepath.Dir(rel)) c.JSON(http.StatusOK, gin.H{"ok": true, "path": rel}) } } // fireReindex wraps pp.reindex with logging and a detached context so // it can run in a goroutine after the response has gone out. The Node // prototype kicks reindex with `void reindex(...)` and never awaits; // matching that here keeps the apparent latency of mutating endpoints // low (PhotoPrism's index can take seconds on a big folder). func fireReindex(_ *Config, pp *ppClient, token, parentRel string) { // pp.call's client already enforces a 60s timeout, so the parent // context can be detached from the request — the handler has long // since written its response. if err := pp.reindex(context.Background(), token, parentRel); err != nil { slog.Warn("reindex failed", "path", parentRel, "err", err) } }