feat(11-03): add the lighthouse realtime package and its Centrifugo driver

- lighthouse: Service/From with realtime.driver selection, RegisterDriver
  registry, null/log/memory drivers, Route/Surface/Mount, users and actors
- centrifugo: HTTP API client (apikey header, 2xx success, no request
  without a key), five-generator HS256 TokenIssuer, TokenHandler with the
  WinterCMS 401/503 bodies
- module README and root modules table row
This commit is contained in:
Jakub Zych
2026-09-30 12:18:11 +02:00
parent f1077382f5
commit cada7a4442
11 changed files with 1302 additions and 0 deletions

View File

@@ -0,0 +1,145 @@
package centrifugo
import (
"bytes"
"encoding/json"
"strconv"
"time"
"git.golem15.com/golem15/summercms/modules/lighthouse"
"github.com/golang-jwt/jwt/v5"
)
// anonymousTTL is the lifetime of Anonymous tokens.
const anonymousTTL = 300 * time.Second
// TokenIssuer signs Centrifugo connection and subscription tokens with
// HS256. Its claims match the WinterCMS JwtTokenGenerator. It is safe for
// concurrent use.
type TokenIssuer struct {
secret []byte
ttl time.Duration
// Now is the clock used for exp; nil means time.Now.
Now func() time.Time
}
// NewTokenIssuer returns an issuer for secret with tokens valid for ttl
// (DefaultTokenTTL when ttl is not positive).
func NewTokenIssuer(secret string, ttl time.Duration) *TokenIssuer {
if ttl <= 0 {
ttl = DefaultTokenTTL
}
return &TokenIssuer{secret: []byte(secret), ttl: ttl}
}
// Configured reports whether a signing secret is set.
func (i *TokenIssuer) Configured() bool { return i != nil && len(i.secret) > 0 }
type userInfo struct {
Name *string `json:"name"`
}
type userClaims struct {
Sub string `json:"sub"`
Exp int64 `json:"exp"`
Info userInfo `json:"info"`
}
type channelClaims struct {
Sub string `json:"sub"`
Channel string `json:"channel"`
Exp int64 `json:"exp"`
}
type anonymousClaims struct {
Sub string `json:"sub"`
Exp int64 `json:"exp"`
}
type identifierClaims struct {
Sub string `json:"sub"`
Exp int64 `json:"exp"`
Info json.RawMessage `json:"info"`
}
// ForUser returns a connection token with exactly the claims sub (the user
// id as a string), exp (now + TTL) and info {"name": u.Name}. info carries
// nothing else: no email, no other ids.
func (i *TokenIssuer) ForUser(u lighthouse.User) (string, error) {
return i.sign(userClaims{Sub: userSub(u.ID), Exp: i.exp(i.ttl), Info: userInfo{Name: u.Name}})
}
// Subscription returns a subscription token with the claims sub, channel
// and exp.
func (i *TokenIssuer) Subscription(u lighthouse.User, channel string) (string, error) {
return i.sign(channelClaims{Sub: userSub(u.ID), Channel: channel, Exp: i.exp(i.ttl)})
}
// Anonymous returns a connection token with sub "" and exp now + 5 minutes.
func (i *TokenIssuer) Anonymous() (string, error) {
return i.sign(anonymousClaims{Sub: "", Exp: i.exp(anonymousTTL)})
}
// ForIdentifier returns a connection token for a non-user identifier with
// the claims sub, exp and info. An empty info is encoded as [], as the
// WinterCMS generator's empty PHP array is.
func (i *TokenIssuer) ForIdentifier(identifier string, info map[string]any) (string, error) {
raw := json.RawMessage("[]")
if len(info) > 0 {
b, err := marshal(info)
if err != nil {
return "", err
}
raw = b
}
return i.sign(identifierClaims{Sub: identifier, Exp: i.exp(i.ttl), Info: raw})
}
// SubscriptionForIdentifier returns a subscription token for a non-user
// identifier with the claims sub, channel and exp.
func (i *TokenIssuer) SubscriptionForIdentifier(identifier, channel string) (string, error) {
return i.sign(channelClaims{Sub: identifier, Channel: channel, Exp: i.exp(i.ttl)})
}
func (i *TokenIssuer) exp(ttl time.Duration) int64 {
now := time.Now
if i != nil && i.Now != nil {
now = i.Now
}
return now().Add(ttl).Unix()
}
func (i *TokenIssuer) sign(claims any) (string, error) {
if !i.Configured() {
return "", ErrNotConfigured
}
raw, err := marshal(claims)
if err != nil {
return "", err
}
return jwt.NewWithClaims(jwt.SigningMethodHS256, orderedClaims(raw)).SignedString(i.secret)
}
func userSub(id uint) string { return strconv.FormatUint(uint64(id), 10) }
func marshal(v any) ([]byte, error) {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(v); err != nil {
return nil, err
}
return bytes.TrimSuffix(buf.Bytes(), []byte("\n")), nil
}
// orderedClaims keeps the claim order of the struct it was marshalled
// from. The jwt.Claims methods are never used for signing.
type orderedClaims json.RawMessage
func (c orderedClaims) MarshalJSON() ([]byte, error) { return c, nil }
func (orderedClaims) GetExpirationTime() (*jwt.NumericDate, error) { return nil, nil }
func (orderedClaims) GetIssuedAt() (*jwt.NumericDate, error) { return nil, nil }
func (orderedClaims) GetNotBefore() (*jwt.NumericDate, error) { return nil, nil }
func (orderedClaims) GetIssuer() (string, error) { return "", nil }
func (orderedClaims) GetSubject() (string, error) { return "", nil }
func (orderedClaims) GetAudience() (jwt.ClaimStrings, error) { return nil, nil }