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

@@ -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: