Compare commits

...

5 Commits

Author SHA1 Message Date
Michael Mayer
7e419f7419 Docs: Add "Last Updated" timestamps to AGENTS.md and CODEMAP.md files
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-25 20:08:45 +02:00
Michael Mayer
633d4222ab Auth: Improve JWKS Fetch Concurrency & Timeouts #5230
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-25 18:46:24 +02:00
Michael Mayer
bae8ceb3a7 Auth: Support asymmetric JSON Web Tokens (JWT) and Key Sets (JWKS) #5230
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-25 17:52:44 +02:00
Michael Mayer
4828c0423d Docs: Update Go package documentation requirements
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-25 14:22:23 +02:00
Michael Mayer
cb81f9be12 FFmpeg: Add descriptions to encoder packages in internal/ffmpeg/
Signed-off-by: Michael Mayer <michael@photoprism.app>
2025-09-25 14:20:35 +02:00
79 changed files with 2325 additions and 201 deletions

View File

@@ -1,5 +1,7 @@
# PhotoPrism® Repository Guidelines
**Last Updated:** September 25, 2025
## Purpose
This file tells automated coding agents (and humans) where to find the single sources of truth for building, testing, and contributing to PhotoPrism.
@@ -157,6 +159,7 @@ Note: Across our public documentation, official images, and in production, the c
- Go: run `make fmt-go swag-fmt` to reformat the backend code + Swagger annotations (see `Makefile` for additional targets)
- Doc comments for packages and exported identifiers must be complete sentences that begin with the name of the thing being described and end with a period.
- For short examples inside comments, indent code rather than using backticks; godoc treats indented blocks as preformatted.
- Every Go package must contain a `<package>.go` file in its root (for example, `internal/auth/jwt/jwt.go`) with the standard license header and a short package description comment explaining its purpose.
- JS/Vue: use the lint/format scripts in `frontend/package.json` (ESLint + Prettier)
- All added code and tests **must** be formatted according to our standards.
@@ -258,6 +261,9 @@ If anything in this file conflicts with the `Makefile` or the Developer Guide, t
### API & Config Changes
- Respect precedence: `options.yml` overrides CLI/env values, which override defaults. When adding a new option, update `internal/config/options.go` (yaml/flag tags), register it in `internal/config/flags.go`, expose a getter, surface it in `*config.Report()`, and write generated values back to `options.yml` by setting `c.options.OptionsYaml` before persisting. Use `CliTestContext` in `internal/config/test.go` to exercise new flags.
- When touching configuration in Go code, use the public accessors on `*config.Config` (e.g. `Config.JWKSUrl()`, `Config.SetJWKSUrl()`, `Config.ClusterUUID()`) instead of mutating `Config.Options()` directly; reserve raw option tweaks for test fixtures only.
- Logging: use the shared logger (`event.Log`) via the package-level `log` variable (see `internal/auth/jwt/logger.go`) instead of direct `fmt.Print*` or ad-hoc loggers.
- Cluster registry tests (`internal/service/cluster/registry`) currently rely on a full test config because they persist `entity.Client` rows. They run migrations and seed the SQLite DB, so they are intentionally slow. If you refactor them, consider sharing a single `config.TestConfig()` across subtests or building a lightweight schema harness; do not swap to the minimal config helper unless the tests stop touching the database.
- Favor explicit CLI flags: check `c.cliCtx.IsSet("<flag>")` before overriding user-supplied values, and follow the `ClusterUUID` pattern (`options.yml` → CLI/env → generated UUIDv4 persisted).
- Database helpers: reuse `conf.Db()` / `conf.Database*()`, avoid GORM `WithContext`, quote MySQL identifiers, and reject unsupported drivers early.
- Handler conventions: reuse limiter stacks (`limiter.Auth`, `limiter.Login`) and `limiter.AbortJSON` for 429s, lean on `api.ClientIP`, `header.BearerToken`, and `Abort*` helpers, compare secrets with constant time checks, set `Cache-Control: no-store` on sensitive responses, and register routes in `internal/server/routes.go`. For new list endpoints default `count=100` (max 1000) and `offset≥0`, document parameters explicitly, and set portal mode via `PHOTOPRISM_NODE_ROLE=portal` plus `PHOTOPRISM_JOIN_TOKEN` when needed.

View File

@@ -1,5 +1,7 @@
PhotoPrism — Backend CODEMAP
**Last Updated:** September 24, 2025
Purpose
- Give agents and contributors a fast, reliable map of where things live and how they fit together, so you can add features, fix bugs, and write tests without spelunking.
- Sources of truth: prefer Makefile targets and the Developer Guide linked in AGENTS.md.

1
go.mod
View File

@@ -80,6 +80,7 @@ require (
github.com/davidbyttow/govips/v2 v2.16.0
github.com/go-co-op/gocron/v2 v2.16.5
github.com/go-sql-driver/mysql v1.9.0
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/pquerna/otp v1.5.0
github.com/prometheus/client_model v0.6.2
github.com/robfig/cron/v3 v3.0.1

2
go.sum
View File

@@ -202,6 +202,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=

View File

@@ -29,14 +29,19 @@ func AuthAny(c *gin.Context, resource acl.Resource, perms acl.Permissions) (s *e
clientIp := ClientIP(c)
authToken := AuthToken(c)
// Disable response caching.
c.Header(header.CacheControl, header.CacheControlNoStore)
// Find active session to perform authorization check or deny if no session was found.
if s = Session(clientIp, authToken); s == nil {
if s = authAnyJWT(c, clientIp, authToken, resource, perms); s != nil {
return s
}
event.AuditWarn([]string{clientIp, "%s %s without authentication", authn.Denied}, perms.String(), string(resource))
return entity.SessionStatusUnauthorized()
}
// Disable caching of responses and the client IP.
c.Header(header.CacheControl, header.CacheControlNoStore)
// Set client IP.
s.SetClientIP(clientIp)
// If the request is from a client application, check its authorization based

View File

@@ -0,0 +1,124 @@
package api
import (
"fmt"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/auth/acl"
clusterjwt "github.com/photoprism/photoprism/internal/auth/jwt"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/pkg/authn"
"github.com/photoprism/photoprism/pkg/clean"
)
// authAnyJWT attempts to authenticate a Portal-issued JWT when a cluster
// node receives a request without an existing session. It verifies the token
// against the node's cached JWKS, ensures the issuer/audience/scope match the
// expected portal values, and, if valid, returns a client session mirroring the
// JWT claims. It returns nil on any validation failure so the caller can fall
// back to existing auth flows. Currently cluster and vision resources are
// eligible for JWT-based authorization; vision access requires the `vision`
// scope whereas cluster access requires the `cluster` scope.
func authAnyJWT(c *gin.Context, clientIP, authToken string, resource acl.Resource, perms acl.Permissions) *entity.Session {
if c == nil || authToken == "" {
return nil
}
_ = perms
if resource != acl.ResourceCluster && resource != acl.ResourceVision {
return nil
}
// Basic sanity check for JWT structure.
if strings.Count(authToken, ".") != 2 {
return nil
}
conf := get.Config()
if conf == nil || conf.IsPortal() {
return nil
}
if conf.JWKSUrl() == "" {
return nil
}
verifier := clusterjwt.NewVerifier(conf)
requiredScopes := []string{"cluster"}
if resource == acl.ResourceVision {
requiredScopes = []string{"vision"}
}
expected := clusterjwt.ExpectedClaims{
Audience: fmt.Sprintf("node:%s", conf.NodeUUID()),
Scope: requiredScopes,
JWKSURL: conf.JWKSUrl(),
}
issuers := jwtIssuerCandidates(conf)
if len(issuers) == 0 {
return nil
}
var (
claims *clusterjwt.Claims
err error
)
ctx := c.Request.Context()
for _, issuer := range issuers {
expected.Issuer = issuer
claims, err = verifier.VerifyToken(ctx, authToken, expected)
if err == nil {
break
}
}
if err != nil {
return nil
} else if claims == nil {
return nil
}
sess := &entity.Session{
Status: http.StatusOK,
ClientUID: claims.Subject,
AuthScope: clean.Scope(claims.Scope),
AuthIssuer: claims.Issuer,
AuthID: claims.ID,
GrantType: authn.GrantJwtBearer.String(),
AuthProvider: authn.ProviderClient.String(),
}
sess.SetMethod(authn.MethodJWT)
sess.SetClientName(claims.Subject)
sess.SetClientIP(clientIP)
return sess
}
// jwtIssuerCandidates returns the possible issuer values the node should accept
// for Portal JWTs. It prefers the explicit portal cluster identifier and then
// falls back to configured URLs so legacy installations migrate seamlessly.
func jwtIssuerCandidates(conf *config.Config) []string {
var out []string
if uuid := conf.ClusterUUID(); uuid != "" {
out = append(out, fmt.Sprintf("portal:%s", uuid))
}
if portal := strings.TrimSpace(conf.PortalUrl()); portal != "" {
out = append(out, strings.TrimRight(portal, "/"))
}
if site := strings.TrimSpace(conf.SiteUrl()); site != "" {
out = append(out, strings.TrimRight(site, "/"))
}
return out
}

View File

@@ -0,0 +1,148 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/photoprism/photoprism/internal/auth/acl"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/photoprism/get"
)
func TestAuthAnyJWT(t *testing.T) {
t.Run("ClusterScope", func(t *testing.T) {
fx := newPortalJWTFixture(t, "cluster-jwt-success")
spec := fx.defaultClaimsSpec()
token := fx.issue(t, spec)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "192.0.2.10:12345"
c.Request = req
session := authAnyJWT(c, "192.0.2.10", token, acl.ResourceCluster, nil)
require.NotNil(t, session)
assert.Equal(t, http.StatusOK, session.HttpStatus())
assert.Equal(t, spec.Subject, session.ClientUID)
assert.Contains(t, session.AuthScope, "cluster")
assert.Equal(t, spec.Issuer, session.AuthIssuer)
})
t.Run("VisionScope", func(t *testing.T) {
fx := newPortalJWTFixture(t, "cluster-jwt-vision")
spec := fx.defaultClaimsSpec()
spec.Scope = []string{"vision"}
token := fx.issue(t, spec)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req, _ := http.NewRequest(http.MethodGet, "/api/v1/vision/status", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "198.18.0.5:8080"
c.Request = req
session := authAnyJWT(c, "198.18.0.5", token, acl.ResourceVision, nil)
require.NotNil(t, session)
assert.Equal(t, http.StatusOK, session.HttpStatus())
assert.Contains(t, session.AuthScope, "vision")
assert.Equal(t, spec.Issuer, session.AuthIssuer)
})
t.Run("RejectsMalformedOrUnknown", func(t *testing.T) {
fx := newPortalJWTFixture(t, "cluster-jwt-invalid")
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
req.Header.Set("Authorization", "Bearer invalid-token-without-dots")
req.RemoteAddr = "192.0.2.10:12345"
c.Request = req
assert.Nil(t, authAnyJWT(c, "192.0.2.10", "invalid-token-without-dots", acl.ResourceCluster, nil))
// Ensure we also bail out when JWKS URL is not configured.
fx.nodeConf.SetJWKSUrl("")
get.SetConfig(fx.nodeConf)
assert.Nil(t, authAnyJWT(c, "192.0.2.10", "", acl.ResourceCluster, nil))
})
t.Run("NoIssuerMatch", func(t *testing.T) {
fx := newPortalJWTFixture(t, "cluster-jwt-no-issuer")
spec := fx.defaultClaimsSpec()
token := fx.issue(t, spec)
// Remove all issuer candidates.
origPortal := fx.nodeConf.Options().PortalUrl
origSite := fx.nodeConf.Options().SiteUrl
origClusterUUID := fx.nodeConf.Options().ClusterUUID
fx.nodeConf.Options().PortalUrl = ""
fx.nodeConf.Options().SiteUrl = ""
fx.nodeConf.Options().ClusterUUID = ""
get.SetConfig(fx.nodeConf)
t.Cleanup(func() {
fx.nodeConf.Options().PortalUrl = origPortal
fx.nodeConf.Options().SiteUrl = origSite
fx.nodeConf.Options().ClusterUUID = origClusterUUID
get.SetConfig(fx.nodeConf)
})
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "203.0.113.5:2222"
c.Request = req
assert.Nil(t, authAnyJWT(c, "203.0.113.5", token, acl.ResourceCluster, nil))
})
t.Run("UnsupportedResource", func(t *testing.T) {
fx := newPortalJWTFixture(t, "cluster-jwt-unsupported")
token := fx.issue(t, fx.defaultClaimsSpec())
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "198.51.100.7:9999"
c.Request = req
assert.Nil(t, authAnyJWT(c, "198.51.100.7", token, acl.ResourcePhotos, nil))
})
}
func TestJwtIssuerCandidates(t *testing.T) {
t.Run("IncludesAllSources", func(t *testing.T) {
conf := config.NewConfig(config.CliTestContext())
conf.Options().ClusterUUID = "11111111-1111-4111-8111-111111111111"
conf.Options().PortalUrl = "https://portal.example.test/"
conf.Options().SiteUrl = "https://site.example.test/base/"
orig := get.Config()
get.SetConfig(conf)
t.Cleanup(func() { get.SetConfig(orig) })
cands := jwtIssuerCandidates(conf)
assert.Equal(t, []string{
"portal:11111111-1111-4111-8111-111111111111",
"https://portal.example.test",
"https://site.example.test/base",
}, cands)
})
t.Run("DefaultsToLocalhost", func(t *testing.T) {
conf := config.NewConfig(config.CliTestContext())
conf.Options().ClusterUUID = ""
conf.Options().PortalUrl = ""
conf.Options().SiteUrl = ""
assert.Equal(t, []string{"http://localhost:2342"}, jwtIssuerCandidates(conf))
})
}

View File

@@ -1,15 +1,23 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/photoprism/photoprism/internal/auth/acl"
clusterjwt "github.com/photoprism/photoprism/internal/auth/jwt"
"github.com/photoprism/photoprism/internal/auth/session"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/internal/service/cluster"
"github.com/photoprism/photoprism/pkg/rnd"
"github.com/photoprism/photoprism/pkg/service/http/header"
)
@@ -137,3 +145,167 @@ func TestAuthToken(t *testing.T) {
assert.Equal(t, "", bearerToken)
})
}
func TestAuthAnyPortalJWT(t *testing.T) {
fx := newPortalJWTFixture(t, "ok")
spec := fx.defaultClaimsSpec()
token := fx.issue(t, spec)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "10.0.0.5:1234"
c.Request = req
s := AuthAny(c, acl.ResourceCluster, acl.Permissions{acl.ActionView})
require.NotNil(t, s)
assert.True(t, s.IsClient())
assert.Equal(t, http.StatusOK, s.HttpStatus())
assert.Contains(t, s.AuthScope, "cluster")
assert.Equal(t, fmt.Sprintf("portal:%s", fx.clusterUUID), s.AuthIssuer)
assert.Equal(t, "portal:client-test", s.ClientUID)
assert.False(t, s.Abort(c))
// Audience mismatch should reject the token once the node UUID changes.
req2, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
req2.Header.Set("Authorization", "Bearer "+token)
req2.RemoteAddr = "10.0.0.5:1234"
c.Request = req2
fx.nodeConf.Options().NodeUUID = rnd.UUID()
get.SetConfig(fx.nodeConf)
s = AuthAny(c, acl.ResourceCluster, acl.Permissions{acl.ActionView})
require.NotNil(t, s)
assert.Equal(t, http.StatusUnauthorized, s.HttpStatus())
assert.True(t, s.Abort(c))
}
func TestAuthAnyPortalJWT_MissingScope(t *testing.T) {
fx := newPortalJWTFixture(t, "missing-scope")
spec := fx.defaultClaimsSpec()
spec.Scope = []string{"vision"}
token := fx.issue(t, spec)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "10.0.0.5:1234"
c.Request = req
s := AuthAny(c, acl.ResourceCluster, acl.Permissions{acl.ActionView})
require.NotNil(t, s)
assert.Equal(t, http.StatusUnauthorized, s.HttpStatus())
assert.True(t, s.Abort(c))
}
func TestAuthAnyPortalJWT_InvalidIssuer(t *testing.T) {
fx := newPortalJWTFixture(t, "invalid-issuer")
spec := fx.defaultClaimsSpec()
spec.Issuer = "https://portal.invalid.test"
token := fx.issue(t, spec)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "10.0.0.5:1234"
c.Request = req
s := AuthAny(c, acl.ResourceCluster, acl.Permissions{acl.ActionView})
require.NotNil(t, s)
assert.Equal(t, http.StatusUnauthorized, s.HttpStatus())
assert.True(t, s.Abort(c))
}
func TestAuthAnyPortalJWT_NoJWKSConfigured(t *testing.T) {
fx := newPortalJWTFixture(t, "no-jwks")
fx.nodeConf.SetJWKSUrl("")
get.SetConfig(fx.nodeConf)
spec := fx.defaultClaimsSpec()
token := fx.issue(t, spec)
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
req, _ := http.NewRequest(http.MethodGet, "/api/v1/cluster/theme", nil)
req.Header.Set("Authorization", "Bearer "+token)
req.RemoteAddr = "10.0.0.5:1234"
c.Request = req
s := AuthAny(c, acl.ResourceCluster, acl.Permissions{acl.ActionView})
require.NotNil(t, s)
assert.Equal(t, http.StatusUnauthorized, s.HttpStatus())
assert.True(t, s.Abort(c))
}
type portalJWTFixture struct {
nodeConf *config.Config
issuer *clusterjwt.Issuer
clusterUUID string
nodeUUID string
}
func newPortalJWTFixture(t *testing.T, suffix string) portalJWTFixture {
t.Helper()
t.Setenv("PHOTOPRISM_STORAGE_PATH", t.TempDir())
origConf := get.Config()
t.Cleanup(func() { get.SetConfig(origConf) })
nodeConf := config.NewTestConfig("auth-any-portal-jwt-" + suffix)
nodeConf.Options().NodeRole = cluster.RoleInstance
nodeConf.Options().Public = false
clusterUUID := rnd.UUID()
nodeConf.Options().ClusterUUID = clusterUUID
nodeUUID := nodeConf.NodeUUID()
nodeConf.Options().PortalUrl = "https://portal.example.test"
portalConf := config.NewTestConfig("auth-any-portal-jwt-issuer-" + suffix)
portalConf.Options().NodeRole = cluster.RolePortal
portalConf.Options().ClusterUUID = clusterUUID
mgr, err := clusterjwt.NewManager(portalConf)
require.NoError(t, err)
_, err = mgr.EnsureActiveKey()
require.NoError(t, err)
jwksBytes, err := json.Marshal(mgr.JWKS())
require.NoError(t, err)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(jwksBytes)
}))
t.Cleanup(srv.Close)
nodeConf.SetJWKSUrl(srv.URL + "/.well-known/jwks.json")
get.SetConfig(nodeConf)
return portalJWTFixture{
nodeConf: nodeConf,
issuer: clusterjwt.NewIssuer(mgr),
clusterUUID: clusterUUID,
nodeUUID: nodeUUID,
}
}
func (fx portalJWTFixture) defaultClaimsSpec() clusterjwt.ClaimsSpec {
return clusterjwt.ClaimsSpec{
Issuer: fmt.Sprintf("portal:%s", fx.clusterUUID),
Subject: "portal:client-test",
Audience: fmt.Sprintf("node:%s", fx.nodeUUID),
Scope: []string{"cluster", "vision"},
}
}
func (fx portalJWTFixture) issue(t *testing.T, spec clusterjwt.ClaimsSpec) string {
t.Helper()
token, err := fx.issuer.Issue(spec)
require.NoError(t, err)
return token
}

View File

@@ -48,7 +48,7 @@ func TestMain(m *testing.M) {
// Run unit tests.
code := m.Run()
// Purge local SQLite test artifacts created during this package's tests.
// Remove temporary SQLite files after running the tests.
fs.PurgeTestDbFiles(".", false)
os.Exit(code)

View File

@@ -24,10 +24,10 @@ func TestClusterListNodes_Redaction(t *testing.T) {
// Seed one node with internal URL and DB metadata.
regy, err := reg.NewClientRegistryWithConfig(conf)
assert.NoError(t, err)
// Nodes are UUID-first; seed with a UUID v7 so the registry includes it in List().
n := &reg.Node{UUID: rnd.UUIDv7(), Name: "pp-node-redact", Role: "instance", AdvertiseUrl: "http://pp-node:2342", SiteUrl: "https://photos.example.com"}
n.Database.Name = "pp_db"
n.Database.User = "pp_user"
n := &reg.Node{Node: cluster.Node{UUID: rnd.UUIDv7(), Name: "pp-node-redact", Role: "instance", AdvertiseUrl: "http://pp-node:2342", SiteUrl: "https://photos.example.com"}}
n.Database = &cluster.NodeDatabase{Name: "pp_db", User: "pp_user"}
assert.NoError(t, regy.Put(n))
// Admin session shows internal fields
@@ -55,10 +55,10 @@ func TestClusterListNodes_Redaction_ClientScope(t *testing.T) {
regy, err := reg.NewClientRegistryWithConfig(conf)
assert.NoError(t, err)
// Seed node with internal URL and DB meta.
n := &reg.Node{Name: "pp-node-redact2", Role: "instance", AdvertiseUrl: "http://pp-node2:2342", SiteUrl: "https://photos2.example.com"}
n.Database.Name = "pp_db2"
n.Database.User = "pp_user2"
n := &reg.Node{Node: cluster.Node{Name: "pp-node-redact2", Role: "instance", AdvertiseUrl: "http://pp-node2:2342", SiteUrl: "https://photos2.example.com"}}
n.Database = &cluster.NodeDatabase{Name: "pp_db2", User: "pp_user2"}
assert.NoError(t, regy.Put(n))
// Create client session with cluster scope and no user (redacted view expected).

View File

@@ -9,6 +9,7 @@ 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"
@@ -68,18 +69,7 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
}
// Parse request.
var req struct {
NodeName string `json:"nodeName"`
NodeUUID string `json:"nodeUUID"`
NodeRole string `json:"nodeRole"`
Labels map[string]string `json:"labels"`
AdvertiseUrl string `json:"advertiseUrl"`
SiteUrl string `json:"siteUrl"`
ClientID string `json:"clientId"`
ClientSecret string `json:"clientSecret"`
RotateDatabase bool `json:"rotateDatabase"`
RotateSecret bool `json:"rotateSecret"`
}
var req cluster.RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
event.AuditWarn([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "form invalid", "%s"}, clean.Error(err))
@@ -227,13 +217,22 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
event.AuditInfo([]string{clientIp, string(acl.ResourceCluster), "nodes", "register", "rotate db", event.Succeeded, "node %s"}, clean.LogQuote(name))
}
jwksURL := buildJWKSURL(conf)
// Build response with struct types.
opts := reg.NodeOptsForSession(nil) // registration is token-based, not session; default redaction is fine
dbInfo := cluster.NodeDatabase{}
if n.Database != nil {
dbInfo = *n.Database
}
resp := cluster.RegisterResponse{
UUID: conf.ClusterUUID(),
Node: reg.BuildClusterNode(*n, opts),
Database: cluster.RegisterDatabase{Host: conf.DatabaseHost(), Port: conf.DatabasePort(), Name: n.Database.Name, User: n.Database.User, Driver: provisioner.DatabaseDriver},
Database: cluster.RegisterDatabase{Host: conf.DatabaseHost(), Port: conf.DatabasePort(), Name: dbInfo.Name, User: dbInfo.User, Driver: provisioner.DatabaseDriver},
Secrets: respSecret,
JWKSUrl: jwksURL,
AlreadyRegistered: true,
AlreadyProvisioned: true,
}
@@ -252,14 +251,18 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
// New node (client UID will be generated in registry.Put).
n := &reg.Node{
Name: name,
Role: clean.TypeLowerDash(req.NodeRole),
UUID: requestedUUID,
Labels: req.Labels,
Node: cluster.Node{
Name: name,
Role: clean.TypeLowerDash(req.NodeRole),
UUID: requestedUUID,
Labels: req.Labels,
},
}
if n.UUID == "" {
n.UUID = rnd.UUIDv7()
}
// Derive a sensible default advertise URL when not provided by the client.
if req.AdvertiseUrl != "" {
n.AdvertiseUrl = req.AdvertiseUrl
@@ -281,6 +284,11 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
return
}
if n.Database == nil {
n.Database = &cluster.NodeDatabase{}
}
n.Database.Name, n.Database.User, n.Database.RotatedAt = creds.Name, creds.User, creds.RotatedAt
n.Database.Driver = provisioner.DatabaseDriver
@@ -294,6 +302,7 @@ func ClusterNodesRegister(router *gin.RouterGroup) {
Node: reg.BuildClusterNode(*n, reg.NodeOptsForSession(nil)),
Secrets: &cluster.RegisterSecrets{ClientSecret: n.ClientSecret, RotatedAt: n.RotatedAt},
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},
JWKSUrl: buildJWKSURL(conf),
AlreadyRegistered: false,
AlreadyProvisioned: false,
}
@@ -348,5 +357,23 @@ func validateAdvertiseURL(u string) bool {
return false
}
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
}
// validateSiteURL applies the same rules as validateAdvertiseURL.
func validateSiteURL(u string) bool { return validateAdvertiseURL(u) }

View File

@@ -31,7 +31,7 @@ func TestClusterNodesRegister(t *testing.T) {
// Pre-create a node via registry and rotate to get a plaintext secret for tests
regy, err := reg.NewClientRegistryWithConfig(conf)
assert.NoError(t, err)
n := &reg.Node{UUID: rnd.UUIDv7(), Name: "pp-auth", Role: "instance"}
n := &reg.Node{Node: cluster.Node{UUID: rnd.UUIDv7(), Name: "pp-auth", Role: "instance"}}
assert.NoError(t, regy.Put(n))
nr, err := regy.RotateSecret(n.UUID)
assert.NoError(t, err)
@@ -84,8 +84,9 @@ func TestClusterNodesRegister(t *testing.T) {
regy, err := reg.NewClientRegistryWithConfig(conf)
assert.NoError(t, err)
// Pre-create node with a UUID
n := &reg.Node{UUID: rnd.UUIDv7(), Name: "pp-lock", Role: "instance"}
n := &reg.Node{Node: cluster.Node{UUID: rnd.UUIDv7(), Name: "pp-lock", Role: "instance"}}
assert.NoError(t, regy.Put(n))
// Attempt to change UUID via name without client credentials → 409
@@ -172,7 +173,7 @@ func TestClusterNodesRegister(t *testing.T) {
// used by OAuth tests running in the same package.
regy, err := reg.NewClientRegistryWithConfig(conf)
assert.NoError(t, err)
n := &reg.Node{Name: "pp-node-01", Role: "instance"}
n := &reg.Node{Node: cluster.Node{Name: "pp-node-01", Role: "instance"}}
assert.NoError(t, regy.Put(n))
r := AuthenticatedRequestWithBody(app, http.MethodPost, "/api/v1/cluster/nodes/register", `{"nodeName":"pp-node-01","rotateSecret":true}`, "t0k3n")
@@ -195,7 +196,7 @@ func TestClusterNodesRegister(t *testing.T) {
// Pre-create node in registry so handler goes through existing-node path.
regy, err := reg.NewClientRegistryWithConfig(conf)
assert.NoError(t, err)
n := &reg.Node{Name: "pp-node-02", Role: "instance"}
n := &reg.Node{Node: cluster.Node{Name: "pp-node-02", Role: "instance"}}
assert.NoError(t, regy.Put(n))
// Provisioner is independent; endpoint should respond 200 and persist metadata.

View File

@@ -27,10 +27,13 @@ func TestClusterEndpoints(t *testing.T) {
// Seed nodes in the registry
regy, err := reg.NewClientRegistryWithConfig(conf)
assert.NoError(t, err)
n := &reg.Node{Name: "pp-node-01", Role: "instance", UUID: rnd.UUIDv7()}
n := &reg.Node{Node: cluster.Node{Name: "pp-node-01", Role: "instance", UUID: rnd.UUIDv7()}}
assert.NoError(t, regy.Put(n))
n2 := &reg.Node{Name: "pp-node-02", Role: "service", UUID: rnd.UUIDv7()}
n2 := &reg.Node{Node: cluster.Node{Name: "pp-node-02", Role: "service", UUID: rnd.UUIDv7()}}
assert.NoError(t, regy.Put(n2))
// Resolve actual IDs (client-backed registry generates IDs)
n, err = regy.FindByName("pp-node-01")
assert.NoError(t, err)
@@ -87,8 +90,10 @@ func TestClusterGetNode_UUIDValidation(t *testing.T) {
// Seed a node and resolve its actual ID.
regy, err := reg.NewClientRegistryWithConfig(conf)
assert.NoError(t, err)
n := &reg.Node{Name: "pp-node-99", Role: "instance", UUID: rnd.UUIDv7()}
n := &reg.Node{Node: cluster.Node{Name: "pp-node-99", Role: "instance", UUID: rnd.UUIDv7()}}
assert.NoError(t, regy.Put(n))
n, err = regy.FindByName("pp-node-99")
assert.NoError(t, err)
@@ -114,9 +119,11 @@ func TestClusterGetNode_UUIDValidation(t *testing.T) {
// Excessively long ID (>64 chars) is rejected.
longID := make([]byte, 65)
for i := range longID {
longID[i] = 'a'
}
r = PerformRequest(app, http.MethodGet, "/api/v1/cluster/nodes/"+string(longID))
assert.Equal(t, http.StatusNotFound, r.Code)
}

View File

@@ -21,8 +21,9 @@ func TestClusterUpdateNode_SiteUrl(t *testing.T) {
regy, err := reg.NewClientRegistryWithConfig(conf)
assert.NoError(t, err)
// Seed node
n := &reg.Node{Name: "pp-node-siteurl", Role: "instance", UUID: rnd.UUIDv7()}
n := &reg.Node{Node: cluster.Node{Name: "pp-node-siteurl", Role: "instance", UUID: rnd.UUIDv7()}}
assert.NoError(t, regy.Put(n))
n, err = regy.FindByName("pp-node-siteurl")
assert.NoError(t, err)

View File

@@ -422,6 +422,9 @@
"database": {
"$ref": "#/definitions/cluster.RegisterDatabase"
},
"jwksUrl": {
"type": "string"
},
"node": {
"$ref": "#/definitions/cluster.Node"
},

107
internal/auth/jwt/issuer.go Normal file
View File

@@ -0,0 +1,107 @@
package jwt
import (
"errors"
"strings"
"time"
gojwt "github.com/golang-jwt/jwt/v5"
"github.com/photoprism/photoprism/pkg/rnd"
)
var (
// DefaultTokenTTL is the default lifetime for issued tokens.
DefaultTokenTTL = 300 * time.Second
// MaxTokenTTL clamps configurable lifetimes to a safe upper bound.
MaxTokenTTL = 900 * time.Second
)
// TokenTTL controls the default lifetime used when a ClaimsSpec does not override TTL.
var TokenTTL = DefaultTokenTTL
// ClaimsSpec describes the claims to embed in a signed token.
type ClaimsSpec struct {
Issuer string
Subject string
Audience string
Scope []string
TTL time.Duration
}
// validate performs sanity checks on the claim specification before issuing a token.
func (s ClaimsSpec) validate() error {
if strings.TrimSpace(s.Issuer) == "" {
return errors.New("jwt: issuer required")
}
if strings.TrimSpace(s.Subject) == "" {
return errors.New("jwt: subject required")
}
if strings.TrimSpace(s.Audience) == "" {
return errors.New("jwt: audience required")
}
if len(s.Scope) == 0 {
return errors.New("jwt: scope required")
}
return nil
}
// Issuer signs JWTs on behalf of the Portal using the manager's active key.
type Issuer struct {
manager *Manager
now func() time.Time
}
// NewIssuer returns an Issuer bound to the provided Manager.
func NewIssuer(m *Manager) *Issuer {
return &Issuer{manager: m, now: time.Now}
}
// Issue signs a JWT using the manager's active key according to spec.
func (i *Issuer) Issue(spec ClaimsSpec) (string, error) {
if i == nil || i.manager == nil {
return "", errors.New("jwt: issuer not initialized")
}
if err := spec.validate(); err != nil {
return "", err
}
ttl := spec.TTL
if ttl <= 0 {
ttl = TokenTTL
}
if ttl > MaxTokenTTL {
ttl = MaxTokenTTL
}
key, err := i.manager.EnsureActiveKey()
if err != nil {
return "", err
}
issuedAt := i.now().UTC()
expiresAt := issuedAt.Add(ttl)
claims := &Claims{
Scope: strings.Join(spec.Scope, " "),
RegisteredClaims: gojwt.RegisteredClaims{
Issuer: spec.Issuer,
Subject: spec.Subject,
Audience: gojwt.ClaimStrings{spec.Audience},
IssuedAt: gojwt.NewNumericDate(issuedAt),
NotBefore: gojwt.NewNumericDate(issuedAt),
ExpiresAt: gojwt.NewNumericDate(expiresAt),
ID: rnd.GenerateUID(rnd.PrefixMixed),
},
}
token := gojwt.NewWithClaims(gojwt.SigningMethodEdDSA, claims)
token.Header["kid"] = key.Kid
token.Header["typ"] = "JWT"
signed, err := token.SignedString(key.PrivateKey)
if err != nil {
return "", err
}
return signed, nil
}

27
internal/auth/jwt/jwt.go Normal file
View File

@@ -0,0 +1,27 @@
/*
Package jwt provides helpers for managing Ed25519 signing keys and issuing or
verifying short-lived JWTs used for secure communication between the Portal and
cluster nodes.
Copyright (c) 2018 - 2025 PhotoPrism UG. All rights reserved.
This program is free software: you can redistribute it and/or modify
it under Version 3 of the GNU Affero General Public License (the "AGPL"):
<https://docs.photoprism.app/license/agpl>
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
The AGPL is supplemented by our Trademark and Brand Guidelines,
which describe how our Brand Assets may be used:
<https://www.photoprism.app/trademark>
Feel free to send an email to hello@photoprism.app if you have questions,
want to support our work, or just want to say hello.
Additional information can be found in our Developer Guide:
<https://docs.photoprism.app/developer-guide/>
*/
package jwt

View File

@@ -0,0 +1,30 @@
package jwt
import (
"os"
"testing"
"github.com/sirupsen/logrus"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/pkg/fs"
)
func TestMain(m *testing.M) {
// Init test logger.
log = logrus.StandardLogger()
log.SetLevel(logrus.TraceLevel)
event.AuditLog = log
c := config.TestConfig()
defer c.CloseDb()
// Run unit tests.
code := m.Run()
// Remove temporary SQLite files after running the tests.
fs.PurgeTestDbFiles(".", false)
os.Exit(code)
}

View File

@@ -0,0 +1,6 @@
package jwt
import "github.com/photoprism/photoprism/internal/event"
// log provides package-wide logging using the shared event logger.
var log = event.Log

View File

@@ -0,0 +1,288 @@
package jwt
import (
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/pkg/fs"
)
const (
privateKeyPrefix = "ed25519-"
privateKeyExt = ".jwk"
publicKeyExt = ".pub.jwk"
)
type keyRecord struct {
Kty string `json:"kty"`
Crv string `json:"crv"`
Kid string `json:"kid"`
X string `json:"x"`
D string `json:"d,omitempty"`
CreatedAt int64 `json:"createdAt,omitempty"`
NotAfter int64 `json:"notAfter,omitempty"`
}
// Manager handles Ed25519 key lifecycle for JWT issuance and JWKS exposure.
type Manager struct {
conf *config.Config
mu sync.RWMutex
keys []*Key
now func() time.Time
}
// ErrNoActiveKey indicates that the manager has no active key pair available.
var ErrNoActiveKey = errors.New("jwt: no active signing key")
// NewManager creates a Manager bound to the provided config.
func NewManager(conf *config.Config) (*Manager, error) {
if conf == nil {
return nil, errors.New("jwt: config is nil")
}
m := &Manager{
conf: conf,
now: time.Now,
}
if err := m.loadKeys(); err != nil {
return nil, err
}
return m, nil
}
// keyDir returns the directory in which key material is stored.
func (m *Manager) keyDir() string {
return filepath.Join(m.conf.PortalConfigPath(), "keys")
}
// EnsureActiveKey returns the current active key, generating one if necessary.
func (m *Manager) EnsureActiveKey() (*Key, error) {
if k, err := m.ActiveKey(); err == nil {
return k, nil
}
return m.generateKey()
}
// ActiveKey returns the most recent, non-expired signing key.
func (m *Manager) ActiveKey() (*Key, error) {
m.mu.RLock()
defer m.mu.RUnlock()
now := m.now().Unix()
for i := len(m.keys) - 1; i >= 0; i-- {
k := m.keys[i]
if k.NotAfter != 0 && now > k.NotAfter {
continue
}
return k.clone(), nil
}
return nil, ErrNoActiveKey
}
// JWKS returns the public JWKS representation of all non-expired keys.
func (m *Manager) JWKS() *JWKS {
m.mu.RLock()
defer m.mu.RUnlock()
now := m.now().Unix()
keys := make([]PublicJWK, 0, len(m.keys))
for _, k := range m.keys {
if k.NotAfter != 0 && now > k.NotAfter {
continue
}
keys = append(keys, PublicJWK{
Kty: keyTypeOKP,
Crv: curveEd25519,
Kid: k.Kid,
X: base64.RawURLEncoding.EncodeToString(k.PublicKey),
})
}
return &JWKS{Keys: keys}
}
// AllKeys returns a slice copy containing all loaded keys (for testing/inspection).
func (m *Manager) AllKeys() []*Key {
m.mu.RLock()
defer m.mu.RUnlock()
out := make([]*Key, len(m.keys))
for i, k := range m.keys {
out[i] = k.clone()
}
return out
}
// loadKeys reads existing key records from disk into memory.
func (m *Manager) loadKeys() error {
dir := m.keyDir()
if err := fs.MkdirAll(dir); err != nil {
return err
}
entries, err := os.ReadDir(dir)
if err != nil {
return err
}
keys := make([]*Key, 0, len(entries))
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() {
continue
}
if !strings.HasPrefix(name, privateKeyPrefix) || !strings.HasSuffix(name, privateKeyExt) {
continue
}
if strings.HasSuffix(name, publicKeyExt) {
// Skip public-only artifacts when reloading.
continue
}
keyPath := filepath.Join(dir, name)
b, err := os.ReadFile(keyPath)
if err != nil {
return err
}
var rec keyRecord
if err := json.Unmarshal(b, &rec); err != nil {
return err
}
if rec.Kty != keyTypeOKP || rec.Crv != curveEd25519 || rec.Kid == "" {
continue
}
privBytes, err := base64.RawURLEncoding.DecodeString(rec.D)
if err != nil {
return err
}
if len(privBytes) != ed25519.SeedSize {
return fmt.Errorf("jwt: invalid private key length %d", len(privBytes))
}
priv := ed25519.NewKeyFromSeed(privBytes)
pub := make([]byte, ed25519.PublicKeySize)
copy(pub, priv[ed25519.SeedSize:])
k := &Key{
Kid: rec.Kid,
CreatedAt: rec.CreatedAt,
NotAfter: rec.NotAfter,
PrivateKey: priv,
PublicKey: ed25519.PublicKey(pub),
}
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
return keys[i].CreatedAt < keys[j].CreatedAt
})
m.mu.Lock()
m.keys = keys
m.mu.Unlock()
return nil
}
// generateKey creates a fresh Ed25519 key pair, persists it, and returns a clone.
func (m *Manager) generateKey() (*Key, error) {
seed := make([]byte, ed25519.SeedSize)
if _, err := rand.Read(seed); err != nil {
return nil, err
}
priv := ed25519.NewKeyFromSeed(seed)
pub := priv[ed25519.SeedSize:]
now := m.now().UTC()
fingerprint := sha256.Sum256(pub)
kid := fmt.Sprintf("%s-%s", now.Format("20060102T1504Z"), hex.EncodeToString(fingerprint[:4]))
k := &Key{
Kid: kid,
CreatedAt: now.Unix(),
NotAfter: 0,
PrivateKey: priv,
PublicKey: append(ed25519.PublicKey(nil), pub...),
}
if err := m.persistKey(k); err != nil {
return nil, err
}
m.mu.Lock()
m.keys = append(m.keys, k)
sort.Slice(m.keys, func(i, j int) bool {
return m.keys[i].CreatedAt < m.keys[j].CreatedAt
})
m.mu.Unlock()
return k.clone(), nil
}
// persistKey writes the private and public key records to disk using secure permissions.
func (m *Manager) persistKey(k *Key) error {
dir := m.keyDir()
if err := fs.MkdirAll(dir); err != nil {
return err
}
privRec := keyRecord{
Kty: keyTypeOKP,
Crv: curveEd25519,
Kid: k.Kid,
X: base64.RawURLEncoding.EncodeToString(k.PublicKey),
D: base64.RawURLEncoding.EncodeToString(k.PrivateKey.Seed()),
CreatedAt: k.CreatedAt,
NotAfter: k.NotAfter,
}
privPath := filepath.Join(dir, privateKeyPrefix+k.Kid+privateKeyExt)
pubPath := filepath.Join(dir, privateKeyPrefix+k.Kid+publicKeyExt)
privJSON, err := json.Marshal(privRec)
if err != nil {
return err
}
if err := os.WriteFile(privPath, privJSON, fs.ModeSecretFile); err != nil {
return err
}
// Public record omits private component.
pubRec := privRec
pubRec.D = ""
pubJSON, err := json.Marshal(pubRec)
if err != nil {
return err
}
if err := os.WriteFile(pubPath, pubJSON, fs.ModeFile); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,82 @@
package jwt
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
cfg "github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/pkg/fs"
)
func TestManagerEnsureActiveKey(t *testing.T) {
c := cfg.TestConfig()
m, err := NewManager(c)
require.NoError(t, err)
require.NotNil(t, m)
fixed := time.Date(2025, 9, 24, 10, 30, 0, 0, time.UTC)
m.now = func() time.Time { return fixed }
key, err := m.EnsureActiveKey()
require.NoError(t, err)
require.NotNil(t, key)
require.True(t, strings.HasPrefix(key.Kid, "20250924T1030Z-"))
// Key files should be persisted.
privPath := filepath.Join(c.PortalConfigPath(), "keys", privateKeyPrefix+key.Kid+privateKeyExt)
pubPath := filepath.Join(c.PortalConfigPath(), "keys", privateKeyPrefix+key.Kid+publicKeyExt)
require.True(t, fs.FileExists(privPath))
require.True(t, fs.FileExists(pubPath))
// Second call should reuse same key.
next, err := m.EnsureActiveKey()
require.NoError(t, err)
require.Equal(t, key.Kid, next.Kid)
// JWKS should expose the key.
jwks := m.JWKS()
require.Len(t, jwks.Keys, 1)
require.Equal(t, key.Kid, jwks.Keys[0].Kid)
// Reload manager from disk.
m2, err := NewManager(c)
require.NoError(t, err)
require.NotNil(t, m2)
reloaded, err := m2.ActiveKey()
require.NoError(t, err)
require.Equal(t, key.Kid, reloaded.Kid)
}
func TestManagerGenerateSecondKey(t *testing.T) {
c := cfg.TestConfig()
m, err := NewManager(c)
require.NoError(t, err)
first := time.Date(2025, 9, 24, 10, 30, 0, 0, time.UTC)
m.now = func() time.Time { return first }
k1, err := m.EnsureActiveKey()
require.NoError(t, err)
second := first.Add(24 * time.Hour)
m.now = func() time.Time { return second }
// Force generation by clearing in-memory keys to simulate expiration.
m.mu.Lock()
m.keys[len(m.keys)-1].NotAfter = first.Unix()
m.mu.Unlock()
k2, err := m.EnsureActiveKey()
require.NoError(t, err)
require.NotEqual(t, k1.Kid, k2.Kid)
// JWKS should include both keys (old not expired due to manual NotAfter=CreatedAt).
jwks := m.JWKS()
require.NotEmpty(t, jwks.Keys)
// Clean up generated files.
require.NoError(t, os.RemoveAll(filepath.Join(c.PortalConfigPath(), "keys")))
}

View File

@@ -0,0 +1,56 @@
package jwt
import (
"crypto/ed25519"
gojwt "github.com/golang-jwt/jwt/v5"
)
const (
keyTypeOKP = "OKP"
curveEd25519 = "Ed25519"
)
// PublicJWK represents the public portion of an Ed25519 key in JWK form.
type PublicJWK struct {
Kty string `json:"kty"`
Crv string `json:"crv"`
Kid string `json:"kid"`
X string `json:"x"`
}
// JWKS represents a JSON Web Key Set.
type JWKS struct {
Keys []PublicJWK `json:"keys"`
}
// Claims represents cluster JWT claims.
type Claims struct {
Scope string `json:"scope"`
gojwt.RegisteredClaims
}
// Key encapsulates an Ed25519 keypair with metadata used for JWKS rotation.
type Key struct {
Kid string
CreatedAt int64
NotAfter int64
PrivateKey ed25519.PrivateKey
PublicKey ed25519.PublicKey
}
// clone returns a shallow copy of the key to avoid exposing internal slices.
func (k *Key) clone() *Key {
if k == nil {
return nil
}
c := *k
if k.PrivateKey != nil {
c.PrivateKey = append(ed25519.PrivateKey(nil), k.PrivateKey...)
}
if k.PublicKey != nil {
c.PublicKey = append(ed25519.PublicKey(nil), k.PublicKey...)
}
return &c
}

View File

@@ -0,0 +1,390 @@
package jwt
import (
"context"
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"math/rand"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
gojwt "github.com/golang-jwt/jwt/v5"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/pkg/fs"
)
var (
errKeyNotFound = errors.New("jwt: key not found")
)
const (
// jwksFetchMaxRetries caps the number of immediate retry attempts after a fetch error.
jwksFetchMaxRetries = 3
// jwksFetchBaseDelay is the initial retry delay (with jitter) applied after the first failure.
jwksFetchBaseDelay = 200 * time.Millisecond
// jwksFetchMaxDelay is the upper bound for retry delays to prevent unbounded backoff.
jwksFetchMaxDelay = 2 * time.Second
)
// randInt63n is defined for deterministic testing of jitter (overridable in tests).
var randInt63n = rand.Int63n
// cacheEntry stores the JWKS material cached on disk and in memory.
type cacheEntry struct {
URL string `json:"url"`
ETag string `json:"etag,omitempty"`
Keys []PublicJWK `json:"keys"`
FetchedAt int64 `json:"fetchedAt"`
}
// Verifier validates Portal-issued JWTs on Nodes using JWKS with caching.
type Verifier struct {
conf *config.Config
mu sync.Mutex
cache cacheEntry
cachePath string
httpClient *http.Client
now func() time.Time
}
// ExpectedClaims describes the constraints that must hold for a token.
type ExpectedClaims struct {
Issuer string
Audience string
Scope []string
JWKSURL string
}
// NewVerifier instantiates a verifier with sane defaults.
func NewVerifier(conf *config.Config) *Verifier {
v := &Verifier{
conf: conf,
httpClient: &http.Client{Timeout: 10 * time.Second},
now: time.Now,
}
if conf != nil {
v.cachePath = filepath.Join(conf.ConfigPath(), "jwks-cache.json")
}
_ = v.loadCache()
return v
}
// Prime ensures JWKS material is cached locally.
func (v *Verifier) Prime(ctx context.Context, jwksURL string) error {
_, err := v.keysForURL(ctx, jwksURL, true)
return err
}
// VerifyToken validates a JWT against the expected claims and returns decoded claims.
func (v *Verifier) VerifyToken(ctx context.Context, tokenString string, expected ExpectedClaims) (*Claims, error) {
if v == nil {
return nil, errors.New("jwt: verifier not initialized")
}
if strings.TrimSpace(tokenString) == "" {
return nil, errors.New("jwt: token is empty")
}
if strings.TrimSpace(expected.Issuer) == "" {
return nil, errors.New("jwt: expected issuer required")
}
if strings.TrimSpace(expected.Audience) == "" {
return nil, errors.New("jwt: expected audience required")
}
if len(expected.Scope) == 0 {
return nil, errors.New("jwt: expected scope required")
}
url := strings.TrimSpace(expected.JWKSURL)
if url == "" && v.conf != nil {
url = strings.TrimSpace(v.conf.JWKSUrl())
}
if url == "" {
return nil, errors.New("jwt: jwks url not configured")
}
leeway := 60 * time.Second
if v.conf != nil && v.conf.JWTLeeway() > 0 {
leeway = time.Duration(v.conf.JWTLeeway()) * time.Second
}
parser := gojwt.NewParser(
gojwt.WithLeeway(leeway),
gojwt.WithValidMethods([]string{gojwt.SigningMethodEdDSA.Alg()}),
gojwt.WithIssuer(expected.Issuer),
gojwt.WithAudience(expected.Audience),
)
claims := &Claims{}
keyFunc := func(token *gojwt.Token) (interface{}, error) {
kid, _ := token.Header["kid"].(string)
if kid == "" {
return nil, errors.New("jwt: missing kid header")
}
pk, err := v.publicKeyForKid(ctx, url, kid, false)
if errors.Is(err, errKeyNotFound) {
pk, err = v.publicKeyForKid(ctx, url, kid, true)
}
if err != nil {
return nil, err
}
return pk, nil
}
if _, err := parser.ParseWithClaims(tokenString, claims, keyFunc); err != nil {
return nil, err
}
if claims.IssuedAt == nil || claims.ExpiresAt == nil {
return nil, errors.New("jwt: missing temporal claims")
}
if ttl := claims.ExpiresAt.Time.Sub(claims.IssuedAt.Time); ttl > MaxTokenTTL {
return nil, errors.New("jwt: token ttl exceeds maximum")
}
scopeSet := map[string]struct{}{}
for _, s := range strings.Fields(claims.Scope) {
scopeSet[s] = struct{}{}
}
for _, req := range expected.Scope {
if _, ok := scopeSet[req]; !ok {
return nil, fmt.Errorf("jwt: missing scope %s", req)
}
}
return claims, nil
}
// publicKeyForKid resolves the public key for the given key ID, fetching JWKS data if needed.
func (v *Verifier) publicKeyForKid(ctx context.Context, url, kid string, force bool) (ed25519.PublicKey, error) {
keys, err := v.keysForURL(ctx, url, force)
if err != nil {
return nil, err
}
for _, k := range keys {
if k.Kid != kid {
continue
}
raw, err := base64.RawURLEncoding.DecodeString(k.X)
if err != nil {
return nil, err
}
if len(raw) != ed25519.PublicKeySize {
return nil, fmt.Errorf("jwt: invalid public key length %d", len(raw))
}
pk := make(ed25519.PublicKey, ed25519.PublicKeySize)
copy(pk, raw)
return pk, nil
}
return nil, errKeyNotFound
}
// keysForURL returns JWKS keys for the specified endpoint, reusing cache when possible.
func (v *Verifier) keysForURL(ctx context.Context, url string, force bool) ([]PublicJWK, error) {
ttl := 300 * time.Second
if v.conf != nil && v.conf.JWKSCacheTTL() > 0 {
ttl = time.Duration(v.conf.JWKSCacheTTL()) * time.Second
}
attempts := 0
for {
cached := v.snapshotCache()
if keys, ok := v.cachedKeys(url, ttl, cached, force); ok {
return keys, nil
}
etag := ""
if !force && cached.URL == url {
etag = cached.ETag
}
result, err := v.fetchJWKS(ctx, url, etag)
if err != nil {
if !force && cached.URL == url && len(cached.Keys) > 0 {
return append([]PublicJWK(nil), cached.Keys...), nil
}
attempts++
if attempts >= jwksFetchMaxRetries {
return nil, err
}
delay := backoffDuration(attempts)
log.Debugf("jwt: jwks fetch retry %d for %s in %s (%s)", attempts, url, delay, err)
select {
case <-time.After(delay):
continue
case <-ctx.Done():
return nil, ctx.Err()
}
}
if keys, ok := v.updateCache(url, result); ok {
return keys, nil
}
// Cache changed by another goroutine between snapshot and update; retry.
}
}
// snapshotCache returns the current JWKS cache entry under lock for safe reading.
func (v *Verifier) snapshotCache() cacheEntry {
v.mu.Lock()
defer v.mu.Unlock()
cache := v.cache
return cache
}
// cachedKeys returns cached JWKS keys if they are fresh enough and match the target URL.
func (v *Verifier) cachedKeys(url string, ttl time.Duration, cache cacheEntry, force bool) ([]PublicJWK, bool) {
if force || cache.URL != url || len(cache.Keys) == 0 {
return nil, false
}
age := v.now().Unix() - cache.FetchedAt
if age < 0 {
return nil, false
}
if time.Duration(age)*time.Second > ttl {
return nil, false
}
return append([]PublicJWK(nil), cache.Keys...), true
}
type jwksFetchResult struct {
keys []PublicJWK
etag string
fetchedAt int64
notModified bool
}
// fetchJWKS downloads the JWKS document (respecting conditional requests) and returns the parsed keys.
func (v *Verifier) fetchJWKS(ctx context.Context, url, etag string) (*jwksFetchResult, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
if etag != "" {
req.Header.Set("If-None-Match", etag)
}
resp, err := v.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
switch resp.StatusCode {
case http.StatusNotModified:
return &jwksFetchResult{
etag: etag,
fetchedAt: v.now().Unix(),
notModified: true,
}, nil
case http.StatusOK:
var body JWKS
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
return nil, err
}
if len(body.Keys) == 0 {
return nil, errors.New("jwt: jwks contains no keys")
}
return &jwksFetchResult{
keys: append([]PublicJWK(nil), body.Keys...),
etag: resp.Header.Get("ETag"),
fetchedAt: v.now().Unix(),
}, nil
default:
return nil, fmt.Errorf("jwt: jwks fetch failed: %s", resp.Status)
}
}
// updateCache stores the JWKS fetch result on success and returns the fresh keys.
func (v *Verifier) updateCache(url string, result *jwksFetchResult) ([]PublicJWK, bool) {
v.mu.Lock()
defer v.mu.Unlock()
if result.notModified {
if v.cache.URL != url {
return nil, false
}
v.cache.FetchedAt = result.fetchedAt
if result.etag != "" {
v.cache.ETag = result.etag
}
_ = v.saveCacheLocked()
return append([]PublicJWK(nil), v.cache.Keys...), true
}
v.cache = cacheEntry{
URL: url,
ETag: result.etag,
Keys: append([]PublicJWK(nil), result.keys...),
FetchedAt: result.fetchedAt,
}
_ = v.saveCacheLocked()
return append([]PublicJWK(nil), v.cache.Keys...), true
}
// loadCache restores a previously persisted JWKS cache entry from disk.
func (v *Verifier) loadCache() error {
if v.cachePath == "" || !fs.FileExists(v.cachePath) {
return nil
}
b, err := os.ReadFile(v.cachePath)
if err != nil || len(b) == 0 {
return err
}
var entry cacheEntry
if err := json.Unmarshal(b, &entry); err != nil {
return err
}
v.cache = entry
return nil
}
// saveCacheLocked persists the current cache entry to disk; caller must hold the mutex.
func (v *Verifier) saveCacheLocked() error {
if v.cachePath == "" {
return nil
}
if err := fs.MkdirAll(filepath.Dir(v.cachePath)); err != nil {
return err
}
data, err := json.Marshal(v.cache)
if err != nil {
return err
}
return os.WriteFile(v.cachePath, data, fs.ModeSecretFile)
}
// backoffDuration returns the retry delay for the given fetch attempt, adding jitter.
func backoffDuration(attempt int) time.Duration {
if attempt < 1 {
attempt = 1
}
base := jwksFetchBaseDelay << (attempt - 1)
if base > jwksFetchMaxDelay {
base = jwksFetchMaxDelay
}
jitterRange := base / 2
if jitterRange > 0 {
base += time.Duration(randInt63n(int64(jitterRange) + 1))
}
return base
}

View File

@@ -0,0 +1,168 @@
package jwt
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/require"
gojwt "github.com/golang-jwt/jwt/v5"
cfg "github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/pkg/rnd"
)
func TestVerifierPrimeAndVerify(t *testing.T) {
portalCfg := cfg.TestConfig()
clusterUUID := rnd.UUIDv7()
portalCfg.Options().ClusterUUID = clusterUUID
mgr, err := NewManager(portalCfg)
require.NoError(t, err)
mgr.now = func() time.Time { return time.Date(2025, 9, 24, 10, 30, 0, 0, time.UTC) }
_, err = mgr.EnsureActiveKey()
require.NoError(t, err)
jwksBytes, err := json.Marshal(mgr.JWKS())
require.NoError(t, err)
etag := `"v1"`
var requestCount int
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount++
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "max-age=300")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(jwksBytes)
}))
defer server.Close()
nodeCfg := cfg.NewTestConfig("jwt-verifier-node")
nodeCfg.SetJWKSUrl(server.URL + "/.well-known/jwks.json")
nodeCfg.Options().ClusterUUID = clusterUUID
nodeUUID := nodeCfg.NodeUUID()
issuer := NewIssuer(mgr)
issuer.now = func() time.Time { return time.Now().UTC() }
spec := ClaimsSpec{
Issuer: fmt.Sprintf("portal:%s", clusterUUID),
Subject: "portal:client-test",
Audience: fmt.Sprintf("node:%s", nodeUUID),
Scope: []string{"cluster", "vision"},
}
token, err := issuer.Issue(spec)
require.NoError(t, err)
verifier := NewVerifier(nodeCfg)
ctx := context.Background()
require.NoError(t, verifier.Prime(ctx, nodeCfg.JWKSUrl()))
require.Equal(t, 1, requestCount)
claims, err := verifier.VerifyToken(ctx, token, ExpectedClaims{
Issuer: spec.Issuer,
Audience: spec.Audience,
Scope: []string{"cluster"},
JWKSURL: nodeCfg.JWKSUrl(),
})
require.NoError(t, err)
require.Equal(t, spec.Subject, claims.Subject)
require.Contains(t, claims.Scope, "cluster")
// Force cache refresh by expiring entry and verify 304 handling.
verifier.mu.Lock()
verifier.cache.FetchedAt -= 1000
verifier.mu.Unlock()
_, err = verifier.VerifyToken(ctx, token, ExpectedClaims{
Issuer: spec.Issuer,
Audience: spec.Audience,
Scope: []string{"cluster"},
JWKSURL: nodeCfg.JWKSUrl(),
})
require.NoError(t, err)
require.Equal(t, 2, requestCount)
// Missing scope should fail.
_, err = verifier.VerifyToken(ctx, token, ExpectedClaims{
Issuer: spec.Issuer,
Audience: spec.Audience,
Scope: []string{"cluster", "unknown"},
JWKSURL: nodeCfg.JWKSUrl(),
})
require.Error(t, err)
}
func TestIssuerClampTTL(t *testing.T) {
portalCfg := cfg.TestConfig()
mgr, err := NewManager(portalCfg)
require.NoError(t, err)
mgr.now = func() time.Time { return time.Unix(0, 0) }
_, err = mgr.EnsureActiveKey()
require.NoError(t, err)
issuer := NewIssuer(mgr)
issuer.now = func() time.Time { return time.Unix(1000, 0) }
spec := ClaimsSpec{
Issuer: "portal:test",
Subject: "portal:client",
Audience: "node:test",
Scope: []string{"cluster"},
TTL: 7200 * time.Second,
}
token, err := issuer.Issue(spec)
require.NoError(t, err)
parsed := &Claims{}
parser := gojwt.NewParser(gojwt.WithValidMethods([]string{gojwt.SigningMethodEdDSA.Alg()}), gojwt.WithoutClaimsValidation())
_, err = parser.ParseWithClaims(token, parsed, func(token *gojwt.Token) (interface{}, error) {
key, _ := mgr.ActiveKey()
return key.PublicKey, nil
})
require.NoError(t, err)
ttl := parsed.ExpiresAt.Time.Sub(parsed.IssuedAt.Time)
require.Equal(t, MaxTokenTTL, ttl)
}
func TestBackoffDuration(t *testing.T) {
origRand := randInt63n
randInt63n = func(n int64) int64 {
if n <= 0 {
return 0
}
return n - 1
}
t.Cleanup(func() { randInt63n = origRand })
tests := []struct {
name string
attempt int
expect time.Duration
}{
{"Attempt1", 1, 300 * time.Millisecond},
{"Attempt2", 2, 600 * time.Millisecond},
{"Attempt3", 3, 1200 * time.Millisecond},
{"Attempt4", 4, 2400 * time.Millisecond},
{"Attempt5", 5, 3 * time.Second},
{"AttemptZero", 0, 300 * time.Millisecond},
}
for _, tt := range tests {
if got := backoffDuration(tt.attempt); got != tt.expect {
t.Errorf("%s: expected %s, got %s", tt.name, tt.expect, got)
}
}
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/pkg/fs"
)
func TestMain(m *testing.M) {
@@ -20,5 +21,8 @@ func TestMain(m *testing.M) {
code := m.Run()
// Remove temporary SQLite files after running the tests.
fs.PurgeTestDbFiles(".", false)
os.Exit(code)
}

View File

@@ -99,12 +99,12 @@ func clusterNodesRotateAction(ctx *cli.Context) error {
}
}
body := map[string]interface{}{
"nodeName": name,
"rotate": rotateDatabase,
"rotateSecret": rotateSecret,
payload := cluster.RegisterRequest{
NodeName: name,
RotateDatabase: rotateDatabase,
RotateSecret: rotateSecret,
}
b, _ := json.Marshal(body)
b, _ := json.Marshal(payload)
url := stringsTrimRightSlash(portalURL) + "/api/v1/cluster/nodes/register"
var resp cluster.RegisterResponse

View File

@@ -85,28 +85,28 @@ func clusterRegisterAction(ctx *cli.Context) error {
}
site := conf.SiteUrl()
body := map[string]interface{}{
"nodeName": name,
"nodeRole": nodeRole,
"labels": parseLabelSlice(ctx.StringSlice("label")),
"advertiseUrl": advertise,
"rotate": ctx.Bool("rotate"),
"rotateSecret": ctx.Bool("rotate-secret"),
payload := cluster.RegisterRequest{
NodeName: name,
NodeRole: nodeRole,
Labels: parseLabelSlice(ctx.StringSlice("label")),
AdvertiseUrl: advertise,
RotateDatabase: ctx.Bool("rotate"),
RotateSecret: ctx.Bool("rotate-secret"),
}
// If we already have client credentials (e.g., re-register), include them so the
// portal can verify and authorize UUID/name moves or metadata updates.
if id, secret := strings.TrimSpace(conf.NodeClientID()), strings.TrimSpace(conf.NodeClientSecret()); id != "" && secret != "" {
body["clientId"] = id
body["clientSecret"] = secret
payload.ClientID = id
payload.ClientSecret = secret
}
if site != "" && site != advertise {
body["siteUrl"] = site
payload.SiteUrl = site
}
b, _ := json.Marshal(body)
b, _ := json.Marshal(payload)
if ctx.Bool("dry-run") {
if ctx.Bool("json") {
out := map[string]any{"portalUrl": portalURL, "payload": body}
out := map[string]any{"portalUrl": portalURL, "payload": payload}
jb, _ := json.Marshal(out)
fmt.Println(string(jb))
} else {
@@ -116,19 +116,19 @@ func clusterRegisterAction(ctx *cli.Context) error {
fmt.Println("(derived defaults were used where flags were omitted)")
}
fmt.Printf("Advertise: %s\n", advertise)
if v, ok := body["siteUrl"].(string); ok && v != "" {
fmt.Printf("Site URL: %s\n", v)
if payload.SiteUrl != "" {
fmt.Printf("Site URL: %s\n", payload.SiteUrl)
}
// 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).")
}
if v, ok := body["siteUrl"].(string); ok && v != "" && warnInsecurePublicURL(v) {
if payload.SiteUrl != "" && warnInsecurePublicURL(payload.SiteUrl) {
fmt.Println("Warning: site-url is http for a public host; server may reject it (HTTPS required).")
}
// Single-line summary for quick operator scan
if v, ok := body["siteUrl"].(string); ok && v != "" {
fmt.Printf("Derived: portal=%s advertise=%s site=%s\n", portalURL, advertise, v)
if payload.SiteUrl != "" {
fmt.Printf("Derived: portal=%s advertise=%s site=%s\n", portalURL, advertise, payload.SiteUrl)
} else {
fmt.Printf("Derived: portal=%s advertise=%s\n", portalURL, advertise)
}

View File

@@ -151,7 +151,7 @@ func TestClusterNodesRotate_DBOnly_JSON(t *testing.T) {
}
// Read payload to assert rotate flags
b, _ := io.ReadAll(r.Body)
rotate := gjson.GetBytes(b, "rotate").Bool()
rotate := gjson.GetBytes(b, "rotateDatabase").Bool()
rotateSecret := gjson.GetBytes(b, "rotateSecret").Bool()
// Expect DB rotation only
if !rotate || rotateSecret {
@@ -203,7 +203,7 @@ func TestClusterNodesRotate_SecretOnly_JSON(t *testing.T) {
return
}
b, _ := io.ReadAll(r.Body)
rotate := gjson.GetBytes(b, "rotate").Bool()
rotate := gjson.GetBytes(b, "rotateDatabase").Bool()
rotateSecret := gjson.GetBytes(b, "rotateSecret").Bool()
// Expect secret-only rotation
if rotate || !rotateSecret {
@@ -422,7 +422,7 @@ func TestClusterRegister_RotateDatabase_JSON(t *testing.T) {
return
}
b, _ := io.ReadAll(r.Body)
if !gjson.GetBytes(b, "rotate").Bool() || gjson.GetBytes(b, "rotateSecret").Bool() {
if !gjson.GetBytes(b, "rotateDatabase").Bool() || gjson.GetBytes(b, "rotateSecret").Bool() {
w.WriteHeader(http.StatusBadRequest)
return
}
@@ -463,7 +463,7 @@ func TestClusterRegister_RotateSecret_JSON(t *testing.T) {
return
}
b, _ := io.ReadAll(r.Body)
if gjson.GetBytes(b, "rotate").Bool() || !gjson.GetBytes(b, "rotateSecret").Bool() {
if gjson.GetBytes(b, "rotateDatabase").Bool() || !gjson.GetBytes(b, "rotateSecret").Bool() {
w.WriteHeader(http.StatusBadRequest)
return
}

View File

@@ -23,6 +23,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/internal/service/cluster"
reg "github.com/photoprism/photoprism/internal/service/cluster/registry"
"github.com/photoprism/photoprism/pkg/fs"
)
@@ -95,7 +96,7 @@ func TestClusterSuccessPaths_PortalLocal(t *testing.T) {
// Create a registry node via FileRegistry.
r, err := reg.NewClientRegistryWithConfig(c)
assert.NoError(t, err)
n := &reg.Node{Name: "pp-node-01", Role: "instance", Labels: map[string]string{"env": "test"}}
n := &reg.Node{Node: cluster.Node{Name: "pp-node-01", Role: "instance", Labels: map[string]string{"env": "test"}}}
assert.NoError(t, r.Put(n))
// nodes ls (JSON)

View File

@@ -42,7 +42,7 @@ func TestMain(m *testing.M) {
// Run unit tests.
code := m.Run()
// Purge local SQLite test artifacts created during this package's tests.
// Remove temporary SQLite files after running the tests.
fs.PurgeTestDbFiles(".", false)
os.Exit(code)

View File

@@ -210,6 +210,43 @@ func (c *Config) NodeClientSecret() string {
}
}
// JWKSUrl returns the configured JWKS endpoint for portal-issued JWTs. Nodes normally
// persist this URL from the portal's register response, which derives it from SiteUrl;
// manual overrides are only required for custom deployments.
func (c *Config) JWKSUrl() string {
return strings.TrimSpace(c.options.JWKSUrl)
}
// SetJWKSUrl updates the configured JWKS endpoint for portal-issued JWTs.
func (c *Config) SetJWKSUrl(url string) {
if c == nil || c.options == nil {
return
}
c.options.JWKSUrl = strings.TrimSpace(url)
}
// JWKSCacheTTL returns the JWKS cache lifetime in seconds (default 300, max 3600).
func (c *Config) JWKSCacheTTL() int {
if c.options.JWKSCacheTTL <= 0 {
return 300
}
if c.options.JWKSCacheTTL > 3600 {
return 3600
}
return c.options.JWKSCacheTTL
}
// JWTLeeway returns the permitted clock skew in seconds (default 60, max 300).
func (c *Config) JWTLeeway() int {
if c.options.JWTLeeway <= 0 {
return 60
}
if c.options.JWTLeeway > 300 {
return 300
}
return c.options.JWTLeeway
}
// AdvertiseUrl returns the advertised node URL for intra-cluster calls (scheme://host[:port]).
func (c *Config) AdvertiseUrl() string {
if c.options.AdvertiseUrl != "" {

View File

@@ -72,6 +72,17 @@ func TestConfig_Cluster(t *testing.T) {
assert.True(t, c.IsPortal())
c.Options().NodeRole = ""
})
t.Run("JWKSUrlSetter", func(t *testing.T) {
c := NewConfig(CliTestContext())
c.options.JWKSUrl = ""
assert.Equal(t, "", c.JWKSUrl())
c.SetJWKSUrl(" https://portal.example/.well-known/jwks.json ")
assert.Equal(t, "https://portal.example/.well-known/jwks.json", c.JWKSUrl())
c.SetJWKSUrl("")
assert.Equal(t, "", c.JWKSUrl())
})
t.Run("Paths", func(t *testing.T) {
c := NewConfig(CliTestContext())

View File

@@ -31,7 +31,7 @@ func TestMain(m *testing.M) {
code := m.Run()
// Purge local SQLite test artifacts created during this package's tests.
// Remove temporary SQLite files after running the tests.
fs.PurgeTestDbFiles(".", false)
os.Exit(code)

View File

@@ -720,6 +720,23 @@ var Flags = CliFlags{
EnvVars: EnvVars("NODE_CLIENT_SECRET"),
Hidden: true,
}}, {
Flag: &cli.StringFlag{
Name: "jwks-url",
Usage: "JWKS endpoint `URL` provided by the cluster portal for JWT verification",
EnvVars: EnvVars("JWKS_URL"),
}}, {
Flag: &cli.IntFlag{
Name: "jwks-cache-ttl",
Usage: "JWKS cache lifetime in `SECONDS` (default 300, max 3600)",
Value: 300,
EnvVars: EnvVars("JWKS_CACHE_TTL"),
}}, {
Flag: &cli.IntFlag{
Name: "jwt-leeway",
Usage: "JWT clock skew allowance in `SECONDS` (default 60, max 300)",
Value: 60,
EnvVars: EnvVars("JWT_LEEWAY"),
}}, {
Flag: &cli.StringFlag{
Name: "advertise-url",
Usage: "advertised `URL` for intra-cluster calls (scheme://host[:port])",

View File

@@ -152,6 +152,9 @@ type Options struct {
NodeRole string `yaml:"-" json:"-" flag:"node-role"`
NodeClientID string `yaml:"NodeClientID" json:"-" flag:"node-client-id"`
NodeClientSecret string `yaml:"NodeClientSecret" json:"-" flag:"node-client-secret"`
JWKSUrl string `yaml:"JWKSUrl" json:"-" flag:"jwks-url"`
JWKSCacheTTL int `yaml:"JWKSCacheTTL" json:"-" flag:"jwks-cache-ttl"`
JWTLeeway int `yaml:"JWTLeeway" json:"-" flag:"jwt-leeway"`
AdvertiseUrl string `yaml:"AdvertiseUrl" json:"-" flag:"advertise-url"`
HttpsProxy string `yaml:"HttpsProxy" json:"HttpsProxy" flag:"https-proxy"`
HttpsProxyInsecure bool `yaml:"HttpsProxyInsecure" json:"HttpsProxyInsecure" flag:"https-proxy-insecure"`

View File

@@ -188,6 +188,9 @@ func (c *Config) Report() (rows [][]string, cols []string) {
{"node-uuid", c.NodeUUID()},
{"node-client-id", c.NodeClientID()},
{"node-client-secret", fmt.Sprintf("%s", strings.Repeat("*", utf8.RuneCountInString(c.NodeClientSecret())))},
{"jwks-url", c.JWKSUrl()},
{"jwks-cache-ttl", fmt.Sprintf("%d", c.JWKSCacheTTL())},
{"jwt-leeway", fmt.Sprintf("%d", c.JWTLeeway())},
{"advertise-url", c.AdvertiseUrl()},
// Proxy Servers.

View File

@@ -24,7 +24,7 @@ func TestMain(m *testing.M) {
code := m.Run()
// Purge local SQLite test artifacts created during this package's tests.
// Remove temporary SQLite files after running the tests.
fs.PurgeTestDbFiles(".", false)
os.Exit(code)

View File

@@ -8,6 +8,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/pkg/fs"
)
func TestMain(m *testing.M) {
@@ -22,6 +23,9 @@ func TestMain(m *testing.M) {
code := m.Run()
// Remove temporary SQLite files after running the tests.
fs.PurgeTestDbFiles(".", false)
os.Exit(code)
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/sirupsen/logrus"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/pkg/fs"
)
func TestMain(m *testing.M) {
@@ -21,5 +22,8 @@ func TestMain(m *testing.M) {
code := m.Run()
// Remove temporary SQLite files after running the tests.
fs.PurgeTestDbFiles(".", false)
os.Exit(code)
}

View File

@@ -0,0 +1,26 @@
/*
Package apple contains helper routines and constants for wiring up Apple
VideoToolbox (VT) encoder support in ffmpeg command generation.
Copyright (c) 2018 - 2025 PhotoPrism UG. All rights reserved.
This program is free software: you can redistribute it and/or modify
it under Version 3 of the GNU Affero General Public License (the "AGPL"):
<https://docs.photoprism.app/license/agpl>
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
The AGPL is supplemented by our Trademark and Brand Guidelines,
which describe how our Brand Assets may be used:
<https://www.photoprism.app/trademark>
Feel free to send an email to hello@photoprism.app if you have questions,
want to support our work, or just want to say hello.
Additional information can be found in our Developer Guide:
<https://docs.photoprism.app/developer-guide/>
*/
package apple

View File

@@ -0,0 +1,26 @@
/*
Package intel collects ffmpeg helpers for configuring Intel Quick Sync Video
(QSV) encoders when building PhotoPrism transcoding pipelines.
Copyright (c) 2018 - 2025 PhotoPrism UG. All rights reserved.
This program is free software: you can redistribute it and/or modify
it under Version 3 of the GNU Affero General Public License (the "AGPL"):
<https://docs.photoprism.app/license/agpl>
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
The AGPL is supplemented by our Trademark and Brand Guidelines,
which describe how our Brand Assets may be used:
<https://www.photoprism.app/trademark>
Feel free to send an email to hello@photoprism.app if you have questions,
want to support our work, or just want to say hello.
Additional information can be found in our Developer Guide:
<https://docs.photoprism.app/developer-guide/>
*/
package intel

View File

@@ -0,0 +1,26 @@
/*
Package nvidia provides ffmpeg helpers for NVENC/NVDEC integration so
PhotoPrism can leverage NVIDIA GPUs for hardware-accelerated workflows.
Copyright (c) 2018 - 2025 PhotoPrism UG. All rights reserved.
This program is free software: you can redistribute it and/or modify
it under Version 3 of the GNU Affero General Public License (the "AGPL"):
<https://docs.photoprism.app/license/agpl>
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
The AGPL is supplemented by our Trademark and Brand Guidelines,
which describe how our Brand Assets may be used:
<https://www.photoprism.app/trademark>
Feel free to send an email to hello@photoprism.app if you have questions,
want to support our work, or just want to say hello.
Additional information can be found in our Developer Guide:
<https://docs.photoprism.app/developer-guide/>
*/
package nvidia

View File

@@ -0,0 +1,26 @@
/*
Package v4l wraps ffmpeg command helpers for Video4Linux (V4L2) devices used
on Linux systems when capturing or transcoding with hardware-backed codecs.
Copyright (c) 2018 - 2025 PhotoPrism UG. All rights reserved.
This program is free software: you can redistribute it and/or modify
it under Version 3 of the GNU Affero General Public License (the "AGPL"):
<https://docs.photoprism.app/license/agpl>
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
The AGPL is supplemented by our Trademark and Brand Guidelines,
which describe how our Brand Assets may be used:
<https://www.photoprism.app/trademark>
Feel free to send an email to hello@photoprism.app if you have questions,
want to support our work, or just want to say hello.
Additional information can be found in our Developer Guide:
<https://docs.photoprism.app/developer-guide/>
*/
package v4l

View File

@@ -0,0 +1,26 @@
/*
Package vaapi contains ffmpeg helpers for configuring VA-API hardware
acceleration on compatible GPUs when building PhotoPrism encode pipelines.
Copyright (c) 2018 - 2025 PhotoPrism UG. All rights reserved.
This program is free software: you can redistribute it and/or modify
it under Version 3 of the GNU Affero General Public License (the "AGPL"):
<https://docs.photoprism.app/license/agpl>
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
The AGPL is supplemented by our Trademark and Brand Guidelines,
which describe how our Brand Assets may be used:
<https://www.photoprism.app/trademark>
Feel free to send an email to hello@photoprism.app if you have questions,
want to support our work, or just want to say hello.
Additional information can be found in our Developer Guide:
<https://docs.photoprism.app/developer-guide/>
*/
package vaapi

View File

@@ -10,6 +10,7 @@ import (
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/pkg/fs"
)
func TestMain(m *testing.M) {
@@ -25,5 +26,8 @@ func TestMain(m *testing.M) {
code := m.Run()
// Remove temporary SQLite files after running the tests.
fs.PurgeTestDbFiles(".", false)
os.Exit(code)
}

View File

@@ -0,0 +1,58 @@
package get
import (
"sync"
"github.com/photoprism/photoprism/internal/auth/jwt"
"github.com/photoprism/photoprism/pkg/clean"
)
var (
onceJWTManager sync.Once
onceJWTIssuer sync.Once
)
func initJWTManager() {
conf := Config()
if conf == nil || !conf.IsPortal() {
return
}
manager, err := jwt.NewManager(conf)
if err != nil {
log.Warnf("jwt: manager init failed (%s)", clean.Error(err))
return
}
if _, err := manager.EnsureActiveKey(); err != nil {
log.Warnf("jwt: ensure signing key failed (%s)", clean.Error(err))
}
services.JWTManager = manager
}
// JWTManager returns the portal key manager; nil on nodes.
func JWTManager() *jwt.Manager {
onceJWTManager.Do(initJWTManager)
return services.JWTManager
}
func initJWTIssuer() {
manager := JWTManager()
if manager == nil {
return
}
services.JWTIssuer = jwt.NewIssuer(manager)
}
// JWTIssuer returns the portal JWT issuer helper; nil on nodes.
func JWTIssuer() *jwt.Issuer {
onceJWTIssuer.Do(initJWTIssuer)
return services.JWTIssuer
}
// JWTVerifier returns a verifier bound to the current config.
func JWTVerifier() *jwt.Verifier {
conf := Config()
if conf == nil {
return nil
}
return jwt.NewVerifier(conf)
}

View File

@@ -27,6 +27,7 @@ package get
import (
gc "github.com/patrickmn/go-cache"
clusterjwt "github.com/photoprism/photoprism/internal/auth/jwt"
"github.com/photoprism/photoprism/internal/auth/oidc"
"github.com/photoprism/photoprism/internal/auth/session"
"github.com/photoprism/photoprism/internal/config"
@@ -54,6 +55,8 @@ var services struct {
Thumbs *photoprism.Thumbs
Session *session.Session
OIDC *oidc.Client
JWTManager *clusterjwt.Manager
JWTIssuer *clusterjwt.Issuer
}
func SetConfig(c *config.Config) {

View File

@@ -20,7 +20,7 @@ func TestMain(m *testing.M) {
code := m.Run()
// Purge local SQLite test artifacts created during this package's tests.
// Remove temporary SQLite files after running the tests.
fs.PurgeTestDbFiles(".", false)
os.Exit(code)

View File

@@ -1,12 +1,17 @@
package server
import (
"crypto/sha256"
"encoding/json"
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/internal/server/wellknown"
"github.com/photoprism/photoprism/pkg/service/http/header"
)
// registerWellknownRoutes adds "/.well-known/" service discovery routes.
@@ -20,4 +25,32 @@ func registerWellknownRoutes(router *gin.Engine, conf *config.Config) {
router.Any(conf.BaseUri("/.well-known/openid-configuration"), func(c *gin.Context) {
c.JSON(http.StatusOK, wellknown.NewOpenIDConfiguration(conf))
})
// Registers the "/.well-known/jwks.json" endpoint for cluster JWT verification.
router.GET(conf.BaseUri("/.well-known/jwks.json"), func(c *gin.Context) {
if !conf.IsPortal() {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
manager := get.JWTManager()
if manager == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "jwks unavailable"})
return
}
jwks := manager.JWKS()
payload, err := json.Marshal(jwks)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "jwks marshal failed"})
return
}
sum := sha256.Sum256(payload)
etag := fmt.Sprintf("\"%x\"", sum[:8])
ttl := conf.JWKSCacheTTL()
if ttl <= 0 {
ttl = 300
}
c.Header(header.CacheControl, fmt.Sprintf("max-age=%d, public", ttl))
c.Header(header.ETag, etag)
c.Data(http.StatusOK, header.ContentTypeJson, payload)
})
}

View File

@@ -10,6 +10,7 @@ import (
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/internal/server/limiter"
"github.com/photoprism/photoprism/pkg/fs"
)
func TestMain(m *testing.M) {
@@ -29,5 +30,8 @@ func TestMain(m *testing.M) {
// Run unit tests.
code := m.Run()
// Remove temporary SQLite files after running the tests.
fs.PurgeTestDbFiles(".", false)
os.Exit(code)
}

View File

@@ -2,6 +2,7 @@ package wellknown
import (
"fmt"
"strings"
"github.com/photoprism/photoprism/internal/auth/acl"
"github.com/photoprism/photoprism/internal/config"
@@ -37,13 +38,19 @@ type OpenIDConfiguration struct {
// NewOpenIDConfiguration creates a service discovery endpoint response based on the config provided.
func NewOpenIDConfiguration(conf *config.Config) *OpenIDConfiguration {
jwksPath := conf.BaseUri("/.well-known/jwks.json")
if jwksPath == "" {
jwksPath = "/.well-known/jwks.json"
}
jwksURL := strings.TrimRight(conf.SiteUrl(), "/") + jwksPath
return &OpenIDConfiguration{
Issuer: conf.SiteUrl(),
AuthorizationEndpoint: fmt.Sprintf("%sapi/v1/oauth/authorize", conf.SiteUrl()),
TokenEndpoint: fmt.Sprintf("%sapi/v1/oauth/token", conf.SiteUrl()),
UserinfoEndpoint: fmt.Sprintf("%sapi/v1/oauth/userinfo", conf.SiteUrl()),
RegistrationEndpoint: "",
JwksUri: "",
JwksUri: jwksURL,
ResponseTypesSupported: OAuthResponseTypes,
ResponseModesSupported: []string{},
GrantTypesSupported: OAuthGrantTypes,

View File

@@ -16,6 +16,7 @@ func TestOpenIDConfiguration(t *testing.T) {
assert.IsType(t, &OpenIDConfiguration{}, result)
assert.Equal(t, "http://localhost:2342/api/v1/oauth/token", result.TokenEndpoint)
assert.Equal(t, "http://localhost:2342/api/v1/oauth/revoke", result.RevocationEndpoint)
assert.Equal(t, "http://localhost:2342/.well-known/jwks.json", result.JwksUri)
assert.Equal(t, OAuthResponseTypes, result.ResponseTypesSupported)
assert.Equal(t, OAuthRevocationEndpointAuthMethods, result.RevocationEndpointAuthMethodsSupported)
})

View File

@@ -0,0 +1,18 @@
package wellknown
import (
"os"
"testing"
"github.com/photoprism/photoprism/pkg/fs"
)
func TestMain(m *testing.M) {
// Run unit tests.
code := m.Run()
// Remove temporary SQLite files after running the tests.
fs.PurgeTestDbFiles(".", false)
os.Exit(code)
}

View File

@@ -1,6 +1,7 @@
package instance
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
@@ -17,6 +18,7 @@ import (
"gopkg.in/yaml.v2"
clusterjwt "github.com/photoprism/photoprism/internal/auth/jwt"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/service/cluster"
@@ -117,27 +119,27 @@ func registerWithPortal(c *config.Config, portal *url.URL, token string) error {
opts.DatabaseDSN == "" && opts.DatabaseName == "" && opts.DatabaseUser == "" && opts.DatabasePassword == "" &&
c.DatabasePassword() == ""
payload := map[string]interface{}{
"nodeName": c.NodeName(),
"nodeUUID": c.NodeUUID(),
"nodeRole": cluster.RoleInstance, // JSON wire format is string
"advertiseUrl": c.AdvertiseUrl(),
payload := cluster.RegisterRequest{
NodeName: c.NodeName(),
NodeUUID: c.NodeUUID(),
NodeRole: cluster.RoleInstance,
AdvertiseUrl: c.AdvertiseUrl(),
}
// Include client credentials when present so the Portal can verify re-registration
// and authorize UUID/name changes.
if id, secret := strings.TrimSpace(c.NodeClientID()), strings.TrimSpace(c.NodeClientSecret()); id != "" && secret != "" {
payload["clientId"] = id
payload["clientSecret"] = secret
payload.ClientID = id
payload.ClientSecret = secret
}
// Include siteUrl when it differs from advertiseUrl; server will validate/normalize.
if su := c.SiteUrl(); su != "" && su != c.AdvertiseUrl() {
payload["siteUrl"] = su
payload.SiteUrl = su
}
if wantRotateDatabase {
// Align with API: request database rotation/creation on (re)register.
payload["rotateDatabase"] = true
payload.RotateDatabase = true
}
bodyBytes, _ := json.Marshal(payload)
@@ -171,6 +173,7 @@ func registerWithPortal(c *config.Config, portal *url.URL, token string) error {
if err := persistRegistration(c, &r, wantRotateDatabase); err != nil {
return err
}
primeJWKS(c, r.JWKSUrl)
if resp.StatusCode == http.StatusCreated {
log.Infof("cluster: registered as %s (%d)", clean.LogQuote(r.Node.Name), resp.StatusCode)
} else {
@@ -226,8 +229,13 @@ func persistRegistration(c *config.Config, r *cluster.RegisterResponse, wantRota
updates["NodeClientSecret"] = r.Secrets.ClientSecret
}
if url := strings.TrimSpace(r.JWKSUrl); url != "" {
updates["JWKSUrl"] = url
c.SetJWKSUrl(url)
}
// Persist NodeUUID from portal response if provided and not set locally.
if r.Node.UUID != "" && c.Options().NodeUUID == "" {
if r.Node.UUID != "" && c.NodeUUID() == "" {
updates["NodeUUID"] = r.Node.UUID
}
@@ -267,6 +275,22 @@ func persistRegistration(c *config.Config, r *cluster.RegisterResponse, wantRota
return nil
}
func primeJWKS(c *config.Config, url string) {
if c == nil {
return
}
url = strings.TrimSpace(url)
if url == "" {
return
}
verifier := clusterjwt.NewVerifier(c)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := verifier.Prime(ctx, url); err != nil {
log.Debugf("cluster: jwks prime skipped (%s)", clean.Error(err))
}
}
func hasDBUpdate(m map[string]interface{}) bool {
if _, ok := m["DatabaseDSN"]; ok {
return true

View File

@@ -29,6 +29,7 @@ func TestInitConfig_NoPortal_NoOp(t *testing.T) {
func TestRegister_PersistSecretAndDB(t *testing.T) {
t.Setenv("PHOTOPRISM_STORAGE_PATH", t.TempDir())
// Fake Portal server.
var jwksURL string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/cluster/nodes/register":
@@ -39,6 +40,7 @@ func TestRegister_PersistSecretAndDB(t *testing.T) {
Node: cluster.Node{Name: "pp-node-01"},
UUID: rnd.UUID(),
Secrets: &cluster.RegisterSecrets{ClientSecret: "SECRET"},
JWKSUrl: jwksURL,
Database: cluster.RegisterDatabase{
Driver: config.MySQL,
Host: "db.local",
@@ -57,6 +59,7 @@ func TestRegister_PersistSecretAndDB(t *testing.T) {
http.NotFound(w, r)
}
}))
jwksURL = srv.URL + "/.well-known/jwks.json"
defer srv.Close()
c := config.NewTestConfig("bootstrap-reg")
@@ -78,6 +81,7 @@ func TestRegister_PersistSecretAndDB(t *testing.T) {
// DSN branch should be preferred and persisted.
assert.Contains(t, c.Options().DatabaseDSN, "@tcp(db.local:3306)/pp_db")
assert.Equal(t, config.MySQL, c.Options().DatabaseDriver)
assert.Equal(t, srv.URL+"/.well-known/jwks.json", c.JWKSUrl())
}
func TestThemeInstall_Missing(t *testing.T) {
@@ -90,12 +94,13 @@ func TestThemeInstall_Missing(t *testing.T) {
_ = zw.Close()
// Fake Portal server (register -> oauth token -> theme)
var jwksURL2 string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/cluster/nodes/register":
w.Header().Set("Content-Type", "application/json")
// Return NodeClientID + NodeClientSecret so bootstrap can request OAuth token
_ = json.NewEncoder(w).Encode(cluster.RegisterResponse{UUID: rnd.UUID(), Node: cluster.Node{ClientID: "cs5gfen1bgxz7s9i", Name: "pp-node-01"}, Secrets: &cluster.RegisterSecrets{ClientSecret: "s3cr3t"}})
_ = json.NewEncoder(w).Encode(cluster.RegisterResponse{UUID: rnd.UUID(), Node: cluster.Node{ClientID: "cs5gfen1bgxz7s9i", Name: "pp-node-01"}, Secrets: &cluster.RegisterSecrets{ClientSecret: "s3cr3t"}, JWKSUrl: jwksURL2})
case "/api/v1/oauth/token":
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"access_token": "tok", "token_type": "Bearer"})
@@ -107,6 +112,7 @@ func TestThemeInstall_Missing(t *testing.T) {
http.NotFound(w, r)
}
}))
jwksURL2 = srv.URL + "/.well-known/jwks.json"
defer srv.Close()
c := config.NewTestConfig("bootstrap-theme")
@@ -133,6 +139,7 @@ func TestThemeInstall_Missing(t *testing.T) {
func TestRegister_SQLite_NoDBPersist(t *testing.T) {
t.Setenv("PHOTOPRISM_STORAGE_PATH", t.TempDir())
// Portal responds with DB DSN, but local driver is SQLite → must not persist DB.
var jwksURL3 string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/cluster/nodes/register":
@@ -141,6 +148,7 @@ func TestRegister_SQLite_NoDBPersist(t *testing.T) {
resp := cluster.RegisterResponse{
Node: cluster.Node{Name: "pp-node-01"},
Secrets: &cluster.RegisterSecrets{ClientSecret: "SECRET"},
JWKSUrl: jwksURL3,
Database: cluster.RegisterDatabase{Host: "db.local", Port: 3306, Name: "pp_db", User: "pp_user", Password: "pp_pw", DSN: "pp_user:pp_pw@tcp(db.local:3306)/pp_db?charset=utf8mb4&parseTime=true"},
}
_ = json.NewEncoder(w).Encode(resp)
@@ -148,6 +156,7 @@ func TestRegister_SQLite_NoDBPersist(t *testing.T) {
http.NotFound(w, r)
}
}))
jwksURL3 = srv.URL + "/.well-known/jwks.json"
defer srv.Close()
c := config.NewTestConfig("bootstrap-sqlite")
@@ -165,6 +174,7 @@ func TestRegister_SQLite_NoDBPersist(t *testing.T) {
assert.Equal(t, "SECRET", c.NodeClientSecret())
assert.Equal(t, config.SQLite3, c.DatabaseDriver())
assert.Equal(t, origDSN, c.Options().DatabaseDSN)
assert.Equal(t, srv.URL+"/.well-known/jwks.json", c.JWKSUrl())
}
func TestRegister_404_NoRetry(t *testing.T) {

View File

@@ -9,7 +9,11 @@ import (
// TestMain ensures SQLite test DB artifacts are purged after the suite runs.
func TestMain(m *testing.M) {
// Run unit tests.
code := m.Run()
// Remove temporary SQLite files after running the tests.
fs.PurgeTestDbFiles(".", false)
os.Exit(code)
}

View File

@@ -29,21 +29,22 @@ type Credentials struct {
func GetCredentials(ctx context.Context, conf *config.Config, nodeUUID, nodeName string, rotate bool) (Credentials, bool, error) {
out := Credentials{}
// Normalize provisioner driver to lower-case to accept variants like "MySQL"/"MariaDB".
DatabaseDriver = strings.ToLower(DatabaseDriver)
// Normalize the configured admin driver locally so we accept variants like "MySQL"/"MariaDB"
// without mutating the global setting (keeps config reporting consistent).
driver := strings.ToLower(DatabaseDriver)
switch DatabaseDriver {
switch driver {
case config.MySQL, config.MariaDB:
// ok
case config.SQLite3, config.Postgres:
return out, false, errors.New("database must be MySQL/MariaDB for auto-provisioning")
default:
// Driver is configured externally for the provisioner (decoupled from app config).
return out, false, fmt.Errorf("unsupported auto-provisioning database driver: %s", DatabaseDriver)
return out, false, fmt.Errorf("unsupported auto-provisioning database driver: %s", driver)
}
// Compute deterministic names and a candidate password.
dbName, dbUser, dbPass := GenerateCreds(conf, nodeUUID, nodeName)
dbName, dbUser, dbPass := GenerateCredentials(conf, nodeUUID, nodeName)
// Extra safety: enforce allowed identifier charset.
if !identRe.MatchString(dbName) || !identRe.MatchString(dbUser) {
@@ -122,10 +123,10 @@ func GetCredentials(ctx context.Context, conf *config.Config, nodeUUID, nodeName
out.Port = DatabasePort
out.Name = dbName
out.User = dbUser
out.Driver = DatabaseDriver
out.Driver = driver
if out.Password != "" {
out.DSN = BuildDSN(DatabaseDriver, out.Host, out.Port, out.User, out.Password, out.Name)
out.DSN = BuildDSN(driver, out.Host, out.Port, out.User, out.Password, out.Name)
}
return out, created, nil

View File

@@ -103,6 +103,7 @@ func TestGetCredentials_DriverNormalization(t *testing.T) {
DatabaseDriver = "PostGreS"
_, _, err := GetCredentials(ctx, c, "11111111-1111-4111-8111-111111111111", "pp-node", false)
assert.Error(t, err)
assert.Equal(t, "PostGreS", DatabaseDriver)
// Unknown driver should return the unsupported error including normalized name.
DatabaseDriver = "TiDB"
@@ -110,4 +111,5 @@ func TestGetCredentials_DriverNormalization(t *testing.T) {
if assert.Error(t, err) {
assert.Contains(t, err.Error(), "unsupported auto-provisioning database driver: tidb")
}
assert.Equal(t, "TiDB", DatabaseDriver)
}

View File

@@ -13,11 +13,17 @@ import (
_ "github.com/go-sql-driver/mysql"
)
// ProvisionDSN specifies database auto-provisioning DSN, for example:
// ProvisionDSN specifies the admin DSN used for auto-provisioning, for example:
// root:insecure@tcp(127.0.0.1:3306)/photoprism?charset=utf8mb4,utf8&collation=utf8mb4_unicode_ci&parseTime=true
var ProvisionDSN = "root:photoprism@tcp(mariadb:4001)/photoprism?charset=utf8mb4,utf8&collation=utf8mb4_unicode_ci&parseTime=true"
// DatabaseHost is the hostname of the admin server used for provisioning operations.
var DatabaseHost = "mariadb"
// DatabasePort is the port of the admin server used for provisioning operations.
var DatabasePort = 4001
// DatabaseDriver indicates the SQL driver used for provisioning (independent from the app DB driver).
var DatabaseDriver = "mysql"
// -----------------------------------------------------------------------------

View File

@@ -24,10 +24,10 @@ const (
dbMax = 64
)
// GenerateCreds computes deterministic database name and user for a node under the given portal
// GenerateCredentials computes deterministic database name and user for a node under the given portal
// plus a random password. Naming is stable for a given (clusterUUID, nodeUUID) pair and changes
// if the cluster UUID or node UUID changes.
func GenerateCreds(conf *config.Config, nodeUUID, nodeName string) (dbName, dbUser, dbPass string) {
func GenerateCredentials(conf *config.Config, nodeUUID, nodeName string) (dbName, dbUser, dbPass string) {
clusterUUID := conf.ClusterUUID()
// Compute base32 (no padding) HMAC suffixes scoped by cluster UUID and node UUID.
@@ -59,6 +59,9 @@ func BuildDSN(driver, host string, port int, user, pass, name string) string {
}
}
// hmacBase32 returns a lowercase Base32 (no padding) encoded HMAC-SHA256 digest
// derived from the provided key and data. It is used to generate deterministic
// suffixes for database identifiers while keeping the resulting string URL/identifier safe.
func hmacBase32(key, data string) string {
mac := hmac.New(sha256.New, []byte(key))
_, _ = mac.Write([]byte(data))

View File

@@ -1,6 +1,7 @@
package provisioner
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -8,13 +9,13 @@ import (
"github.com/photoprism/photoprism/internal/config"
)
func TestGenerateCreds_StabilityAndBudgets(t *testing.T) {
func TestGenerateCredentials_StabilityAndBudgets(t *testing.T) {
c := config.NewConfig(config.CliTestContext())
// Fix the cluster UUID via options to ensure determinism.
c.Options().ClusterUUID = "11111111-1111-4111-8111-111111111111"
db1, user1, pass1 := GenerateCreds(c, "11111111-1111-4111-8111-111111111111", "pp-node-01")
db2, user2, pass2 := GenerateCreds(c, "11111111-1111-4111-8111-111111111111", "pp-node-01")
db1, user1, pass1 := GenerateCredentials(c, "11111111-1111-4111-8111-111111111111", "pp-node-01")
db2, user2, pass2 := GenerateCredentials(c, "11111111-1111-4111-8111-111111111111", "pp-node-01")
// Names stable; password random.
assert.Equal(t, db1, db2)
@@ -28,24 +29,24 @@ func TestGenerateCreds_StabilityAndBudgets(t *testing.T) {
assert.Contains(t, user1, "photoprism_")
}
func TestGenerateCreds_DifferentPortal(t *testing.T) {
func TestGenerateCredentials_DifferentPortal(t *testing.T) {
c1 := config.NewConfig(config.CliTestContext())
c2 := config.NewConfig(config.CliTestContext())
c1.Options().ClusterUUID = "11111111-1111-4111-8111-111111111111"
c2.Options().ClusterUUID = "22222222-2222-4222-8222-222222222222"
db1, user1, _ := GenerateCreds(c1, "11111111-1111-4111-8111-111111111111", "pp-node-01")
db2, user2, _ := GenerateCreds(c2, "11111111-1111-4111-1111-111111111111", "pp-node-01")
db1, user1, _ := GenerateCredentials(c1, "11111111-1111-4111-8111-111111111111", "pp-node-01")
db2, user2, _ := GenerateCredentials(c2, "11111111-1111-4111-1111-111111111111", "pp-node-01")
assert.NotEqual(t, db1, db2)
assert.NotEqual(t, user1, user2)
}
func TestGenerateCreds_Truncation(t *testing.T) {
func TestGenerateCredentials_Truncation(t *testing.T) {
c := config.NewConfig(config.CliTestContext())
c.Options().ClusterUUID = "11111111-1111-4111-8111-111111111111"
longName := "this-is-a-very-very-long-node-name-that-should-be-truncated-to-fit-username-and-db-budgets"
db, user, _ := GenerateCreds(c, "11111111-1111-4111-8111-111111111111", longName)
db, user, _ := GenerateCredentials(c, "11111111-1111-4111-8111-111111111111", longName)
assert.LessOrEqual(t, len(user), 32)
assert.LessOrEqual(t, len(db), 64)
@@ -58,7 +59,21 @@ func TestBuildDSN(t *testing.T) {
assert.Contains(t, dsn, "parseTime=true")
}
func TestEnsureNodeDatabase_SqliteRejected(t *testing.T) {
func TestHmacBase32_LowercaseDeterministic(t *testing.T) {
a := hmacBase32("k1", "data")
b := hmacBase32("k1", "data")
c := hmacBase32("k1", "other")
assert.Equal(t, a, b, "same key/data should produce identical digest")
assert.NotEqual(t, a, c, "different data should change the digest")
assert.NotZero(t, len(a))
assert.Equal(t, strings.ToLower(a), a, "digest must be lowercase")
for _, ch := range a {
assert.Contains(t, "abcdefghijklmnopqrstuvwxyz234567", string(ch))
}
}
func TestGetCredentials_SqliteRejected(t *testing.T) {
c := config.NewConfig(config.CliTestContext())
// Ensure we're on SQLite in tests.
if c.DatabaseDriver() != config.SQLite3 {

View File

@@ -26,7 +26,8 @@ func toNode(c *entity.Client) *Node {
if c == nil {
return nil
}
n := &Node{
n := &Node{}
n.Node = cluster.Node{
UUID: c.NodeUUID,
Name: c.ClientName,
Role: c.ClientRole,
@@ -43,10 +44,11 @@ func toNode(c *entity.Client) *Node {
}
n.SiteUrl = data.SiteURL
if db := data.Database; db != nil {
n.Database.Name = db.Name
n.Database.User = db.User
n.Database.Driver = db.Driver
n.Database.RotatedAt = db.RotatedAt
dest := n.ensureDatabase()
dest.Name = db.Name
dest.User = db.User
dest.Driver = db.Driver
dest.RotatedAt = db.RotatedAt
}
n.RotatedAt = data.RotatedAt
}
@@ -126,14 +128,14 @@ func (r *ClientRegistry) Put(n *Node) error {
m.NodeUUID = n.UUID
}
data.RotatedAt = n.RotatedAt
if n.Database.Name != "" || n.Database.User != "" || n.Database.RotatedAt != "" {
if db := n.Database; db != nil && (db.Name != "" || db.User != "" || db.RotatedAt != "") {
if data.Database == nil {
data.Database = &entity.ClientDatabase{}
}
data.Database.Name = n.Database.Name
data.Database.User = n.Database.User
data.Database.Driver = n.Database.Driver
data.Database.RotatedAt = n.Database.RotatedAt
data.Database.Name = db.Name
data.Database.User = db.User
data.Database.Driver = db.Driver
data.Database.RotatedAt = db.RotatedAt
}
m.SetData(data)
@@ -165,9 +167,10 @@ func (r *ClientRegistry) Put(n *Node) error {
}
n.SiteUrl = data.SiteURL
if db := data.Database; db != nil {
n.Database.Name = db.Name
n.Database.User = db.User
n.Database.RotatedAt = db.RotatedAt
dest := n.ensureDatabase()
dest.Name = db.Name
dest.User = db.User
dest.RotatedAt = db.RotatedAt
}
n.RotatedAt = data.RotatedAt
}

View File

@@ -8,6 +8,7 @@ import (
cfg "github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/service/cluster"
"github.com/photoprism/photoprism/pkg/rnd"
)
@@ -44,7 +45,7 @@ func TestClientRegistry_RoleChange(t *testing.T) {
assert.NoError(t, c.Init())
r, _ := NewClientRegistryWithConfig(c)
n := &Node{Name: "pp-role", Role: "service"}
n := &Node{Node: cluster.Node{Name: "pp-role", Role: "service"}}
assert.NoError(t, r.Put(n))
got, err := r.FindByName("pp-role")
assert.NoError(t, err)
@@ -52,7 +53,7 @@ func TestClientRegistry_RoleChange(t *testing.T) {
assert.Equal(t, "service", got.Role)
}
// Change to instance
upd := &Node{ClientID: got.ClientID, Name: got.Name, Role: "instance"}
upd := &Node{Node: cluster.Node{ClientID: got.ClientID, Name: got.Name, Role: "instance"}}
assert.NoError(t, r.Put(upd))
got2, err := r.FindByName("pp-role")
assert.NoError(t, err)

View File

@@ -8,6 +8,7 @@ import (
cfg "github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/service/cluster"
"github.com/photoprism/photoprism/pkg/rnd"
)
@@ -23,16 +24,19 @@ func TestClientRegistry_PutFindListRotate(t *testing.T) {
// Create new node
n := &Node{
UUID: rnd.UUIDv7(),
Name: "pp-node-a",
Role: "instance",
SiteUrl: "https://photos.example.com",
AdvertiseUrl: "http://pp-node-a:2342",
Labels: map[string]string{"env": "test"},
Node: cluster.Node{
UUID: rnd.UUIDv7(),
Name: "pp-node-a",
Role: "instance",
SiteUrl: "https://photos.example.com",
AdvertiseUrl: "http://pp-node-a:2342",
Labels: map[string]string{"env": "test"},
},
}
n.Database.Name = "pp_db"
n.Database.User = "pp_user"
n.Database.RotatedAt = time.Now().UTC().Format(time.RFC3339)
db := n.ensureDatabase()
db.Name = "pp_db"
db.User = "pp_user"
db.RotatedAt = time.Now().UTC().Format(time.RFC3339)
n.RotatedAt = time.Now().UTC().Format(time.RFC3339)
n.ClientSecret = rnd.ClientSecret()
@@ -49,8 +53,10 @@ func TestClientRegistry_PutFindListRotate(t *testing.T) {
assert.Equal(t, "instance", got.Role)
assert.Equal(t, "http://pp-node-a:2342", got.AdvertiseUrl)
assert.Equal(t, "https://photos.example.com", got.SiteUrl)
assert.Equal(t, "pp_db", got.Database.Name)
assert.Equal(t, "pp_user", got.Database.User)
if assert.NotNil(t, got.Database) {
assert.Equal(t, "pp_db", got.Database.Name)
assert.Equal(t, "pp_user", got.Database.User)
}
assert.NotEmpty(t, got.CreatedAt)
assert.NotEmpty(t, got.UpdatedAt)
// Secret is not persisted in plaintext
@@ -88,7 +94,7 @@ func TestClientRegistry_PutFindListRotate(t *testing.T) {
}
// Update labels and site URL via Put (upsert by id)
upd := &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"}}
assert.NoError(t, r.Put(upd))
got2, err := r.FindByName("pp-node-a")
assert.NoError(t, err)

View File

@@ -1,23 +1,20 @@
package registry
import "github.com/photoprism/photoprism/internal/service/cluster"
// Node represents a registered cluster node (transport DTO inside registry package).
// It is used by both client-backed and (legacy) file-backed registries.
// It embeds the public cluster.Node DTO so we have a single source of truth for fields.
// Additional internal-only metadata is stored alongside the embedded struct.
type Node struct {
UUID string `json:"uuid"` // primary identifier (UUID v7)
Name string `json:"name"`
Role string `json:"role"`
ClientID string `json:"clientId,omitempty"` // OAuth client identifier (legacy)
ClientSecret string `json:"-"` // plaintext only when newly created/rotated in-memory
SiteUrl string `json:"siteUrl,omitempty"`
AdvertiseUrl string `json:"advertiseUrl,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
RotatedAt string `json:"rotatedAt,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Database struct {
Name string `json:"name"`
User string `json:"user"`
Driver string `json:"driver,omitempty"`
RotatedAt string `json:"rotatedAt,omitempty"`
} `json:"database,omitempty"`
cluster.Node
ClientSecret string `json:"-"` // plaintext only when newly created/rotated in-memory
RotatedAt string `json:"rotatedAt,omitempty"` // secret rotation timestamp
}
// ensureDatabase returns a writable NodeDatabase, creating one if missing.
func (n *Node) ensureDatabase() *cluster.NodeDatabase {
if n.Node.Database == nil {
n.Node.Database = &cluster.NodeDatabase{}
}
return n.Node.Database
}

View File

@@ -7,6 +7,7 @@ import (
cfg "github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/service/cluster"
"github.com/photoprism/photoprism/pkg/rnd"
)
@@ -20,13 +21,13 @@ func TestClientRegistry_ClientIDReuse_CannotHijackExistingUUID(t *testing.T) {
r, _ := NewClientRegistryWithConfig(c)
// Seed two independent nodes
a := &Node{UUID: rnd.UUIDv7(), Name: "pp-a", Role: "instance"}
b := &Node{UUID: rnd.UUIDv7(), Name: "pp-b", Role: "service"}
a := &Node{Node: cluster.Node{UUID: rnd.UUIDv7(), Name: "pp-a", Role: "instance"}}
b := &Node{Node: cluster.Node{UUID: rnd.UUIDv7(), Name: "pp-b", Role: "service"}}
assert.NoError(t, r.Put(a))
assert.NoError(t, r.Put(b))
// Attempt to update UUID=b while passing ClientID of a
assert.NoError(t, r.Put(&Node{UUID: b.UUID, ClientID: a.ClientID, Role: "service"}))
assert.NoError(t, r.Put(&Node{Node: cluster.Node{UUID: b.UUID, ClientID: a.ClientID, Role: "service"}}))
// a stays attached to its original UUID and ClientID
gotA, err := r.FindByNodeUUID(a.UUID)
@@ -56,12 +57,12 @@ func TestClientRegistry_ClientIDReuse_ChangesUUIDWhenTargetMissing(t *testing.T)
r, _ := NewClientRegistryWithConfig(c)
// Seed one node
a := &Node{UUID: rnd.UUIDv7(), Name: "pp-x", Role: "instance"}
a := &Node{Node: cluster.Node{UUID: rnd.UUIDv7(), Name: "pp-x", Role: "instance"}}
assert.NoError(t, r.Put(a))
// Move the row to a new UUID by referencing the same ClientID and a new UUID
newUUID := rnd.UUIDv7()
assert.NoError(t, r.Put(&Node{UUID: newUUID, ClientID: a.ClientID}))
assert.NoError(t, r.Put(&Node{Node: cluster.Node{UUID: newUUID, ClientID: a.ClientID}}))
// Old UUID no longer resolves
_, err := r.FindByNodeUUID(a.UUID)

View File

@@ -8,6 +8,7 @@ import (
cfg "github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/service/cluster"
"github.com/photoprism/photoprism/pkg/rnd"
)
@@ -18,7 +19,7 @@ func TestClientRegistry_FindByClientID(t *testing.T) {
assert.NoError(t, c.Init())
r, _ := NewClientRegistryWithConfig(c)
n := &Node{Name: "pp-find-client", Role: "instance", UUID: rnd.UUIDv7()}
n := &Node{Node: cluster.Node{Name: "pp-find-client", Role: "instance", UUID: rnd.UUIDv7()}}
assert.NoError(t, r.Put(n))
got, err := r.FindByClientID(n.ClientID)
@@ -75,15 +76,15 @@ func TestClientRegistry_SwapNames_UUIDAuthoritative(t *testing.T) {
assert.NoError(t, c.Init())
r, _ := NewClientRegistryWithConfig(c)
a := &Node{UUID: rnd.UUIDv7(), Name: "pp-a", Role: "instance"}
b := &Node{UUID: rnd.UUIDv7(), Name: "pp-b", Role: "service"}
a := &Node{Node: cluster.Node{UUID: rnd.UUIDv7(), Name: "pp-a", Role: "instance"}}
b := &Node{Node: cluster.Node{UUID: rnd.UUIDv7(), Name: "pp-b", Role: "service"}}
assert.NoError(t, r.Put(a))
assert.NoError(t, r.Put(b))
// Swap names via UUID-targeted updates
assert.NoError(t, r.Put(&Node{UUID: a.UUID, Name: "pp-b"}))
assert.NoError(t, r.Put(&Node{Node: cluster.Node{UUID: a.UUID, Name: "pp-b"}}))
time.Sleep(1100 * time.Millisecond)
assert.NoError(t, r.Put(&Node{UUID: b.UUID, Name: "pp-a"}))
assert.NoError(t, r.Put(&Node{Node: cluster.Node{UUID: b.UUID, Name: "pp-a"}}))
// UUID lookups map to the correct updated names
gotA, err := r.FindByNodeUUID(a.UUID)
@@ -121,11 +122,12 @@ func TestClientRegistry_DBDriverAndFields(t *testing.T) {
assert.NoError(t, c.Init())
r, _ := NewClientRegistryWithConfig(c)
n := &Node{UUID: rnd.UUIDv7(), Name: "pp-db", Role: "instance"}
n.Database.Name = "photoprism_d123"
n.Database.User = "photoprism_u123"
n.Database.Driver = "mysql"
n.Database.RotatedAt = time.Now().UTC().Format(time.RFC3339)
n := &Node{Node: cluster.Node{UUID: rnd.UUIDv7(), Name: "pp-db", Role: "instance"}}
db := n.ensureDatabase()
db.Name = "photoprism_d123"
db.User = "photoprism_u123"
db.Driver = "mysql"
db.RotatedAt = time.Now().UTC().Format(time.RFC3339)
assert.NoError(t, r.Put(n))
got, err := r.FindByNodeUUID(n.UUID)

View File

@@ -8,6 +8,7 @@ import (
cfg "github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/service/cluster"
"github.com/photoprism/photoprism/pkg/rnd"
)
@@ -21,10 +22,10 @@ func TestClientRegistry_RotateSecretByUUID_LatestRow(t *testing.T) {
uuid := rnd.UUIDv7()
// Create two entries for same NodeUUID; c2 will be latest
n1 := &Node{UUID: uuid, Name: "pp-rot-a", Role: "instance"}
n1 := &Node{Node: cluster.Node{UUID: uuid, Name: "pp-rot-a", Role: "instance"}}
assert.NoError(t, r.Put(n1))
time.Sleep(1100 * time.Millisecond)
n2 := &Node{UUID: uuid, Name: "pp-rot-b", Role: "instance"}
n2 := &Node{Node: cluster.Node{UUID: uuid, Name: "pp-rot-b", Role: "instance"}}
assert.NoError(t, r.Put(n2))
// Rotate by UUID

View File

@@ -9,14 +9,19 @@ import (
cfg "github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/service/cluster"
"github.com/photoprism/photoprism/pkg/fs"
"github.com/photoprism/photoprism/pkg/rnd"
)
// TestMain ensures SQLite test DB artifacts are purged after the suite runs.
func TestMain(m *testing.M) {
// Run unit tests.
code := m.Run()
// Remove temporary SQLite files after running the tests.
fs.PurgeTestDbFiles(".", false)
os.Exit(code)
}
@@ -33,7 +38,7 @@ func TestClientRegistry_GetAndDelete(t *testing.T) {
}
// Create node
n := &Node{Name: "pp-del", Role: "instance", UUID: rnd.UUIDv7()}
n := &Node{Node: cluster.Node{Name: "pp-del", Role: "instance", UUID: rnd.UUIDv7()}}
assert.NoError(t, r.Put(n))
assert.NotEmpty(t, n.ClientID)
assert.True(t, rnd.IsUID(n.ClientID, entity.ClientUID))
@@ -69,8 +74,8 @@ func TestClientRegistry_ListOrderByUpdatedAtDesc(t *testing.T) {
r, _ := NewClientRegistryWithConfig(c)
a := &Node{Name: "pp-a", Role: "instance", UUID: rnd.UUIDv7()}
b := &Node{Name: "pp-b", Role: "service", UUID: rnd.UUIDv7()}
a := &Node{Node: cluster.Node{Name: "pp-a", Role: "instance", UUID: rnd.UUIDv7()}}
b := &Node{Node: cluster.Node{Name: "pp-b", Role: "service", UUID: rnd.UUIDv7()}}
assert.NoError(t, r.Put(a))
// Ensure distinct UpdatedAt values (DBs often have second precision)
time.Sleep(1100 * time.Millisecond)
@@ -78,7 +83,7 @@ func TestClientRegistry_ListOrderByUpdatedAtDesc(t *testing.T) {
// Update a to make it most recent
time.Sleep(1100 * time.Millisecond)
assert.NoError(t, r.Put(&Node{ClientID: a.ClientID, Name: a.Name}))
assert.NoError(t, r.Put(&Node{Node: cluster.Node{ClientID: a.ClientID, Name: a.Name}}))
list, err := r.List()
assert.NoError(t, err)
@@ -94,18 +99,21 @@ func TestClientRegistry_ListOrderByUpdatedAtDesc(t *testing.T) {
func TestResponseBuilders_RedactionAndOpts(t *testing.T) {
// Base node with all fields
n := Node{
ClientID: "cs5gfen1bgxz7s9i",
Name: "pp-node",
Role: "instance",
SiteUrl: "https://photos.example.com",
AdvertiseUrl: "http://node:2342",
Labels: map[string]string{"env": "prod"},
CreatedAt: time.Now().UTC().Format(time.RFC3339),
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
Node: cluster.Node{
ClientID: "cs5gfen1bgxz7s9i",
Name: "pp-node",
Role: "instance",
SiteUrl: "https://photos.example.com",
AdvertiseUrl: "http://node:2342",
Labels: map[string]string{"env": "prod"},
CreatedAt: time.Now().UTC().Format(time.RFC3339),
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
},
}
n.Database.Name = "dbn"
n.Database.User = "dbu"
n.Database.RotatedAt = time.Now().UTC().Format(time.RFC3339)
dbInfo := n.ensureDatabase()
dbInfo.Name = "dbn"
dbInfo.User = "dbu"
dbInfo.RotatedAt = time.Now().UTC().Format(time.RFC3339)
// Non-admin (default opts): redact advertise/database
out := BuildClusterNode(n, NodeOpts{})
@@ -190,7 +198,7 @@ func TestClientRegistry_GetClusterNodeByUUID(t *testing.T) {
r, _ := NewClientRegistryWithConfig(c)
// Insert a node with NodeUUID
nu := rnd.UUIDv7()
n := &Node{Name: "pp-getuuid", Role: "instance", UUID: nu}
n := &Node{Node: cluster.Node{Name: "pp-getuuid", Role: "instance", UUID: nu}}
assert.NoError(t, r.Put(n))
// Fetch DTO by NodeUUID
@@ -208,7 +216,7 @@ func TestClientRegistry_FindByName_NormalizesDNSLabel(t *testing.T) {
r, _ := NewClientRegistryWithConfig(c)
// Create canonical node name
n := &Node{Name: "my-node-prod", Role: "instance"}
n := &Node{Node: cluster.Node{Name: "my-node-prod", Role: "instance"}}
assert.NoError(t, r.Put(n))
// Lookup using mixed separators and case
got, err := r.FindByName("My.Node/Prod")

View File

@@ -8,6 +8,7 @@ import (
cfg "github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/internal/entity"
"github.com/photoprism/photoprism/internal/service/cluster"
"github.com/photoprism/photoprism/pkg/rnd"
)
@@ -21,14 +22,14 @@ func TestClientRegistry_PutUpdateByUUID(t *testing.T) {
uuid := rnd.UUIDv7()
// Create via UUID
n := &Node{UUID: uuid, Name: "pp-uuid", Role: "instance", Labels: map[string]string{"a": "1"}}
n := &Node{Node: cluster.Node{UUID: uuid, Name: "pp-uuid", Role: "instance", Labels: map[string]string{"a": "1"}}}
assert.NoError(t, r.Put(n))
assert.NotEmpty(t, n.ClientID)
assert.True(t, rnd.IsUUID(n.UUID))
assert.True(t, rnd.IsUID(n.ClientID, entity.ClientUID))
// Update same record by UUID only; change name and labels
upd := &Node{UUID: uuid, Name: "pp-uuid-new", Labels: map[string]string{"a": "2", "b": "x"}}
upd := &Node{Node: cluster.Node{UUID: uuid, Name: "pp-uuid-new", Labels: map[string]string{"a": "2", "b": "x"}}}
assert.NoError(t, r.Put(upd))
got, err := r.FindByNodeUUID(uuid)
@@ -127,14 +128,14 @@ func TestClientRegistry_PutPrefersUUIDOverClientID(t *testing.T) {
r, _ := NewClientRegistryWithConfig(c)
// Seed two separate records
n1 := &Node{UUID: rnd.UUIDv7(), Name: "pp-a", Role: "instance"}
n1 := &Node{Node: cluster.Node{UUID: rnd.UUIDv7(), Name: "pp-a", Role: "instance"}}
assert.NoError(t, r.Put(n1))
n2 := &Node{Name: "pp-b", Role: "service"}
n2 := &Node{Node: cluster.Node{Name: "pp-b", Role: "service"}}
assert.NoError(t, r.Put(n2))
// Now attempt to update by UUID of n1 while also passing n2.ClientID:
// implementation must use UUID and not attach to n2.
upd := &Node{UUID: n1.UUID, ClientID: n2.ClientID, Role: "service"}
upd := &Node{Node: cluster.Node{UUID: n1.UUID, ClientID: n2.ClientID, Role: "service"}}
assert.NoError(t, r.Put(upd))
got1, err := r.FindByNodeUUID(n1.UUID)

View File

@@ -23,28 +23,14 @@ func NodeOptsForSession(s *entity.Session) NodeOpts {
// BuildClusterNode builds a cluster.Node DTO from a registry.Node with redaction according to opts.
func BuildClusterNode(n Node, opts NodeOpts) cluster.Node {
out := cluster.Node{
UUID: n.UUID,
Name: n.Name,
Role: n.Role,
ClientID: n.ClientID,
SiteUrl: n.SiteUrl,
Labels: n.Labels,
CreatedAt: n.CreatedAt,
UpdatedAt: n.UpdatedAt,
out := n.Node
if !opts.IncludeAdvertiseUrl {
out.AdvertiseUrl = ""
}
if opts.IncludeAdvertiseUrl && n.AdvertiseUrl != "" {
out.AdvertiseUrl = n.AdvertiseUrl
}
if opts.IncludeDatabase {
out.Database = &cluster.NodeDatabase{
Name: n.Database.Name,
User: n.Database.User,
Driver: n.Database.Driver,
RotatedAt: n.Database.RotatedAt,
}
if !opts.IncludeDatabase {
out.Database = nil
}
return out

View File

@@ -0,0 +1,18 @@
package cluster
// RegisterRequest represents the JSON payload sent to the Portal when a node
// registers or refreshes its metadata.
//
// swagger:model RegisterRequest
type RegisterRequest struct {
NodeName string `json:"nodeName"`
NodeUUID string `json:"nodeUUID,omitempty"`
NodeRole string `json:"nodeRole,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
AdvertiseUrl string `json:"advertiseUrl,omitempty"`
SiteUrl string `json:"siteUrl,omitempty"`
ClientID string `json:"clientId,omitempty"`
ClientSecret string `json:"clientSecret,omitempty"`
RotateDatabase bool `json:"rotateDatabase,omitempty"`
RotateSecret bool `json:"rotateSecret,omitempty"`
}

View File

@@ -68,6 +68,7 @@ type RegisterResponse struct {
Node Node `json:"node"`
Database RegisterDatabase `json:"database"`
Secrets *RegisterSecrets `json:"secrets,omitempty"`
JWKSUrl string `json:"jwksUrl,omitempty"`
AlreadyRegistered bool `json:"alreadyRegistered"`
AlreadyProvisioned bool `json:"alreadyProvisioned"`
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/sirupsen/logrus"
"github.com/photoprism/photoprism/internal/config"
"github.com/photoprism/photoprism/pkg/fs"
)
func TestMain(m *testing.M) {
@@ -22,6 +23,9 @@ func TestMain(m *testing.M) {
// Close database connection.
_ = c.CloseDb()
// Remove temporary SQLite files after running the tests.
fs.PurgeTestDbFiles(".", false)
os.Exit(code)
}

View File

@@ -10,6 +10,7 @@ import (
"github.com/photoprism/photoprism/internal/event"
"github.com/photoprism/photoprism/internal/photoprism"
"github.com/photoprism/photoprism/internal/photoprism/get"
"github.com/photoprism/photoprism/pkg/fs"
)
func TestMain(m *testing.M) {
@@ -23,7 +24,11 @@ func TestMain(m *testing.M) {
get.SetConfig(c)
photoprism.SetConfig(c)
// Run unit tests.
code := m.Run()
// Remove temporary SQLite files after running the tests.
fs.PurgeTestDbFiles(".", false)
os.Exit(code)
}

View File

@@ -17,6 +17,7 @@ const (
MethodSession MethodType = "session"
MethodOAuth2 MethodType = "oauth2"
Method2FA MethodType = "2fa"
MethodJWT MethodType = "jwt"
)
// Method casts a string to a normalized method type.
@@ -31,6 +32,8 @@ func Method(s string) MethodType {
return MethodOAuth2
case "2fa", "mfa", "otp", "totp":
return Method2FA
case "jwt", "jwks":
return MethodJWT
case "access_token":
return MethodDefault
default:
@@ -57,6 +60,8 @@ func (t MethodType) Pretty() string {
return "OAuth2"
case Method2FA:
return "2FA"
case MethodJWT:
return "JWT"
default:
return txt.UpperFirst(t.String())
}
@@ -110,3 +115,8 @@ func (t MethodType) IsDefault() bool {
func (t MethodType) IsSession() bool {
return t.String() == MethodSession.String()
}
// IsJWT checks if this is the JSON Web Token (JWT) method.
func (t MethodType) IsJWT() bool {
return t.String() == MethodJWT.String()
}

View File

@@ -211,6 +211,21 @@ func TestAuthorization(t *testing.T) {
assert.Equal(t, AuthBearer, authType)
assert.Equal(t, "69be27ac5ca305b394046a83f6fda18167ca3d3f2dbe7ac0", authToken)
})
t.Run("JWTToken", func(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = &http.Request{
Header: make(http.Header),
}
token := "eyJhbGciOiJFZERTQSIsImtpZCI6IjEyMyJ9.eyJpc3MiOiJwb3J0YWw6dGVzdCIsImF1ZCI6Im5vZGU6YWJjIiwiZXhwIjoxNzAwMDAwMDB9.dGVzdC1zaWduYXR1cmUtYnl0ZXM"
c.Request.Header.Add(Auth, "Bearer "+token)
authType, authToken := Authorization(c)
assert.Equal(t, AuthBearer, authType)
assert.Equal(t, token, authToken)
})
}
func TestBasicAuth(t *testing.T) {

View File

@@ -0,0 +1,5 @@
package header
// ETag is the response header containing an entity tag that allows
// clients and caches to perform conditional requests.
const ETag = "ETag"

View File

@@ -19,7 +19,7 @@ func ID(s string) string {
prev = r
switch r {
case ' ', '"', '-', '+', '/', '=', '#', '$', '@', ':', ';', '_':
case ' ', '"', '-', '+', '/', '=', '#', '$', '@', ':', ';', '_', '.':
return r
}