Centrifugo calls the proxy from 127.0.0.1 for every client, so the per-IP fallback put every subscribe into one bucket that any token holder could exhaust. Throttle only the token route, per user.
203 lines
7.5 KiB
Go
203 lines
7.5 KiB
Go
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"
|
|
"git.golem15.com/jakub/sm-bm-plugin/controllers"
|
|
"git.golem15.com/jakub/sm-bm-plugin/controllers/api"
|
|
"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"
|
|
)
|
|
|
|
// 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 {
|
|
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)
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
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.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)
|
|
})
|
|
// 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("/courses", h.ManagedCourses, "user.scope:read", "user.permission:"+controllers.PermCourses)
|
|
g.Get("/materials", h.ManagedMaterials, "user.scope:read", "user.permission:"+controllers.PermMaterials)
|
|
g.Post("/materials", h.UpsertMaterial, "user.scope:write", "user.permission:"+controllers.PermMaterials)
|
|
g.Delete("/materials/{id}", h.DeleteMaterial, "user.scope:write", "user.permission:"+controllers.PermMaterials)
|
|
g.Where("id", "[0-9]+")
|
|
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 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.
|
|
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", 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
|
|
},
|
|
Translator: func() *phrasebook.Translator {
|
|
if p.app == nil {
|
|
return nil
|
|
}
|
|
tr, _ := p.app.Lookup[*phrasebook.Translator]()
|
|
return tr
|
|
},
|
|
}
|
|
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
|
|
}
|