Files
sm-bm-plugin/controllers/api/manage_files.go

222 lines
6.3 KiB
Go

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, 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 {
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()
if err == io.EOF {
break
}
if err != nil {
return err
}
if part.FormName() != "file" || part.FileName() == "" {
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)
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
}
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
}
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
}
// 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)
}