Files
sm-bm-plugin/controllers/api/api.go
Jakub Zych ce6ac0a78e test: expect signed one-time material uploads
- TestUploadTicket: HMAC under the jz.bm.upload.v1: label, expiry,
  malformed and foreign tickets, single use through the cache
- TestSignedMaterialUploadRefusesBadTickets, TestCreateMaterialUploadNeedsCaller
- TestRoutes, TestBMMCP and TestManageBucketKey expect the upload-url
  route, the uploads route with its two buckets and the upload tool
- compile-only stubs
2026-10-28 15:07:30 +01:00

163 lines
5.5 KiB
Go

// Package api holds the student-facing JSON handlers of the jz.bm plugin,
// served under /_bm/api/v1 behind the golem15.user jwt.auth guard.
//
// Every response is built field by field and wrapped in {"data": ...}; a
// paginated list adds {"meta": ...}. Timestamps are RFC 3339 in
// Europe/Warsaw, dates are YYYY-MM-DD.
package api
import (
"net/http"
"sort"
"time"
chatclasses "git.golem15.com/golem15/sm-chat-plugin/classes"
chanclasses "git.golem15.com/golem15/sm-chatchannels-plugin/classes"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/cooler"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/wire"
"gocloud.dev/blob"
"gorm.io/gorm"
)
// Handlers serves the API. The plugin builds it in Routes; tests build it
// with their own database and clock.
type Handlers struct {
// DB returns the application database, or nil while none is published.
// It is resolved per request: the binary boots plugins before serve
// publishes the database.
DB func() *gorm.DB
// Translator returns the application translator for validation
// messages; nil gives English.
Translator func() *phrasebook.Translator
// Now is the clock; nil means time.Now.
Now func() time.Time
// PerPage is the default page size of /materials.
PerPage int
// DashboardLimit is the number of sessions and deadlines on /dashboard.
DashboardLimit int
// Bucket returns the uploads bucket for material files, or nil while
// none is published.
Bucket func() *blob.Bucket
// PublicURL is the absolute base URL of the application
// (jz.bm.public_url), used for the calendar feed URL and the links in
// it. Empty means the scheme and Host of the request.
PublicURL string
// AppURL is the application URL (app.url). The MCP link rewriter
// (jz.bm.mcp-links) prefers it to PublicURL when it makes the links of
// an MCP answer absolute.
AppURL string
// ChatService returns golem15.chat's service, or nil while none is published
// (golem15.chat inactive, or no database yet).
ChatService func() *chatclasses.Service
// Channels returns golem15.chatchannels' channel service, or nil while
// none is published.
Channels func() *chanclasses.ChannelService
// ChatGroup is the code of the user group whose members are in the chat
// (jz.bm.chat.group). Empty means nobody is.
ChatGroup string
// ChatModeratorGroup is the code of the chat's moderator group
// (jz.bm.chat.moderator_group).
ChatModeratorGroup string
// ChatMaxUploadBytes is the total attachment size of one chat message.
ChatMaxUploadBytes int
// ChatMaxEmbeds is the number of entity embeds one chat message may carry.
ChatMaxEmbeds int
// ChatBots is whether golem15.chatbots is active, so its is_bot flags
// can be read.
ChatBots bool
// UploadKey returns the key the signed material upload URLs are signed
// with (app.key), or nil when none is set: then no URL is issued.
UploadKey func() []byte
// Cache returns the application cache that holds the issued upload
// tickets until they are used, or nil while none is available.
Cache func() cooler.Cache
}
func (h *Handlers) now() time.Time {
if h.Now != nil {
return h.Now()
}
return time.Now()
}
func (h *Handlers) perPage() int {
if h.PerPage > 0 {
return min(h.PerPage, maxPerPage)
}
return defaultPerPage
}
func (h *Handlers) dashboardLimit() int {
if h.DashboardLimit > 0 {
return h.DashboardLimit
}
return 5
}
func (h *Handlers) translator() *phrasebook.Translator {
if h.Translator == nil {
return nil
}
return h.Translator()
}
const (
defaultPerPage = 20
maxPerPage = 100
)
// caller returns the signed-in user's ID. It answers 401 itself and returns
// false when there is none (the jwt.auth guard normally answers first).
func (h *Handlers) caller(w http.ResponseWriter, r *http.Request) (uint, bool) {
user, ok := bouncer.User(r.Context())
if !ok || user == nil || user.ID == 0 {
wire.WriteJSON(w, http.StatusUnauthorized, map[string]any{"error": true, "message": "Unauthenticated."})
return 0, false
}
return user.ID, true
}
// db returns the database bound to the request context. It answers 503
// itself and returns false while no database is published.
func (h *Handlers) db(w http.ResponseWriter, r *http.Request) (*gorm.DB, bool) {
var db *gorm.DB
if h.DB != nil {
db = h.DB()
}
if db == nil {
wire.WriteJSON(w, http.StatusServiceUnavailable, map[string]any{"error": true, "message": "Database unavailable."})
return nil, false
}
return db.WithContext(r.Context()), true
}
func writeData(w http.ResponseWriter, v any) {
wire.WriteJSON(w, http.StatusOK, map[string]any{"data": v})
}
func writeNotFound(w http.ResponseWriter) {
wire.WriteJSON(w, http.StatusNotFound, map[string]any{"error": true, "message": "Not found."})
}
// writeValidation answers 422 with Laravel's validation body: the first
// message and the errors object from lagoon.ValidateRequest.
func writeValidation(w http.ResponseWriter, errs map[string][]string) {
keys := make([]string, 0, len(errs))
for k := range errs {
keys = append(keys, k)
}
sort.Strings(keys)
message := "The given data was invalid."
if len(keys) > 0 && len(errs[keys[0]]) > 0 {
message = errs[keys[0]][0]
}
wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]any{"message": message, "errors": errs})
}
func writeOpaque500(w http.ResponseWriter) { wire.WriteOpaque500(w) }
// writeJSONPage writes a lagoon.Page as it is: {data, meta}.
func writeJSONPage(w http.ResponseWriter, page any) { wire.WriteJSON(w, http.StatusOK, page) }