feat: wire the lighthouse realtime service

- Boot builds the realtime service and installs the user lookup
- the Centrifugo driver ships with the app; config keeps the null driver
- token and subscribe-proxy routes mount only with golem15.presentations,
  the token route behind presentations.realtime-owner
- jz.bm.realtime per-user throttle bucket and websockets:health
This commit is contained in:
Jakub Zych
2026-10-08 17:53:54 +02:00
parent 4b36eb940d
commit a740373b0e
3 changed files with 106 additions and 6 deletions

View File

@@ -8,6 +8,8 @@ import (
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/lighthouse"
"git.golem15.com/golem15/summercms/modules/lighthouse/centrifugo"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/jakub/sm-bm-plugin/console"
@@ -49,6 +51,9 @@ type Plugin struct {
// publicFS is the tree of the student app. Nil means the embedded
// public/ directory; tests plant their own.
publicFS fs.FS
// realtime is the app's lighthouse service, built at Boot. Nil when
// Boot did not run, as in tests that build routes on a bare plugin.
realtime *lighthouse.Service
}
func (p *Plugin) ID() string { return "jz.bm" }
@@ -63,10 +68,16 @@ func (p *Plugin) Register(app *backpack.App) error {
}
// Boot keeps the application, so route handlers and commands can reach its
// services, such as the database, when they run. The management API uses the
// shared user.api_token middleware owned by golem15.user.
// services, such as the database, when they run, and wires the realtime
// service. The management API uses the shared user.api_token middleware
// owned by golem15.user.
func (p *Plugin) Boot(app *backpack.App) error {
p.app = app
if app != nil {
if err := p.wireRealtime(app); err != nil {
return err
}
}
return nil
}
@@ -84,10 +95,11 @@ func (p *Plugin) AdminControllers() []pact.AdminController {
return generatedAdminControllers()
}
// Commands returns the generated commands plus bm:seed. API tokens are
// managed application-wide by golem15.user.
// Commands returns the generated commands, bm:seed and websockets:health.
// API tokens are managed application-wide by golem15.user.
func (p *Plugin) Commands() []bonfire.Command {
return append(generatedCommands(), console.SeedCommand(p.withDB))
cmds := append(generatedCommands(), console.SeedCommand(p.withDB))
return append(cmds, centrifugo.Commands(p.app)...)
}
// AdminFS is the admin YAML the controllers read.

49
realtime.go Normal file
View File

@@ -0,0 +1,49 @@
package bm
import (
"context"
"errors"
usermodels "git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/lighthouse"
"gorm.io/gorm"
// The app always ships the Centrifugo driver; config/realtime.yaml
// keeps the null driver until rome's .env selects centrifugo.
_ "git.golem15.com/golem15/summercms/modules/lighthouse/centrifugo"
)
// errDatabaseUnavailable is the user lookup's answer before serve has
// published the database.
var errDatabaseUnavailable = errors.New("jz.bm: database unavailable")
// wireRealtime binds the app to the framework realtime service: it builds
// the service selected by realtime.driver and installs the user lookup the
// connection token reads its display name from. Channel authorizers belong
// to the plugins that own the channels (golem15.presentations).
func (p *Plugin) wireRealtime(app *backpack.App) error {
svc, err := lighthouse.From(app)
if err != nil {
return err
}
p.realtime = svc
svc.SetUserLookup(func(ctx context.Context, id uint) (lighthouse.User, bool, error) {
// Boot runs before serve publishes the database, so the handle is
// resolved per call.
gdb, ok := app.Lookup[*gorm.DB]()
if !ok || gdb == nil {
return lighthouse.User{}, false, errDatabaseUnavailable
}
var u usermodels.User
err := gdb.WithContext(ctx).Select("id", "name").Where("id = ?", id).Take(&u).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return lighthouse.User{}, false, nil
}
if err != nil {
return lighthouse.User{}, false, err
}
return lighthouse.User{ID: u.ID, Name: u.Name}, true, nil
})
return nil
}

View File

@@ -2,10 +2,13 @@ package bm
import (
"net/http"
"strconv"
"time"
userclasses "git.golem15.com/golem15/sm-user-plugin/classes"
usermiddleware "git.golem15.com/golem15/sm-user-plugin/middleware"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/lighthouse"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/surf"
@@ -27,9 +30,13 @@ const (
// bucketManage throttles the management API per token, or per client IP
// for a request without one.
bucketManage = "throttle:jz.bm.manage"
// bucketRealtime throttles the realtime token and subscribe-proxy
// routes per user, or per client IP for a request without one.
bucketRealtime = "throttle:jz.bm.realtime"
)
// Buckets declares the rate limits of the public calendar feed.
// Buckets declares the rate limits of the public calendar feed, the
// management API and the realtime routes.
func (p *Plugin) Buckets() map[string]surf.Bucket {
trusted := surf.TrustedProxies(nil)
if p.app != nil {
@@ -56,6 +63,16 @@ func (p *Plugin) Buckets() map[string]surf.Bucket {
return "jz.bm.manage.ip|" + surf.ClientIP(r, trusted)
},
},
"jz.bm.realtime": {
Max: 120,
Decay: time.Minute,
Key: func(r *http.Request) string {
if u, ok := bouncer.User(r.Context()); ok && u.ID != 0 {
return "jz.bm.realtime|u:" + strconv.FormatUint(uint64(u.ID), 10)
}
return "jz.bm.realtime|ip:" + surf.ClientIP(r, trusted)
},
},
}
}
@@ -113,6 +130,9 @@ func (p *Plugin) Routes(r pact.Router) error {
r.Group(api.CalendarPath, surf.Use(bucketCalendarIP, bucketCalendarToken), func(g pact.Router) {
g.Get("/{token}", h.CalendarICS)
})
if err := p.mountRealtime(r); err != nil {
return err
}
// The student app: the least specific pattern, so every route above and
// the admin's keep precedence. It also answers (404) the reserved paths.
static, err := newStaticHandler(p.app, p.publicFS)
@@ -128,6 +148,25 @@ func (p *Plugin) Routes(r pact.Router) error {
return nil
}
// mountRealtime mounts the realtime driver's routes: with the Centrifugo
// driver the connection-token route (user) and the subscribe proxy
// (server-to-server, authenticated by its shared secret). The phone remote
// of golem15.presentations is the only realtime consumer, so nothing is
// mounted without it, and the token route is limited to presentation owners
// by its presentations.realtime-owner guard, named here without importing
// the plugin. Routes built without Boot (bare tests) mount nothing; the
// default null driver declares no routes.
func (p *Plugin) mountRealtime(r pact.Router) error {
if p.realtime == nil || p.app == nil || !p.app.HasPlugin("golem15.presentations") {
return nil
}
return lighthouse.Mount(r, p.realtime.Driver(), lighthouse.Surfaces{
UserAuth: surf.Use("jwt.auth", "presentations.realtime-owner"),
ServerToServer: surf.Use(),
Middleware: surf.Use(bucketRealtime),
})
}
// handlers builds the API handlers on the application. The database is
// looked up per request, because the binary boots plugins before serve
// publishes it.