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:
Jakub Zych
2026-11-09 01:05:54 +01:00
parent 7bf18de987
commit 40e4a2438b
9 changed files with 499 additions and 27 deletions

View File

@@ -53,6 +53,9 @@ func handlers(h *api.Handlers) map[string]http.HandlerFunc {
"material": h.Material,
"mat-access": h.MaterialAccess,
"mat-edit": h.EditMaterial,
"tags": h.Tags,
"unpublish": h.UnpublishMaterial,
"mat-image": h.UploadMaterialImage,
"feed": h.CalendarFeed,
"feed-put": h.UpdateCalendarFeed,
"rotate": h.RotateCalendarFeed,

View File

@@ -165,37 +165,65 @@ func (h *Handlers) storeParts(ctx context.Context, db *gorm.DB, bucket *blob.Buc
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)
// 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 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
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, "", err
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

View File

@@ -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,
}})
}
}

View File

@@ -1,8 +1,12 @@
package api
import (
"errors"
"fmt"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
)
const fullEditBody = `{"revision":3,"title":"Notatka","type":"notatka","body":"Tekst","source":null,"url":null,"meeting_id":null,"tags":["lektura"]}`
@@ -144,3 +148,37 @@ func TestNormalizeBody(t *testing.T) {
}
}
}
// TestFileRefusalReasons checks how attach.Store errors become refusals of
// the material file routes: size for ErrTooLarge, type for ErrFileType,
// ErrMIMEType and the image-mode ErrNotImage (no longer a 500), and no
// refusal for other errors.
func TestFileRefusalReasons(t *testing.T) {
cases := []struct {
err error
reason string
}{
{attach.ErrTooLarge, "size"},
{attach.ErrFileType, "type"},
{attach.ErrMIMEType, "type"},
{attach.ErrNotImage, "type"},
{fmt.Errorf("store: %w", attach.ErrNotImage), "type"},
}
for _, tc := range cases {
why, ok := fileRefusalFor(tc.err, "a.png", imageLimits)
if !ok || why.Reason != tc.reason || !strings.HasPrefix(why.Text, "a.png: ") {
t.Errorf("fileRefusalFor(%v) = %+v %v, want reason %s", tc.err, why, ok, tc.reason)
}
}
if why, _ := fileRefusalFor(attach.ErrTooLarge, "a.png", imageLimits); why.Text != "a.png: the file is larger than 50 MB." {
t.Errorf("size text = %q", why.Text)
}
for _, err := range []error{nil, errors.New("disk full")} {
if why, ok := fileRefusalFor(err, "a.png", imageLimits); ok {
t.Errorf("fileRefusalFor(%v) = %+v, want no refusal", err, why)
}
}
if !imageLimits.Image || imageLimits.MaxBytes != MaterialFileMaxBytes {
t.Errorf("imageLimits = %+v", imageLimits)
}
}