Cluster: Add AppName, AppVersion and Theme request/response fields #98

Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
Michael Mayer
2025-10-19 12:44:21 +02:00
parent 1c0f68aa39
commit 1b85f84943
24 changed files with 491 additions and 35 deletions

View File

@@ -220,6 +220,8 @@ Frequently Touched Files (by topic)
- Migrations: `internal/entity/migrate/*`
- Workers: `internal/workers/*`
- Cluster: `internal/service/cluster/*`
- Theme support: `internal/service/cluster/theme/version.go` exposes `DetectVersion`, used by bootstrap, CLI, and API handlers to compare portal vs node theme revisions (prefers `fs.VersionTxtFile`, falls back to `app.js` mtime).
- Registration sanitizes `AppName`, `AppVersion`, and `Theme` with `clean.TypeUnicode`; `cluster.RegisterResponse` now includes a `Theme` hint when the portal has a newer bundle so nodes can decide whether to download immediately.
- Headers: `pkg/service/http/header/*`
Downloads (CLI) & yt-dlp helpers

View File

@@ -17,6 +17,7 @@ import (
"github.com/photoprism/photoprism/internal/service/cluster"
"github.com/photoprism/photoprism/internal/service/cluster/provisioner"
reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
"github.com/photoprism/photoprism/internal/service/cluster/theme"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/service/http/header"
@@ -33,7 +34,7 @@ var RegisterRequireClientSecret = true
// @Tags Cluster
// @Accept json
// @Produce json
// @Param request body object true "registration payload (NodeName required; optional: NodeRole, Labels, AdvertiseUrl, SiteUrl; to authorize UUID/name changes include ClientID+ClientSecret; rotation: RotateDatabase, RotateSecret)"
// @Param request body object true "registration payload (NodeName required; optional: NodeRole, Labels, AdvertiseUrl, SiteUrl, AppName, AppVersion, Theme; to authorize UUID/name changes include ClientID+ClientSecret; rotation: RotateDatabase, RotateSecret)"
// @Success 200,201 {object} cluster.RegisterResponse
// @Failure 400,401,403,409,429 {object} i18n.Response
// @Router /api/v1/cluster/nodes/register [post]
@@ -77,6 +78,10 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
return
}
appName := clean.TypeUnicode(req.AppName)
appVersion := clean.TypeUnicode(req.AppVersion)
nodeTheme := clean.TypeUnicode(req.Theme)
// If an existing ClientID is provided, require the corresponding client secret for verification.
if RegisterRequireClientSecret && req.ClientID != "" {
if !rnd.IsUID(req.ClientID, entity.ClientUID) {
@@ -139,6 +144,11 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
return
}
portalTheme := ""
if t, err := theme.DetectVersion(conf.PortalThemePath()); err == nil {
portalTheme = t
}
// Try to find existing node.
if n, _ := regy.FindByName(name); n != nil {
// If caller attempts to change UUID by name without proving client secret, block with 409.
@@ -159,6 +169,15 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
if s := normalizeSiteURL(req.SiteUrl); s != "" {
n.SiteUrl = s
}
if appName != "" {
n.AppName = appName
}
if appVersion != "" {
n.AppVersion = appVersion
}
if nodeTheme != "" {
n.Theme = nodeTheme
}
// Apply UUID changes for existing node: if a UUID was requested and differs, or if none exists yet.
if requestedUUID != "" {
oldUUID := n.UUID
@@ -239,6 +258,10 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
AlreadyProvisioned: n.Database != nil && n.Database.Name != "",
}
if portalTheme != "" && (nodeTheme == "" || nodeTheme != portalTheme) {
resp.Theme = portalTheme
}
if n.Database != nil {
resp.Database = cluster.RegisterDatabase{Host: conf.DatabaseHost(), Port: conf.DatabasePort(), Name: n.Database.Name, User: n.Database.User, Driver: provisioner.DatabaseDriver, RotatedAt: n.Database.RotatedAt}
}
@@ -258,10 +281,13 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
// New node (client UID will be generated in registry.Put).
n := &reg.Node{
Node: cluster.Node{
Name: name,
Role: clean.TypeLowerDash(req.NodeRole),
UUID: requestedUUID,
Labels: req.Labels,
Name: name,
Role: clean.TypeLowerDash(req.NodeRole),
UUID: requestedUUID,
Labels: req.Labels,
AppName: appName,
AppVersion: appVersion,
Theme: nodeTheme,
},
}
@@ -318,6 +344,10 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
AlreadyProvisioned: shouldProvisionDB,
}
if portalTheme != "" && (nodeTheme == "" || nodeTheme != portalTheme) {
resp.Theme = portalTheme
}
if shouldProvisionDB {
resp.Database = cluster.RegisterDatabase{Host: conf.DatabaseHost(), Port: conf.DatabasePort(), Name: creds.Name, User: creds.User, Driver: provisioner.DatabaseDriver, Password: creds.Password, DSN: creds.DSN, RotatedAt: creds.RotatedAt}
}
@@ -334,23 +364,31 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
// Rules: require http/https scheme, non-empty host, <=255 chars; lowercase host.
func normalizeSiteURL(u string) string {
u = strings.TrimSpace(u)
if u == "" {
return ""
}
if len(u) > 255 {
return ""
}
parsed, err := url.Parse(u)
if err != nil {
return ""
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return ""
}
if parsed.Host == "" {
return ""
}
parsed.Host = strings.ToLower(parsed.Host)
return parsed.String()
}
@@ -358,19 +396,24 @@ func normalizeSiteURL(u string) string {
// and requires https for non-local hosts. http is allowed only for localhost/127.0.0.1/::1.
func validateAdvertiseURL(u string) bool {
parsed, err := url.Parse(strings.TrimSpace(u))
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return false
}
host := strings.ToLower(parsed.Hostname())
if parsed.Scheme == "https" {
return true
}
if parsed.Scheme == "http" {
if host == "localhost" || host == "127.0.0.1" || host == "::1" {
return true
}
return false
}
return false
}
@@ -378,17 +421,23 @@ func buildJWKSURL(conf *config.Config) string {
if conf == nil {
return "/.well-known/jwks.json"
}
path := conf.BaseUri("/.well-known/jwks.json")
if path == "" {
path = "/.well-known/jwks.json"
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
site := strings.TrimRight(conf.SiteUrl(), "/")
if site == "" {
return path
}
return site + path
}

View File

@@ -5,6 +5,8 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
@@ -15,6 +17,7 @@ import (
"github.com/photoprism/photoprism/internal/service/cluster"
"github.com/photoprism/photoprism/internal/service/cluster/provisioner"
reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/rnd"
)
@@ -262,6 +265,38 @@ func TestClusterNodesRegister(t *testing.T) {
assert.NotEmpty(t, n.UUID)
}
})
t.Run("ThemeHintProvided", func(t *testing.T) {
app, router, conf := NewApiTest()
conf.Options().NodeRole = cluster.RolePortal
conf.Options().JoinToken = cluster.ExampleJoinToken
ClusterNodesRegister(router)
themeDir := conf.PortalThemePath()
assert.NoError(t, os.MkdirAll(themeDir, fs.ModeDir))
assert.NoError(t, os.WriteFile(filepath.Join(themeDir, fs.AppJsFile), []byte("// app\n"), fs.ModeFile))
assert.NoError(t, os.WriteFile(filepath.Join(themeDir, fs.VersionTxtFile), []byte(" 2.0.0\n"), fs.ModeFile))
t.Cleanup(func() { _ = os.RemoveAll(themeDir) })
body := `{"NodeName":"pp-node-theme","Theme":"1.0.0"}`
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", body, cluster.ExampleJoinToken)
assert.Equal(t, http.StatusCreated, r.Code)
assert.Equal(t, "2.0.0", gjson.Get(r.Body.String(), "Theme").String())
cleanupRegisterProvisioning(t, conf, r)
regy, err := reg.NewClientRegistryWithConfig(conf)
assert.NoError(t, err)
node, err := regy.FindByName("pp-node-theme")
assert.NoError(t, err)
if assert.NotNil(t, node) {
assert.Equal(t, "1.0.0", node.Theme)
}
body = `{"NodeName":"pp-node-theme","Theme":"2.0.0"}`
r2 := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", body, cluster.ExampleJoinToken)
assert.Equal(t, http.StatusOK, r2.Code)
assert.False(t, gjson.Get(r2.Body.String(), "Theme").Exists())
cleanupRegisterProvisioning(t, conf, r2)
})
}
func cleanupRegisterProvisioning(t *testing.T, conf *config.Config, r *httptest.ResponseRecorder) {

View File

@@ -10,6 +10,7 @@ import (
"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/internal/service/cluster/theme"
"github.com/photoprism/photoprism/pkg/service/http/header"
)
@@ -44,12 +45,18 @@ func ClusterSummary(router *gin.RouterGroup) {
}
nodes, _ := regy.List()
themeVersion := ""
if v, err := theme.DetectVersion(conf.PortalThemePath()); err == nil {
themeVersion = v
}
c.JSON(http.StatusOK, cluster.SummaryResponse{
UUID: conf.ClusterUUID(),
ClusterCIDR: conf.ClusterCIDR(),
Nodes: len(nodes),
Database: cluster.DatabaseInfo{Driver: conf.DatabaseDriverName(), Host: conf.DatabaseHost(), Port: conf.DatabasePort()},
Theme: themeVersion,
Time: time.Now().UTC().Format(time.RFC3339),
})
})

View File

@@ -9,8 +9,12 @@ import (
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/auth/acl"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/photoprism/get"
reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
"github.com/photoprism/photoprism/internal/service/cluster/theme"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/service/http/header"
@@ -33,6 +37,8 @@ func ClusterGetTheme(router *gin.RouterGroup) {
// Optional IP-based allowance via ClusterCIDR.
refID := "-"
var session *entity.Session
if cidr := conf.ClusterCIDR(); cidr != "" {
if _, ipnet, err := net.ParseCIDR(cidr); err == nil {
if ip := net.ParseIP(clientIp); ip != nil && ipnet.Contains(ip) {
@@ -49,6 +55,7 @@ func ClusterGetTheme(router *gin.RouterGroup) {
return
}
refID = s.RefID
session = s
}
/*
@@ -93,6 +100,12 @@ func ClusterGetTheme(router *gin.RouterGroup) {
event.AuditDebug([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "download", "creating theme archive from %s"}, refID, clean.Log(themePath))
if version, err := theme.DetectVersion(themePath); err == nil {
updateNodeThemeVersion(conf, session, version, clientIp, refID)
} else {
event.AuditDebug([]string{clientIp, "session %s", string(acl.ResourceCluster), "theme", "version", "%s"}, refID, clean.Error(err))
}
// Add response headers.
AddDownloadHeader(c, "theme.zip")
AddContentTypeHeader(c, header.ContentTypeZip)
@@ -162,3 +175,62 @@ func ClusterGetTheme(router *gin.RouterGroup) {
}
})
}
// updateNodeThemeVersion persists the reported theme version for the active
// node when the request is authenticated as a cluster client.
func updateNodeThemeVersion(conf *config.Config, session *entity.Session, version, clientIP, refID string) {
if conf == nil || session == nil {
return
}
normalized := clean.TypeUnicode(version)
if normalized == "" {
return
}
client := session.GetClient()
if client == nil || client.ClientUID == "" {
return
}
regy, err := reg.NewClientRegistryWithConfig(conf)
if err != nil {
event.AuditDebug([]string{clientIP, "session %s", string(acl.ResourceCluster), "theme", "metadata", "registry", "%s"}, refID, clean.Error(err))
return
}
var node *reg.Node
if client.NodeUUID != "" {
if n, err := regy.Get(client.NodeUUID); err == nil {
node = n
}
}
if node == nil {
if n, err := regy.FindByClientID(client.ClientUID); err == nil {
node = n
}
}
if node == nil {
event.AuditDebug([]string{clientIP, "session %s", string(acl.ResourceCluster), "theme", "metadata", "node not found"}, refID)
return
}
if node.Theme == normalized {
return
}
node.Theme = normalized
if err = regy.Put(node); err != nil {
event.AuditWarn([]string{clientIP, "session %s", string(acl.ResourceCluster), "theme", "metadata", "%s"}, refID, clean.Error(err))
return
}
event.AuditDebug([]string{clientIP, "session %s", string(acl.ResourceCluster), "theme", "metadata", "updated"}, refID)
}

View File

@@ -11,8 +11,11 @@ import (
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/service/cluster"
reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/service/http/header"
)
@@ -58,6 +61,7 @@ func TestClusterGetTheme(t *testing.T) {
// Visible files
assert.NoError(t, os.WriteFile(filepath.Join(tempTheme, "app.js"), []byte("console.log('ok')\n"), fs.ModeFile))
assert.NoError(t, os.WriteFile(filepath.Join(tempTheme, "style.css"), []byte("body{}\n"), fs.ModeFile))
assert.NoError(t, os.WriteFile(filepath.Join(tempTheme, fs.VersionTxtFile), []byte(" 1.0.0\n"), fs.ModeFile))
assert.NoError(t, os.WriteFile(filepath.Join(tempTheme, "sub", "visible.txt"), []byte("ok\n"), fs.ModeFile))
// Hidden file
assert.NoError(t, os.WriteFile(filepath.Join(tempTheme, ".hidden.txt"), []byte("secret\n"), fs.ModeFile))
@@ -143,4 +147,26 @@ func TestClusterGetTheme(t *testing.T) {
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, header.ContentTypeZip, w.Header().Get(header.ContentType))
})
t.Run("UpdateThemeVersion", func(t *testing.T) {
app, _, conf := NewApiTest()
_ = app // unused
conf.Options().NodeRole = cluster.RolePortal
regy, err := reg.NewClientRegistryWithConfig(conf)
assert.NoError(t, err)
node := &reg.Node{Node: cluster.Node{Name: "pp-node-01", Role: "instance", UUID: rnd.UUIDv7()}}
assert.NoError(t, regy.Put(node))
client := entity.FindClientByUID(node.ClientID)
sess := entity.NewSession(-1, -1)
sess.SetClient(client)
sess.RefID = "sess-test"
updateNodeThemeVersion(conf, sess, " theme-v1 \n", "127.0.0.1", sess.RefID)
stored, err := regy.Get(node.UUID)
assert.NoError(t, err)
assert.Equal(t, "theme-v1", stored.Theme)
})
}

View File

@@ -349,6 +349,12 @@
"AdvertiseUrl": {
"type": "string"
},
"AppName": {
"type": "string"
},
"AppVersion": {
"type": "string"
},
"ClientID": {
"type": "string"
},
@@ -375,6 +381,9 @@
"SiteUrl": {
"type": "string"
},
"Theme": {
"type": "string"
},
"UUID": {
"description": "NodeUUID",
"type": "string"
@@ -454,6 +463,9 @@
"Secrets": {
"$ref": "#/definitions/cluster.RegisterSecrets"
},
"Theme": {
"type": "string"
},
"UUID": {
"description": "ClusterUUID",
"type": "string"
@@ -491,6 +503,9 @@
"Nodes": {
"type": "integer"
},
"Theme": {
"type": "string"
},
"Time": {
"type": "string"
},
@@ -6644,7 +6659,7 @@
"operationId": "ClusterNodesRegister",
"parameters": [
{
"description": "registration payload (NodeName required; optional: NodeRole, Labels, AdvertiseUrl, SiteUrl; to authorize UUID/name changes include ClientID+ClientSecret; rotation: RotateDatabase, RotateSecret)",
"description": "registration payload (NodeName required; optional: NodeRole, Labels, AdvertiseUrl, SiteUrl, AppName, AppVersion, Theme; to authorize UUID/name changes include ClientID+ClientSecret; rotation: RotateDatabase, RotateSecret)",
"in": "body",
"name": "request",
"required": true,

View File

@@ -12,6 +12,7 @@ import (
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/service/cluster"
reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
"github.com/photoprism/photoprism/internal/service/cluster/theme"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/txt"
"github.com/photoprism/photoprism/pkg/txt/report"
@@ -136,6 +137,11 @@ func clusterNodesRotateAction(ctx *cli.Context) error {
NodeName: name,
RotateDatabase: rotateDatabase,
RotateSecret: rotateSecret,
AppName: clean.TypeUnicode(conf.AppName()),
AppVersion: clean.TypeUnicode(conf.Version()),
}
if themeVersion, err := theme.DetectVersion(conf.ThemePath()); err == nil && themeVersion != "" {
payload.Theme = themeVersion
}
b, _ := json.Marshal(payload)

View File

@@ -19,6 +19,7 @@ import (
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/service/cluster"
clusternode "github.com/photoprism/photoprism/internal/service/cluster/node"
"github.com/photoprism/photoprism/internal/service/cluster/theme"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/rnd"
@@ -32,6 +33,8 @@ var (
regRoleFlag = &cli.StringFlag{Name: "role", Usage: "node `ROLE` (instance, service)", Value: "instance"}
regIntUrlFlag = &cli.StringFlag{Name: "advertise-url", Usage: "internal service `URL`"}
regSiteUrlFlag = &cli.StringFlag{Name: "site-url", Usage: "public site `URL` (https://...)"}
regAppNameFlag = &cli.StringFlag{Name: "app-name", Usage: "override application `NAME` reported to the portal"}
regAppVersionFlag = &cli.StringFlag{Name: "app-version", Usage: "override application `VERSION` reported to the portal"}
regLabelFlag = &cli.StringSliceFlag{Name: "label", Usage: "`k=v` label (repeatable)"}
regRotateDatabase = &cli.BoolFlag{Name: "rotate", Usage: "rotates the node's database password"}
regRotateSec = &cli.BoolFlag{Name: "rotate-secret", Usage: "rotates the node's secret used for JWT"}
@@ -55,6 +58,8 @@ var ClusterRegisterCommand = &cli.Command{
regPortalTok,
regIntUrlFlag,
regSiteUrlFlag,
regAppNameFlag,
regAppVersionFlag,
regLabelFlag,
regRotateDatabase,
regRotateSec,
@@ -109,6 +114,19 @@ func clusterRegisterAction(ctx *cli.Context) error {
site = conf.SiteUrl()
}
overrideAppName := clean.TypeUnicode(ctx.String("app-name"))
overrideAppVersion := clean.TypeUnicode(ctx.String("app-version"))
defaultAppName := clean.TypeUnicode(conf.AppName())
defaultAppVersion := clean.TypeUnicode(conf.Version())
if overrideAppName == "" {
overrideAppName = defaultAppName
}
if overrideAppVersion == "" {
overrideAppVersion = defaultAppVersion
}
payload := cluster.RegisterRequest{
NodeName: name,
NodeRole: nodeRole,
@@ -116,6 +134,8 @@ func clusterRegisterAction(ctx *cli.Context) error {
AdvertiseUrl: advertise,
RotateDatabase: ctx.Bool("rotate"),
RotateSecret: ctx.Bool("rotate-secret"),
AppName: overrideAppName,
AppVersion: overrideAppVersion,
}
// If auto detection is allowed, rotate database only when the current node lacks configured credentials.
@@ -134,6 +154,9 @@ func clusterRegisterAction(ctx *cli.Context) error {
if site != "" {
payload.SiteUrl = site
}
if themeVersion, err := theme.DetectVersion(conf.ThemePath()); err == nil && themeVersion != "" {
payload.Theme = themeVersion
}
b, _ := json.Marshal(payload)
// In dry-run, we allow empty portalURL (will print derived/empty values).
@@ -155,6 +178,12 @@ func clusterRegisterAction(ctx *cli.Context) error {
if payload.SiteUrl != "" {
fmt.Printf("Site URL: %s\n", payload.SiteUrl)
}
if overrideAppName != "" {
fmt.Printf("App Name: %s\n", overrideAppName)
}
if overrideAppVersion != "" {
fmt.Printf("App Version:%s\n", overrideAppVersion)
}
// Warn if non-HTTPS on public host; server will enforce too.
if warnInsecurePublicURL(advertise) {
fmt.Println("Warning: advertise-url is http for a public host; server may reject it (HTTPS required).")

View File

@@ -10,6 +10,7 @@ import (
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/service/cluster"
reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
"github.com/photoprism/photoprism/internal/service/cluster/theme"
"github.com/photoprism/photoprism/pkg/txt/report"
)
@@ -35,11 +36,17 @@ func clusterSummaryAction(ctx *cli.Context) error {
nodes, _ := r.List()
themeVersion := ""
if v, err := theme.DetectVersion(conf.PortalThemePath()); err == nil {
themeVersion = v
}
resp := cluster.SummaryResponse{
UUID: conf.ClusterUUID(),
ClusterCIDR: conf.ClusterCIDR(),
Nodes: len(nodes),
Database: cluster.DatabaseInfo{Driver: conf.DatabaseDriverName(), Host: conf.DatabaseHost(), Port: conf.DatabasePort()},
Theme: themeVersion,
Time: time.Now().UTC().Format(time.RFC3339),
}
@@ -49,8 +56,8 @@ func clusterSummaryAction(ctx *cli.Context) error {
return nil
}
cols := []string{"Portal UUID", "Cluster CIDR", "Nodes", "DB Driver", "DB Host", "DB Port", "Time"}
rows := [][]string{{resp.UUID, resp.ClusterCIDR, fmt.Sprintf("%d", resp.Nodes), resp.Database.Driver, resp.Database.Host, fmt.Sprintf("%d", resp.Database.Port), resp.Time}}
cols := []string{"Portal UUID", "Cluster CIDR", "Nodes", "DB Driver", "DB Host", "DB Port", "Theme", "Time"}
rows := [][]string{{resp.UUID, resp.ClusterCIDR, fmt.Sprintf("%d", resp.Nodes), resp.Database.Driver, resp.Database.Host, fmt.Sprintf("%d", resp.Database.Port), resp.Theme, resp.Time}}
out, err := report.RenderFormat(rows, cols, report.CliFormat(ctx))
fmt.Printf("\n%s\n", out)
return err

View File

@@ -35,6 +35,8 @@ type Client struct {
UserUID string `gorm:"type:VARBINARY(42);index;default:'';" json:"UserUID" yaml:"UserUID,omitempty"`
UserName string `gorm:"size:200;index;" json:"UserName" yaml:"UserName,omitempty"`
user *User `gorm:"-" yaml:"-"`
AppName string `gorm:"size:64;" json:"AppName" yaml:"AppName,omitempty"`
AppVersion string `gorm:"size:64;" json:"AppVersion" yaml:"AppVersion,omitempty"`
ClientName string `gorm:"size:200;" json:"ClientName" yaml:"ClientName,omitempty"`
ClientRole string `gorm:"size:64;default:'';" json:"ClientRole" yaml:"ClientRole,omitempty"`
ClientType string `gorm:"type:VARBINARY(16)" json:"ClientType" yaml:"ClientType,omitempty"`
@@ -112,12 +114,24 @@ func FindClientByNodeUUID(nodeUUID string) *Client {
return nil
}
m := &Client{}
if err := UnscopedDb().Where("node_uuid = ?", nodeUUID).First(m).Error; err != nil {
if err := UnscopedDb().Where("node_uuid = ?", nodeUUID).Order("updated_at DESC").First(m).Error; err != nil {
return nil
}
return m
}
// FindClientsByNodeUUID returns all client rows matching the given NodeUUID ordered by UpdatedAt descending.
func FindClientsByNodeUUID(nodeUUID string) []Client {
if nodeUUID == "" {
return nil
}
var list []Client
if err := UnscopedDb().Where("node_uuid = ?", nodeUUID).Order("updated_at DESC").Find(&list).Error; err != nil {
return nil
}
return list
}
// GetUID returns the client uid string.
func (m *Client) GetUID() string {
return m.ClientUID

View File

@@ -16,6 +16,7 @@ type ClientDatabase struct {
type ClientData struct {
Labels map[string]string `json:"labels,omitempty"`
Database *ClientDatabase `json:"database,omitempty"`
Theme string `json:"theme,omitempty"`
RotatedAt string `json:"rotatedAt,omitempty"`
SiteURL string `json:"siteUrl,omitempty"`
ClusterUUID string `json:"clusterUUID,omitempty"`

View File

@@ -21,6 +21,7 @@ import (
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/service/cluster"
"github.com/photoprism/photoprism/internal/service/cluster/theme"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/rnd"
@@ -140,6 +141,12 @@ func registerWithPortal(c *config.Config, portal *url.URL, token string) error {
NodeUUID: c.NodeUUID(),
NodeRole: c.NodeRole(),
AdvertiseUrl: c.AdvertiseUrl(),
AppName: clean.TypeUnicode(c.AppName()),
AppVersion: clean.TypeUnicode(c.Version()),
}
if v, err := theme.DetectVersion(c.ThemePath()); err == nil && v != "" {
payload.Theme = v
}
// Auto-derive Advertise/Site URLs from node name and cluster domain when not configured.

View File

@@ -40,12 +40,16 @@ func TestRegister_PersistSecretAndDB(t *testing.T) {
// Fake Portal server.
var jwksURL string
expectedSite := "https://public.example.test/"
var expectedAppName string
var expectedAppVersion string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/cluster/nodes/register":
var req cluster.RegisterRequest
assert.NoError(t, json.NewDecoder(r.Body).Decode(&req))
assert.Equal(t, expectedSite, req.SiteUrl)
assert.Equal(t, expectedAppName, req.AppName)
assert.Equal(t, expectedAppVersion, req.AppVersion)
// Minimal successful registration with secrets + DSN.
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
@@ -84,6 +88,8 @@ func TestRegister_PersistSecretAndDB(t *testing.T) {
c.Options().JoinToken = cluster.ExampleJoinToken
c.Options().SiteUrl = expectedSite
c.Options().AdvertiseUrl = expectedSite
expectedAppName = c.AppName()
expectedAppVersion = c.Version()
// Gate rotate=true: driver mysql and no DSN/fields.
c.Options().DatabaseDriver = config.MySQL
c.Options().DatabaseDSN = ""

View File

@@ -33,6 +33,8 @@ func toNode(c *entity.Client) *Node {
Role: c.ClientRole,
ClientID: c.ClientUID,
AdvertiseUrl: c.ClientURL,
AppName: c.AppName,
AppVersion: c.AppVersion,
Labels: map[string]string{},
CreatedAt: c.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: c.UpdatedAt.UTC().Format(time.RFC3339),
@@ -51,6 +53,7 @@ func toNode(c *entity.Client) *Node {
dest.RotatedAt = db.RotatedAt
}
n.RotatedAt = data.RotatedAt
n.Theme = data.Theme
}
return n
}
@@ -61,9 +64,8 @@ func (r *ClientRegistry) Put(n *Node) error {
// 1) Try NodeUUID first, if provided.
if n.UUID != "" {
var existing entity.Client
if err := entity.UnscopedDb().Where("node_uuid = ?", n.UUID).First(&existing).Error; err == nil && existing.ClientUID != "" {
m = &existing
if existing := entity.FindClientByNodeUUID(n.UUID); existing != nil && existing.ClientUID != "" {
m = existing
}
}
@@ -114,6 +116,12 @@ func (r *ClientRegistry) Put(n *Node) error {
if n.AdvertiseUrl != "" {
m.ClientURL = n.AdvertiseUrl
}
if v := clean.TypeUnicode(n.AppName); v != "" {
m.AppName = v
}
if v := clean.TypeUnicode(n.AppVersion); v != "" {
m.AppVersion = v
}
data := m.GetData()
if data.Labels == nil {
data.Labels = map[string]string{}
@@ -128,6 +136,9 @@ func (r *ClientRegistry) Put(n *Node) error {
m.NodeUUID = n.UUID
}
data.RotatedAt = n.RotatedAt
if theme := clean.TypeUnicode(n.Theme); theme != "" {
data.Theme = theme
}
if db := n.Database; db != nil && (db.Name != "" || db.User != "" || db.RotatedAt != "") {
if data.Database == nil {
data.Database = &entity.ClientDatabase{}
@@ -157,6 +168,8 @@ func (r *ClientRegistry) Put(n *Node) error {
n.Name = m.ClientName
n.Role = m.ClientRole
n.AdvertiseUrl = m.ClientURL
n.AppName = m.AppName
n.AppVersion = m.AppVersion
n.CreatedAt = m.CreatedAt.UTC().Format(time.RFC3339)
n.UpdatedAt = m.UpdatedAt.UTC().Format(time.RFC3339)
@@ -173,6 +186,7 @@ func (r *ClientRegistry) Put(n *Node) error {
dest.RotatedAt = db.RotatedAt
}
n.RotatedAt = data.RotatedAt
n.Theme = data.Theme
}
// Set initial secret if provided on create/update.
if n.ClientSecret != "" {
@@ -188,11 +202,11 @@ func (r *ClientRegistry) Get(id string) (*Node, error) {
if id == "" {
return nil, ErrNotFound
}
var c entity.Client
if err := entity.UnscopedDb().Where("node_uuid = ?", id).First(&c).Error; err != nil || c.ClientUID == "" {
c := entity.FindClientByNodeUUID(id)
if c == nil || c.ClientUID == "" {
return nil, ErrNotFound
}
return toNode(&c), nil
return toNode(c), nil
}
func (r *ClientRegistry) FindByName(name string) (*Node, error) {
@@ -221,20 +235,11 @@ func (r *ClientRegistry) FindByNodeUUID(nodeUUID string) (*Node, error) {
if nodeUUID == "" {
return nil, ErrNotFound
}
var list []entity.Client
if err := entity.UnscopedDb().Where("node_uuid = ?", nodeUUID).Find(&list).Error; err != nil {
return nil, err
}
list := entity.FindClientsByNodeUUID(nodeUUID)
if len(list) == 0 {
return nil, ErrNotFound
}
latest := &list[0]
for i := 1; i < len(list); i++ {
if list[i].UpdatedAt.After(latest.UpdatedAt) {
latest = &list[i]
}
}
return toNode(latest), nil
return toNode(&list[0]), nil
}
// FindByClientID looks up a node by its OAuth client identifier.
@@ -296,10 +301,7 @@ func (r *ClientRegistry) DeleteAllByUUID(uuid string) error {
if uuid == "" {
return ErrNotFound
}
var list []entity.Client
if err := entity.UnscopedDb().Where("node_uuid = ?", uuid).Find(&list).Error; err != nil {
return err
}
list := entity.FindClientsByNodeUUID(uuid)
if len(list) == 0 {
return ErrNotFound
}

View File

@@ -25,6 +25,9 @@ func TestClientRegistry_PutFindListRotate(t *testing.T) {
UUID: rnd.UUIDv7(),
Name: "pp-node-a",
Role: "instance",
AppName: "PhotoPrism",
AppVersion: "1.0.0",
Theme: "theme-v1",
SiteUrl: "https://photos.example.com",
AdvertiseUrl: "http://pp-node-a:2342",
Labels: map[string]string{"env": "test"},
@@ -48,6 +51,9 @@ func TestClientRegistry_PutFindListRotate(t *testing.T) {
assert.True(t, rnd.IsUUID(got.UUID))
assert.Equal(t, "pp-node-a", got.Name)
assert.Equal(t, "instance", got.Role)
assert.Equal(t, "PhotoPrism", got.AppName)
assert.Equal(t, "1.0.0", got.AppVersion)
assert.Equal(t, "theme-v1", got.Theme)
assert.Equal(t, "http://pp-node-a:2342", got.AdvertiseUrl)
assert.Equal(t, "https://photos.example.com", got.SiteUrl)
if assert.NotNil(t, got.Database) {
@@ -91,12 +97,15 @@ func TestClientRegistry_PutFindListRotate(t *testing.T) {
}
// Update labels and site URL via Put (upsert by id)
upd := &Node{Node: cluster.Node{ClientID: got.ClientID, Name: got.Name, Labels: map[string]string{"env": "prod"}, SiteUrl: "https://photos.example.org"}}
upd := &Node{Node: cluster.Node{ClientID: got.ClientID, Name: got.Name, Labels: map[string]string{"env": "prod"}, SiteUrl: "https://photos.example.org", AppVersion: "1.1.0"}}
assert.NoError(t, r.Put(upd))
got2, err := r.FindByName("pp-node-a")
assert.NoError(t, err)
if assert.NotNil(t, got2) {
assert.Equal(t, "prod", got2.Labels["env"])
assert.Equal(t, "https://photos.example.org", got2.SiteUrl)
assert.Equal(t, "PhotoPrism", got2.AppName)
assert.Equal(t, "1.1.0", got2.AppVersion)
assert.Equal(t, "theme-v1", got2.Theme)
}
}

View File

@@ -88,10 +88,7 @@ func TestClientRegistry_DeleteAllByUUID(t *testing.T) {
assert.NoError(t, r.DeleteAllByUUID(uuid))
// Ensure no rows remain for this UUID
var list []entity.Client
err := entity.UnscopedDb().Where("node_uuid = ?", uuid).Find(&list).Error
assert.NoError(t, err)
assert.Equal(t, 0, len(list))
assert.Empty(t, entity.FindClientsByNodeUUID(uuid))
}
// List() should only include clients that represent cluster nodes (i.e., have a NodeUUID).

View File

@@ -9,6 +9,9 @@ type RegisterRequest struct {
NodeUUID string `json:"NodeUUID,omitempty"`
NodeRole string `json:"NodeRole,omitempty"`
Labels map[string]string `json:"Labels,omitempty"`
AppName string `json:"AppName,omitempty"`
AppVersion string `json:"AppVersion,omitempty"`
Theme string `json:"Theme,omitempty"`
AdvertiseUrl string `json:"AdvertiseUrl,omitempty"`
SiteUrl string `json:"SiteUrl,omitempty"`
ClientID string `json:"ClientID,omitempty"`

View File

@@ -16,6 +16,9 @@ type Node struct {
Name string `json:"Name"` // NodeName
Role string `json:"Role"` // NodeRole
ClientID string `json:"ClientID,omitempty"`
AppName string `json:"AppName,omitempty"`
AppVersion string `json:"AppVersion,omitempty"`
Theme string `json:"Theme,omitempty"`
SiteUrl string `json:"SiteUrl,omitempty"`
AdvertiseUrl string `json:"AdvertiseUrl,omitempty"`
Labels map[string]string `json:"Labels,omitempty"`
@@ -39,6 +42,7 @@ type SummaryResponse struct {
ClusterCIDR string `json:"ClusterCIDR,omitempty"`
Nodes int `json:"Nodes"`
Database DatabaseInfo `json:"Database"`
Theme string `json:"Theme,omitempty"`
Time string `json:"Time"`
}
@@ -82,6 +86,7 @@ type RegisterResponse struct {
JWKSUrl string `json:"JWKSUrl,omitempty"`
AlreadyRegistered bool `json:"AlreadyRegistered"`
AlreadyProvisioned bool `json:"AlreadyProvisioned"`
Theme string `json:"Theme,omitempty"`
}
// StatusResponse is a generic status wrapper for simple ok responses.

View File

@@ -0,0 +1,32 @@
package theme
import (
"os"
"path/filepath"
"time"
"github.com/photoprism/photoprism/pkg/clean"
"github.com/photoprism/photoprism/pkg/fs"
)
// DetectVersion returns the sanitized theme version for the given directory.
// It prefers a version.txt file when present and falls back to the app.js
// modification timestamp when the version file is missing or empty.
func DetectVersion(themePath string) (string, error) {
versionFile := filepath.Join(themePath, fs.VersionTxtFile)
if data, err := os.ReadFile(versionFile); err == nil {
if v := clean.TypeUnicode(string(data)); v != "" {
return v, nil
}
}
appPath := filepath.Join(themePath, fs.AppJsFile)
info, err := os.Stat(appPath)
if err != nil {
return "", err
}
return clean.TypeUnicode(info.ModTime().UTC().Format(time.RFC3339)), nil
}

View File

@@ -0,0 +1,41 @@
package theme
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/pkg/fs"
)
func TestDetectVersion(t *testing.T) {
t.Run("VersionFilePreferred", func(t *testing.T) {
dir := t.TempDir()
assert.NoError(t, os.WriteFile(filepath.Join(dir, fs.VersionTxtFile), []byte(" 1.2.3 \n"), fs.ModeFile))
assert.NoError(t, os.WriteFile(filepath.Join(dir, fs.AppJsFile), []byte("// app"), fs.ModeFile))
got, err := DetectVersion(dir)
assert.NoError(t, err)
assert.Equal(t, "1.2.3", got)
})
t.Run("FallsBackToAppJS", func(t *testing.T) {
dir := t.TempDir()
appPath := filepath.Join(dir, fs.AppJsFile)
assert.NoError(t, os.WriteFile(appPath, []byte("// app"), fs.ModeFile))
want := time.Now().UTC().Truncate(time.Second)
assert.NoError(t, os.Chtimes(appPath, want, want))
got, err := DetectVersion(dir)
assert.NoError(t, err)
assert.Equal(t, want.Format(time.RFC3339), got)
})
t.Run("MissingAppJS", func(t *testing.T) {
dir := t.TempDir()
_, err := DetectVersion(dir)
assert.Error(t, err)
})
}

View File

@@ -16,6 +16,50 @@ func Type(s string) string {
return clip.Chars(ASCII(s), LengthType)
}
// TypeUnicode removes unsafe runes, collapses whitespace, and enforces the
// maximum type length while preserving non-ASCII characters when possible.
func TypeUnicode(s string) string {
if s == "" {
return s
}
buf := make([]rune, 0, len([]rune(s)))
lastWasSpace := false
for _, r := range s {
if len(buf) >= LengthType {
break
}
if unicode.IsSpace(r) {
if len(buf) == 0 || lastWasSpace {
continue
}
buf = append(buf, ' ')
lastWasSpace = true
continue
}
if r <= 31 {
continue
}
switch r {
case '`', '\\', '|', '"', '\'', '?', '*', '<', '>', '{', '}':
continue
}
buf = append(buf, r)
lastWasSpace = false
}
for len(buf) > 0 && unicode.IsSpace(buf[len(buf)-1]) {
buf = buf[:len(buf)-1]
}
return string(buf)
}
// TypeUnderscore replaces whitespace, dividers, quotes, brackets, and other special characters with an underscore.
func TypeUnderscore(s string) string {
if s == "" {

View File

@@ -53,6 +53,52 @@ func TestType(t *testing.T) {
})
}
func TestTypeUnicode(t *testing.T) {
tests := []struct {
name string
input string
want string
maxRunes64 bool
}{
{
name: "Clip",
input: " 幸福 Hanzi are logograms developed for the writing of Chinese! Expressions in an index may not ...!",
want: "幸福 Hanzi are logograms developed for the writing of Chinese! Exp",
maxRunes64: true,
},
{
name: "WhitespaceCollapsed",
input: "a b\tc\nd",
want: "a b c d",
},
{
name: "SpecialCharacters",
input: "a-`~/\\:|\"'?*<>{}b",
want: "a-~/:b",
},
{
name: "NonASCII",
input: "äöü",
want: "äöü",
},
{
name: "Empty",
input: "",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := TypeUnicode(tt.input)
assert.Equal(t, tt.want, got)
if tt.maxRunes64 {
assert.LessOrEqual(t, len([]rune(got)), LengthType)
}
})
}
}
func TestTypeLower(t *testing.T) {
t.Run("Clip", func(t *testing.T) {
result := TypeLower(" 幸福 Hanzi are logograms developed for the writing of Chinese! Expressions in an index may not ...!")

View File

@@ -45,4 +45,5 @@ const (
AssetsJsonFile = "assets.json"
ManifestJsonFile = "manifest.json"
SwJsFile = "sw.js"
VersionTxtFile = "version.txt"
)