Files
sm-bm-plugin/routes.go
Jakub Zych 418f8240a4 feat: private materials with per-user shares
A material may be private: only its author and the users it is shared
with see it. Every student read (list, detail, course detail and
counters, the checklist linked material, the token twins) goes through
one visibility fragment. Existing materials stay public with no author.

GET/PUT /_bm/api/v1/materials/{id}/access let the author, a material
manager, toggle the flag and pick the users; the first change of an
author-less material makes the caller its author.
2026-11-08 11:52:47 +01:00

429 lines
18 KiB
Go

package bm
import (
"crypto/sha256"
"encoding/hex"
"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/cooler"
"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/classes"
"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 user for an MCP tool
// call, else 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"
// bucketToken throttles the token twins of the student API per user
// (it runs after user.api_token), or per client IP for a request
// without one.
bucketToken = "throttle:jz.bm.token"
// bucketUploadIP and bucketUploadTicket throttle the signed material
// upload per client IP and per ticket: it carries no bearer, so the
// ticket in its path is the only credential (D-12).
bucketUploadIP = "throttle:jz.bm.upload-ip"
bucketUploadTicket = "throttle:jz.bm.upload-ticket"
)
// Buckets declares the rate limits of the public calendar feed, the
// management API, the token twins of the student API, the signed material
// upload, 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 {
// An MCP tool call carries no bearer and comes from the
// endpoint's own address; the dispatch marker (set in-process
// only, never by an HTTP request) names its user. The bucket
// runs before user.api_token, so bouncer.User is not set yet.
if d, ok := bouncer.Dispatched(r.Context(), "api_token"); ok && d.Principal.ID != 0 {
return "jz.bm.manage.user|" + strconv.FormatUint(uint64(d.Principal.ID), 10)
}
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.upload-ip": {
Max: 10,
Decay: time.Minute,
Key: func(r *http.Request) string { return "jz.bm.upload.ip|" + surf.ClientIP(r, trusted) },
},
"jz.bm.upload-ticket": {
Max: 3,
Decay: time.Minute,
Key: func(r *http.Request) string {
// A digest, so the ticket itself is not kept as a limiter key.
sum := sha256.Sum256([]byte(r.PathValue("ticket")))
return "jz.bm.upload.ticket|" + hex.EncodeToString(sum[:])[:32]
},
},
"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.token": {
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.token|u:" + strconv.FormatUint(uint64(u.ID), 10)
}
return "jz.bm.token|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"
// TokenPrefix is the path prefix of the token twins of the student API: the
// same routes and handlers behind golem15.user's API token guard, for
// personal tokens and MCP sessions.
const TokenPrefix = APIPrefix + "/token"
// 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 and their own checklists.
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) {
mountStudent(g, h, nil, nil)
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 author's material access panel. It has no token twin: tokens
// and MCP change access through the management API.
g.Get("/materials/{id}/access", h.MaterialAccess)
g.Where("id", "[0-9]+")
g.Put("/materials/{id}/access", h.UpdateMaterialAccess, "body.limit:16384")
g.Where("id", "[0-9]+")
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 personal-token and MCP twins of the student API: the same routes
// and handlers behind user.api_token (golem15.user's MCP guard dispatches
// only to user.api_token routes), with a scope on each. The handlers read
// bouncer.User, so a token reaches only its own user's data. The bucket
// runs after the guard, so it keys by user. jz.bm.mcp-links makes the
// links of an MCP answer absolute. The calendar feed and the chat have no
// twin.
r.Group(TokenPrefix, surf.Use("user.api_token", bucketToken, MiddlewareMCPLinks, "locale.from-principal"), func(g pact.Router) {
mountStudent(g, h, []string{"user.scope:read"}, []string{"user.scope:write"})
})
// 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.
// jz.bm.mcp-links makes the links of an MCP answer absolute.
r.Group(ManagePrefix, surf.Use(bucketManage, "user.api_token", MiddlewareMCPLinks), 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.Post("/materials/{id}/files/inline", h.AttachMaterialInline, "user.scope:write", "user.permission:"+controllers.PermMaterials, "body.limit:"+strconv.Itoa(api.MaterialInlineBodyLimit))
g.Where("id", "[0-9]+")
g.Post("/materials/{id}/files/upload-url", h.CreateMaterialUpload, "user.scope:write", "user.permission:"+controllers.PermMaterials)
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", "meeting_id", "is_required"}, Order: "course_id, sort_order, id",
References: map[string]string{"course_id": "jz_bm_courses", "meeting_id": "jz_bm_meetings"},
}, 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 signed material upload (D-10, D-12): no bearer, because curl sends
// the file without one; the ticket in the path is the credential. It is
// bound to one material and one user, expires and works once, and the
// handler re-checks the user's jz.bm.access_materials permission.
// Throttled per IP and per ticket; the body is capped at one material
// file plus the multipart framing.
r.Group(APIPrefix+"/uploads", surf.Use(bucketUploadIP, bucketUploadTicket), func(g pact.Router) {
g.Post("/{ticket}", h.SignedMaterialUpload, "body.limit:"+strconv.Itoa(api.MaterialFilesBodyLimit))
})
// 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")
h.AppURL = p.app.Config.String("app.url")
h.UploadKey = func() []byte { return classes.UploadKey(p.app.Config.String("app.key")) }
}
if p.app != nil {
h.Cache = func() cooler.Cache {
cache, err := cooler.FromApp(p.app)
if err != nil {
return nil
}
return cache
}
}
return h
}
// mountStudent mounts the student routes on g, with read appended to every
// GET route and write to the reading marks. The jwt.auth group and the token
// twins share it, so both lists cannot drift.
func mountStudent(g pact.Router, h *api.Handlers, read, write []string) {
g.Get("/dashboard", h.Dashboard, read...)
g.Get("/courses", h.Courses, read...)
g.Get("/courses/{id}", h.Course, read...)
g.Where("id", "[0-9]+")
g.Get("/schedule", h.Schedule, read...)
g.Get("/meetings", h.Meetings, read...)
g.Get("/deadlines", h.Deadlines, read...)
g.Get("/readings", h.Readings, read...)
g.Put("/readings/{id}/progress", h.MarkRead, write...)
g.Where("id", "[0-9]+")
g.Delete("/readings/{id}/progress", h.UnmarkRead, write...)
g.Where("id", "[0-9]+")
g.Put("/readings/{id}/owned", h.MarkOwned, write...)
g.Where("id", "[0-9]+")
g.Delete("/readings/{id}/owned", h.UnmarkOwned, write...)
g.Where("id", "[0-9]+")
g.Get("/materials", h.Materials, read...)
g.Get("/materials/{id}", h.Material, read...)
g.Where("id", "[0-9]+")
g.Get("/checklists", h.Checklists, read...)
g.Post("/checklists", h.CreateChecklist, write...)
g.Get("/checklists/{id}", h.Checklist, read...)
g.Where("id", "[0-9]+")
g.Patch("/checklists/{id}", h.UpdateChecklist, write...)
g.Where("id", "[0-9]+")
g.Delete("/checklists/{id}", h.DeleteChecklist, write...)
g.Where("id", "[0-9]+")
g.Post("/checklists/{id}/items", h.CreateChecklistItem, write...)
g.Where("id", "[0-9]+")
g.Patch("/checklist-items/{id}", h.UpdateChecklistItem, write...)
g.Where("id", "[0-9]+")
g.Delete("/checklist-items/{id}", h.DeleteChecklistItem, write...)
g.Where("id", "[0-9]+")
g.Put("/checklist-items/{id}/done", h.MarkChecklistItemDone, write...)
g.Where("id", "[0-9]+")
g.Delete("/checklist-items/{id}/done", h.UnmarkChecklistItemDone, write...)
g.Where("id", "[0-9]+")
}
// 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]+")
}
}