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:
142
controllers/api/uploads.go
Normal file
142
controllers/api/uploads.go
Normal 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})
|
||||
}
|
||||
Reference in New Issue
Block a user