Files
photoprism/internal/api/cluster_nodes.go
2025-09-24 08:28:38 +02:00

310 lines
7.1 KiB
Go

package api
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/auth/acl"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/internal/service/cluster"
reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/txt"
)
// isSafeNodeID validates that an ID contains only allowed characters to avoid path traversal.
// Allows: lowercase letters, digits, and dashes; length 1..64.
func isSafeNodeID(id string) bool {
if id == "" || len(id) > 64 {
return false
}
for _, r := range id {
if r >= 'a' && r <= 'z' {
continue
}
if r >= '0' && r <= '9' {
continue
}
if r == '-' {
continue
}
return false
}
return true
}
// ClusterListNodes lists registered nodes from the file-backed registry.
//
// @Summary lists registered nodes
// @Id ClusterListNodes
// @Tags Cluster
// @Produce json
// @Param count query int false "maximum number of results (default 100, max 1000)" minimum(1) maximum(1000)
// @Param offset query int false "result offset" minimum(0)
// @Success 200 {array} cluster.Node
// @Failure 401,403,429 {object} i18n.Response
// @Router /api/v1/cluster/nodes [get]
func ClusterListNodes(router *gin.RouterGroup) {
router.GET("/cluster/nodes", func(c *gin.Context) {
s := Auth(c, acl.ResourceCluster, acl.ActionSearch)
if s.Abort(c) {
return
}
conf := get.Config()
if !conf.IsPortal() {
AbortFeatureDisabled(c)
return
}
regy, err := reg.NewClientRegistryWithConfig(conf)
if err != nil {
AbortUnexpectedError(c)
return
}
items, err := regy.List()
if err != nil {
AbortUnexpectedError(c)
return
}
// Pagination: count (1..1000), offset (>=0)
count, offset := 100, 0
if v := c.Query("count"); v != "" {
if n := txt.Int(v); n > 0 && n <= 1000 {
count = n
}
}
if v := c.Query("offset"); v != "" {
if n := txt.Int(v); n >= 0 {
offset = n
}
}
if offset > len(items) {
offset = len(items)
}
end := offset + count
if end > len(items) {
end = len(items)
}
page := items[offset:end]
// Build response with session-based redaction.
opts := reg.NodeOptsForSession(s)
resp := reg.BuildClusterNodes(page, opts)
// Audit list access.
event.AuditInfo([]string{ClientIP(c), "session %s", string(acl.ResourceCluster), "nodes", "list", event.Succeeded, "count=%d", "offset=%d", "returned=%d"}, s.RefID, count, offset, len(resp))
c.JSON(http.StatusOK, resp)
})
}
// ClusterGetNode returns a single node by uuid.
//
// @Summary get node by uuid
// @Id ClusterGetNode
// @Tags Cluster
// @Produce json
// @Param uuid path string true "node uuid"
// @Success 200 {object} cluster.Node
// @Failure 401,403,404,429 {object} i18n.Response
// @Router /api/v1/cluster/nodes/{uuid} [get]
func ClusterGetNode(router *gin.RouterGroup) {
router.GET("/cluster/nodes/:uuid", func(c *gin.Context) {
s := Auth(c, acl.ResourceCluster, acl.ActionView)
if s.Abort(c) {
return
}
conf := get.Config()
if !conf.IsPortal() {
AbortFeatureDisabled(c)
return
}
uuid := c.Param("uuid")
// Validate id to avoid path traversal and unexpected file access.
if !isSafeNodeID(uuid) {
AbortEntityNotFound(c)
return
}
regy, err := reg.NewClientRegistryWithConfig(conf)
if err != nil {
AbortUnexpectedError(c)
return
}
// Prefer NodeUUID identifier for cluster nodes.
n, err := regy.FindByNodeUUID(uuid)
if err != nil || n == nil {
AbortEntityNotFound(c)
return
}
// Build response with session-based redaction.
opts := reg.NodeOptsForSession(s)
resp := reg.BuildClusterNode(*n, opts)
// Audit get access.
event.AuditInfo([]string{ClientIP(c), "session %s", string(acl.ResourceCluster), "nodes", "get", uuid, event.Succeeded}, s.RefID)
c.JSON(http.StatusOK, resp)
})
}
// ClusterUpdateNode updates mutable fields: role, labels, advertiseUrl.
//
// @Summary update node fields
// @Id ClusterUpdateNode
// @Tags Cluster
// @Accept json
// @Produce json
// @Param uuid path string true "node uuid"
// @Param node body object true "properties to update (role, labels, advertiseUrl, siteUrl)"
// @Success 200 {object} cluster.StatusResponse
// @Failure 400,401,403,404,429 {object} i18n.Response
// @Router /api/v1/cluster/nodes/{uuid} [patch]
func ClusterUpdateNode(router *gin.RouterGroup) {
router.PATCH("/cluster/nodes/:uuid", func(c *gin.Context) {
s := Auth(c, acl.ResourceCluster, acl.ActionManage)
if s.Abort(c) {
return
}
conf := get.Config()
if !conf.IsPortal() {
AbortFeatureDisabled(c)
return
}
uuid := c.Param("uuid")
var req struct {
Role string `json:"role"`
Labels map[string]string `json:"labels"`
AdvertiseUrl string `json:"advertiseUrl"`
SiteUrl string `json:"siteUrl"`
}
if err := c.ShouldBindJSON(&req); err != nil {
AbortBadRequest(c, err)
return
}
regy, err := reg.NewClientRegistryWithConfig(conf)
if err != nil {
AbortUnexpectedError(c)
return
}
// Resolve by NodeUUID first (preferred).
n, err := regy.FindByNodeUUID(uuid)
if err != nil || n == nil {
AbortEntityNotFound(c)
return
}
if req.Role != "" {
n.Role = clean.TypeLowerDash(req.Role)
}
if req.Labels != nil {
n.Labels = req.Labels
}
if req.AdvertiseUrl != "" {
n.AdvertiseUrl = req.AdvertiseUrl
}
if s := normalizeSiteURL(req.SiteUrl); s != "" {
n.SiteUrl = s
}
n.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
if err = regy.Put(n); err != nil {
AbortUnexpectedError(c)
return
}
event.AuditInfo([]string{ClientIP(c), string(acl.ResourceCluster), "nodes", "update", uuid, event.Succeeded})
c.JSON(http.StatusOK, cluster.StatusResponse{Status: "ok"})
})
}
// ClusterDeleteNode removes a node entry from the registry.
//
// @Summary delete node by uuid
// @Id ClusterDeleteNode
// @Tags Cluster
// @Produce json
// @Param uuid path string true "node uuid"
// @Success 200 {object} cluster.StatusResponse
// @Failure 401,403,404,429 {object} i18n.Response
// @Router /api/v1/cluster/nodes/{uuid} [delete]
func ClusterDeleteNode(router *gin.RouterGroup) {
router.DELETE("/cluster/nodes/:uuid", func(c *gin.Context) {
s := Auth(c, acl.ResourceCluster, acl.ActionManage)
if s.Abort(c) {
return
}
conf := get.Config()
if !conf.IsPortal() {
AbortFeatureDisabled(c)
return
}
uuid := c.Param("uuid")
// Validate uuid format to avoid path traversal or unexpected input.
if !isSafeNodeID(uuid) {
AbortEntityNotFound(c)
return
}
regy, err := reg.NewClientRegistryWithConfig(conf)
if err != nil {
AbortUnexpectedError(c)
return
}
// Delete by NodeUUID
if err = regy.Delete(uuid); err != nil {
if err == reg.ErrNotFound {
AbortEntityNotFound(c)
} else {
AbortUnexpectedError(c)
}
return
}
event.AuditInfo([]string{ClientIP(c), string(acl.ResourceCluster), "nodes", "delete", uuid, event.Succeeded})
c.JSON(http.StatusOK, cluster.StatusResponse{Status: "ok"})
})
}