feat: attach material files over MCP as base64

- storeMaterialFile: the one attach.Store call of the management API,
  shared by the multipart route and the new inline route
- POST /api/v1/bm/materials/{id}/files/inline: JSON {name, mime?,
  content_base64}, at most 3000000 bytes decoded, body.limit 4300000
- bm_manage_attach_material_file and bm_manage_detach_material_file,
  both warning that material files are public to anyone with the link
This commit is contained in:
2026-10-28 15:03:43 +01:00
parent a0aaeba0b7
commit 93066c63ea
10 changed files with 406 additions and 29 deletions

View File

@@ -0,0 +1,4 @@
package api
// DecodeInline exposes decodeInline to the api_test package.
var DecodeInline = decodeInline

View File

@@ -476,6 +476,12 @@ func (h *Handlers) ManagedMaterial(w http.ResponseWriter, r *http.Request) {
// shows it, or 404 when there is none. Under MCP a GET carries the body only
// with include_body, and any other method's answer never does.
func (h *Handlers) writeMaterialDetail(w http.ResponseWriter, r *http.Request, db *gorm.DB, id uint, status int) {
h.writeMaterialDetailWith(w, r, db, id, status, nil)
}
// writeMaterialDetailWith is writeMaterialDetail with extra top-level keys
// beside data (the file warning of the inline upload).
func (h *Handlers) writeMaterialDetailWith(w http.ResponseWriter, r *http.Request, db *gorm.DB, id uint, status int, extra map[string]any) {
var m models.Material
res := preloadMaterial(db).Where("jz_bm_materials.id = ?", id).Limit(1).Find(&m)
if res.Error != nil {
@@ -499,7 +505,11 @@ func (h *Handlers) writeMaterialDetail(w http.ResponseWriter, r *http.Request, d
}
out.Files = append(out.Files, fileJSON{ID: f.ID, URL: f.URL(), Name: f.FileName, Size: f.FileSize, Mime: f.ContentType})
}
wire.WriteJSON(w, status, map[string]any{"data": managedMaterialDetailView(out, viaMCP(r), r.Method == http.MethodGet && includeBody(r))})
answer := map[string]any{"data": managedMaterialDetailView(out, viaMCP(r), r.Method == http.MethodGet && includeBody(r))}
for k, v := range extra {
answer[k] = v
}
wire.WriteJSON(w, status, answer)
}
// DeleteMaterial answers DELETE /materials/{id}.

View File

@@ -122,10 +122,6 @@ func (h *Handlers) storeParts(ctx context.Context, db *gorm.DB, bucket *blob.Buc
var keys []string
errRefused := errors.New("refused")
err := lagoon.Transaction(ctx, db, func(_ context.Context, tx *gorm.DB) error {
var have int64
if err := materialFiles(tx.Model(&attach.File{}), m).Count(&have).Error; err != nil {
return err
}
added := 0
for {
part, err := mr.NextPart()
@@ -139,32 +135,18 @@ func (h *Handlers) storeParts(ctx context.Context, db *gorm.DB, bucket *blob.Buc
part.Close()
continue
}
if int(have)+added >= MaterialFilesMax {
part.Close()
*refusal = "A material holds at most " + strconv.Itoa(MaterialFilesMax) + " files."
return errRefused
}
f, err := attach.Store(ctx, tx, bucket, attach.Upload{FileName: part.FileName(), Body: part, Public: true}, fileLimits)
f, why, err := storeMaterialFile(ctx, tx, bucket, m, part.FileName(), part, fileLimits)
part.Close()
switch {
case errors.Is(err, attach.ErrTooLarge):
*refusal = part.FileName() + ": the file is larger than 50 MB."
return errRefused
case errors.Is(err, attach.ErrFileType), errors.Is(err, attach.ErrMIMEType):
*refusal = part.FileName() + ": this file type is not allowed."
return errRefused
case err != nil:
return err
if f != nil {
keys = append(keys, attach.BlobKeys(*f)...)
}
keys = append(keys, attach.BlobKeys(*f)...)
err = tx.Model(&attach.File{}).Where("id = ?", f.ID).Updates(map[string]any{
"attachment_type": m.MorphName(),
"attachment_id": strconv.FormatUint(uint64(m.ID), 10),
"field": models.MaterialFilesField,
}).Error
if err != nil {
return err
}
if why != "" {
*refusal = why
return errRefused
}
added++
}
if added == 0 {
@@ -179,6 +161,48 @@ func (h *Handlers) storeParts(ctx context.Context, db *gorm.DB, bucket *blob.Buc
return keys, err
}
// storeMaterialFile stores one file through attach.Store as a public file of
// m and binds it to the material, inside tx. It is the only way the
// management API writes a material file: the multipart route, the inline
// (base64) route and the signed upload all call it. The material's
// MaterialFilesMax cap and attach's extension, MIME and size checks (lim)
// apply. A refusal is returned as a text for the answer, with no error; the
// returned file, when not nil, has blobs the caller deletes when tx does
// not commit.
func storeMaterialFile(ctx context.Context, tx *gorm.DB, bucket *blob.Bucket, m models.Material, name string, body io.Reader, lim attach.Limits) (*attach.File, string, error) {
var have int64
if err := materialFiles(tx.Model(&attach.File{}), m).Count(&have).Error; err != nil {
return nil, "", err
}
if have >= MaterialFilesMax {
return nil, "A material holds at most " + strconv.Itoa(MaterialFilesMax) + " files.", nil
}
f, err := attach.Store(ctx, tx, bucket, attach.Upload{FileName: name, Body: body, Public: true}, lim)
switch {
case errors.Is(err, attach.ErrTooLarge):
return nil, name + ": the file is larger than " + sizeMB(lim.MaxBytes) + ".", nil
case errors.Is(err, attach.ErrFileType), errors.Is(err, attach.ErrMIMEType):
return nil, name + ": this file type is not allowed.", nil
case err != nil:
return nil, "", err
}
err = tx.Model(&attach.File{}).Where("id = ?", f.ID).Updates(map[string]any{
"attachment_type": m.MorphName(),
"attachment_id": strconv.FormatUint(uint64(m.ID), 10),
"field": models.MaterialFilesField,
}).Error
return f, "", err
}
// sizeMB names a byte limit in the refusal texts: 50 MB for 50 MiB, 3 MB
// for 3000000 bytes.
func sizeMB(n int64) string {
if n%(1<<20) == 0 {
return strconv.FormatInt(n>>20, 10) + " MB"
}
return strconv.FormatInt(n/1_000_000, 10) + " MB"
}
// DetachMaterialFile answers DELETE /materials/{id}/files/{file}: the file
// row goes, then its blob and thumbnails. A file of another material is a
// 404.

142
controllers/api/uploads.go Normal file
View File

@@ -0,0 +1,142 @@
package api
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"net/http"
"strconv"
"strings"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
"gorm.io/gorm"
)
// Material files over MCP (D-10, D-11, D-18): a small file goes in as base64
// in one tool call (AttachMaterialInline). Every path stores through
// storeMaterialFile, so attach.Store and the material's file cap apply.
// MaterialInlineMaxBytes is the largest decoded file of POST
// /materials/{id}/files/inline. pier refuses MCP request bodies over 4 MiB,
// and base64 grows a file by a third, so about 3 MB is what fits (D-18).
const MaterialInlineMaxBytes = 3_000_000
// MaterialInlineBodyLimit is the body cap of POST
// /materials/{id}/files/inline: the base64 of MaterialInlineMaxBytes plus
// the JSON around it.
const MaterialInlineBodyLimit = 4_300_000
// MaterialFileWarning is the sentence every material file tool and answer
// carries (D-11).
const MaterialFileWarning = "Files attached to a material are public to anyone with the link."
// inlineFile is the body of POST /materials/{id}/files/inline.
type inlineFile struct {
Name string `json:"name"`
Mime string `json:"mime"`
ContentBase64 string `json:"content_base64"`
}
// decodeInline reads and checks the body of POST /materials/{id}/files/inline.
// The encoded content is measured before it is decoded, so an oversized one
// costs no decoding.
func decodeInline(raw []byte) (name string, mime string, data []byte, errs map[string][]string) {
var in inlineFile
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&in); err != nil {
return "", "", nil, map[string][]string{"json": {"Send a JSON object with name, content_base64 and optionally mime: " + err.Error()}}
}
errs = map[string][]string{}
name = strings.TrimSpace(in.Name)
if name == "" || len(name) > 255 {
errs["name"] = []string{"The name is required (the file name with its extension) and may not be longer than 255 characters."}
}
mime = strings.ToLower(strings.TrimSpace(in.Mime))
if mime != "" && (len(mime) > 100 || !strings.Contains(mime, "/") || strings.ContainsAny(mime, " \t;,")) {
errs["mime"] = []string{"The mime must be one MIME type such as application/pdf."}
}
switch {
case in.ContentBase64 == "":
errs["content_base64"] = []string{"The content_base64 is required."}
case len(in.ContentBase64) > base64.StdEncoding.EncodedLen(MaterialInlineMaxBytes)+inlineSlack(in.ContentBase64):
errs["content_base64"] = []string{"The file is larger than " + strconv.Itoa(MaterialInlineMaxBytes) + " bytes: use bm_manage_create_material_upload for larger files."}
default:
b, err := base64.StdEncoding.DecodeString(in.ContentBase64)
switch {
case err != nil:
errs["content_base64"] = []string{"The content_base64 is not valid standard base64."}
case len(b) == 0:
errs["content_base64"] = []string{"The file is empty."}
case len(b) > MaterialInlineMaxBytes:
errs["content_base64"] = []string{"The file is larger than " + strconv.Itoa(MaterialInlineMaxBytes) + " bytes: use bm_manage_create_material_upload for larger files."}
default:
data = b
}
}
if len(errs) > 0 {
return "", "", nil, errs
}
return name, mime, data, nil
}
// inlineSlack is the number of line breaks in s, which the base64 decoder
// skips: wrapped base64 is accepted at the same decoded size.
func inlineSlack(s string) int {
return strings.Count(s, "\n") + strings.Count(s, "\r")
}
// AttachMaterialInline answers POST /materials/{id}/files/inline: one file
// sent as JSON {name, mime?, content_base64}, stored as a public file of the
// material through attach.Store, at most MaterialInlineMaxBytes decoded. A
// sent mime must match the stored content type. The answer is the material
// as GET /materials/{id} shows it, with the public-file warning, 201.
func (h *Handlers) AttachMaterialInline(w http.ResponseWriter, r *http.Request) {
raw, ok := body(w, r)
if !ok {
return
}
name, mime, data, errs := decodeInline(raw)
if errs != nil {
writeValidation(w, errs)
return
}
db, ok := h.db(w, r)
if !ok {
return
}
bucket, ok := h.bucket(w)
if !ok {
return
}
m, ok := findMaterial(w, r, db)
if !ok {
return
}
lim := attach.Limits{MaxBytes: MaterialInlineMaxBytes}
if mime != "" {
lim.MIMETypes = []string{mime}
}
var stored *attach.File
var refusal string
err := lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
f, why, err := storeMaterialFile(ctx, tx, bucket, m, name, bytes.NewReader(data), lim)
stored, refusal = f, why
return err
})
if err != nil || refusal != "" {
// The blob is deleted again when the transaction did not commit.
if stored != nil {
_ = attach.DeleteKeys(context.WithoutCancel(r.Context()), bucket, attach.BlobKeys(*stored))
}
if refusal != "" {
writeValidation(w, map[string][]string{"file": {refusal}})
return
}
writeOpaque500(w)
return
}
h.writeMaterialDetailWith(w, r, db, m.ID, http.StatusCreated, map[string]any{"warning": MaterialFileWarning})
}

View File

@@ -0,0 +1,90 @@
package api_test
import (
"encoding/base64"
"net/http"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/jakub/sm-bm-plugin/controllers/api"
"gorm.io/gorm"
)
// TestDecodeInline (D-11, D-18) checks the body of POST
// /materials/{id}/files/inline before any database work.
func TestDecodeInline(t *testing.T) {
pdf := []byte("%PDF-1.4\n%notatki\n")
enc := base64.StdEncoding.EncodeToString(pdf)
t.Run("valid", func(t *testing.T) {
name, mime, data, errs := api.DecodeInline([]byte(`{"name":" notatki.pdf ","mime":"Application/PDF","content_base64":"` + enc + `"}`))
if errs != nil {
t.Fatalf("errors %v", errs)
}
if name != "notatki.pdf" || mime != "application/pdf" || string(data) != string(pdf) {
t.Errorf("got %q %q %q", name, mime, data)
}
})
t.Run("mime optional", func(t *testing.T) {
_, mime, data, errs := api.DecodeInline([]byte(`{"name":"a.pdf","content_base64":"` + enc + `"}`))
if errs != nil || mime != "" || len(data) != len(pdf) {
t.Errorf("got mime %q, %d bytes, errors %v", mime, len(data), errs)
}
})
big := strings.Repeat("A", base64.StdEncoding.EncodedLen(api.MaterialInlineMaxBytes)+4)
cases := []struct {
name, body, field, contains string
}{
{"missing name", `{"content_base64":"` + enc + `"}`, "name", ""},
{"blank name", `{"name":" ","content_base64":"` + enc + `"}`, "name", ""},
{"empty content", `{"name":"a.pdf","content_base64":""}`, "content_base64", ""},
{"missing content", `{"name":"a.pdf"}`, "content_base64", ""},
{"invalid base64", `{"name":"a.pdf","content_base64":"%%%not base64"}`, "content_base64", "base64"},
{"url-safe alphabet", `{"name":"a.pdf","content_base64":"-_-_"}`, "content_base64", "base64"},
{"unknown key", `{"name":"a.pdf","content_base64":"` + enc + `","path":"/etc/passwd"}`, "json", ""},
{"not an object", `[1,2]`, "json", ""},
{"bad mime", `{"name":"a.pdf","mime":"pdf","content_base64":"` + enc + `"}`, "mime", ""},
{"too large", `{"name":"a.pdf","content_base64":"` + big + `"}`, "content_base64", "bm_manage_create_material_upload"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, _, data, errs := api.DecodeInline([]byte(tc.body))
if data != nil {
t.Errorf("data returned with an invalid body")
}
msgs := errs[tc.field]
if len(msgs) == 0 {
t.Fatalf("errors %v have no %s entry", errs, tc.field)
}
if tc.contains != "" && !strings.Contains(msgs[0], tc.contains) {
t.Errorf("%s message %q does not name %q", tc.field, msgs[0], tc.contains)
}
})
}
}
// TestAttachMaterialInlineNoDatabase: an invalid body is a 422 before the
// database is looked up; a valid one without a database is a 503.
func TestAttachMaterialInlineNoDatabase(t *testing.T) {
user := &bouncer.Principal{ID: 7}
enc := base64.StdEncoding.EncodeToString([]byte("%PDF-1.4\n"))
valid := bodyRequest{noDB().AttachMaterialInline, http.MethodPost, "/", `{"name":"a.pdf","content_base64":"` + enc + `"}`}
if code, body := serveBody(t, valid, user); code != http.StatusServiceUnavailable {
t.Errorf("valid body without a database = %d %v, want 503", code, body)
}
dbLooked := false
h := &api.Handlers{DB: func() *gorm.DB { dbLooked = true; return nil }}
invalid := bodyRequest{h.AttachMaterialInline, http.MethodPost, "/", `{"name":"","content_base64":""}`}
code, body := serveBody(t, invalid, user)
if code != http.StatusUnprocessableEntity {
t.Errorf("invalid body = %d %v, want 422", code, body)
}
if errs, _ := body["errors"].(map[string]any); errs["name"] == nil || errs["content_base64"] == nil {
t.Errorf("errors %v lack name and content_base64", body["errors"])
}
if dbLooked {
t.Error("the database was looked up for an invalid body")
}
}

3
mcp.go
View File

@@ -12,5 +12,6 @@ var _ pact.HasMCP = (*Plugin)(nil)
// signed-in user under golem15.user's "user" guard, and their titles and
// descriptions are static text, never built from data.
func (p *Plugin) MCP() pact.MCP {
return pact.MCP{Tools: append(studentTools(), manageTools()...)}
tools := append(studentTools(), manageTools()...)
return pact.MCP{Tools: append(tools, fileTools()...)}
}

42
mcp_files.go Normal file
View File

@@ -0,0 +1,42 @@
package bm
import (
"net/http"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/jakub/sm-bm-plugin/controllers/api"
)
// attachMaterialFileInput is the input of bm_manage_attach_material_file.
type attachMaterialFileInput struct {
ID uint `json:"id" path:"id" jsonschema:"the material id"`
Name string `json:"name" jsonschema:"file name with extension, for example notatki.pdf"`
Mime string `json:"mime,omitempty" jsonschema:"optional MIME type; the stored type must match"`
ContentBase64 string `json:"content_base64" jsonschema:"standard base64, at most 3000000 bytes decoded; use bm_manage_create_material_upload for larger files"`
}
// detachMaterialFileInput is the input of bm_manage_detach_material_file.
type detachMaterialFileInput struct {
ID uint `json:"id" path:"id" jsonschema:"the material id"`
File uint `json:"file" path:"file" jsonschema:"the file id, as listed in the files of bm_manage_show_material"`
}
// fileTools are the material file tools over the management API (D-10,
// D-11, D-17): attach a small file as base64 and detach a file. Every
// description carries the public-file warning and says no file contents
// are returned.
func fileTools() []pact.MCPTool {
tool := func(name, title, desc, method, path string, input any) pact.MCPTool {
return pact.MCPTool{Name: name, Title: title, Description: desc, Guard: "user", Method: method, Path: ManagePrefix + path, Input: input}
}
return []pact.MCPTool{
tool("manage_attach_material_file", "Attach a file to a material",
"Attaches one file sent as standard base64 (at most 3000000 bytes decoded; for larger files use bm_manage_create_material_upload) to a material, after its files; a material holds at most 20. The extension and the content are checked; a sent mime must match the content. Returns the material with its files (id, absolute link, name, size and type) and a warning; 404 when the material is unknown, 422 when the file is refused. "+
api.MaterialFileWarning+" "+MCPMaterialPrivacy+writeMaterials,
http.MethodPost, "/materials/{id}/files/inline", attachMaterialFileInput{}),
tool("manage_detach_material_file", "Detach a file from a material",
"Deletes one file of a material, its row and its stored blob, so its link stops working; 404 when the material or the file is unknown or the file belongs to another material."+deleteForever+" "+
api.MaterialFileWarning+" "+MCPMaterialPrivacy+writeMaterials,
http.MethodDelete, "/materials/{id}/files/{file}", detachMaterialFileInput{}),
}
}

View File

@@ -18,6 +18,7 @@ import (
"git.golem15.com/golem15/summercms/modules/pier"
"git.golem15.com/golem15/summercms/modules/surf"
bm "git.golem15.com/jakub/sm-bm-plugin"
"git.golem15.com/jakub/sm-bm-plugin/controllers/api"
"git.golem15.com/jakub/sm-bm-plugin/models"
)
@@ -52,6 +53,7 @@ var pinnedTools = []string{
"bm_courses",
"bm_dashboard",
"bm_deadlines",
"bm_manage_attach_material_file",
"bm_manage_create_class_session",
"bm_manage_create_course",
"bm_manage_create_deadline",
@@ -61,6 +63,7 @@ var pinnedTools = []string{
"bm_manage_delete_deadline",
"bm_manage_delete_material",
"bm_manage_delete_reading",
"bm_manage_detach_material_file",
"bm_manage_list_class_sessions",
"bm_manage_list_courses",
"bm_manage_list_deadlines",
@@ -306,6 +309,13 @@ func testManageTools(t *testing.T, tools []pact.MCPTool) {
}
}
fields := bodyFields(tool.Input)
if segment == "materials" && strings.Contains(rest, "/files") {
testFileTool(t, tool, fields)
if tool.Method == http.MethodDelete {
deletes[segment] = true
}
continue
}
if segment == "materials" {
if !strings.Contains(tool.Description, bm.MCPMaterialPrivacy) {
t.Errorf("%s: description lacks the material privacy sentence", tool.Name)
@@ -353,8 +363,8 @@ func testManageTools(t *testing.T, tools []pact.MCPTool) {
}
}
}
if manage != 28 {
t.Errorf("%d management tools, want 28", manage)
if manage != manageToolCount {
t.Errorf("%d management tools, want %d", manage, manageToolCount)
}
want := map[string]bool{"class-sessions": true, "readings": true, "deadlines": true, "materials": true}
if !reflect.DeepEqual(deletes, want) {
@@ -362,6 +372,57 @@ func testManageTools(t *testing.T, tools []pact.MCPTool) {
}
}
// manageToolCount is the number of management tools: 28 record and
// material tools plus the material file tools.
const manageToolCount = 30
// fileBodyKeys are the body keys of each material file tool, exactly.
var fileBodyKeys = map[string]map[string]bool{
"manage_attach_material_file": {"name": false, "mime": true, "content_base64": false},
"manage_detach_material_file": {},
}
// testFileTool (D-10, D-11, D-17) checks one material file tool: its body
// keys, the public-file warning and the privacy sentence, and that the
// detach tool is destructive.
func testFileTool(t *testing.T, tool pact.MCPTool, fields map[string]bool) {
t.Helper()
want, ok := fileBodyKeys[tool.Name]
if !ok {
t.Errorf("%s: not a known material file tool", tool.Name)
return
}
if !reflect.DeepEqual(fields, want) {
t.Errorf("%s: body keys (optional) %v, want %v", tool.Name, fields, want)
}
if !strings.Contains(tool.Description, api.MaterialFileWarning) {
t.Errorf("%s: description lacks the public-file warning", tool.Name)
}
if !strings.Contains(tool.Description, bm.MCPMaterialPrivacy) {
t.Errorf("%s: description lacks the material privacy sentence", tool.Name)
}
if !strings.Contains(tool.Description, "Needs scope write and the jz.bm.access_materials permission") {
t.Errorf("%s: description does not name scope write and jz.bm.access_materials", tool.Name)
}
rt := reflect.TypeOf(tool.Input)
idPath := false
for i := 0; i < rt.NumField(); i++ {
if rt.Field(i).Tag.Get("path") == "id" {
idPath = true
}
}
if !idPath {
t.Errorf("%s: no id path field", tool.Name)
}
if tool.Name == "manage_detach_material_file" {
if tool.Method != http.MethodDelete || (tool.Destructive != nil && !*tool.Destructive) {
t.Errorf("%s is not destructive (method %s)", tool.Name, tool.Method)
}
} else if tool.Method == http.MethodDelete || (tool.Destructive != nil && *tool.Destructive) {
t.Errorf("%s is destructive", tool.Name)
}
}
// studentRoutes are the routes mountStudent registers, in order.
var studentRoutes = []string{
"GET /dashboard",

View File

@@ -139,6 +139,7 @@ func TestRoutes(t *testing.T) {
"PUT /api/v1/bm/materials/{id}",
"DELETE /api/v1/bm/materials/{id}",
"POST /api/v1/bm/materials/{id}/files",
"POST /api/v1/bm/materials/{id}/files/inline",
"DELETE /api/v1/bm/materials/{id}/files/{file}",
"GET /api/v1/bm/courses",
"GET /api/v1/bm/courses/{id}",

View File

@@ -172,6 +172,8 @@ func (p *Plugin) Routes(r pact.Router) error {
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.Delete("/materials/{id}/files/{file}", h.DetachMaterialFile, "user.scope:write", "user.permission:"+controllers.PermMaterials)
g.Where("id", "[0-9]+")
g.Where("file", "[0-9]+")