package api import ( "context" "errors" "io" "mime/multipart" "net/http" "strconv" "git.golem15.com/golem15/summercms/modules/lagoon" "git.golem15.com/golem15/summercms/modules/lagoon/attach" "git.golem15.com/golem15/summercms/modules/surf" "git.golem15.com/golem15/summercms/modules/wire" "git.golem15.com/jakub/sm-bm-plugin/models" "gocloud.dev/blob" "gorm.io/gorm" ) // MaterialFileMaxBytes is the largest file the management API stores, the // admin form's maxFilesize (50 MB). const MaterialFileMaxBytes = 50 << 20 // MaterialFilesBodyLimit is the body cap of POST /materials/{id}/files: one // file of MaterialFileMaxBytes plus the multipart framing. const MaterialFilesBodyLimit = MaterialFileMaxBytes + 1<<20 // MaterialFilesMax is the most files a material holds, the admin form's // maxFiles. const MaterialFilesMax = 20 // fileLimits are the attach.Store rules of a material file: the default // document and image extensions, up to MaterialFileMaxBytes. var fileLimits = attach.Limits{MaxBytes: MaterialFileMaxBytes} func (h *Handlers) bucket(w http.ResponseWriter) (*blob.Bucket, bool) { var b *blob.Bucket if h.Bucket != nil { b = h.Bucket() } if b == nil { writeOpaque500(w) return nil, false } return b, true } func materialFiles(db *gorm.DB, m models.Material) *gorm.DB { return db.Where("attachment_type = ? AND attachment_id = ? AND field = ?", m.MorphName(), strconv.FormatUint(uint64(m.ID), 10), models.MaterialFilesField) } // findMaterial loads the material of the {id} path parameter and answers 404 // itself when there is none. func findMaterial(w http.ResponseWriter, r *http.Request, db *gorm.DB) (models.Material, bool) { var m models.Material id, ok := surf.IntParam(r, "id") if !ok { writeNotFound(w) return m, false } res := db.Where("id = ?", id).Limit(1).Find(&m) if res.Error != nil { writeOpaque500(w) return m, false } if res.RowsAffected == 0 { writeNotFound(w) return m, false } return m, true } // AttachMaterialFiles answers POST /materials/{id}/files: a multipart body // whose "file" parts are stored as public files of the material, after the // ones it has. Every part is checked by attach.Store (type, size); one // refused part refuses the request and stores nothing. The answer is the // material as GET /materials/{id} shows it, 201. func (h *Handlers) AttachMaterialFiles(w http.ResponseWriter, r *http.Request) { 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 } mr, err := r.MultipartReader() if err != nil { writeInvalid(w, "Send the files as multipart/form-data in parts named \"file\".") return } var refusal string // The blobs written are deleted again when the transaction did not commit. stored, err := h.storeParts(r.Context(), db, bucket, m, mr, &refusal) if err != nil || refusal != "" { if len(stored) > 0 { _ = attach.DeleteKeys(context.WithoutCancel(r.Context()), bucket, 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 } h.writeMaterialDetail(w, r, db, m.ID, http.StatusCreated) } // storeParts stores and binds every "file" part in one transaction. A // refusal (no file, too many, a type or size attach.Store turns down) is // set in *refusal and rolls back; the returned keys are the blobs written. func (h *Handlers) storeParts(ctx context.Context, db *gorm.DB, bucket *blob.Bucket, m models.Material, mr *multipart.Reader, refusal *string) ([]string, error) { var keys []string errRefused := errors.New("refused") err := lagoon.Transaction(ctx, db, func(_ context.Context, tx *gorm.DB) error { added := 0 for { part, err := mr.NextPart() if err == io.EOF { break } if err != nil { return err } if part.FormName() != "file" || part.FileName() == "" { part.Close() continue } f, why, err := storeMaterialFile(ctx, tx, bucket, m, part.FileName(), part, fileLimits) part.Close() if f != nil { keys = append(keys, attach.BlobKeys(*f)...) } if err != nil { return err } if why != "" { *refusal = why return errRefused } added++ } if added == 0 { *refusal = "No file: send at least one multipart part named \"file\"." return errRefused } return nil }) if errors.Is(err, errRefused) { err = nil } 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. func (h *Handlers) DetachMaterialFile(w http.ResponseWriter, r *http.Request) { 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 } fileID, ok := surf.IntParam(r, "file") if !ok { writeNotFound(w) return } var f attach.File res := materialFiles(db, m).Where("id = ?", fileID).Limit(1).Find(&f) if res.Error != nil { writeOpaque500(w) return } if res.RowsAffected == 0 { writeNotFound(w) return } if err := db.Where("id = ?", f.ID).Delete(&attach.File{}).Error; err != nil { writeOpaque500(w) return } // The row is gone, so the file is no longer served; a blob left behind // by a failed delete is only disk space. _ = attach.DeleteKeys(r.Context(), bucket, attach.BlobKeys(f)) w.WriteHeader(http.StatusNoContent) }