Management API: POST/DELETE /materials/{id}/files (attach.Store, 50 MB, 20 files), DELETE /materials/{id} removes files; download keeps the file name
This commit is contained in:
@@ -14,6 +14,7 @@ import (
|
||||
"git.golem15.com/golem15/summercms/modules/bouncer"
|
||||
"git.golem15.com/golem15/summercms/modules/phrasebook"
|
||||
"git.golem15.com/golem15/summercms/modules/wire"
|
||||
"gocloud.dev/blob"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -33,6 +34,9 @@ type Handlers struct {
|
||||
PerPage int
|
||||
// DashboardLimit is the number of sessions and deadlines on /dashboard.
|
||||
DashboardLimit int
|
||||
// Bucket returns the uploads bucket for material files, or nil while
|
||||
// none is published.
|
||||
Bucket func() *blob.Bucket
|
||||
// PublicURL is the absolute base URL of the application
|
||||
// (jz.bm.public_url), used for the calendar feed URL and the links in
|
||||
// it. Empty means the scheme and Host of the request.
|
||||
|
||||
@@ -465,6 +465,12 @@ func (h *Handlers) ManagedMaterial(w http.ResponseWriter, r *http.Request) {
|
||||
writeNotFound(w)
|
||||
return
|
||||
}
|
||||
h.writeMaterialDetail(w, db, uint(id), http.StatusOK)
|
||||
}
|
||||
|
||||
// writeMaterialDetail answers with the material id as GET /materials/{id}
|
||||
// shows it, or 404 when there is none.
|
||||
func (h *Handlers) writeMaterialDetail(w http.ResponseWriter, db *gorm.DB, id uint, status int) {
|
||||
var m models.Material
|
||||
res := preloadMaterial(db).Where("jz_bm_materials.id = ?", id).Limit(1).Find(&m)
|
||||
if res.Error != nil {
|
||||
@@ -476,10 +482,7 @@ func (h *Handlers) ManagedMaterial(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
var files []attach.File
|
||||
err := db.Where("attachment_type = ? AND attachment_id = ? AND field = ?", m.MorphName(), strconv.FormatUint(uint64(m.ID), 10), models.MaterialFilesField).
|
||||
Order("sort_order, id").
|
||||
Find(&files).Error
|
||||
if err != nil {
|
||||
if err := materialFiles(db, m).Order("sort_order, id").Find(&files).Error; err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
@@ -491,7 +494,7 @@ func (h *Handlers) ManagedMaterial(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
out.Files = append(out.Files, fileJSON{ID: f.ID, URL: f.URL(), Name: f.FileName, Size: f.FileSize, Mime: f.ContentType})
|
||||
}
|
||||
writeData(w, out)
|
||||
wire.WriteJSON(w, status, map[string]any{"data": out})
|
||||
}
|
||||
|
||||
// DeleteMaterial answers DELETE /materials/{id}.
|
||||
@@ -505,15 +508,33 @@ func (h *Handlers) DeleteMaterial(w http.ResponseWriter, r *http.Request) {
|
||||
writeNotFound(w)
|
||||
return
|
||||
}
|
||||
res := db.Where("id = ?", id).Delete(&models.Material{})
|
||||
if res.Error != nil {
|
||||
var keys []string
|
||||
found := false
|
||||
err := lagoon.Transaction(r.Context(), db, func(_ context.Context, tx *gorm.DB) error {
|
||||
res := tx.Where("id = ?", id).Delete(&models.Material{})
|
||||
if res.Error != nil || res.RowsAffected == 0 {
|
||||
return res.Error
|
||||
}
|
||||
found = true
|
||||
return attach.DeleteForOwner(tx, models.Material{}, strconv.FormatInt(id, 10), func(k []string) error {
|
||||
keys = k
|
||||
return nil
|
||||
})
|
||||
})
|
||||
switch {
|
||||
case err != nil:
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
case !found:
|
||||
writeNotFound(w)
|
||||
return
|
||||
}
|
||||
// The files go with the material: rows in the transaction, blobs after it.
|
||||
if b := h.Bucket; b != nil && len(keys) > 0 {
|
||||
if bucket := b(); bucket != nil {
|
||||
_ = attach.DeleteKeys(r.Context(), bucket, keys)
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
|
||||
221
controllers/api/manage_files.go
Normal file
221
controllers/api/manage_files.go
Normal file
@@ -0,0 +1,221 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user