feat: tags, unpublish and image upload for the author
- 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
This commit is contained in:
@@ -4,12 +4,15 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"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/models"
|
||||
"gorm.io/gorm"
|
||||
@@ -274,3 +277,189 @@ func (h *Handlers) writeStudentConflict(w http.ResponseWriter, r *http.Request,
|
||||
"data": out,
|
||||
})
|
||||
}
|
||||
|
||||
// imageLimits are the attach.Store rules of an editor image (D-16): image
|
||||
// mode sniffs and decodes the bytes and admits only JPEG, PNG, GIF and
|
||||
// WebP (never SVG), up to MaterialFileMaxBytes.
|
||||
var imageLimits = attach.Limits{MaxBytes: MaterialFileMaxBytes, Image: true}
|
||||
|
||||
// Tags answers GET /tags: the tag dictionary the editor picks from (A2),
|
||||
// every row of jz_bm_tags by name, to material managers; anyone else gets a
|
||||
// 403.
|
||||
func (h *Handlers) Tags(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := h.caller(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
db, ok := h.db(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
manager, err := h.managesMaterials(r.Context(), db, userID)
|
||||
if err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
if !manager {
|
||||
writeForbidden(w)
|
||||
return
|
||||
}
|
||||
var tags []models.Tag
|
||||
if err := db.Order("name, id").Find(&tags).Error; err != nil {
|
||||
writeOpaque500(w)
|
||||
return
|
||||
}
|
||||
writeData(w, newTagsJSON(tags))
|
||||
}
|
||||
|
||||
// UnpublishMaterial answers POST /materials/{id}/unpublish (D-11, "Usuń" in
|
||||
// the app): the material becomes a draft (published_at NULL), which the
|
||||
// student API no longer serves, so even its author gets a 404 afterwards.
|
||||
// Nothing is deleted: a push without --draft or /bmadmin publishes it
|
||||
// again. The revision stays (D-03). The search document is resynced, not
|
||||
// removed, because the management search keeps drafts. 204.
|
||||
func (h *Handlers) UnpublishMaterial(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := h.caller(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
db, ok := h.db(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
m, manager, ok := h.editableMaterialForCaller(w, r, db, userID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
outcome := editAllowed
|
||||
err := lagoon.Transaction(r.Context(), db, func(_ context.Context, tx *gorm.DB) error {
|
||||
cur, why, err := h.lockEditableMaterial(tx, userID, m.ID, manager)
|
||||
if err != nil || why != editAllowed {
|
||||
outcome = why
|
||||
return err
|
||||
}
|
||||
return tx.Model(&models.Material{}).Where("id = ?", cur.ID).Update("published_at", nil).Error
|
||||
})
|
||||
switch {
|
||||
case err != nil:
|
||||
writeOpaque500(w)
|
||||
case outcome == editGone:
|
||||
writeNotFound(w)
|
||||
case outcome == editForbidden:
|
||||
writeForbidden(w)
|
||||
default:
|
||||
h.MaterialIndex.Sync(r.Context(), db, m.ID)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// writeFileRefusal answers 422 to a refused editor image, with the reason
|
||||
// (type, size or count) for the editor's message.
|
||||
func writeFileRefusal(w http.ResponseWriter, why fileRefusal) {
|
||||
wire.WriteJSON(w, http.StatusUnprocessableEntity, map[string]any{
|
||||
"message": why.Text,
|
||||
"errors": map[string][]string{"file": {why.Text}},
|
||||
"reason": why.Reason,
|
||||
})
|
||||
}
|
||||
|
||||
// UploadMaterialImage answers POST /materials/{id}/images (D-16): the first
|
||||
// multipart part named "file" is stored as a public file of the material
|
||||
// through storeMaterialUpload with imageLimits, and the file text job is
|
||||
// queued in the same transaction. One image per request. The revision does
|
||||
// not change: the body edit that links the image bumps it on save. 201
|
||||
// {data: {id, url, name, size, mime}}; a refused file is a 422 with reason
|
||||
// type, size or count, an over-limit body a 413.
|
||||
func (h *Handlers) UploadMaterialImage(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := h.caller(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
db, ok := h.db(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
bucket, ok := h.bucket(w)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
m, manager, ok := h.editableMaterialForCaller(w, r, db, userID)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
mr, err := r.MultipartReader()
|
||||
if err != nil {
|
||||
writeInvalid(w, "Send the image as multipart/form-data in a part named \"file\".")
|
||||
return
|
||||
}
|
||||
errRefused := errors.New("refused")
|
||||
outcome := editAllowed
|
||||
var stored *attach.File
|
||||
var refusal fileRefusal
|
||||
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
|
||||
cur, why, err := h.lockEditableMaterial(tx, userID, m.ID, manager)
|
||||
if err != nil || why != editAllowed {
|
||||
outcome = why
|
||||
return err
|
||||
}
|
||||
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 stored != nil {
|
||||
part.Close()
|
||||
refusal = fileRefusal{Reason: "type", Text: "Send one image per request."}
|
||||
return errRefused
|
||||
}
|
||||
f, why, err := storeMaterialUpload(ctx, tx, bucket, cur, part.FileName(), part, imageLimits)
|
||||
part.Close()
|
||||
if f != nil {
|
||||
stored = f
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if why.Reason != "" {
|
||||
refusal = why
|
||||
return errRefused
|
||||
}
|
||||
}
|
||||
if stored == nil {
|
||||
refusal = fileRefusal{Reason: "type", Text: "No image: send one multipart part named \"file\"."}
|
||||
return errRefused
|
||||
}
|
||||
// The text job joins the transaction: it exists only when the file
|
||||
// commits, and it syncs the material's document.
|
||||
h.MaterialIndex.QueueExtract(ctx, tx, cur.ID)
|
||||
return nil
|
||||
})
|
||||
if err != nil && stored != nil {
|
||||
// The blobs written are deleted again: the transaction did not commit.
|
||||
_ = attach.DeleteKeys(context.WithoutCancel(r.Context()), bucket, attach.BlobKeys(*stored))
|
||||
}
|
||||
var tooLarge *http.MaxBytesError
|
||||
switch {
|
||||
case errors.Is(err, errRefused):
|
||||
writeFileRefusal(w, refusal)
|
||||
case errors.As(err, &tooLarge):
|
||||
wire.WriteJSON(w, http.StatusRequestEntityTooLarge, map[string]any{"error": true, "message": "Request body too large.", "reason": "size"})
|
||||
case err != nil:
|
||||
writeOpaque500(w)
|
||||
case outcome == editGone:
|
||||
writeNotFound(w)
|
||||
case outcome == editForbidden:
|
||||
writeForbidden(w)
|
||||
default:
|
||||
wire.WriteJSON(w, http.StatusCreated, map[string]any{"data": fileJSON{
|
||||
ID: stored.ID, URL: stored.URL(), Name: stored.FileName, Size: stored.FileSize, Mime: stored.ContentType,
|
||||
}})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user