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 }