Files
sm-bm-plugin/routes.go
Jakub Zych 2681d304e2 feat: delete-only chat moderation and embed search and resolve
- ChatContext implements golem15.chat's MessageModerator: moderator
  group members who are chat members delete in group conversations
  only, never in DMs
- GET /chat/embeds?type=deadline|class|reading&q= (members only,
  at most 20 rows, escaped ILIKE, q at most 100 characters)
- POST /chat/embeds/resolve {refs} (members only, at most 50 distinct
  refs, 8 KiB body): current data or null per ref
2026-10-26 16:07:26 +01:00

313 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)
g.Get("/chat/embeds", h.ChatEmbeds, bucketChat)
g.Post("/chat/embeds/resolve", h.ResolveChatEmbeds, bucketChat, "body.limit:8192")
}
})
// 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]+")
}
}