- GET /_bm/api/v1/tags: the jz_bm_tags dictionary, to material managers
- POST /materials/{id}/unpublish: back to drafts, no revision bump, the
search document resynced
- POST /materials/{id}/images: one JPEG/PNG/GIF/WebP per request through
storeMaterialUpload, refusals with reason type, size or count
- storeMaterialUpload/fileRefusalFor: attach.ErrNotImage is a type
refusal, not a 500; storeMaterialFile keeps its texts
- the author's routes get their own per-user buckets (the inline
throttle form shares one counter per user)
- README: the author routes and revision on the student detail
282 lines
9.1 KiB
Go
282 lines
9.1 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, r, db, m.ID, http.StatusCreated)
|
|
}
|
|
|
|
// storeParts stores and binds every "file" part in one transaction and
|
|
// queues the file text job in it. 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
|
|
}
|
|
// The text job joins the transaction: it exists only when the
|
|
// files commit, and it syncs the material's document.
|
|
h.MaterialIndex.QueueExtract(ctx, tx, m.ID)
|
|
return nil
|
|
})
|
|
if errors.Is(err, errRefused) {
|
|
err = nil
|
|
}
|
|
return keys, err
|
|
}
|
|
|
|
// fileRefusal is a file the material routes turn down, with no error:
|
|
// Reason is "count" (the material holds MaterialFilesMax files), "size"
|
|
// (attach.ErrTooLarge) or "type" (a type attach.Store refuses), and Text the
|
|
// message of the answer. The zero value is no refusal.
|
|
type fileRefusal struct {
|
|
Reason string
|
|
Text string
|
|
}
|
|
|
|
// fileRefusalFor maps an attach.Store error to the refusal of file name
|
|
// under lim, and reports false for an error that is not a refusal (nil
|
|
// included). attach.ErrNotImage, the refusal of image mode, is a type
|
|
// refusal, not a 500.
|
|
func fileRefusalFor(err error, name string, lim attach.Limits) (fileRefusal, bool) {
|
|
switch {
|
|
case errors.Is(err, attach.ErrTooLarge):
|
|
return fileRefusal{Reason: "size", Text: name + ": the file is larger than " + sizeMB(lim.MaxBytes) + "."}, true
|
|
case errors.Is(err, attach.ErrFileType), errors.Is(err, attach.ErrMIMEType), errors.Is(err, attach.ErrNotImage):
|
|
return fileRefusal{Reason: "type", Text: name + ": this file type is not allowed."}, true
|
|
}
|
|
return fileRefusal{}, false
|
|
}
|
|
|
|
// storeMaterialUpload 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 API
|
|
// writes a material file: the multipart route, the inline (base64) route,
|
|
// the signed upload and the editor image route all come through it. The
|
|
// material's MaterialFilesMax cap and attach's extension, MIME, image and
|
|
// size checks (lim) apply. A refusal is returned with no error; the
|
|
// returned file, when not nil, has blobs the caller deletes when tx does
|
|
// not commit.
|
|
func storeMaterialUpload(ctx context.Context, tx *gorm.DB, bucket *blob.Bucket, m models.Material, name string, body io.Reader, lim attach.Limits) (*attach.File, fileRefusal, error) {
|
|
var have int64
|
|
if err := materialFiles(tx.Model(&attach.File{}), m).Count(&have).Error; err != nil {
|
|
return nil, fileRefusal{}, err
|
|
}
|
|
if have >= MaterialFilesMax {
|
|
return nil, fileRefusal{Reason: "count", Text: "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)
|
|
if why, ok := fileRefusalFor(err, name, lim); ok {
|
|
return nil, why, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fileRefusal{}, 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, fileRefusal{}, err
|
|
}
|
|
|
|
// storeMaterialFile is storeMaterialUpload with the refusal as its text,
|
|
// for the management routes that answer only the text.
|
|
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) {
|
|
f, why, err := storeMaterialUpload(ctx, tx, bucket, m, name, body, lim)
|
|
return f, why.Text, 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 (its extracted text with it), the material is resynced, then the
|
|
// blob and thumbnails go. 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 file's text row went with it (foreign-key cascade); the resync
|
|
// drops the text from the material's document.
|
|
h.MaterialIndex.Sync(r.Context(), db, m.ID)
|
|
// 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)
|
|
}
|