diff --git a/plugin.go b/plugin.go index bd779f3..ce0a245 100644 --- a/plugin.go +++ b/plugin.go @@ -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. diff --git a/realtime.go b/realtime.go new file mode 100644 index 0000000..036de5a --- /dev/null +++ b/realtime.go @@ -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 +} diff --git a/routes.go b/routes.go index a03f4e1..2aca0ad 100644 --- a/routes.go +++ b/routes.go @@ -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.