feat: signed one-time uploads for material files

- classes/uploads.go: tickets signed with HMAC-SHA256 under the
  jz.bm.upload.v1: label (app.key), 10-minute expiry, the nonce pulled
  from the cache once (duplicated from golem15.presentations on purpose)
- POST /api/v1/bm/materials/{id}/files/upload-url issues the URL with a
  curl example; POST /_bm/api/v1/uploads/{ticket} takes one file part,
  no bearer, throttled per IP and per ticket, user and permission
  re-checked, stored through storeMaterialFile
- bm_manage_create_material_upload; README documents the file routes
This commit is contained in:
2026-10-28 15:10:32 +01:00
parent ce6ac0a78e
commit 924e6aa0dc
5 changed files with 450 additions and 27 deletions

View File

@@ -202,6 +202,8 @@ The calendar is RFC 5545 (CRLF lines folded at 75 octets, escaped TEXT) with tim
| PUT | `/api/v1/bm/materials/{id}` | write, materials | The same body applied to that material, so the title or course can change. 404 when unknown; 422 on `title` when another material already has the new title and course. |
| DELETE | `/api/v1/bm/materials/{id}` | write, materials | 204, with the material's files (rows, then blobs); 404 when unknown. |
| POST | `/api/v1/bm/materials/{id}/files` | write, materials | Multipart body, parts named `file`: each is stored as a public file of the material, after the ones it has. 201 with the material as GET shows it; 404 when unknown; 422 on `file` when there is no file part, a type is not allowed, a file is over 50 MB or the material would hold more than 20 files (nothing is stored then); 413 over the body cap (`body.limit`, 51 MB, so one large file per request). |
| POST | `/api/v1/bm/materials/{id}/files/inline` | write, materials | JSON `{"name": "notatki.pdf", "mime": "application/pdf", "content_base64": "…"}` (`mime` optional; unknown keys are a 422): one file of at most 3000000 bytes decoded (`MaterialInlineMaxBytes`, so it fits under the MCP endpoint's 4 MiB request cap), stored like a multipart file; a sent `mime` must match the stored content type. Larger content is a 422 on `content_base64` that points at the upload URL. 201 with the material as GET shows it plus a top-level `warning`; 413 over the body cap (`body.limit`, 4300000). |
| POST | `/api/v1/bm/materials/{id}/files/upload-url` | write, materials | 201 `{"data": {"upload_url", "expires_at", "max_bytes", "accept", "curl_example", "warning"}}`: a signed, single-use URL for one file of this material by the caller, valid 10 minutes, at most 50 MiB (`max_bytes` 52428800). 503 when `app.key` or the cache is missing. See [Signed uploads](#signed-uploads). |
| DELETE | `/api/v1/bm/materials/{id}/files/{file}` | write, materials | 204: the file row, then its blob and thumbnails; 404 when the material or a file of it is unknown. |
| GET | `/api/v1/bm/{records}` | read, see below | Every row of the table, filtered by `?column=value` on the columns listed below (`null` matches NULL). |
| GET | `/api/v1/bm/{records}/{id}` | read | One row; 404 when unknown. |
@@ -235,7 +237,15 @@ The material body (POST and PUT), unknown keys are a 422:
- `meeting_id`: absent keeps the stored meeting (so scripts that never send it keep working), `null` clears it, otherwise an existing meeting (else a 422 on `meeting_id`). The managed material JSON shows `meeting_id`, and the material then lists under that zjazd in `GET /_bm/api/v1/materials?meeting=`.
- `published_at`: absent publishes a new material now and keeps an existing one as it is; `null` makes it a draft; otherwise a time as in the seed file.
- `tags`: absent keeps the tags; otherwise the list of names replaces them, and unknown tags are created (slug from the name, Polish letters folded).
- Files go through `POST /materials/{id}/files` (`attach.Store`: the default document and image extensions, content type sniffed from the bytes, a random disk name; `file_name` keeps the client's name). The file `url` is under `storage.uploads.public_path_prefix`, served by the application when `storage.uploads.serve` is true (the app's `config/storage.yaml` sets it): GET only, `is_public` rows only, anything but a raster image as `Content-Disposition: attachment`.
- Files go through `POST /materials/{id}/files`, `/files/inline` or a signed upload. All three use one helper, `storeMaterialFile`, which applies the 20-file cap, stores through `attach.Store` (the default document and image extensions, content type sniffed from the bytes, a random disk name; `file_name` keeps the client's name) and binds the file to the material; a refused or failed store deletes the blobs it wrote. The file `url` is under `storage.uploads.public_path_prefix`, served by the application when `storage.uploads.serve` is true (the app's `config/storage.yaml` sets it): GET only, `is_public` rows only, anything but a raster image as `Content-Disposition: attachment`.
### Signed uploads
`POST /_bm/api/v1/uploads/{ticket}` takes the file of a signed upload URL, for example `curl -fsS -F 'file=@wyklad.pdf' '<upload_url>'`. It has no bearer: the ticket in the path is the credential. The ticket is `base64url(payload).base64url(HMAC-SHA256(app.key, "jz.bm.upload.v1:" + payload))` with the payload `material|<material id>|<user id>|<expiry unix>|<nonce>` (16 random bytes in hex). Issuing stores the payload under the nonce in the application cache (`cooler`) for 10 minutes; the upload takes it out again with an atomic pull, so a URL works once, and a restart of the memory cache voids every issued URL. The URL carries no bearer token and no key.
The route is throttled per client IP (`throttle:jz.bm.upload-ip`, 10 a minute) and per ticket (`throttle:jz.bm.upload-ticket`, 3 a minute, keyed by a SHA-256 digest of the ticket) and capped at `body.limit` 51 MB. A malformed, forged, expired or used ticket is a 403 `{"error": true, "message": "Invalid or expired upload URL."}` before the database is touched; so is a ticket whose user no longer exists or no longer holds `jz.bm.access_materials`. A deleted material is a 404. The body must be multipart with exactly one part, named `file` (else a 422 on `file`); the file goes through `attach.Store` with the material file limits (50 MiB, the default extensions, the 20-file cap). The answer is 201 `{"data": {"material_id", "file": {"id", "name", "size", "mime", "url"}}, "warning"}` with an absolute `url` (`app.url`, else `jz.bm.public_url`, else the request). A refused upload still uses up its URL: ask for a new one.
**Material files are public to anyone with the link.** The inline and signed answers and every file tool say so.
## MCP tools
@@ -259,7 +269,9 @@ The material body (POST and PUT), unknown keys are a 422:
| deadlines | `bm_manage_list_deadlines`, `_show_deadline`, `_create_deadline`, `_update_deadline`, `_delete_deadline` | `jz.bm.access_schedule` |
| materials | `bm_manage_list_materials`, `_show_material`, `_save_material` (upsert on title and course), `_update_material`, `_delete_material` | `jz.bm.access_materials` |
`POST /seed` and `GET /me` are not tools. Courses and meetings have no delete tool, because the route has none. The material save and update tools require `body`, `source` and `url` (each may be `null`), because the route replaces them as sent; their descriptions tell the agent to read the material with `include_body` first to keep its body.
| material files | `bm_manage_attach_material_file` (base64, at most 3000000 bytes), `_create_material_upload` (signed one-time URL, then curl), `_detach_material_file` (destructive) | `jz.bm.access_materials` |
The file tools return metadata and links, never file contents, and their descriptions carry the public-file warning. `POST /seed` and `GET /me` are not tools. Courses and meetings have no delete tool, because the route has none. The material save and update tools require `body`, `source` and `url` (each may be `null`), because the route replaces them as sent; their descriptions tell the agent to read the material with `include_body` first to keep its body.
**The MCP view.** Only an answer to an MCP tool call (a request `pier` dispatched in-process, carrying the `api_token` dispatch marker, which an HTTP request can never carry) is changed; plain HTTP answers are byte for byte as before.