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:
43
README.md
43
README.md
@@ -64,6 +64,10 @@ Every route except the calendar feed itself needs `Authorization: Bearer <golem1
|
||||
| DELETE | `/_bm/api/v1/calendar-feed` | | Deletes the feed; 204, also when there was none |
|
||||
| GET | `/_bm/api/v1/materials/{id}/access` | | The material's visibility, shares and the users to pick from; the author only, see [Private materials](#private-materials) |
|
||||
| PUT | `/_bm/api/v1/materials/{id}/access` | | Sets the visibility and the shares; the author only |
|
||||
| PUT | `/_bm/api/v1/materials/{id}` | | Saves the author's edit, revision-checked; see [the author's routes](#author-routes-no-token-twin) |
|
||||
| GET | `/_bm/api/v1/tags` | | The tag dictionary, to material managers |
|
||||
| POST | `/_bm/api/v1/materials/{id}/unpublish` | | Moves the material to drafts; 204 |
|
||||
| POST | `/_bm/api/v1/materials/{id}/images` | | Stores one editor image; 201 |
|
||||
|
||||
### Shared shapes
|
||||
|
||||
@@ -186,9 +190,11 @@ Newest publication first. `meeting` keeps the materials assigned to that zjazd (
|
||||
```json
|
||||
{"data": {…material…, "body": "# …",
|
||||
"files": [{"id": 3, "url": "/storage/uploads/5f1/d0c/2e9/5f1d0c2e9a7b4c3d8e6f.pdf", "name": "wyklad.pdf", "size": 123456, "mime": "application/pdf"}],
|
||||
"can_manage": false}}
|
||||
"can_manage": false, "revision": 3}}
|
||||
```
|
||||
|
||||
`revision` is the content revision (see [Revisions](#revisions)); the author's edit sends it back.
|
||||
|
||||
`q` searches (see [Search](#search)): a trimmed `q` of fewer than 2 characters is ignored, one over 100 is a 422. With `q` the page is in relevance order, combined with the other filters (AND); `meta.total` is counted in SQL over the materials the caller may see, never taken from the engine. A row whose match is in the body carries one `snippet`:
|
||||
|
||||
```json
|
||||
@@ -217,6 +223,41 @@ GET /_bm/api/v1/materials/{id}/access
|
||||
|
||||
The files of a private material are still public storage URLs: the API never shows them to a user who cannot see the material, but a leaked or guessed URL still downloads.
|
||||
|
||||
### Author routes (no token twin)
|
||||
|
||||
The student app edits, unpublishes and adds editor images to a material through four routes. They are jwt only, like the access panel; tokens and MCP write materials through the management API. The `{id}` routes are for a caller with `jz.bm.access_materials` who is the material's author, or any such caller while the material has no author (the `can_manage` rule). Unlike the access panel they tell the two refusals apart: a material the caller cannot see (private and not theirs, a draft, not published yet, unknown) is a 404, and one they see but may not manage is a 403 `{"error": true, "message": "Forbidden."}`. Each route checks again on the row locked `FOR UPDATE`. No route changes the author, `is_private` or the shares, and only unpublish changes `published_at`. Each route has its own per-user throttle: 30 a minute for the edit and for images, 10 for unpublish.
|
||||
|
||||
**`PUT /materials/{id}`** takes exactly these keys, all required:
|
||||
|
||||
```json
|
||||
{"revision": 3, "title": "Notatka", "type": "notatka", "body": "# …", "source": null, "url": null, "meeting_id": 2, "tags": ["lektura", "cyber"]}
|
||||
```
|
||||
|
||||
- An unknown key is a 422, `course_id` and `published_at` included. The course stays the stored one.
|
||||
- A missing key is a 422 on that key, and so is a null `revision`, `title`, `type` or `tags`.
|
||||
- The rules of the management `PUT` apply: title 1–255, `type` one of `pdf`, `notatka`, `link`, `inne`, source ≤ 500, url ≤ 2048, `meeting_id` an existing meeting or null. A title and course another material has is a 422 on `title`.
|
||||
- Student-only rules: `url`, when not empty, starts with `http://` or `https://`. There are at most 4 tags, and each must be in the dictionary (`GET /tags`), else a 422 on `tags`. The app never creates a tag.
|
||||
- The body is normalised as `bm-notatka push` does it: LF line ends, trimmed, one trailing newline, and empty means null. So a web save, a pull and a push of the same text never bump the revision.
|
||||
- A stale `revision` is a 409, and nothing is written. `data` is the student detail of the current version, body included:
|
||||
|
||||
```json
|
||||
{"error": true, "message": "The material was changed since the revision you sent.", "revision": 4, "updated_at": "2026-11-09T10:00:00+01:00", "data": {…detail…}}
|
||||
```
|
||||
|
||||
The answer is the student detail (200), with the revision bumped when the content changed. The search document is resynced.
|
||||
|
||||
**`GET /tags`** answers `{"data": [{"id": 1, "name": "Cyber", "slug": "cyber"}, …]}`: every row of `jz_bm_tags`, by name. A caller without `jz.bm.access_materials` gets a 403.
|
||||
|
||||
**`POST /materials/{id}/unpublish`** ("Usuń" in the app) sets `published_at` to null, which makes the material a draft. The student API no longer serves it, so even the author gets a 404 afterwards. Nothing is deleted: a `bm-notatka push` without `--draft`, or `/bmadmin`, publishes it again. The revision stays. The search document is resynced (the management search keeps drafts). It answers 204.
|
||||
|
||||
**`POST /materials/{id}/images`** takes `multipart/form-data` with one part named `file`. The file is stored as a public file of the material through the same path as `POST /api/v1/bm/materials/{id}/files`, and the file text job is queued with it. Only JPEG, PNG, GIF and WebP pass, judged by the bytes; SVG and every other type are refused. The limits are 50 MB per file and 20 files per material, and the body is capped like the management upload. The revision does not change; the edit that links the image bumps it on save. The answer is 201:
|
||||
|
||||
```json
|
||||
{"data": {"id": 12, "url": "/storage/app/uploads/public/5f1/d0c/2e9/5f1d0c2e9a7b4c3d8e6f.png", "name": "wklejony.png", "size": 48213, "mime": "image/png"}}
|
||||
```
|
||||
|
||||
A refused file is a 422 `{"message": "…", "errors": {"file": ["…"]}, "reason": "type"}`, where `reason` is `type`, `size` or `count`. A second `file` part and a request with no file are both `type` refusals. A body over the limit is a 413 with `"reason": "size"`. Editor images are public storage URLs, like every material file.
|
||||
|
||||
### Calendar feed
|
||||
|
||||
The calendar-feed routes answer the caller's feed:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,39 @@
|
||||
package bm_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"image"
|
||||
"image/png"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/surf"
|
||||
"git.golem15.com/jakub/sm-bm-plugin/models"
|
||||
"gocloud.dev/blob"
|
||||
_ "gocloud.dev/blob/memblob"
|
||||
)
|
||||
|
||||
// TestMaterialEditAgainstDatabase checks the author's student routes
|
||||
// (D-07..D-11, D-16) against Postgres: who may edit (the author; 403 for
|
||||
// another manager or a non-manager; 404 for what the caller cannot see),
|
||||
// the revision check with the server version in the 409, the edit body's
|
||||
// rules, and that an edit keeps the course and the author.
|
||||
// rules, that an edit keeps the course and the author, the tag dictionary,
|
||||
// unpublish to drafts and the editor image upload with its refusals.
|
||||
func TestMaterialEditAgainstDatabase(t *testing.T) {
|
||||
app, plugins, gdb := migrated(t)
|
||||
seedFixture(t, plugins, "testdata/seed.example.json")
|
||||
bucket, err := blob.OpenBucket(t.Context(), "mem://")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { bucket.Close() })
|
||||
if err := app.Publish(bucket); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := surf.Assemble(app, plugins)
|
||||
if err != nil {
|
||||
t.Fatalf("Assemble: %v", err)
|
||||
@@ -171,4 +188,91 @@ VALUES (?, ?, 'notatka', 'Stara treść', `+published+`, ?, ?) RETURNING id`, co
|
||||
if again := obj(author.want(http.StatusOK, "PUT", path(own), edit(2, nil))["data"]); again["revision"] != float64(2) {
|
||||
t.Errorf("unchanged save revision = %v, want 2", again["revision"])
|
||||
}
|
||||
|
||||
// The tag dictionary: managers only, by name.
|
||||
var names []string
|
||||
for _, tag := range list(author.ok("GET", base+"/tags")) {
|
||||
names = append(names, tag["name"].(string))
|
||||
}
|
||||
if strings.Join(names, ",") != "Cyber,Lektura,Pokój,Prawo,Wojna" {
|
||||
t.Errorf("tags = %v, want the dictionary by name", names)
|
||||
}
|
||||
plain.want(http.StatusForbidden, "GET", base+"/tags", "")
|
||||
|
||||
// Editor images: one PNG per request, public, no revision bump.
|
||||
pngBytes := func() []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, image.NewRGBA(image.Rect(0, 0, 2, 2))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}()
|
||||
upload := func(c apiClient, materialID uint, name string, content []byte) (int, map[string]any) {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
mw := multipart.NewWriter(&buf)
|
||||
fw, err := mw.CreateFormFile("file", name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fw.Write(content)
|
||||
mw.Close()
|
||||
req := httptest.NewRequest("POST", path(materialID)+"/images", &buf)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
||||
rec := httptest.NewRecorder()
|
||||
c.h.ServeHTTP(rec, req)
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
|
||||
t.Fatalf("upload %s = %d, not JSON: %s", name, rec.Code, rec.Body.String())
|
||||
}
|
||||
return rec.Code, out
|
||||
}
|
||||
code, out := upload(author, own, "obraz.png", pngBytes)
|
||||
if code != http.StatusCreated {
|
||||
t.Fatalf("PNG upload = %d %v, want 201", code, out)
|
||||
}
|
||||
if img := obj(out["data"]); !strings.HasPrefix(img["url"].(string), "/storage/app/uploads/public/") || img["mime"] != "image/png" || img["name"] != "obraz.png" {
|
||||
t.Errorf("uploaded image = %v", img)
|
||||
}
|
||||
if m := row(own); m.Revision != 2 {
|
||||
t.Errorf("revision after the upload = %d, want 2", m.Revision)
|
||||
}
|
||||
if code, out := upload(author, own, "image.png", []byte("to nie jest obrazek")); code != http.StatusUnprocessableEntity || out["reason"] != "type" {
|
||||
t.Errorf("text as image.png = %d %v, want 422 reason type", code, out)
|
||||
}
|
||||
if code, out := upload(author, own, "rysunek.svg", []byte(`<svg xmlns="http://www.w3.org/2000/svg"/>`)); code != http.StatusUnprocessableEntity || out["reason"] != "type" {
|
||||
t.Errorf("SVG = %d %v, want 422 reason type", code, out)
|
||||
}
|
||||
if code, out := upload(other, own, "obraz.png", pngBytes); code != http.StatusForbidden {
|
||||
t.Errorf("another manager's upload = %d %v, want 403", code, out)
|
||||
}
|
||||
if code, out := upload(author, othersPrivate, "obraz.png", pngBytes); code != http.StatusNotFound {
|
||||
t.Errorf("upload to an invisible material = %d %v, want 404", code, out)
|
||||
}
|
||||
for i := 1; i < 20; i++ {
|
||||
if code, out := upload(author, own, "obraz.png", pngBytes); code != http.StatusCreated {
|
||||
t.Fatalf("upload %d = %d %v, want 201", i+1, code, out)
|
||||
}
|
||||
}
|
||||
if code, out := upload(author, own, "obraz.png", pngBytes); code != http.StatusUnprocessableEntity || out["reason"] != "count" {
|
||||
t.Errorf("21st file = %d %v, want 422 reason count", code, out)
|
||||
}
|
||||
if d := obj(author.ok("GET", path(own))["data"]); d["files_count"] != float64(20) {
|
||||
t.Errorf("files_count = %v, want 20", d["files_count"])
|
||||
}
|
||||
|
||||
// Unpublish: a draft, 404 even for the author afterwards, revision kept.
|
||||
other.want(http.StatusForbidden, "POST", path(own)+"/unpublish", "")
|
||||
author.want(http.StatusNotFound, "POST", path(othersPrivate)+"/unpublish", "")
|
||||
if code, _ := author.send("POST", path(own)+"/unpublish", ""); code != http.StatusNoContent {
|
||||
t.Fatalf("unpublish = %d, want 204", code)
|
||||
}
|
||||
author.want(http.StatusNotFound, "GET", path(own), "")
|
||||
if m := row(own); m.PublishedAt != nil || m.Revision != 2 || m.AuthorUserID == nil || *m.AuthorUserID != authorID {
|
||||
t.Errorf("row after unpublish = %+v, want a draft at revision 2 with its author", m)
|
||||
}
|
||||
author.want(http.StatusNotFound, "POST", path(own)+"/unpublish", "")
|
||||
author.want(http.StatusNotFound, "PUT", path(own), edit(2, nil))
|
||||
}
|
||||
|
||||
34
mcp_test.go
34
mcp_test.go
@@ -503,6 +503,9 @@ var authorRoutes = []string{
|
||||
"GET /materials/{id}/access",
|
||||
"PUT /materials/{id}/access",
|
||||
"PUT /materials/{id}",
|
||||
"GET /tags",
|
||||
"POST /materials/{id}/unpublish",
|
||||
"POST /materials/{id}/images",
|
||||
}
|
||||
|
||||
// TestTokenMirrorRoutes is the invariant of the two student surfaces: every
|
||||
@@ -733,3 +736,34 @@ func TestManageBucketKey(t *testing.T) {
|
||||
t.Errorf("token twin key = %q, want jz.bm.token|u:7", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAuthorBucketKeys: the author's material routes are throttled per user,
|
||||
// each under its own name, so an editor's saves and uploads never use up
|
||||
// the unpublish budget.
|
||||
func TestAuthorBucketKeys(t *testing.T) {
|
||||
buckets := (&bm.Plugin{}).Buckets()
|
||||
req := httptest.NewRequest(http.MethodPut, "/", nil)
|
||||
req = req.WithContext(bouncer.WithUser(req.Context(), &bouncer.Principal{ID: 7}))
|
||||
keys := map[string]bool{}
|
||||
for name, max := range map[string]int{"jz.bm.material-edit": 30, "jz.bm.material-images": 30, "jz.bm.material-unpublish": 10} {
|
||||
b, ok := buckets[name]
|
||||
if !ok {
|
||||
t.Errorf("%s is not declared", name)
|
||||
continue
|
||||
}
|
||||
if b.Max != max {
|
||||
t.Errorf("%s Max = %d, want %d", name, b.Max, max)
|
||||
}
|
||||
key := b.Key(req)
|
||||
if key != name+"|u:7" {
|
||||
t.Errorf("%s key = %q, want %s|u:7", name, key, name)
|
||||
}
|
||||
keys[key] = true
|
||||
if got := b.Key(httptest.NewRequest(http.MethodPut, "/", nil)); !strings.HasPrefix(got, name+"|ip:") {
|
||||
t.Errorf("%s bare key = %q, want the IP", name, got)
|
||||
}
|
||||
}
|
||||
if len(keys) != 3 {
|
||||
t.Errorf("keys = %v, want three distinct counters", keys)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,6 +148,9 @@ func TestRoutes(t *testing.T) {
|
||||
"GET /_bm/api/v1/materials/{id}/access",
|
||||
"PUT /_bm/api/v1/materials/{id}/access",
|
||||
"PUT /_bm/api/v1/materials/{id}",
|
||||
"GET /_bm/api/v1/tags",
|
||||
"POST /_bm/api/v1/materials/{id}/unpublish",
|
||||
"POST /_bm/api/v1/materials/{id}/images",
|
||||
"GET /_bm/api/v1/token/dashboard",
|
||||
"GET /_bm/api/v1/token/courses",
|
||||
"GET /_bm/api/v1/token/courses/{id}",
|
||||
|
||||
36
routes.go
36
routes.go
@@ -4,6 +4,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -54,11 +55,34 @@ const (
|
||||
// ticket in its path is the only credential (D-12).
|
||||
bucketUploadIP = "throttle:jz.bm.upload-ip"
|
||||
bucketUploadTicket = "throttle:jz.bm.upload-ticket"
|
||||
// bucketMaterialEdit, bucketMaterialImages and bucketMaterialUnpublish
|
||||
// throttle the author's routes per user (they run after jwt.auth), each
|
||||
// with its own counter: the inline throttle:N,M form keys by user only,
|
||||
// so every inline-throttled route would share one count.
|
||||
bucketMaterialEdit = "throttle:jz.bm.material-edit"
|
||||
bucketMaterialImages = "throttle:jz.bm.material-images"
|
||||
bucketMaterialUnpublish = "throttle:jz.bm.material-unpublish"
|
||||
)
|
||||
|
||||
// perUserBucket is a bucket counted per signed-in user under its own name,
|
||||
// or per client IP for a request without one.
|
||||
func perUserBucket(name string, max int, trusted []netip.Prefix) surf.Bucket {
|
||||
return surf.Bucket{
|
||||
Max: max,
|
||||
Decay: time.Minute,
|
||||
Key: func(r *http.Request) string {
|
||||
if u, ok := bouncer.User(r.Context()); ok && u.ID != 0 {
|
||||
return name + "|u:" + strconv.FormatUint(uint64(u.ID), 10)
|
||||
}
|
||||
return name + "|ip:" + surf.ClientIP(r, trusted)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Buckets declares the rate limits of the public calendar feed, the
|
||||
// management API, the token twins of the student API, the signed material
|
||||
// upload, the realtime routes and the BM chat routes.
|
||||
// upload, the realtime routes, the BM chat routes and the author's material
|
||||
// routes.
|
||||
func (p *Plugin) Buckets() map[string]surf.Bucket {
|
||||
trusted := surf.TrustedProxies(nil)
|
||||
if p.app != nil {
|
||||
@@ -126,6 +150,9 @@ func (p *Plugin) Buckets() map[string]surf.Bucket {
|
||||
return "jz.bm.token|ip:" + surf.ClientIP(r, trusted)
|
||||
},
|
||||
},
|
||||
"jz.bm.material-edit": perUserBucket("jz.bm.material-edit", 30, trusted),
|
||||
"jz.bm.material-images": perUserBucket("jz.bm.material-images", 30, trusted),
|
||||
"jz.bm.material-unpublish": perUserBucket("jz.bm.material-unpublish", 10, trusted),
|
||||
"jz.bm.chat": {
|
||||
Max: 240,
|
||||
Decay: time.Minute,
|
||||
@@ -170,7 +197,12 @@ func (p *Plugin) Routes(r pact.Router) error {
|
||||
// The author's routes (D-07): edit, unpublish and editor images. Like
|
||||
// the access panel they have no token twin; tokens and MCP write
|
||||
// materials through the management API.
|
||||
g.Put("/materials/{id}", h.EditMaterial, "throttle:30,1")
|
||||
g.Put("/materials/{id}", h.EditMaterial, bucketMaterialEdit)
|
||||
g.Where("id", "[0-9]+")
|
||||
g.Get("/tags", h.Tags)
|
||||
g.Post("/materials/{id}/unpublish", h.UnpublishMaterial, bucketMaterialUnpublish)
|
||||
g.Where("id", "[0-9]+")
|
||||
g.Post("/materials/{id}/images", h.UploadMaterialImage, bucketMaterialImages, "body.limit:"+strconv.Itoa(api.MaterialFilesBodyLimit))
|
||||
g.Where("id", "[0-9]+")
|
||||
if p.app != nil && p.app.HasPlugin("golem15.chat") {
|
||||
g.Get("/chat", h.Chat, bucketChat)
|
||||
|
||||
Reference in New Issue
Block a user