package api import ( "bytes" "context" "encoding/base64" "encoding/json" "errors" "io" "net/http" "strconv" "strings" "time" userclasses "git.golem15.com/golem15/sm-user-plugin/classes" "git.golem15.com/golem15/summercms/modules/cabana" "git.golem15.com/golem15/summercms/modules/cooler" "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/lagoon/attach" "git.golem15.com/golem15/summercms/modules/wire" "git.golem15.com/jakub/sm-bm-plugin/classes" "git.golem15.com/jakub/sm-bm-plugin/controllers" "git.golem15.com/jakub/sm-bm-plugin/models" "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}) } // Signed one-time uploads (D-10, D-12, D-19): for anything larger, the // agent asks for an upload URL (CreateMaterialUpload) and sends the file // with curl, so the bytes never pass through MCP. The upload route // (SignedMaterialUpload) has no bearer: the ticket in its path is the // credential. It is bound to one material and one user, valid // classes.UploadTTL and works once; the user and the permission are // checked again when it is redeemed. // UploadPath is the path of the signed upload route; a ticket follows it. // The plugin mounts the route at APIPrefix + "/uploads/{ticket}", and // TestRoutes pins the two together. const UploadPath = "/_bm/api/v1/uploads/" // uploadKind is the ticket kind of a material file upload. const uploadKind = "material" // materialUploadJSON is the answer of CreateMaterialUpload. type materialUploadJSON struct { UploadURL string `json:"upload_url"` ExpiresAt string `json:"expires_at"` MaxBytes int `json:"max_bytes"` Accept string `json:"accept"` CurlExample string `json:"curl_example"` Warning string `json:"warning"` } // uploadedFileJSON is the file in the answer of SignedMaterialUpload. type uploadedFileJSON struct { ID uint `json:"id"` Name string `json:"name"` Size int64 `json:"size"` Mime string `json:"mime"` URL string `json:"url"` } // CreateMaterialUpload answers POST /materials/{id}/files/upload-url: a // signed, single-use URL, valid classes.UploadTTL, for one file upload to // this material by the caller, with a curl example. No URL is issued // without app.key or a cache (503). func (h *Handlers) CreateMaterialUpload(w http.ResponseWriter, r *http.Request) { user, ok := h.caller(w, r) if !ok { return } db, ok := h.db(w, r) if !ok { return } m, ok := findMaterial(w, r, db) if !ok { return } key, cache := h.uploadKey(), h.uploadCache() if len(key) == 0 || cache == nil { wire.WriteJSON(w, http.StatusServiceUnavailable, map[string]any{"error": true, "message": "Uploads are not configured."}) return } ticket, expires, err := classes.IssueUpload(r.Context(), cache, key, uploadKind, m.ID, user, h.now()) if err != nil { writeOpaque500(w) return } url := h.appBase(r) + UploadPath + ticket wire.WriteJSON(w, http.StatusCreated, map[string]any{"data": materialUploadJSON{ UploadURL: url, ExpiresAt: expires.UTC().Format(time.RFC3339), MaxBytes: MaterialFileMaxBytes, Accept: strings.Join(attach.DefaultFileExtensions, ", "), CurlExample: "curl -fsS -F 'file=@' '" + url + "'", Warning: MaterialFileWarning, }}) } // SignedMaterialUpload answers POST /_bm/api/v1/uploads/{ticket}. The // ticket is redeemed first (once; any failure is the same 403 before the // database), then the ticket's user must still exist and hold // jz.bm.access_materials, and the material must still exist. The body is // multipart with exactly one part, named file, stored through // storeMaterialFile with the material file limits. func (h *Handlers) SignedMaterialUpload(w http.ResponseWriter, r *http.Request) { ticket, err := classes.RedeemUpload(r.Context(), h.uploadCache(), h.uploadKey(), r.PathValue("ticket"), h.now()) if err != nil || ticket.Kind != uploadKind { writeUploadForbidden(w) return } db, ok := h.db(w, r) if !ok { return } principal, err := userclasses.PrincipalByID(r.Context(), db, ticket.User) if err != nil { writeOpaque500(w) return } if principal == nil || !cabana.Allows(principal, []string{controllers.PermMaterials}) { writeUploadForbidden(w) return } var m models.Material res := db.Where("id = ?", ticket.Target).Limit(1).Find(&m) if res.Error != nil { writeOpaque500(w) return } if res.RowsAffected == 0 { writeNotFound(w) return } bucket, ok := h.bucket(w) if !ok { return } mr, err := r.MultipartReader() if err != nil { writeValidation(w, map[string][]string{"file": {singleFilePart}}) return } var stored *attach.File var refusal string err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error { part, err := mr.NextPart() if err != nil && err != io.EOF { return err } if err == io.EOF || part.FormName() != "file" || part.FileName() == "" { if part != nil { part.Close() } refusal = singleFilePart return errUploadRefused } f, why, err := storeMaterialFile(ctx, tx, bucket, m, part.FileName(), part, fileLimits) part.Close() stored = f if err != nil { return err } if why != "" { refusal = why return errUploadRefused } if next, err := mr.NextPart(); err != io.EOF { if next != nil { next.Close() } if err != nil { return err } refusal = singleFilePart return errUploadRefused } return nil }) if err != nil { // The blob is deleted again when the transaction did not commit. if stored != nil { _ = attach.DeleteKeys(context.WithoutCancel(r.Context()), bucket, attach.BlobKeys(*stored)) } var tooLarge *http.MaxBytesError switch { case refusal != "": writeValidation(w, map[string][]string{"file": {refusal}}) case errors.As(err, &tooLarge): wire.WriteJSON(w, http.StatusRequestEntityTooLarge, map[string]any{"error": true, "message": "Request body too large."}) default: writeOpaque500(w) } return } url := stored.URL() if strings.HasPrefix(url, "/") && !strings.HasPrefix(url, "//") { url = h.appBase(r) + url } wire.WriteJSON(w, http.StatusCreated, map[string]any{ "data": map[string]any{ "material_id": m.ID, "file": uploadedFileJSON{ID: stored.ID, Name: stored.FileName, Size: stored.FileSize, Mime: stored.ContentType, URL: url}, }, "warning": MaterialFileWarning, }) } // singleFilePart is the refusal of a signed upload body that is not one // multipart part named file. const singleFilePart = "Send exactly one file as multipart/form-data in a part named \"file\", for example curl -F 'file=@'." // errUploadRefused rolls back a refused signed upload. var errUploadRefused = errors.New("api: upload refused") // writeUploadForbidden is the one answer of every refused upload ticket. func writeUploadForbidden(w http.ResponseWriter) { wire.WriteJSON(w, http.StatusForbidden, map[string]any{"error": true, "message": "Invalid or expired upload URL."}) } func (h *Handlers) uploadKey() []byte { if h.UploadKey == nil { return nil } return h.UploadKey() } func (h *Handlers) uploadCache() cooler.Cache { if h.Cache == nil { return nil } return h.Cache() } // appBase is the base of the absolute links of the upload answers (D-16): // app.url, else jz.bm.public_url, else the scheme (TLS or // X-Forwarded-Proto) and Host of the request. func (h *Handlers) appBase(r *http.Request) string { for _, base := range []string{h.AppURL, h.PublicURL} { if base = strings.TrimRight(strings.TrimSpace(base), "/"); base != "" { return base } } scheme := "http" if r.TLS != nil || strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")), "https") { scheme = "https" } return scheme + "://" + r.Host }