feat(03-01): serve seeded genres behind JWT middleware

golem15.user registers jwt.auth, golem15.fonoteka mounts GET /_fonoteka/api/v1/genres
through that guard, and a smoke test reads the 15 migrated rows with a test-only token.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jakub Zych
2026-09-17 20:04:18 +02:00
parent f975ad94bb
commit 5530fa4ebf
4 changed files with 91 additions and 6 deletions

1
go.mod
View File

@@ -13,6 +13,7 @@ require (
require (
github.com/fsnotify/fsnotify v1.10.1 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect

2
go.sum
View File

@@ -7,6 +7,8 @@ github.com/go-gormigrate/gormigrate/v2 v2.1.7 h1:PdT4jVPbRb4R+0Ey2R0yJOdctVf4Whi
github.com/go-gormigrate/gormigrate/v2 v2.1.7/go.mod h1:3ouXglTuPrKF5+7cQyVGfvAXTU4vLMaYh9+EPl03uog=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=

View File

@@ -5,33 +5,59 @@ import (
"io/fs"
"git.golem15.com/golem15/summercms/backpack"
"git.golem15.com/golem15/summercms/bouncer"
"git.golem15.com/golem15/summercms/pact"
"git.golem15.com/golem15/summercms/party"
"github.com/go-gormigrate/gormigrate/v2"
)
var (
_ party.Plugin = (*Plugin)(nil)
_ pact.HasConfig = (*Plugin)(nil)
_ pact.HasMigrations = (*Plugin)(nil)
_ party.Plugin = (*Plugin)(nil)
_ pact.HasConfig = (*Plugin)(nil)
_ pact.HasMigrations = (*Plugin)(nil)
_ pact.HasMiddleware = (*Plugin)(nil)
_ pact.HasModels = (*Plugin)(nil)
_ bouncer.UserProvider = gormUsers{}
)
//go:embed config
var configFS embed.FS
// Plugin is the stub golem15.user plugin.
type Plugin struct{}
type Plugin struct {
app *backpack.App
}
func (p *Plugin) ID() string { return "golem15.user" }
func (p *Plugin) Requires() []string { return nil }
func (p *Plugin) Register(*backpack.App) error { return nil }
func (p *Plugin) Boot(*backpack.App) error { return nil }
func (p *Plugin) Register(app *backpack.App) error {
p.app = app
return nil
}
func (p *Plugin) Boot(app *backpack.App) error {
p.app = app
_, err := jwtSecret(app)
return err
}
func (p *Plugin) ConfigFS() fs.FS { return configFS }
func (p *Plugin) Migrations() []*gormigrate.Migration { return migrations }
func (p *Plugin) Models() []any { return []any{User{}} }
func (p *Plugin) Middlewares() map[string]pact.Middleware {
secret, err := jwtSecret(p.app)
if err != nil {
return nil
}
return map[string]pact.Middleware{
"jwt.auth": bouncer.Middleware(secret, gormUsers{app: p.app}),
}
}
func init() {
party.Register(&Plugin{})
}

56
user.go Normal file
View File

@@ -0,0 +1,56 @@
package user
import (
"context"
"errors"
"fmt"
"strings"
"git.golem15.com/golem15/summercms/backpack"
"git.golem15.com/golem15/summercms/bouncer"
"gorm.io/gorm"
)
// User is the minimal users row the JWT guard loads.
type User struct {
ID uint `gorm:"column:id;primaryKey"`
Email string `gorm:"column:email"`
Password string `gorm:"column:password"`
MustChangePassword bool `gorm:"column:must_change_password"`
}
func (User) TableName() string { return "users" }
type gormUsers struct {
app *backpack.App
}
func (g gormUsers) FindByID(ctx context.Context, id uint) (*bouncer.Principal, error) {
if g.app == nil {
return nil, fmt.Errorf("golem15.user: app is nil")
}
gdb, ok := g.app.Lookup[*gorm.DB]()
if !ok || gdb == nil {
return nil, fmt.Errorf("golem15.user: gorm handle is missing")
}
var row User
err := gdb.WithContext(ctx).Where("id = ?", id).Take(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &bouncer.Principal{ID: row.ID, MustChangePassword: row.MustChangePassword}, nil
}
func jwtSecret(app *backpack.App) (string, error) {
if app == nil || app.Config == nil {
return "", fmt.Errorf("golem15.user: jwt.secret is empty (set SUMMER_GOLEM15__USER__JWT__SECRET)")
}
secret := strings.TrimSpace(app.Config.String("golem15.user.jwt.secret"))
if secret == "" {
return "", fmt.Errorf("golem15.user: jwt.secret is empty (set SUMMER_GOLEM15__USER__JWT__SECRET)")
}
return secret, nil
}