From eea171cee6e40eb2e40ed67afd34b37a3d6cfe25 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Mon, 26 Oct 2026 15:49:29 +0100 Subject: [PATCH] feat: open realtime tokens to chat members The connection-token route is guarded by jz.bm.realtime-access instead of presentations.realtime-owner: chat-group members get a token, and when golem15.presentations is active so does everyone its owner guard lets through (looked up by name among the activated plugins, no import). Everyone else gets 401, 403 or 503 in the guard's error shape. The realtime routes mount when golem15.chat or golem15.presentations is active. /_chat, /_chatchannels and /_chatbots are reserved prefixes of the student app. --- plugin.go | 5 +++ realtime_access.go | 105 +++++++++++++++++++++++++++++++++++++++++++++ routes.go | 30 +++++++------ static.go | 8 ++-- 4 files changed, 131 insertions(+), 17 deletions(-) create mode 100644 realtime_access.go diff --git a/plugin.go b/plugin.go index 445f906..586fadd 100644 --- a/plugin.go +++ b/plugin.go @@ -12,6 +12,7 @@ import ( "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/classes" "git.golem15.com/jakub/sm-bm-plugin/console" "git.golem15.com/jakub/sm-bm-plugin/controllers" "github.com/go-gormigrate/gormigrate/v2" @@ -30,6 +31,7 @@ var ( _ pact.AdminAssets = (*Plugin)(nil) _ pact.HasPermissions = (*Plugin)(nil) _ pact.HasNavigation = (*Plugin)(nil) + _ pact.HasMiddleware = (*Plugin)(nil) ) //go:embed config @@ -54,6 +56,9 @@ type Plugin struct { // 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 + // chatMember is the chat-group check of the realtime access guard; nil + // means classes.IsChatMember. Tests inject their own. + chatMember classes.MemberFunc } func (p *Plugin) ID() string { return "jz.bm" } diff --git a/realtime_access.go b/realtime_access.go new file mode 100644 index 0000000..d934edf --- /dev/null +++ b/realtime_access.go @@ -0,0 +1,105 @@ +package bm + +import ( + "net/http" + "sync" + + "git.golem15.com/golem15/summercms/modules/bouncer" + "git.golem15.com/golem15/summercms/modules/pact" + "git.golem15.com/golem15/summercms/modules/party" + "git.golem15.com/golem15/summercms/modules/wire" + "git.golem15.com/jakub/sm-bm-plugin/classes" +) + +// MiddlewareRealtimeAccess guards the realtime connection-token route: a +// token is minted only for someone with a realtime consumer, a chat-group +// member or a presentation owner. It runs after jwt.auth. +const MiddlewareRealtimeAccess = "jz.bm.realtime-access" + +// presentationsOwnerGuard is golem15.presentations' owner guard, named +// here without importing that plugin. +const presentationsOwnerGuard = "presentations.realtime-owner" + +// Middlewares exports the realtime access guard. +func (p *Plugin) Middlewares() map[string]pact.Middleware { + return map[string]pact.Middleware{MiddlewareRealtimeAccess: p.realtimeAccess} +} + +// realtimeAccess lets a chat-group member through, otherwise, when +// golem15.presentations is active, whoever its owner guard lets through +// (it answers its own refusals), and refuses everyone else. The owner guard +// is looked up once, by name, among the activated plugins. With BM's own +// Centrifugo instance the tokens it mints are valid for BM only. +func (p *Plugin) realtimeAccess(next http.Handler) http.Handler { + var once sync.Once + var owner http.Handler + ownerGuard := func() http.Handler { + once.Do(func() { + if mw := p.presentationsMiddleware(presentationsOwnerGuard); mw != nil { + owner = mw(next) + } + }) + return owner + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, ok := bouncer.User(r.Context()) + if !ok || user == nil || user.ID == 0 { + writeRealtimeGuard(w, http.StatusUnauthorized, "Unauthorized") + return + } + db := p.database() + if db == nil { + writeRealtimeGuard(w, http.StatusServiceUnavailable, "Service unavailable") + return + } + if p.app.HasPlugin("golem15.chat") { + isMember := p.chatMember + if isMember == nil { + isMember = classes.IsChatMember + } + member, err := isMember(r.Context(), db, user.ID, p.chatGroup()) + if err != nil { + writeRealtimeGuard(w, http.StatusServiceUnavailable, "Service unavailable") + return + } + if member { + next.ServeHTTP(w, r) + return + } + } + if p.app.HasPlugin("golem15.presentations") { + if h := ownerGuard(); h != nil { + h.ServeHTTP(w, r) + return + } + } + writeRealtimeGuard(w, http.StatusForbidden, "Forbidden") + }) +} + +// presentationsMiddleware returns the named middleware of the activated +// golem15.presentations plugin, or nil. +func (p *Plugin) presentationsMiddleware(name string) pact.Middleware { + if p.app == nil { + return nil + } + activated, ok := p.app.Lookup[party.Activated]() + if !ok { + return nil + } + for _, pl := range activated { + if pl.ID() != "golem15.presentations" { + continue + } + if hm, ok := pl.(pact.HasMiddleware); ok { + return hm.Middlewares()[name] + } + } + return nil +} + +// writeRealtimeGuard answers in the token route's own error shape. +func writeRealtimeGuard(w http.ResponseWriter, status int, message string) { + w.Header().Set("Cache-Control", "no-cache, private") + wire.WriteJSON(w, status, map[string]string{"error": message}) +} diff --git a/routes.go b/routes.go index a0578f7..bad3c09 100644 --- a/routes.go +++ b/routes.go @@ -203,23 +203,27 @@ func (p *Plugin) Routes(r pact.Router) error { // 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. Only the token route is throttled, per user. The subscribe -// proxy is called by Centrifugo from 127.0.0.1 for every client, so any -// throttle there would be one shared bucket that a single token holder -// could exhaust and lock the owner out; it relies on its constant-time -// secret check and Centrifugo's own limits instead. Routes built without -// Boot (bare tests) mount nothing; the default null driver declares no -// routes. +// (server-to-server, authenticated by its shared secret). The realtime +// consumers are the BM chat (golem15.chat) and the phone remote of +// golem15.presentations, so nothing is mounted without either, and the +// token route is limited to chat-group members and presentation owners by +// jz.bm.realtime-access, which reaches the presentations guard by name +// without importing the plugin. Only the token route is throttled, per +// user. The subscribe proxy is called by Centrifugo from 127.0.0.1 for +// every client, so any throttle there would be one shared bucket that a +// single token holder could exhaust and lock everyone out; it relies on its +// constant-time secret check and Centrifugo's own limits instead. 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") { + if p.realtime == nil || p.app == nil { + return nil + } + if !p.app.HasPlugin("golem15.chat") && !p.app.HasPlugin("golem15.presentations") { return nil } return lighthouse.Mount(r, p.realtime.Driver(), lighthouse.Surfaces{ - UserAuth: surf.Use("jwt.auth", "presentations.realtime-owner", bucketRealtime), + UserAuth: surf.Use("jwt.auth", MiddlewareRealtimeAccess, bucketRealtime), ServerToServer: surf.Use(), Middleware: surf.Use(), }) diff --git a/static.go b/static.go index 0aaae6b..d75d79a 100644 --- a/static.go +++ b/static.go @@ -37,10 +37,10 @@ const ( const defaultAdminPath = "/bmadmin" // reservedPrefixes are the path prefixes the student app never answers: the -// APIs of the plugins, the public uploads and /oauth (the social sign-in -// routes of golem15.sociallogin and their provider callbacks). The admin -// path is added from the configuration. -var reservedPrefixes = []string{"/_user", "/_bm", "/_quizzes", "/_presentations", "/api", "/storage", "/oauth"} +// APIs of the plugins (the chat plugins' included), the public uploads and +// /oauth (the social sign-in routes of golem15.sociallogin and their +// provider callbacks). The admin path is added from the configuration. +var reservedPrefixes = []string{"/_user", "/_bm", "/_quizzes", "/_presentations", "/_chat", "/_chatchannels", "/_chatbots", "/api", "/storage", "/oauth"} // errMissingIndex reports a tree without index.html (an unbuilt tree). var errMissingIndex = errors.New("jz.bm: public/index.html missing; run the frontend build (cd frontend && npm ci && npm run build)")