package bm import ( "net/http" "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/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" ) // Buckets declares the rate limits of the public calendar feed. 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) }, }, } } // 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) }) // 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) { g.Get("/{path...}", static.ServeHTTP) }) return nil } // 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 }