feat(09-01): implement separate-admin genre list tracer
- Audience-aware mint, verify, refresh, and backend guard keep frontend tokens compatible - Cabana mounts raw admin login, list schema, and record list behind admin.jwt.secret - Framework migration seeds Winter backend users and developer/publisher roles
This commit is contained in:
@@ -9,11 +9,15 @@ type userKey struct{}
|
|||||||
|
|
||||||
// Principal is the authenticated identity stored on the request context.
|
// Principal is the authenticated identity stored on the request context.
|
||||||
// PreferredLocale empty means no override. TokensValidAfter zero means no cutoff.
|
// PreferredLocale empty means no override. TokensValidAfter zero means no cutoff.
|
||||||
|
// IsSuperuser and PermissionGrants are set only for backend-admin principals.
|
||||||
|
// A grant ending in ".*" matches permission codes by prefix.
|
||||||
type Principal struct {
|
type Principal struct {
|
||||||
ID uint
|
ID uint
|
||||||
MustChangePassword bool
|
MustChangePassword bool
|
||||||
PreferredLocale string
|
PreferredLocale string
|
||||||
TokensValidAfter time.Time
|
TokensValidAfter time.Time
|
||||||
|
IsSuperuser bool `json:"-"`
|
||||||
|
PermissionGrants map[string]bool `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithUser stores the verified principal on ctx.
|
// WithUser stores the verified principal on ctx.
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ type jwtGuard struct {
|
|||||||
users UserProvider
|
users UserProvider
|
||||||
bl BlacklistStore
|
bl BlacklistStore
|
||||||
cookieNames []string
|
cookieNames []string
|
||||||
|
audience string
|
||||||
|
requireAudience bool
|
||||||
|
writeFn func(http.ResponseWriter, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -84,12 +87,31 @@ func NewJWTGuard(secret string, users UserProvider, bl BlacklistStore, cookieNam
|
|||||||
return &jwtGuard{secret: secret, users: users, bl: bl, cookieNames: cookieNames}
|
return &jwtGuard{secret: secret, users: users, bl: bl, cookieNames: cookieNames}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewBackendJWTGuard is bearer-only and requires AudienceBackend.
|
||||||
|
// write may replace the PHP-shaped 401 body; nil keeps write401.
|
||||||
|
func NewBackendJWTGuard(secret string, users UserProvider, bl BlacklistStore, write func(http.ResponseWriter, error)) Guard {
|
||||||
|
return &jwtGuard{
|
||||||
|
secret: secret,
|
||||||
|
users: users,
|
||||||
|
bl: bl,
|
||||||
|
audience: AudienceBackend,
|
||||||
|
requireAudience: true,
|
||||||
|
writeFn: write,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (g *jwtGuard) Authenticate(r *http.Request) (*Principal, error) {
|
func (g *jwtGuard) Authenticate(r *http.Request) (*Principal, error) {
|
||||||
raw, err := extractToken(r, g.cookieNames)
|
raw, err := extractToken(r, g.cookieNames)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
sub, iat, _, jti, err := VerifyClaims(raw, g.secret)
|
var sub, jti string
|
||||||
|
var iat time.Time
|
||||||
|
if g.requireAudience {
|
||||||
|
sub, iat, _, jti, err = VerifyClaimsAudience(raw, g.secret, g.audience)
|
||||||
|
} else {
|
||||||
|
sub, iat, _, jti, err = VerifyClaims(raw, g.secret)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -123,6 +145,10 @@ func (g *jwtGuard) Authenticate(r *http.Request) (*Principal, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (g *jwtGuard) WriteUnauthorized(w http.ResponseWriter, err error) {
|
func (g *jwtGuard) WriteUnauthorized(w http.ResponseWriter, err error) {
|
||||||
|
if g.writeFn != nil {
|
||||||
|
g.writeFn(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
write401(w, err.Error())
|
write401(w, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,7 +173,20 @@ func Verify(tokenString, secret string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// VerifyClaims parses a token the same way Verify does and also returns iat, exp, and jti.
|
// VerifyClaims parses a token the same way Verify does and also returns iat, exp, and jti.
|
||||||
|
// A missing audience stays valid so PHP-issued frontend tokens keep working.
|
||||||
func VerifyClaims(tokenString, secret string) (sub string, iat, exp time.Time, jti string, err error) {
|
func VerifyClaims(tokenString, secret string) (sub string, iat, exp time.Time, jti string, err error) {
|
||||||
|
return verifyClaims(tokenString, secret, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyClaimsAudience is VerifyClaims plus a required audience claim.
|
||||||
|
func VerifyClaimsAudience(tokenString, secret, audience string) (sub string, iat, exp time.Time, jti string, err error) {
|
||||||
|
if strings.TrimSpace(audience) == "" {
|
||||||
|
return "", time.Time{}, time.Time{}, "", fmt.Errorf("bouncer: jwt audience is empty")
|
||||||
|
}
|
||||||
|
return verifyClaims(tokenString, secret, audience)
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyClaims(tokenString, secret, audience string) (sub string, iat, exp time.Time, jti string, err error) {
|
||||||
if strings.TrimSpace(secret) == "" {
|
if strings.TrimSpace(secret) == "" {
|
||||||
return "", time.Time{}, time.Time{}, "", fmt.Errorf("bouncer: jwt secret is empty")
|
return "", time.Time{}, time.Time{}, "", fmt.Errorf("bouncer: jwt secret is empty")
|
||||||
}
|
}
|
||||||
@@ -159,6 +198,9 @@ func VerifyClaims(tokenString, secret string) (sub string, iat, exp time.Time, j
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", time.Time{}, time.Time{}, "", mapJWTError(err)
|
return "", time.Time{}, time.Time{}, "", mapJWTError(err)
|
||||||
}
|
}
|
||||||
|
if audience != "" && !audienceMatches(claims, audience) {
|
||||||
|
return "", time.Time{}, time.Time{}, "", errors.New(msgBadSignature)
|
||||||
|
}
|
||||||
sub = subject(claims)
|
sub = subject(claims)
|
||||||
if sub == "" {
|
if sub == "" {
|
||||||
return "", time.Time{}, time.Time{}, "", errors.New(msgRequiredClaims)
|
return "", time.Time{}, time.Time{}, "", errors.New(msgRequiredClaims)
|
||||||
@@ -169,6 +211,38 @@ func VerifyClaims(tokenString, secret string) (sub string, iat, exp time.Time, j
|
|||||||
return sub, iat, exp, jti, nil
|
return sub, iat, exp, jti, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func audienceMatches(claims jwt.MapClaims, expected string) bool {
|
||||||
|
for _, aud := range claimAudiences(claims) {
|
||||||
|
if aud == expected {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func claimAudiences(claims jwt.MapClaims) []string {
|
||||||
|
switch v := claims["aud"].(type) {
|
||||||
|
case string:
|
||||||
|
if strings.TrimSpace(v) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []string{v}
|
||||||
|
case []string:
|
||||||
|
return v
|
||||||
|
case []any:
|
||||||
|
out := make([]string, 0, len(v))
|
||||||
|
for _, item := range v {
|
||||||
|
s, ok := item.(string)
|
||||||
|
if ok && s != "" {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func extractToken(r *http.Request, cookieNames []string) (string, error) {
|
func extractToken(r *http.Request, cookieNames []string) (string, error) {
|
||||||
raw, err := bearerToken(r)
|
raw, err := bearerToken(r)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
@@ -11,35 +11,56 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// prvHash is sha1("Golem15\User\Models\User"), the lock-subject PHP jwt-auth
|
// prvHash is sha1("Golem15\User\Models\User"), the lock-subject PHP jwt-auth
|
||||||
// stamps on every user token.
|
// stamps on every frontend user token. Backend tokens do not carry it.
|
||||||
const prvHash = "a867434cbc213adfbe78a02bed7082a6bd99c883"
|
const prvHash = "a867434cbc213adfbe78a02bed7082a6bd99c883"
|
||||||
|
|
||||||
|
const (
|
||||||
|
// AudienceUser is the frontend jwt-guard audience.
|
||||||
|
AudienceUser = "user"
|
||||||
|
// AudienceBackend is the admin jwt-guard audience.
|
||||||
|
AudienceBackend = "backend"
|
||||||
|
)
|
||||||
|
|
||||||
type registeredClaims struct {
|
type registeredClaims struct {
|
||||||
jwt.RegisteredClaims
|
jwt.RegisteredClaims
|
||||||
Prv string `json:"prv,omitempty"`
|
Prv string `json:"prv,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mint signs an HS256 token whose iss is the full URL of the minting endpoint.
|
// Mint signs a frontend-audience HS256 token. iss is the minting endpoint URL.
|
||||||
// The returned jti is the token's own jti claim.
|
// The returned jti is the token's own jti claim.
|
||||||
func Mint(secret, sub, issuerURL string, ttl time.Duration) (string, string, error) {
|
func Mint(secret, sub, issuerURL string, ttl time.Duration) (string, string, error) {
|
||||||
|
return MintAudience(secret, sub, issuerURL, ttl, AudienceUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MintAudience signs an HS256 token for audience. Frontend tokens keep the PHP
|
||||||
|
// prv lock-subject; backend tokens omit it.
|
||||||
|
func MintAudience(secret, sub, issuerURL string, ttl time.Duration, audience string) (string, string, error) {
|
||||||
if strings.TrimSpace(secret) == "" {
|
if strings.TrimSpace(secret) == "" {
|
||||||
return "", "", fmt.Errorf("bouncer: jwt secret is empty")
|
return "", "", fmt.Errorf("bouncer: jwt secret is empty")
|
||||||
}
|
}
|
||||||
|
if strings.TrimSpace(audience) == "" {
|
||||||
|
return "", "", fmt.Errorf("bouncer: jwt audience is empty")
|
||||||
|
}
|
||||||
jti, err := newJTI()
|
jti, err := newJTI()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
prv := ""
|
||||||
|
if audience == AudienceUser {
|
||||||
|
prv = prvHash
|
||||||
|
}
|
||||||
claims := registeredClaims{
|
claims := registeredClaims{
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
Issuer: issuerURL,
|
Issuer: issuerURL,
|
||||||
Subject: sub,
|
Subject: sub,
|
||||||
|
Audience: jwt.ClaimStrings{audience},
|
||||||
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
|
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
|
||||||
NotBefore: jwt.NewNumericDate(now),
|
NotBefore: jwt.NewNumericDate(now),
|
||||||
IssuedAt: jwt.NewNumericDate(now),
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
ID: jti,
|
ID: jti,
|
||||||
},
|
},
|
||||||
Prv: prvHash,
|
Prv: prv,
|
||||||
}
|
}
|
||||||
signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret))
|
signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(secret))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -15,6 +15,18 @@ import (
|
|||||||
// jwt-auth: the later of the old exp and iat+refreshTTL, plus one minute, so
|
// jwt-auth: the later of the old exp and iat+refreshTTL, plus one minute, so
|
||||||
// a logged-out token cannot be refreshed again for the rest of its refresh window.
|
// a logged-out token cannot be refreshed again for the rest of its refresh window.
|
||||||
func Refresh(secret, tokenString string, refreshTTL time.Duration, bl BlacklistStore, grace time.Duration, issuerURL string) (string, error) {
|
func Refresh(secret, tokenString string, refreshTTL time.Duration, bl BlacklistStore, grace time.Duration, issuerURL string) (string, error) {
|
||||||
|
return refreshAudience(secret, tokenString, AudienceUser, true, refreshTTL, bl, grace, issuerURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshAudience reissues a token that already carries audience. Missing aud is rejected.
|
||||||
|
func RefreshAudience(secret, tokenString, audience string, refreshTTL time.Duration, bl BlacklistStore, grace time.Duration, issuerURL string) (string, error) {
|
||||||
|
if strings.TrimSpace(audience) == "" {
|
||||||
|
return "", errors.New("bouncer: jwt audience is empty")
|
||||||
|
}
|
||||||
|
return refreshAudience(secret, tokenString, audience, false, refreshTTL, bl, grace, issuerURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshAudience(secret, tokenString, audience string, allowMissing bool, refreshTTL time.Duration, bl BlacklistStore, grace time.Duration, issuerURL string) (string, error) {
|
||||||
if strings.TrimSpace(secret) == "" {
|
if strings.TrimSpace(secret) == "" {
|
||||||
return "", errors.New("bouncer: jwt secret is empty")
|
return "", errors.New("bouncer: jwt secret is empty")
|
||||||
}
|
}
|
||||||
@@ -29,6 +41,14 @@ func Refresh(secret, tokenString string, refreshTTL time.Duration, bl BlacklistS
|
|||||||
if sub == "" {
|
if sub == "" {
|
||||||
return "", errors.New(msgRequiredClaims)
|
return "", errors.New(msgRequiredClaims)
|
||||||
}
|
}
|
||||||
|
auds := claimAudiences(claims)
|
||||||
|
if len(auds) == 0 {
|
||||||
|
if !allowMissing {
|
||||||
|
return "", errors.New(msgBadSignature)
|
||||||
|
}
|
||||||
|
} else if !audienceMatches(claims, audience) {
|
||||||
|
return "", errors.New(msgBadSignature)
|
||||||
|
}
|
||||||
iat, ok := claimTime(claims, "iat")
|
iat, ok := claimTime(claims, "iat")
|
||||||
if !ok || time.Now().After(iat.Add(refreshTTL)) {
|
if !ok || time.Now().After(iat.Add(refreshTTL)) {
|
||||||
return "", errors.New("Token has expired and can no longer be refreshed")
|
return "", errors.New("Token has expired and can no longer be refreshed")
|
||||||
@@ -48,7 +68,7 @@ func Refresh(secret, tokenString string, refreshTTL time.Duration, bl BlacklistS
|
|||||||
if !expOK || ttl <= 0 {
|
if !expOK || ttl <= 0 {
|
||||||
return "", errors.New(msgRequiredClaims)
|
return "", errors.New(msgRequiredClaims)
|
||||||
}
|
}
|
||||||
next, _, err := Mint(secret, sub, issuerURL, ttl)
|
next, _, err := MintAudience(secret, sub, issuerURL, ttl, audience)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|||||||
236
cabana/auth.go
Normal file
236
cabana/auth.go
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
package cabana
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/bouncer"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
msgInvalidCredentials = "Invalid credentials"
|
||||||
|
msgUnauthenticated = "Unauthenticated"
|
||||||
|
msgForbidden = "Forbidden"
|
||||||
|
msgNotFound = "Not found"
|
||||||
|
msgServerError = "Server error"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BackendUserRole is the Winter backend_user_roles row.
|
||||||
|
type BackendUserRole struct {
|
||||||
|
ID uint `gorm:"column:id;primaryKey"`
|
||||||
|
Name string `gorm:"column:name"`
|
||||||
|
Code string `gorm:"column:code"`
|
||||||
|
Description string `gorm:"column:description"`
|
||||||
|
Permissions string `gorm:"column:permissions"`
|
||||||
|
IsSystem bool `gorm:"column:is_system"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at"`
|
||||||
|
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (BackendUserRole) TableName() string { return "backend_user_roles" }
|
||||||
|
|
||||||
|
// BackendUser is the Winter backend_users row. It is not a frontend user.
|
||||||
|
type BackendUser struct {
|
||||||
|
ID uint `gorm:"column:id;primaryKey"`
|
||||||
|
FirstName string `gorm:"column:first_name"`
|
||||||
|
LastName string `gorm:"column:last_name"`
|
||||||
|
Login string `gorm:"column:login"`
|
||||||
|
Email string `gorm:"column:email"`
|
||||||
|
Password string `gorm:"column:password"`
|
||||||
|
IsActivated bool `gorm:"column:is_activated"`
|
||||||
|
IsSuperuser bool `gorm:"column:is_superuser"`
|
||||||
|
RoleID *uint `gorm:"column:role_id"`
|
||||||
|
LastLogin *time.Time `gorm:"column:last_login"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at"`
|
||||||
|
UpdatedAt time.Time `gorm:"column:updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at"`
|
||||||
|
Role BackendUserRole
|
||||||
|
}
|
||||||
|
|
||||||
|
func (BackendUser) TableName() string { return "backend_users" }
|
||||||
|
|
||||||
|
// BackendUsers loads activated backend principals. It never reads frontend users.
|
||||||
|
type BackendUsers struct {
|
||||||
|
DB *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p BackendUsers) FindByID(ctx context.Context, id uint) (*bouncer.Principal, error) {
|
||||||
|
if p.DB == nil || id == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
var user BackendUser
|
||||||
|
err := p.DB.WithContext(ctx).Preload("Role").First(&user, id).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !user.IsActivated {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return principalFrom(user), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func principalFrom(user BackendUser) *bouncer.Principal {
|
||||||
|
return &bouncer.Principal{
|
||||||
|
ID: user.ID,
|
||||||
|
IsSuperuser: user.IsSuperuser,
|
||||||
|
PermissionGrants: parseGrants(user.Role.Permissions),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseGrants(raw string) map[string]bool {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" || raw == "{}" || raw == "null" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var decoded map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(raw), &decoded); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make(map[string]bool, len(decoded))
|
||||||
|
for code, value := range decoded {
|
||||||
|
if truthyGrant(value) {
|
||||||
|
out[code] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func truthyGrant(value any) bool {
|
||||||
|
switch v := value.(type) {
|
||||||
|
case bool:
|
||||||
|
return v
|
||||||
|
case float64:
|
||||||
|
return v == 1
|
||||||
|
case string:
|
||||||
|
return v == "1" || strings.EqualFold(v, "true")
|
||||||
|
case json.Number:
|
||||||
|
return v.String() == "1"
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type loginBody struct {
|
||||||
|
Login string `json:"login"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) login(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body loginBody
|
||||||
|
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096))
|
||||||
|
if err := dec.Decode(&body); err != nil {
|
||||||
|
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgInvalidCredentials)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
identifier := strings.TrimSpace(body.Login)
|
||||||
|
if identifier == "" {
|
||||||
|
identifier = strings.TrimSpace(body.Email)
|
||||||
|
}
|
||||||
|
if identifier == "" || body.Password == "" {
|
||||||
|
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgInvalidCredentials)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
db, err := s.db()
|
||||||
|
if err != nil {
|
||||||
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, found, err := findBackendLogin(db.WithContext(r.Context()), identifier)
|
||||||
|
if err != nil {
|
||||||
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hash := user.Password
|
||||||
|
if !found {
|
||||||
|
hash = dummyPasswordHash
|
||||||
|
}
|
||||||
|
if !found || !user.IsActivated || !bouncer.CheckPassword(hash, body.Password) {
|
||||||
|
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgInvalidCredentials)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token, _, err := bouncer.MintAudience(s.secret, uitoa(user.ID), s.issuer, s.ttl, bouncer.AudienceBackend)
|
||||||
|
if err != nil {
|
||||||
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
WriteData(w, http.StatusOK, map[string]string{
|
||||||
|
"access_token": token,
|
||||||
|
"token_type": "bearer",
|
||||||
|
}, map[string]any{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func findBackendLogin(db *gorm.DB, identifier string) (BackendUser, bool, error) {
|
||||||
|
email := strings.ToLower(identifier)
|
||||||
|
var user BackendUser
|
||||||
|
err := db.Preload("Role").Where("login = ? OR lower(email) = ?", identifier, email).First(&user).Error
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return BackendUser{}, false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return BackendUser{}, false, err
|
||||||
|
}
|
||||||
|
return user, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) db() (*gorm.DB, error) {
|
||||||
|
if s == nil || s.app == nil {
|
||||||
|
return nil, errors.New("cabana: database is not configured")
|
||||||
|
}
|
||||||
|
db, ok := s.app.Lookup[*gorm.DB]()
|
||||||
|
if !ok || db == nil {
|
||||||
|
return nil, errors.New("cabana: database is not configured")
|
||||||
|
}
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminSecret(app *backpack.App) (string, error) {
|
||||||
|
secret := ""
|
||||||
|
if app != nil && app.Config != nil {
|
||||||
|
secret = strings.TrimSpace(app.Config.String("admin.jwt.secret"))
|
||||||
|
}
|
||||||
|
if secret == "" {
|
||||||
|
return "", errors.New("cabana: admin.jwt.secret is empty (set SUMMER_ADMIN__JWT__SECRET)")
|
||||||
|
}
|
||||||
|
return secret, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminTTL(app *backpack.App) time.Duration {
|
||||||
|
minutes := 60
|
||||||
|
if app != nil && app.Config != nil && app.Config.Int("admin.jwt.ttl") > 0 {
|
||||||
|
minutes = app.Config.Int("admin.jwt.ttl")
|
||||||
|
}
|
||||||
|
return time.Duration(minutes) * time.Minute
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminIssuer(app *backpack.App) string {
|
||||||
|
base := ""
|
||||||
|
if app != nil && app.Config != nil {
|
||||||
|
base = strings.TrimRight(strings.TrimSpace(app.Config.String("app.url")), "/")
|
||||||
|
}
|
||||||
|
if base == "" {
|
||||||
|
return "/_admin/api/v1/auth/login"
|
||||||
|
}
|
||||||
|
return base + "/_admin/api/v1/auth/login"
|
||||||
|
}
|
||||||
|
|
||||||
|
// dummyPasswordHash keeps a missing-user login on the bcrypt path.
|
||||||
|
var dummyPasswordHash = func() string {
|
||||||
|
hash, err := bouncer.HashPassword(10, "cabana-invalid-credentials")
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return hash
|
||||||
|
}()
|
||||||
117
cabana/contracts.go
Normal file
117
cabana/contracts.go
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
package cabana
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/bouncer"
|
||||||
|
"git.golem15.com/golem15/summercms/pact"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Option is a dropdown choice shared with later schema plans.
|
||||||
|
type Option = pact.Option
|
||||||
|
|
||||||
|
// ListColumn is one compiled columns.yaml entry, in file order.
|
||||||
|
type ListColumn struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Searchable bool `json:"searchable"`
|
||||||
|
Sortable bool `json:"sortable"`
|
||||||
|
Type string `json:"type,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSchema is the boot-compiled list contract for one controller.
|
||||||
|
type ListSchema struct {
|
||||||
|
Title string `json:"title,omitempty"`
|
||||||
|
RecordsPerPage int `json:"recordsPerPage"`
|
||||||
|
ShowSearch bool `json:"showSearch"`
|
||||||
|
Columns []ListColumn `json:"columns"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompiledController is one admin controller after YAML compilation.
|
||||||
|
type CompiledController struct {
|
||||||
|
PluginID string
|
||||||
|
Controller pact.AdminController
|
||||||
|
List *ListSchema
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registry is the immutable controller map keyed by controller ID.
|
||||||
|
type Registry struct {
|
||||||
|
byID map[string]*CompiledController
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the compiled controller for a D-09 id (vendor.plugin.controller).
|
||||||
|
func (r *Registry) Get(id string) (*CompiledController, bool) {
|
||||||
|
if r == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
cc, ok := r.byID[id]
|
||||||
|
return cc, ok && cc != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allows reports whether principal satisfies every required permission code.
|
||||||
|
// A nil principal fails. Superusers pass. An empty requirement list allows
|
||||||
|
// any authenticated principal. Grants ending in ".*" match by prefix.
|
||||||
|
func Allows(principal *bouncer.Principal, required []string) bool {
|
||||||
|
if principal == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if principal.IsSuperuser || len(required) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, code := range required {
|
||||||
|
if !granted(principal.PermissionGrants, code) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func granted(grants map[string]bool, code string) bool {
|
||||||
|
if grants[code] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for key, on := range grants {
|
||||||
|
if !on || !strings.HasSuffix(key, ".*") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
prefix := strings.TrimSuffix(key, "*")
|
||||||
|
if strings.HasPrefix(code, prefix) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func requiredOf(ctl pact.AdminController) []string {
|
||||||
|
if p, ok := ctl.(pact.AdminPermissioned); ok && p != nil {
|
||||||
|
return p.RequiredPermissions()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteData writes a D-10 success envelope.
|
||||||
|
func WriteData(w http.ResponseWriter, status int, data, meta any) {
|
||||||
|
if meta == nil {
|
||||||
|
meta = map[string]any{}
|
||||||
|
}
|
||||||
|
writeJSON(w, status, map[string]any{"data": data, "meta": meta})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteError writes a D-10 error envelope. details is always an object.
|
||||||
|
func WriteError(w http.ResponseWriter, status int, code, message string) {
|
||||||
|
writeJSON(w, status, map[string]any{
|
||||||
|
"error": map[string]any{
|
||||||
|
"code": code,
|
||||||
|
"message": message,
|
||||||
|
"details": map[string]any{},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, body any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
_ = json.NewEncoder(w).Encode(body)
|
||||||
|
}
|
||||||
278
cabana/http.go
Normal file
278
cabana/http.go
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
package cabana
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"reflect"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/bouncer"
|
||||||
|
"git.golem15.com/golem15/summercms/pact"
|
||||||
|
"git.golem15.com/golem15/summercms/party"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Routes is the raw admin API mounted by surf.BuildRouter.
|
||||||
|
type Routes struct {
|
||||||
|
Middleware pact.Middleware
|
||||||
|
Mount func(r pact.Router)
|
||||||
|
}
|
||||||
|
|
||||||
|
type service struct {
|
||||||
|
app *backpack.App
|
||||||
|
reg *Registry
|
||||||
|
secret string
|
||||||
|
ttl time.Duration
|
||||||
|
issuer string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activate compiles admin controllers and, when any exist, requires
|
||||||
|
// admin.jwt.secret. No controllers means no admin routes and no secret check.
|
||||||
|
func Activate(app *backpack.App, plugins []party.Plugin) (*Routes, error) {
|
||||||
|
items, err := collectControllers(plugins)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
secret, err := adminSecret(app)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
reg, err := compileRegistry(items)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if app == nil {
|
||||||
|
return nil, errors.New("cabana: app is nil")
|
||||||
|
}
|
||||||
|
guards, ok := app.Lookup[*bouncer.Registry]()
|
||||||
|
if !ok || guards == nil {
|
||||||
|
guards = bouncer.NewRegistry()
|
||||||
|
if err := app.Publish(guards); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var bl bouncer.BlacklistStore
|
||||||
|
if store, ok := app.Lookup[bouncer.BlacklistStore](); ok {
|
||||||
|
bl = store
|
||||||
|
}
|
||||||
|
guard := bouncer.NewBackendJWTGuard(secret, lazyBackendUsers{app: app}, bl, writeUnauthenticated)
|
||||||
|
if _, err := guards.Middleware("backend"); err != nil {
|
||||||
|
if err := guards.Register("summercms.cabana", "backend", guard); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mw, err := guards.Middleware("backend")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
svc := &service{
|
||||||
|
app: app,
|
||||||
|
reg: reg,
|
||||||
|
secret: secret,
|
||||||
|
ttl: adminTTL(app),
|
||||||
|
issuer: adminIssuer(app),
|
||||||
|
}
|
||||||
|
return &Routes{Middleware: mw, Mount: svc.mount}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeUnauthenticated(w http.ResponseWriter, _ error) {
|
||||||
|
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
|
||||||
|
}
|
||||||
|
|
||||||
|
type lazyBackendUsers struct {
|
||||||
|
app *backpack.App
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p lazyBackendUsers) FindByID(ctx context.Context, id uint) (*bouncer.Principal, error) {
|
||||||
|
if p.app == nil {
|
||||||
|
return nil, errors.New("cabana: database is not configured")
|
||||||
|
}
|
||||||
|
db, ok := p.app.Lookup[*gorm.DB]()
|
||||||
|
if !ok || db == nil {
|
||||||
|
return nil, errors.New("cabana: database is not configured")
|
||||||
|
}
|
||||||
|
return (BackendUsers{DB: db}).FindByID(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) mount(r pact.Router) {
|
||||||
|
r.GroupRaw("/_admin/api/v1/auth", nil, func(g pact.Router) {
|
||||||
|
g.Post("/login", s.login)
|
||||||
|
})
|
||||||
|
r.GroupRaw("/_admin/api/v1", []string{"backend"}, func(g pact.Router) {
|
||||||
|
g.Get("/{vendor}/{plugin}/{controller}/schema/list", s.listSchema)
|
||||||
|
constrainController(g)
|
||||||
|
g.Get("/{vendor}/{plugin}/{controller}", s.list)
|
||||||
|
constrainController(g)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func constrainController(g pact.Router) {
|
||||||
|
g.Where("vendor", "[A-Za-z0-9_-]+")
|
||||||
|
g.Where("plugin", "[A-Za-z0-9_-]+")
|
||||||
|
g.Where("controller", "[A-Za-z0-9_-]+")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) listSchema(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.protect(w, r, func(cc *CompiledController) {
|
||||||
|
cols := cc.List.Columns
|
||||||
|
if cols == nil {
|
||||||
|
cols = []ListColumn{}
|
||||||
|
}
|
||||||
|
WriteData(w, http.StatusOK, &ListSchema{
|
||||||
|
Title: cc.List.Title,
|
||||||
|
RecordsPerPage: cc.List.RecordsPerPage,
|
||||||
|
ShowSearch: cc.List.ShowSearch,
|
||||||
|
Columns: cols,
|
||||||
|
}, map[string]any{})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *service) list(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.protect(w, r, func(cc *CompiledController) {
|
||||||
|
db, err := s.db()
|
||||||
|
if err != nil {
|
||||||
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rows, total, err := queryList(r.Context(), db, cc)
|
||||||
|
if err != nil {
|
||||||
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data := make([]map[string]any, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
data = append(data, projectRow(row, cc.List.Columns))
|
||||||
|
}
|
||||||
|
per := cc.List.RecordsPerPage
|
||||||
|
if per < 1 {
|
||||||
|
per = 20
|
||||||
|
}
|
||||||
|
last := int((total + int64(per) - 1) / int64(per))
|
||||||
|
if last < 1 {
|
||||||
|
last = 1
|
||||||
|
}
|
||||||
|
WriteData(w, http.StatusOK, data, map[string]any{
|
||||||
|
"page": 1,
|
||||||
|
"per_page": per,
|
||||||
|
"total": total,
|
||||||
|
"last_page": last,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// protect runs after the backend guard. Controller lookup precedes permission
|
||||||
|
// evaluation, and schema/SQL run only inside fn.
|
||||||
|
func (s *service) protect(w http.ResponseWriter, r *http.Request, fn func(*CompiledController)) {
|
||||||
|
id := r.PathValue("vendor") + "." + r.PathValue("plugin") + "." + r.PathValue("controller")
|
||||||
|
cc, ok := s.reg.Get(id)
|
||||||
|
if !ok {
|
||||||
|
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
principal, _ := bouncer.User(r.Context())
|
||||||
|
if principal == nil {
|
||||||
|
WriteError(w, http.StatusUnauthorized, "unauthenticated", msgUnauthenticated)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !Allows(principal, requiredOf(cc.Controller)) {
|
||||||
|
WriteError(w, http.StatusForbidden, "forbidden", msgForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fn(cc)
|
||||||
|
}
|
||||||
|
|
||||||
|
func queryList(ctx context.Context, db *gorm.DB, cc *CompiledController) ([]any, int64, error) {
|
||||||
|
src, ok := cc.Controller.(pact.AdminRecordSource)
|
||||||
|
if !ok || src == nil {
|
||||||
|
return nil, 0, errors.New("cabana: admin controller has no record source")
|
||||||
|
}
|
||||||
|
model := src.NewRecord()
|
||||||
|
mt := reflect.TypeOf(model)
|
||||||
|
if mt == nil || mt.Kind() != reflect.Pointer {
|
||||||
|
return nil, 0, errors.New("cabana: admin model must be a pointer")
|
||||||
|
}
|
||||||
|
q := db.WithContext(ctx).Model(model)
|
||||||
|
if ext, ok := cc.Controller.(pact.ListExtendQuery); ok && ext != nil {
|
||||||
|
if next := ext.ListExtendQuery(ctx, q); next != nil {
|
||||||
|
q = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var total int64
|
||||||
|
if err := q.Session(&gorm.Session{}).Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
per := 20
|
||||||
|
if cc.List != nil && cc.List.RecordsPerPage > 0 {
|
||||||
|
per = cc.List.RecordsPerPage
|
||||||
|
}
|
||||||
|
slice := reflect.New(reflect.SliceOf(mt.Elem()))
|
||||||
|
if err := q.Session(&gorm.Session{}).Limit(per).Find(slice.Interface()).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
values := slice.Elem()
|
||||||
|
out := make([]any, values.Len())
|
||||||
|
for i := 0; i < values.Len(); i++ {
|
||||||
|
out[i] = values.Index(i).Addr().Interface()
|
||||||
|
}
|
||||||
|
return out, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func projectRow(row any, cols []ListColumn) map[string]any {
|
||||||
|
v := reflect.ValueOf(row)
|
||||||
|
for v.Kind() == reflect.Pointer {
|
||||||
|
if v.IsNil() {
|
||||||
|
return map[string]any{}
|
||||||
|
}
|
||||||
|
v = v.Elem()
|
||||||
|
}
|
||||||
|
out := make(map[string]any, len(cols)+1)
|
||||||
|
if id := fieldByColumn(v, "id"); id.IsValid() && id.CanInterface() {
|
||||||
|
out["id"] = id.Interface()
|
||||||
|
}
|
||||||
|
for _, col := range cols {
|
||||||
|
field := fieldByColumn(v, col.Key)
|
||||||
|
if !field.IsValid() || !field.CanInterface() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[col.Key] = field.Interface()
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func fieldByColumn(v reflect.Value, column string) reflect.Value {
|
||||||
|
if v.Kind() != reflect.Struct {
|
||||||
|
return reflect.Value{}
|
||||||
|
}
|
||||||
|
t := v.Type()
|
||||||
|
for i := 0; i < t.NumField(); i++ {
|
||||||
|
field := t.Field(i)
|
||||||
|
if field.PkgPath != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if gormColumn(field) == column || strings.EqualFold(field.Name, column) {
|
||||||
|
return v.Field(i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return reflect.Value{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func gormColumn(field reflect.StructField) string {
|
||||||
|
for _, part := range strings.Split(field.Tag.Get("gorm"), ";") {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if name, ok := strings.CutPrefix(part, "column:"); ok {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func uitoa(id uint) string {
|
||||||
|
return strconv.FormatUint(uint64(id), 10)
|
||||||
|
}
|
||||||
59
cabana/registry.go
Normal file
59
cabana/registry.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package cabana
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/pact"
|
||||||
|
"git.golem15.com/golem15/summercms/party"
|
||||||
|
)
|
||||||
|
|
||||||
|
type controllerRef struct {
|
||||||
|
plugin party.Plugin
|
||||||
|
ctl pact.AdminController
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectControllers(plugins []party.Plugin) ([]controllerRef, error) {
|
||||||
|
var out []controllerRef
|
||||||
|
for _, p := range plugins {
|
||||||
|
src, ok := p.(pact.HasAdminControllers)
|
||||||
|
if !ok || p == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, ctl := range src.AdminControllers() {
|
||||||
|
if ctl == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id := ctl.ID()
|
||||||
|
if id != p.ID() && !strings.HasPrefix(id, p.ID()+".") {
|
||||||
|
return nil, fmt.Errorf("cabana: controller %s is not owned by plugin %s", id, p.ID())
|
||||||
|
}
|
||||||
|
out = append(out, controllerRef{plugin: p, ctl: ctl})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func compileRegistry(items []controllerRef) (*Registry, error) {
|
||||||
|
byID := make(map[string]*CompiledController, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
id := item.ctl.ID()
|
||||||
|
if _, exists := byID[id]; exists {
|
||||||
|
return nil, fmt.Errorf("cabana: duplicate admin controller %s", id)
|
||||||
|
}
|
||||||
|
assets, ok := item.plugin.(pact.AdminAssets)
|
||||||
|
if !ok || assets == nil || assets.AdminFS() == nil {
|
||||||
|
return nil, fmt.Errorf("cabana: plugin %s has admin controllers but no AdminFS", item.plugin.ID())
|
||||||
|
}
|
||||||
|
list, err := compileList(item.plugin.ID(), item.ctl, assets.AdminFS())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
byID[id] = &CompiledController{
|
||||||
|
PluginID: item.plugin.ID(),
|
||||||
|
Controller: item.ctl,
|
||||||
|
List: list,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &Registry{byID: byID}, nil
|
||||||
|
}
|
||||||
161
cabana/schema.go
Normal file
161
cabana/schema.go
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
package cabana
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.golem15.com/golem15/summercms/pact"
|
||||||
|
"github.com/goccy/go-yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
type listDocument struct {
|
||||||
|
List string `yaml:"list"`
|
||||||
|
ModelClass string `yaml:"modelClass"`
|
||||||
|
Title string `yaml:"title"`
|
||||||
|
RecordURL string `yaml:"recordUrl"`
|
||||||
|
NoRecordsMessage string `yaml:"noRecordsMessage"`
|
||||||
|
RecordsPerPage int `yaml:"recordsPerPage"`
|
||||||
|
ShowCheckboxes bool `yaml:"showCheckboxes"`
|
||||||
|
ShowSearch bool `yaml:"showSearch"`
|
||||||
|
Toolbar *struct {
|
||||||
|
Buttons string `yaml:"buttons"`
|
||||||
|
Search *struct {
|
||||||
|
Prompt string `yaml:"prompt"`
|
||||||
|
} `yaml:"search"`
|
||||||
|
} `yaml:"toolbar"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type columnsDocument struct {
|
||||||
|
Columns yaml.MapSlice `yaml:"columns"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type columnDocument struct {
|
||||||
|
Label string `yaml:"label"`
|
||||||
|
Searchable bool `yaml:"searchable"`
|
||||||
|
Sortable bool `yaml:"sortable"`
|
||||||
|
Type string `yaml:"type"`
|
||||||
|
Relation string `yaml:"relation"`
|
||||||
|
Select string `yaml:"select"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func compileList(pluginID string, ctl pact.AdminController, fsys fs.FS) (*ListSchema, error) {
|
||||||
|
dir := strings.Trim(path.Clean(ctl.ConfigDir()), "/")
|
||||||
|
if dir == "." || strings.HasPrefix(dir, "..") {
|
||||||
|
return nil, bootErr(pluginID, ctl.ID(), ctl.ConfigDir(), fmt.Errorf("config directory escapes the plugin"))
|
||||||
|
}
|
||||||
|
cfgPath := path.Join(dir, "config_list.yaml")
|
||||||
|
raw, err := readAsset(fsys, cfgPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, bootErr(pluginID, ctl.ID(), cfgPath, err)
|
||||||
|
}
|
||||||
|
var doc listDocument
|
||||||
|
if err := decodeStrict(raw, &doc); err != nil {
|
||||||
|
return nil, bootErr(pluginID, ctl.ID(), cfgPath, err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(doc.List) == "" {
|
||||||
|
return nil, bootErr(pluginID, ctl.ID(), cfgPath, fmt.Errorf("list file is empty"))
|
||||||
|
}
|
||||||
|
colPath, err := assetPath(pluginID, doc.List)
|
||||||
|
if err != nil {
|
||||||
|
return nil, bootErr(pluginID, ctl.ID(), cfgPath, err)
|
||||||
|
}
|
||||||
|
colRaw, err := readAsset(fsys, colPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, bootErr(pluginID, ctl.ID(), colPath, err)
|
||||||
|
}
|
||||||
|
var cols columnsDocument
|
||||||
|
if err := decodeStrict(colRaw, &cols); err != nil {
|
||||||
|
return nil, bootErr(pluginID, ctl.ID(), colPath, err)
|
||||||
|
}
|
||||||
|
compiled := make([]ListColumn, 0, len(cols.Columns))
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
for _, item := range cols.Columns {
|
||||||
|
key, ok := item.Key.(string)
|
||||||
|
if !ok || !identifier(key) {
|
||||||
|
return nil, bootErr(pluginID, ctl.ID(), colPath, fmt.Errorf("column key %v is not an identifier", item.Key))
|
||||||
|
}
|
||||||
|
if _, dup := seen[key]; dup {
|
||||||
|
return nil, bootErr(pluginID, ctl.ID(), colPath, fmt.Errorf("duplicate column %s", key))
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
encoded, err := yaml.Marshal(item.Value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, bootErr(pluginID, ctl.ID(), colPath, err)
|
||||||
|
}
|
||||||
|
var spec columnDocument
|
||||||
|
if err := decodeStrict(encoded, &spec); err != nil {
|
||||||
|
return nil, bootErr(pluginID, ctl.ID(), colPath, fmt.Errorf("column %s: %w", key, err))
|
||||||
|
}
|
||||||
|
compiled = append(compiled, ListColumn{
|
||||||
|
Key: key,
|
||||||
|
Label: spec.Label,
|
||||||
|
Searchable: spec.Searchable,
|
||||||
|
Sortable: spec.Sortable,
|
||||||
|
Type: spec.Type,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
per := doc.RecordsPerPage
|
||||||
|
if per < 1 {
|
||||||
|
per = 20
|
||||||
|
}
|
||||||
|
showSearch := doc.ShowSearch
|
||||||
|
if doc.Toolbar != nil && doc.Toolbar.Search != nil {
|
||||||
|
showSearch = true
|
||||||
|
}
|
||||||
|
return &ListSchema{
|
||||||
|
Title: doc.Title,
|
||||||
|
RecordsPerPage: per,
|
||||||
|
ShowSearch: showSearch,
|
||||||
|
Columns: compiled,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeStrict(raw []byte, dest any) error {
|
||||||
|
dec := yaml.NewDecoder(bytes.NewReader(raw), yaml.DisallowUnknownField())
|
||||||
|
if err := dec.Decode(dest); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readAsset(fsys fs.FS, name string) ([]byte, error) {
|
||||||
|
name = path.Clean(name)
|
||||||
|
if name == "." || strings.HasPrefix(name, "..") || strings.Contains(name, "..") {
|
||||||
|
return nil, fmt.Errorf("path escapes the plugin")
|
||||||
|
}
|
||||||
|
return fs.ReadFile(fsys, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func assetPath(pluginID, ref string) (string, error) {
|
||||||
|
ref = strings.TrimSpace(ref)
|
||||||
|
ref = strings.TrimPrefix(ref, "~/")
|
||||||
|
prefix := "plugins/" + strings.ReplaceAll(pluginID, ".", "/") + "/"
|
||||||
|
ref = strings.TrimPrefix(ref, prefix)
|
||||||
|
ref = path.Clean(ref)
|
||||||
|
if ref == "." || strings.HasPrefix(ref, "..") || strings.Contains(ref, "..") {
|
||||||
|
return "", fmt.Errorf("list path escapes the plugin")
|
||||||
|
}
|
||||||
|
return ref, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func identifier(s string) bool {
|
||||||
|
if s == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i, r := range s {
|
||||||
|
switch {
|
||||||
|
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r == '_':
|
||||||
|
case i > 0 && r >= '0' && r <= '9':
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func bootErr(pluginID, controllerID, file string, err error) error {
|
||||||
|
return fmt.Errorf("cabana: admin schema %s/%s/%s: %w", pluginID, controllerID, file, err)
|
||||||
|
}
|
||||||
67
lagoon/backend_admin_migrations.go
Normal file
67
lagoon/backend_admin_migrations.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package lagoon
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-gormigrate/gormigrate/v2"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BackendAdminMigrations creates Winter-shaped backend identity tables and
|
||||||
|
// seeds the developer and publisher system roles. History is isolated under
|
||||||
|
// the summercms.cabana plugin id.
|
||||||
|
var BackendAdminMigrations = []*gormigrate.Migration{
|
||||||
|
{
|
||||||
|
ID: "202609240001_backend_admin_identity",
|
||||||
|
Migrate: func(tx *gorm.DB) error {
|
||||||
|
stmts := []string{
|
||||||
|
`CREATE TABLE backend_user_roles (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
code TEXT UNIQUE,
|
||||||
|
description TEXT,
|
||||||
|
permissions TEXT,
|
||||||
|
is_system BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE backend_users (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
first_name TEXT,
|
||||||
|
last_name TEXT,
|
||||||
|
login TEXT NOT NULL UNIQUE,
|
||||||
|
email TEXT NOT NULL UNIQUE,
|
||||||
|
password TEXT NOT NULL,
|
||||||
|
activation_code TEXT,
|
||||||
|
persist_code TEXT,
|
||||||
|
reset_password_code TEXT,
|
||||||
|
permissions TEXT,
|
||||||
|
is_activated BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
is_superuser BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
role_id INTEGER REFERENCES backend_user_roles(id),
|
||||||
|
activated_at TIMESTAMPTZ,
|
||||||
|
last_login TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
deleted_at TIMESTAMPTZ
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX backend_users_role_id_index ON backend_users (role_id)`,
|
||||||
|
`CREATE INDEX backend_users_deleted_at_index ON backend_users (deleted_at)`,
|
||||||
|
`INSERT INTO backend_user_roles (name, code, description, permissions, is_system)
|
||||||
|
VALUES
|
||||||
|
('Developer', 'developer', 'Site administrator with access to developer tools.', '{}', TRUE),
|
||||||
|
('Publisher', 'publisher', 'Site editor with access to publishing tools.', '{}', TRUE)`,
|
||||||
|
}
|
||||||
|
for _, stmt := range stmts {
|
||||||
|
if err := tx.Exec(stmt).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
Rollback: func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Exec(`DROP TABLE IF EXISTS backend_users`).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tx.Exec(`DROP TABLE IF EXISTS backend_user_roles`).Error
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -57,8 +57,8 @@ func migrator(gdb *gorm.DB, pluginID string, migrations []*gormigrate.Migration)
|
|||||||
}, migrations), nil
|
}, migrations), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Migrate runs the framework-owned system_files set first, then each
|
// Migrate runs the framework-owned system_files and backend-admin sets
|
||||||
// plugin's HasMigrations set in party.Activate order.
|
// first, then each plugin's HasMigrations set in party.Activate order.
|
||||||
func Migrate(gdb *gorm.DB, plugins []party.Plugin) error {
|
func Migrate(gdb *gorm.DB, plugins []party.Plugin) error {
|
||||||
if gdb == nil {
|
if gdb == nil {
|
||||||
return fmt.Errorf("lagoon: gorm db is nil")
|
return fmt.Errorf("lagoon: gorm db is nil")
|
||||||
@@ -70,6 +70,13 @@ func Migrate(gdb *gorm.DB, plugins []party.Plugin) error {
|
|||||||
if err := m.Migrate(); err != nil {
|
if err := m.Migrate(); err != nil {
|
||||||
return fmt.Errorf("lagoon: migrate system_files: %w", err)
|
return fmt.Errorf("lagoon: migrate system_files: %w", err)
|
||||||
}
|
}
|
||||||
|
admin, err := migrator(gdb, "summercms.cabana", BackendAdminMigrations)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := admin.Migrate(); err != nil {
|
||||||
|
return fmt.Errorf("lagoon: migrate backend admin: %w", err)
|
||||||
|
}
|
||||||
for _, p := range plugins {
|
for _, p := range plugins {
|
||||||
hm, ok := p.(pact.HasMigrations)
|
hm, ok := p.(pact.HasMigrations)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"git.golem15.com/golem15/summercms/bonfire"
|
"git.golem15.com/golem15/summercms/bonfire"
|
||||||
"github.com/go-gormigrate/gormigrate/v2"
|
"github.com/go-gormigrate/gormigrate/v2"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HasCommands is implemented by plugins that register console commands.
|
// HasCommands is implemented by plugins that register console commands.
|
||||||
@@ -111,6 +112,116 @@ type HasAdminControllers interface {
|
|||||||
AdminControllers() []AdminController
|
AdminControllers() []AdminController
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdminAssets is the plugin-owned embedded tree of Winter admin YAML.
|
||||||
|
// Paths are relative to the plugin root (controllers/..., models/...).
|
||||||
|
type AdminAssets interface {
|
||||||
|
AdminFS() fs.FS
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminPermissioned is the D-03 permission list enforced before schema or SQL.
|
||||||
|
type AdminPermissioned interface {
|
||||||
|
RequiredPermissions() []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminRecordSource supplies the GORM model the generic admin handlers query.
|
||||||
|
// NewRecord returns a pointer to the model struct.
|
||||||
|
type AdminRecordSource interface {
|
||||||
|
NewRecord() any
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permission is one registerPermissions() entry.
|
||||||
|
type Permission struct {
|
||||||
|
Code string
|
||||||
|
Tab string
|
||||||
|
Label string
|
||||||
|
Roles []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasPermissions is implemented by plugins that declare backend permissions.
|
||||||
|
type HasPermissions interface {
|
||||||
|
Permissions() []Permission
|
||||||
|
}
|
||||||
|
|
||||||
|
// NavigationItem is one registerNavigation() entry. Controller is the admin
|
||||||
|
// controller ID; the SPA derives its route from that ID.
|
||||||
|
type NavigationItem struct {
|
||||||
|
Label string
|
||||||
|
Icon string
|
||||||
|
Permissions []string
|
||||||
|
Order int
|
||||||
|
Controller string
|
||||||
|
SideMenu []NavigationItem
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasNavigation is implemented by plugins that declare backend navigation.
|
||||||
|
type HasNavigation interface {
|
||||||
|
Navigation() []NavigationItem
|
||||||
|
}
|
||||||
|
|
||||||
|
// SettingsItem is one registerSettings() entry.
|
||||||
|
type SettingsItem struct {
|
||||||
|
Code string
|
||||||
|
Label string
|
||||||
|
Description string
|
||||||
|
Category string
|
||||||
|
Icon string
|
||||||
|
Model string
|
||||||
|
Order int
|
||||||
|
Keywords []string
|
||||||
|
Permissions []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasSettings is implemented by plugins that declare settings screens.
|
||||||
|
type HasSettings interface {
|
||||||
|
Settings() []SettingsItem
|
||||||
|
}
|
||||||
|
|
||||||
|
// Option is one dropdown choice. Label may be a phrase key until request time.
|
||||||
|
type Option struct {
|
||||||
|
Value string `json:"value"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DropdownOptionsProvider serves method-backed dropdown options.
|
||||||
|
type DropdownOptionsProvider interface {
|
||||||
|
DropdownOptions(field string) []Option
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListExtendQuery optionally narrows the admin list query.
|
||||||
|
type ListExtendQuery interface {
|
||||||
|
ListExtendQuery(ctx context.Context, db *gorm.DB) *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormExtendQuery optionally narrows admin form record lookup.
|
||||||
|
type FormExtendQuery interface {
|
||||||
|
FormExtendQuery(ctx context.Context, db *gorm.DB) *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormBeforeCreate optionally rejects or stamps a record before insert.
|
||||||
|
type FormBeforeCreate interface {
|
||||||
|
FormBeforeCreate(ctx context.Context, model any) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormBeforeUpdate optionally rejects or stamps a record before update.
|
||||||
|
type FormBeforeUpdate interface {
|
||||||
|
FormBeforeUpdate(ctx context.Context, model any) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// RelationExtendManageQuery optionally narrows relation-manager candidates.
|
||||||
|
type RelationExtendManageQuery interface {
|
||||||
|
RelationExtendManageQuery(ctx context.Context, relation string, db *gorm.DB) *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// RelationBeforeLink optionally stamps pivot columns before a link insert.
|
||||||
|
type RelationBeforeLink interface {
|
||||||
|
RelationBeforeLink(ctx context.Context, relation string, parent, related any, pivot map[string]any) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// FilterScope is a model method referenced by a config_filter scope name.
|
||||||
|
type FilterScope interface {
|
||||||
|
FilterScope(name string, db *gorm.DB, value any) *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
// HasLang is implemented by plugins that ship embedded translation YAML
|
// HasLang is implemented by plugins that ship embedded translation YAML
|
||||||
// under lang/<locale>/<group>.yaml.
|
// under lang/<locale>/<group>.yaml.
|
||||||
type HasLang interface {
|
type HasLang interface {
|
||||||
@@ -137,8 +248,6 @@ type OptionalMessage interface {
|
|||||||
// packages exist:
|
// packages exist:
|
||||||
//
|
//
|
||||||
// HasListeners
|
// HasListeners
|
||||||
// HasNavigation
|
|
||||||
// HasPermissions
|
|
||||||
// HasSchedule
|
// HasSchedule
|
||||||
//
|
//
|
||||||
// The kernel type-asserts HasConfig (party, before Register), HasCommands
|
// The kernel type-asserts HasConfig (party, before Register), HasCommands
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.golem15.com/golem15/summercms/backpack"
|
"git.golem15.com/golem15/summercms/backpack"
|
||||||
|
"git.golem15.com/golem15/summercms/cabana"
|
||||||
"git.golem15.com/golem15/summercms/pact"
|
"git.golem15.com/golem15/summercms/pact"
|
||||||
"git.golem15.com/golem15/summercms/party"
|
"git.golem15.com/golem15/summercms/party"
|
||||||
"git.golem15.com/golem15/summercms/towel"
|
"git.golem15.com/golem15/summercms/towel"
|
||||||
@@ -518,6 +519,17 @@ func BuildRouter(app *backpack.App, plugins []party.Plugin) (*Router, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
admin, err := cabana.Activate(app, plugins)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if admin != nil {
|
||||||
|
if err := r.RegisterMiddleware("summercms.cabana", "backend", admin.Middleware); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r.BindPlugin("summercms.cabana")
|
||||||
|
admin.Mount(r)
|
||||||
|
}
|
||||||
for _, rt := range r.routes {
|
for _, rt := range r.routes {
|
||||||
if _, err := r.wrap(rt); err != nil {
|
if _, err := r.wrap(rt); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
Reference in New Issue
Block a user