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

@@ -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.