GET /_bm/api/v1/chat gives a chat-group member ogólny (the bm/1 group conversation, adopted once as the system default channel under an advisory lock), its unread and mention counts, the member's DMs newest message first, the moderator flag and the composer limits. Every member bootstrap syncs participants with the group: missing members join ogólny, users no longer in the group lose their rows in every bm/1 conversation. A non-member gets only member:false and is pruned. GET /_bm/api/v1/chat/members lists members (id, name, surname, handle, is_bot from golem15.chatbots when active) and assigns missing handles; non-members get 404. The chatchannels access policy (ChatAccess) is installed from a database hook: bm/1 for members only, nobody manages channels. bm:chat-sync creates the groups when missing, syncs and assigns handles. Migration 20261026130000_seed_jz_bm_chat_groups creates studenci and moderatorzy without members; its rollback keeps them.
307 lines
12 KiB
Go
307 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 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
|
|
},
|
|
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]+")
|
|
}
|
|
}
|