mirror of
https://github.com/photoprism/photoprism.git
synced 2025-12-12 00:34:13 +01:00
Cluster: Add AppName, AppVersion and Theme request/response fields #98
Signed-off-by: Michael Mayer <michael@photoprism.app>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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).")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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.
|
||||
|
||||
32
internal/service/cluster/theme/version.go
Normal file
32
internal/service/cluster/theme/version.go
Normal 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
|
||||
}
|
||||
41
internal/service/cluster/theme/version_test.go
Normal file
41
internal/service/cluster/theme/version_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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 ...!")
|
||||
|
||||
@@ -45,4 +45,5 @@ const (
|
||||
AssetsJsonFile = "assets.json"
|
||||
ManifestJsonFile = "manifest.json"
|
||||
SwJsFile = "sw.js"
|
||||
VersionTxtFile = "version.txt"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user