Files
sm-bm-plugin/realtime_access.go
Jakub Zych 464c74b80f test: expect the MCP view of BM answers
- viaMCP, includeBody and the material views (no body unless asked)
- jz.bm.mcp-links: absolute url / *_url under MCP only
- jz.bm.manage keyed per user for dispatched calls
- bm_material and bm_materials in the pinned tool list
Compile-only stubs; the tests fail on their assertions.
2026-10-28 14:34:58 +01:00

109 lines
3.3 KiB
Go

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 and the MCP link rewriter.
func (p *Plugin) Middlewares() map[string]pact.Middleware {
return map[string]pact.Middleware{
MiddlewareRealtimeAccess: p.realtimeAccess,
MiddlewareMCPLinks: p.mcpLinks,
}
}
// 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})
}