diff --git a/CODEMAP.md b/CODEMAP.md index 42ce3766c..e679a7cf4 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -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 diff --git a/internal/api/cluster_nodes_register.go b/internal/api/cluster_nodes_register.go index caa6f6de3..688b92bb1 100644 --- a/internal/api/cluster_nodes_register.go +++ b/internal/api/cluster_nodes_register.go @@ -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 := ®.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 } diff --git a/internal/api/cluster_nodes_register_test.go b/internal/api/cluster_nodes_register_test.go index 5d0c73a39..f2f5c630c 100644 --- a/internal/api/cluster_nodes_register_test.go +++ b/internal/api/cluster_nodes_register_test.go @@ -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) { diff --git a/internal/api/cluster_summary.go b/internal/api/cluster_summary.go index 2a376f9cc..57cd3c3ec 100644 --- a/internal/api/cluster_summary.go +++ b/internal/api/cluster_summary.go @@ -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), }) }) diff --git a/internal/api/cluster_theme.go b/internal/api/cluster_theme.go index a0956f5ee..26e200059 100644 --- a/internal/api/cluster_theme.go +++ b/internal/api/cluster_theme.go @@ -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) +} diff --git a/internal/api/cluster_theme_test.go b/internal/api/cluster_theme_test.go index ac5c8701a..00db12e3c 100644 --- a/internal/api/cluster_theme_test.go +++ b/internal/api/cluster_theme_test.go @@ -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 := ®.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) + }) } diff --git a/internal/api/swagger.json b/internal/api/swagger.json index 052232c2c..49b7f34ca 100644 --- a/internal/api/swagger.json +++ b/internal/api/swagger.json @@ -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, diff --git a/internal/commands/cluster_nodes_rotate.go b/internal/commands/cluster_nodes_rotate.go index dabbb8f7f..367ced2ef 100644 --- a/internal/commands/cluster_nodes_rotate.go +++ b/internal/commands/cluster_nodes_rotate.go @@ -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) diff --git a/internal/commands/cluster_register.go b/internal/commands/cluster_register.go index 2d13ec776..7e666ea6c 100644 --- a/internal/commands/cluster_register.go +++ b/internal/commands/cluster_register.go @@ -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).") diff --git a/internal/commands/cluster_summary.go b/internal/commands/cluster_summary.go index e93dee650..c14268770 100644 --- a/internal/commands/cluster_summary.go +++ b/internal/commands/cluster_summary.go @@ -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 diff --git a/internal/entity/auth_client.go b/internal/entity/auth_client.go index d36439cae..dff93eabc 100644 --- a/internal/entity/auth_client.go +++ b/internal/entity/auth_client.go @@ -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 diff --git a/internal/entity/auth_client_data.go b/internal/entity/auth_client_data.go index a0f40de5a..b30be9414 100644 --- a/internal/entity/auth_client_data.go +++ b/internal/entity/auth_client_data.go @@ -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"` diff --git a/internal/service/cluster/node/bootstrap.go b/internal/service/cluster/node/bootstrap.go index f3948fab3..cb6b253a2 100644 --- a/internal/service/cluster/node/bootstrap.go +++ b/internal/service/cluster/node/bootstrap.go @@ -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. diff --git a/internal/service/cluster/node/bootstrap_test.go b/internal/service/cluster/node/bootstrap_test.go index 511cfba23..8c66ce326 100644 --- a/internal/service/cluster/node/bootstrap_test.go +++ b/internal/service/cluster/node/bootstrap_test.go @@ -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 = "" diff --git a/internal/service/cluster/registry/client.go b/internal/service/cluster/registry/client.go index 98f09489b..bb3a893b3 100644 --- a/internal/service/cluster/registry/client.go +++ b/internal/service/cluster/registry/client.go @@ -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 } diff --git a/internal/service/cluster/registry/client_test.go b/internal/service/cluster/registry/client_test.go index e295c6ec3..c5fd15a9d 100644 --- a/internal/service/cluster/registry/client_test.go +++ b/internal/service/cluster/registry/client_test.go @@ -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) } } diff --git a/internal/service/cluster/registry/registry_uuid_test.go b/internal/service/cluster/registry/registry_uuid_test.go index a2ee8e94c..ab3d52834 100644 --- a/internal/service/cluster/registry/registry_uuid_test.go +++ b/internal/service/cluster/registry/registry_uuid_test.go @@ -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). diff --git a/internal/service/cluster/request.go b/internal/service/cluster/request.go index 9319c7b39..03b1a2f2a 100644 --- a/internal/service/cluster/request.go +++ b/internal/service/cluster/request.go @@ -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"` diff --git a/internal/service/cluster/response.go b/internal/service/cluster/response.go index cd9ebb6b6..87ad83cd4 100644 --- a/internal/service/cluster/response.go +++ b/internal/service/cluster/response.go @@ -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. diff --git a/internal/service/cluster/theme/version.go b/internal/service/cluster/theme/version.go new file mode 100644 index 000000000..6ef81e089 --- /dev/null +++ b/internal/service/cluster/theme/version.go @@ -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 +} diff --git a/internal/service/cluster/theme/version_test.go b/internal/service/cluster/theme/version_test.go new file mode 100644 index 000000000..40d4a4110 --- /dev/null +++ b/internal/service/cluster/theme/version_test.go @@ -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) + }) +} diff --git a/pkg/clean/type.go b/pkg/clean/type.go index 32ecbacb2..3e58548af 100644 --- a/pkg/clean/type.go +++ b/pkg/clean/type.go @@ -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 == "" { diff --git a/pkg/clean/type_test.go b/pkg/clean/type_test.go index f186b5afe..da8e785bf 100644 --- a/pkg/clean/type_test.go +++ b/pkg/clean/type_test.go @@ -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 ...!") diff --git a/pkg/fs/const.go b/pkg/fs/const.go index e647df708..266e18f2a 100644 --- a/pkg/fs/const.go +++ b/pkg/fs/const.go @@ -45,4 +45,5 @@ const ( AssetsJsonFile = "assets.json" ManifestJsonFile = "manifest.json" SwJsFile = "sw.js" + VersionTxtFile = "version.txt" )