Files
sm-bm-plugin/routes.go
Jakub Zych eea171cee6 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.
2026-10-26 15:49:29 +01:00

311 lines
12 KiB
Go

package bm
import (
"net/http"
"strconv"
"time"
chatclasses "git.golem15.com/golem15/sm-chat-plugin/classes"
chanclasses "git.golem15.com/golem15/sm-chatchannels-plugin/classes"
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"
"git.golem15.com/jakub/sm-bm-plugin/controllers"
"git.golem15.com/jakub/sm-bm-plugin/controllers/api"
"git.golem15.com/jakub/sm-bm-plugin/models"
"gocloud.dev/blob"
"gorm.io/gorm"
)
var (
_ pact.HasRoutes = (*Plugin)(nil)
_ surf.BucketProvider = (*Plugin)(nil)
)
// The throttles of the public calendar feed: per client IP, and per token
// so that one leaked URL cannot be hammered from many addresses.
const (
bucketCalendarIP = "throttle:jz.bm.calendar-ip"
bucketCalendarToken = "throttle:jz.bm.calendar-token"
// 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 route per user (it runs
// after jwt.auth), or per client IP for a request without one. The
// subscribe proxy is not throttled: see mountRealtime.
bucketRealtime = "throttle:jz.bm.realtime"
// bucketChat throttles the BM chat routes per user (they run after
// jwt.auth), or per client IP for a request without one.
bucketChat = "throttle:jz.bm.chat"
)
// Buckets declares the rate limits of the public calendar feed, the
// management API, the realtime routes and the BM chat routes.
func (p *Plugin) Buckets() map[string]surf.Bucket {
trusted := surf.TrustedProxies(nil)
if p.app != nil {
trusted = surf.TrustedProxies(p.app.Config)
}
return map[string]surf.Bucket{
"jz.bm.calendar-ip": {
Max: 60,
Decay: time.Minute,
Key: func(r *http.Request) string { return "jz.bm.calendar.ip|" + surf.ClientIP(r, trusted) },
},
"jz.bm.calendar-token": {
Max: 20,
Decay: time.Minute,
Key: func(r *http.Request) string { return "jz.bm.calendar.token|" + r.PathValue("token") },
},
"jz.bm.manage": {
Max: 120,
Decay: time.Minute,
Key: func(r *http.Request) string {
if secret := usermiddleware.BearerToken(r); secret != "" {
return "jz.bm.manage.token|" + userclasses.HashAPIToken(secret)
}
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)
},
},
"jz.bm.chat": {
Max: 240,
Decay: time.Minute,
Key: func(r *http.Request) string {
if u, ok := bouncer.User(r.Context()); ok && u.ID != 0 {
return "jz.bm.chat|u:" + strconv.FormatUint(uint64(u.ID), 10)
}
return "jz.bm.chat|ip:" + surf.ClientIP(r, trusted)
},
},
}
}
// APIPrefix is the path prefix of the student API.
const APIPrefix = "/_bm/api/v1"
// ManagePrefix is the path prefix of the management API.
const ManagePrefix = "/api/v1/bm"
// Routes declares the student API and the management API. Every student
// route needs a golem15.user JWT (jwt.auth); students only read, except for
// their own reading progress and owned marks.
func (p *Plugin) Routes(r pact.Router) error {
h := p.handlers()
r.Group(APIPrefix, surf.Use("jwt.auth", "locale.from-principal"), func(g pact.Router) {
g.Get("/dashboard", h.Dashboard)
g.Get("/courses", h.Courses)
g.Get("/courses/{id}", h.Course)
g.Where("id", "[0-9]+")
g.Get("/schedule", h.Schedule)
g.Get("/meetings", h.Meetings)
g.Get("/deadlines", h.Deadlines)
g.Get("/readings", h.Readings)
g.Put("/readings/{id}/progress", h.MarkRead)
g.Where("id", "[0-9]+")
g.Delete("/readings/{id}/progress", h.UnmarkRead)
g.Where("id", "[0-9]+")
g.Put("/readings/{id}/owned", h.MarkOwned)
g.Where("id", "[0-9]+")
g.Delete("/readings/{id}/owned", h.UnmarkOwned)
g.Where("id", "[0-9]+")
g.Get("/materials", h.Materials)
g.Get("/materials/{id}", h.Material)
g.Where("id", "[0-9]+")
g.Get("/calendar-feed", h.CalendarFeed)
g.Put("/calendar-feed", h.UpdateCalendarFeed)
g.Post("/calendar-feed/rotate", h.RotateCalendarFeed, "throttle:10,1")
g.Delete("/calendar-feed", h.DeleteCalendarFeed)
if p.app != nil && p.app.HasPlugin("golem15.chat") {
g.Get("/chat", h.Chat, bucketChat)
g.Get("/chat/members", h.ChatMembers, bucketChat)
}
})
// The management API: what bm:seed does, plus materials,
// behind the application-wide user API token. Scope limits delegation;
// user.permission checks the owner's current group permissions.
r.Group(ManagePrefix, surf.Use(bucketManage, "user.api_token"), func(g pact.Router) {
g.Get("/me", h.TokenInfo, "user.scope:read")
g.Get("/materials", h.ManagedMaterials, "user.scope:read", "user.permission:"+controllers.PermMaterials)
g.Get("/materials/{id}", h.ManagedMaterial, "user.scope:read", "user.permission:"+controllers.PermMaterials)
g.Where("id", "[0-9]+")
g.Post("/materials", h.UpsertMaterial, "user.scope:write", "user.permission:"+controllers.PermMaterials)
g.Put("/materials/{id}", h.UpdateMaterial, "user.scope:write", "user.permission:"+controllers.PermMaterials)
g.Where("id", "[0-9]+")
g.Delete("/materials/{id}", h.DeleteMaterial, "user.scope:write", "user.permission:"+controllers.PermMaterials)
g.Where("id", "[0-9]+")
g.Post("/materials/{id}/files", h.AttachMaterialFiles, "user.scope:write", "user.permission:"+controllers.PermMaterials,
"body.limit:"+strconv.Itoa(api.MaterialFilesBodyLimit))
g.Where("id", "[0-9]+")
g.Delete("/materials/{id}/files/{file}", h.DetachMaterialFile, "user.scope:write", "user.permission:"+controllers.PermMaterials)
g.Where("id", "[0-9]+")
g.Where("file", "[0-9]+")
mountRecords(g, h, "/courses", controllers.PermCourses, api.Record[models.Course, *models.Course]{
Filters: []string{"semester", "form", "code"}, Order: "semester, sort_order, id",
}, false)
mountRecords(g, h, "/meetings", controllers.PermSchedule, api.Record[models.Meeting, *models.Meeting]{
Order: "number",
}, false)
mountRecords(g, h, "/class-sessions", controllers.PermSchedule, api.Record[models.ClassSession, *models.ClassSession]{
Filters: []string{"course_id", "meeting_id"}, Order: "starts_at, id",
References: map[string]string{"course_id": "jz_bm_courses", "meeting_id": "jz_bm_meetings"},
}, true)
mountRecords(g, h, "/readings", controllers.PermCourses, api.Record[models.Reading, *models.Reading]{
Filters: []string{"course_id", "is_required"}, Order: "course_id, sort_order, id",
References: map[string]string{"course_id": "jz_bm_courses"},
}, true)
mountRecords(g, h, "/deadlines", controllers.PermSchedule, api.Record[models.Deadline, *models.Deadline]{
Filters: []string{"course_id", "kind"}, Order: "due_at, id",
References: map[string]string{"course_id": "jz_bm_courses"},
}, true)
g.Post("/seed", h.ApplySeed, "user.scope:write",
"user.permission:"+controllers.PermCourses,
"user.permission:"+controllers.PermSchedule,
"user.permission:"+controllers.PermMaterials)
})
// The calendar feed: public, because calendar clients fetch it without
// credentials; the token in the path is the credential. ServeMux cannot
// match a suffix inside a segment, so {token} carries the ".ics" and the
// handler strips it.
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)
if err != nil {
return err
}
r.GroupRaw("", nil, func(g pact.Router) {
if p.app != nil && p.app.HasPlugin("golem15.presentations") {
g.Get("/prezentacje/{slugHash}", static.ServeHTTP, "presentations.public-page")
}
g.Get("/{path...}", static.ServeHTTP)
})
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 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 {
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", MiddlewareRealtimeAccess, bucketRealtime),
ServerToServer: surf.Use(),
Middleware: surf.Use(),
})
}
// handlers builds the API handlers on the application. The database is
// looked up per request, because the binary boots plugins before serve
// publishes it.
func (p *Plugin) handlers() *api.Handlers {
h := &api.Handlers{
DB: func() *gorm.DB {
if p.app == nil {
return nil
}
db, _ := p.app.Lookup[*gorm.DB]()
return db
},
Bucket: func() *blob.Bucket {
if p.app == nil {
return nil
}
b, _ := p.app.Lookup[*blob.Bucket]()
return b
},
Translator: func() *phrasebook.Translator {
if p.app == nil {
return nil
}
tr, _ := p.app.Lookup[*phrasebook.Translator]()
return tr
},
}
if p.app != nil && p.app.HasPlugin("golem15.chat") {
h.ChatService = func() *chatclasses.Service {
svc, _ := p.app.Lookup[*chatclasses.Service]()
return svc
}
h.ChatGroup = p.chatGroup()
h.ChatModeratorGroup = p.chatModeratorGroup()
h.ChatMaxUploadBytes = p.chatMaxUploadBytes()
h.ChatMaxEmbeds = p.chatMaxEmbeds()
h.ChatBots = p.app.HasPlugin("golem15.chatbots")
}
if p.app != nil && p.app.HasPlugin("golem15.chatchannels") {
h.Channels = func() *chanclasses.ChannelService {
svc, _ := p.app.Lookup[*chanclasses.ChannelService]()
return svc
}
}
if p.app != nil && p.app.Config != nil {
h.PerPage = p.app.Config.Int("jz.bm.materials_per_page")
h.DashboardLimit = p.app.Config.Int("jz.bm.dashboard_limit")
h.PublicURL = p.app.Config.String("jz.bm.public_url")
}
return h
}
// recordRoutes is what Record provides to mountRecords.
type recordRoutes interface {
List(*api.Handlers) http.HandlerFunc
Show(*api.Handlers) http.HandlerFunc
Create(*api.Handlers) http.HandlerFunc
Update(*api.Handlers) http.HandlerFunc
Delete(*api.Handlers) http.HandlerFunc
}
// mountRecords mounts the record routes of one table on the management API:
// GET list and one, POST, PATCH, and DELETE when deletable (courses and
// meetings are deleted only in the admin, because rows of other tables hang
// on them).
func mountRecords(g pact.Router, h *api.Handlers, path, perm string, rec recordRoutes, deletable bool) {
read := []string{"user.scope:read", "user.permission:" + perm}
write := []string{"user.scope:write", "user.permission:" + perm}
g.Get(path, rec.List(h), read...)
g.Get(path+"/{id}", rec.Show(h), read...)
g.Where("id", "[0-9]+")
g.Post(path, rec.Create(h), write...)
g.Patch(path+"/{id}", rec.Update(h), write...)
g.Where("id", "[0-9]+")
if deletable {
g.Delete(path+"/{id}", rec.Delete(h), write...)
g.Where("id", "[0-9]+")
}
}