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.

View File

@@ -2,23 +2,63 @@ package classes
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"regexp"
"strconv"
"strings"
"time"
"git.golem15.com/golem15/summercms/modules/cooler"
)
// Signed one-time upload URLs for material files (D-10, D-12). The owner's
// agent asks for an upload URL over MCP and sends the file with curl, so
// the bytes never pass through MCP. Each plugin signs its own tickets with
// its own label: golem15.presentations has the same design, and the code is
// duplicated on purpose so jz.bm imports neither that plugin nor new
// golem15.user code. A ticket is
//
// base64url(payload) + "." + base64url(HMAC-SHA256(key, uploadLabel + payload))
//
// with payload "<kind>|<target>|<user>|<expiry unix>|<nonce>", the nonce 16
// random bytes in hex. It is valid for UploadTTL and redeemable once:
// issuing stores the payload under the nonce in the app cache, redeeming
// pulls it atomically. A restart of a memory cache forgets every issued
// ticket, so they fail closed.
// uploadLabel separates the upload HMAC from any other use of the key.
const uploadLabel = "jz.bm.upload.v1:"
// uploadCachePrefix is the cache key prefix of issued tickets.
const uploadCachePrefix = "jz.bm.upload:"
// UploadTTL is how long an upload URL is valid.
const UploadTTL = 10 * time.Minute
// uploadTicketMaxLen bounds the ticket text read from a URL.
const uploadTicketMaxLen = 512
var (
// ErrUploadTicket is every refused upload ticket.
// ErrUploadTicket is every refused ticket: malformed, forged, signed
// with another key or label, expired, unknown or already used.
ErrUploadTicket = errors.New("bm: invalid or expired upload ticket")
// ErrUploadKeyMissing means no key to sign upload tickets with.
// ErrUploadKeyMissing means no key to sign upload tickets with (app.key
// is empty): no upload URL is issued.
ErrUploadKeyMissing = errors.New("bm: upload key is not configured")
)
// UploadTicket is what a signed upload URL grants.
var (
uploadKind = regexp.MustCompile(`^[a-z]{1,20}$`)
uploadNonce = regexp.MustCompile(`^[0-9a-f]{32}$`)
)
// UploadTicket is what a signed upload URL grants: one upload of Kind to
// Target (a material id) by User, until Expires.
type UploadTicket struct {
Kind string
Target uint
@@ -27,23 +67,126 @@ type UploadTicket struct {
Nonce string
}
// UploadKey is the signing key from the app key.
func UploadKey(appKey string) []byte { return nil }
// payload is the signed text of t.
func (t UploadTicket) payload() string {
return t.Kind + "|" + strconv.FormatUint(uint64(t.Target), 10) + "|" + strconv.FormatUint(uint64(t.User), 10) + "|" + strconv.FormatInt(t.Expires.Unix(), 10) + "|" + t.Nonce
}
// SignUpload signs t.
func SignUpload(key []byte, t UploadTicket) (string, error) { return "", nil }
// UploadKey is the signing key from the app key: trimmed, nil when empty.
func UploadKey(appKey string) []byte {
if k := strings.TrimSpace(appKey); k != "" {
return []byte(k)
}
return nil
}
// VerifyUpload checks a ticket.
func uploadMAC(key []byte, payload string) []byte {
mac := hmac.New(sha256.New, key)
mac.Write([]byte(uploadLabel + payload))
return mac.Sum(nil)
}
// SignUpload signs t. It does not register the ticket; IssueUpload does.
func SignUpload(key []byte, t UploadTicket) (string, error) {
if len(key) == 0 {
return "", ErrUploadKeyMissing
}
payload := t.payload()
return base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + base64.RawURLEncoding.EncodeToString(uploadMAC(key, payload)), nil
}
// VerifyUpload checks a ticket's signature and expiry at now. An expiry
// further away than UploadTTL (and a minute of clock skew) is refused too.
// Every failure is ErrUploadTicket.
func VerifyUpload(key []byte, ticket string, now time.Time) (UploadTicket, error) {
return UploadTicket{}, nil
if len(key) == 0 || ticket == "" || len(ticket) > uploadTicketMaxLen {
return UploadTicket{}, ErrUploadTicket
}
parts := strings.Split(ticket, ".")
if len(parts) != 2 {
return UploadTicket{}, ErrUploadTicket
}
payload, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return UploadTicket{}, ErrUploadTicket
}
mac, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return UploadTicket{}, ErrUploadTicket
}
if !hmac.Equal(mac, uploadMAC(key, string(payload))) {
return UploadTicket{}, ErrUploadTicket
}
t, ok := parseUploadPayload(string(payload))
if !ok || !now.Before(t.Expires) || t.Expires.Sub(now) > UploadTTL+time.Minute {
return UploadTicket{}, ErrUploadTicket
}
return t, nil
}
// IssueUpload signs and registers a ticket.
func parseUploadPayload(payload string) (UploadTicket, bool) {
fields := strings.Split(payload, "|")
if len(fields) != 5 || !uploadKind.MatchString(fields[0]) || !uploadNonce.MatchString(fields[4]) {
return UploadTicket{}, false
}
target, err := strconv.ParseUint(fields[1], 10, 32)
if err != nil || target == 0 {
return UploadTicket{}, false
}
user, err := strconv.ParseUint(fields[2], 10, 32)
if err != nil || user == 0 {
return UploadTicket{}, false
}
expiry, err := strconv.ParseInt(fields[3], 10, 64)
if err != nil {
return UploadTicket{}, false
}
return UploadTicket{Kind: fields[0], Target: uint(target), User: uint(user), Expires: time.Unix(expiry, 0).UTC(), Nonce: fields[4]}, true
}
// IssueUpload signs a ticket for one upload of kind to target by user,
// valid for UploadTTL from now, and stores its payload in cache under the
// nonce for RedeemUpload. It answers the ticket and its expiry.
func IssueUpload(ctx context.Context, cache cooler.Cache, key []byte, kind string, target, user uint, now time.Time) (string, time.Time, error) {
return "", time.Time{}, nil
if len(key) == 0 {
return "", time.Time{}, ErrUploadKeyMissing
}
if cache == nil {
return "", time.Time{}, errors.New("bm: upload cache is not available")
}
if !uploadKind.MatchString(kind) || target == 0 || user == 0 {
return "", time.Time{}, errors.New("bm: invalid upload target")
}
var nonce [16]byte
if _, err := rand.Read(nonce[:]); err != nil {
return "", time.Time{}, err
}
t := UploadTicket{Kind: kind, Target: target, User: user, Expires: now.Add(UploadTTL).Truncate(time.Second).UTC(), Nonce: hex.EncodeToString(nonce[:])}
signed, err := SignUpload(key, t)
if err != nil {
return "", time.Time{}, err
}
if err := cache.Set(ctx, uploadCachePrefix+t.Nonce, []byte(t.payload()), UploadTTL); err != nil {
return "", time.Time{}, err
}
return signed, t.Expires, nil
}
// RedeemUpload verifies and consumes a ticket.
// RedeemUpload verifies ticket and takes it out of cache in one atomic Pull,
// so it works once. A ticket whose cache entry is missing (used, expired or
// forgotten by a restart) or differs from its payload is refused. Every
// failure is ErrUploadTicket.
func RedeemUpload(ctx context.Context, cache cooler.Cache, key []byte, ticket string, now time.Time) (UploadTicket, error) {
return UploadTicket{}, nil
t, err := VerifyUpload(key, ticket, now)
if err != nil {
return UploadTicket{}, err
}
if cache == nil {
return UploadTicket{}, ErrUploadTicket
}
raw, found, err := cache.Pull(ctx, uploadCachePrefix+t.Nonce)
if err != nil || !found || string(raw) != t.payload() {
return UploadTicket{}, ErrUploadTicket
}
return t, nil
}

View File

@@ -5,13 +5,22 @@ import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"strings"
"time"
userclasses "git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/cooler"
"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/classes"
"git.golem15.com/jakub/sm-bm-plugin/controllers"
"git.golem15.com/jakub/sm-bm-plugin/models"
"gorm.io/gorm"
)
@@ -142,15 +151,226 @@ func (h *Handlers) AttachMaterialInline(w http.ResponseWriter, r *http.Request)
h.writeMaterialDetailWith(w, r, db, m.ID, http.StatusCreated, map[string]any{"warning": MaterialFileWarning})
}
// UploadPath is the path of the signed upload route.
// Signed one-time uploads (D-10, D-12, D-19): for anything larger, the
// agent asks for an upload URL (CreateMaterialUpload) and sends the file
// with curl, so the bytes never pass through MCP. The upload route
// (SignedMaterialUpload) has no bearer: the ticket in its path is the
// credential. It is bound to one material and one user, valid
// classes.UploadTTL and works once; the user and the permission are
// checked again when it is redeemed.
// UploadPath is the path of the signed upload route; a ticket follows it.
// The plugin mounts the route at APIPrefix + "/uploads/{ticket}", and
// TestRoutes pins the two together.
const UploadPath = "/_bm/api/v1/uploads/"
// CreateMaterialUpload is not implemented yet.
func (h *Handlers) CreateMaterialUpload(w http.ResponseWriter, r *http.Request) {
wire.WriteJSON(w, http.StatusNotImplemented, map[string]any{"error": true})
// uploadKind is the ticket kind of a material file upload.
const uploadKind = "material"
// materialUploadJSON is the answer of CreateMaterialUpload.
type materialUploadJSON struct {
UploadURL string `json:"upload_url"`
ExpiresAt string `json:"expires_at"`
MaxBytes int `json:"max_bytes"`
Accept string `json:"accept"`
CurlExample string `json:"curl_example"`
Warning string `json:"warning"`
}
// SignedMaterialUpload is not implemented yet.
func (h *Handlers) SignedMaterialUpload(w http.ResponseWriter, r *http.Request) {
wire.WriteJSON(w, http.StatusNotImplemented, map[string]any{"error": true})
// uploadedFileJSON is the file in the answer of SignedMaterialUpload.
type uploadedFileJSON struct {
ID uint `json:"id"`
Name string `json:"name"`
Size int64 `json:"size"`
Mime string `json:"mime"`
URL string `json:"url"`
}
// CreateMaterialUpload answers POST /materials/{id}/files/upload-url: a
// signed, single-use URL, valid classes.UploadTTL, for one file upload to
// this material by the caller, with a curl example. No URL is issued
// without app.key or a cache (503).
func (h *Handlers) CreateMaterialUpload(w http.ResponseWriter, r *http.Request) {
user, ok := h.caller(w, r)
if !ok {
return
}
db, ok := h.db(w, r)
if !ok {
return
}
m, ok := findMaterial(w, r, db)
if !ok {
return
}
key, cache := h.uploadKey(), h.uploadCache()
if len(key) == 0 || cache == nil {
wire.WriteJSON(w, http.StatusServiceUnavailable, map[string]any{"error": true, "message": "Uploads are not configured."})
return
}
ticket, expires, err := classes.IssueUpload(r.Context(), cache, key, uploadKind, m.ID, user, h.now())
if err != nil {
writeOpaque500(w)
return
}
url := h.appBase(r) + UploadPath + ticket
wire.WriteJSON(w, http.StatusCreated, map[string]any{"data": materialUploadJSON{
UploadURL: url,
ExpiresAt: expires.UTC().Format(time.RFC3339),
MaxBytes: MaterialFileMaxBytes,
Accept: strings.Join(attach.DefaultFileExtensions, ", "),
CurlExample: "curl -fsS -F 'file=@<path>' '" + url + "'",
Warning: MaterialFileWarning,
}})
}
// SignedMaterialUpload answers POST /_bm/api/v1/uploads/{ticket}. The
// ticket is redeemed first (once; any failure is the same 403 before the
// database), then the ticket's user must still exist and hold
// jz.bm.access_materials, and the material must still exist. The body is
// multipart with exactly one part, named file, stored through
// storeMaterialFile with the material file limits.
func (h *Handlers) SignedMaterialUpload(w http.ResponseWriter, r *http.Request) {
ticket, err := classes.RedeemUpload(r.Context(), h.uploadCache(), h.uploadKey(), r.PathValue("ticket"), h.now())
if err != nil || ticket.Kind != uploadKind {
writeUploadForbidden(w)
return
}
db, ok := h.db(w, r)
if !ok {
return
}
principal, err := userclasses.PrincipalByID(r.Context(), db, ticket.User)
if err != nil {
writeOpaque500(w)
return
}
if principal == nil || !cabana.Allows(principal, []string{controllers.PermMaterials}) {
writeUploadForbidden(w)
return
}
var m models.Material
res := db.Where("id = ?", ticket.Target).Limit(1).Find(&m)
if res.Error != nil {
writeOpaque500(w)
return
}
if res.RowsAffected == 0 {
writeNotFound(w)
return
}
bucket, ok := h.bucket(w)
if !ok {
return
}
mr, err := r.MultipartReader()
if err != nil {
writeValidation(w, map[string][]string{"file": {singleFilePart}})
return
}
var stored *attach.File
var refusal string
err = lagoon.Transaction(r.Context(), db, func(ctx context.Context, tx *gorm.DB) error {
part, err := mr.NextPart()
if err != nil && err != io.EOF {
return err
}
if err == io.EOF || part.FormName() != "file" || part.FileName() == "" {
if part != nil {
part.Close()
}
refusal = singleFilePart
return errUploadRefused
}
f, why, err := storeMaterialFile(ctx, tx, bucket, m, part.FileName(), part, fileLimits)
part.Close()
stored = f
if err != nil {
return err
}
if why != "" {
refusal = why
return errUploadRefused
}
if next, err := mr.NextPart(); err != io.EOF {
if next != nil {
next.Close()
}
if err != nil {
return err
}
refusal = singleFilePart
return errUploadRefused
}
return nil
})
if err != nil {
// The blob is deleted again when the transaction did not commit.
if stored != nil {
_ = attach.DeleteKeys(context.WithoutCancel(r.Context()), bucket, attach.BlobKeys(*stored))
}
var tooLarge *http.MaxBytesError
switch {
case refusal != "":
writeValidation(w, map[string][]string{"file": {refusal}})
case errors.As(err, &tooLarge):
wire.WriteJSON(w, http.StatusRequestEntityTooLarge, map[string]any{"error": true, "message": "Request body too large."})
default:
writeOpaque500(w)
}
return
}
url := stored.URL()
if strings.HasPrefix(url, "/") && !strings.HasPrefix(url, "//") {
url = h.appBase(r) + url
}
wire.WriteJSON(w, http.StatusCreated, map[string]any{
"data": map[string]any{
"material_id": m.ID,
"file": uploadedFileJSON{ID: stored.ID, Name: stored.FileName, Size: stored.FileSize, Mime: stored.ContentType, URL: url},
},
"warning": MaterialFileWarning,
})
}
// singleFilePart is the refusal of a signed upload body that is not one
// multipart part named file.
const singleFilePart = "Send exactly one file as multipart/form-data in a part named \"file\", for example curl -F 'file=@<path>'."
// errUploadRefused rolls back a refused signed upload.
var errUploadRefused = errors.New("api: upload refused")
// writeUploadForbidden is the one answer of every refused upload ticket.
func writeUploadForbidden(w http.ResponseWriter) {
wire.WriteJSON(w, http.StatusForbidden, map[string]any{"error": true, "message": "Invalid or expired upload URL."})
}
func (h *Handlers) uploadKey() []byte {
if h.UploadKey == nil {
return nil
}
return h.UploadKey()
}
func (h *Handlers) uploadCache() cooler.Cache {
if h.Cache == nil {
return nil
}
return h.Cache()
}
// appBase is the base of the absolute links of the upload answers (D-16):
// app.url, else jz.bm.public_url, else the scheme (TLS or
// X-Forwarded-Proto) and Host of the request.
func (h *Handlers) appBase(r *http.Request) string {
for _, base := range []string{h.AppURL, h.PublicURL} {
if base = strings.TrimRight(strings.TrimSpace(base), "/"); base != "" {
return base
}
}
scheme := "http"
if r.TLS != nil || strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")), "https") {
scheme = "https"
}
return scheme + "://" + r.Host
}

View File

@@ -22,9 +22,9 @@ type detachMaterialFileInput struct {
}
// fileTools are the material file tools over the management API (D-10,
// D-11, D-17): attach a small file as base64 and detach a file. Every
// description carries the public-file warning and says no file contents
// are returned.
// D-11, D-17): attach a small file as base64, get a signed one-time upload
// URL for a larger one, and detach a file. Every description carries the
// public-file warning and says no file contents are returned.
func fileTools() []pact.MCPTool {
tool := func(name, title, desc, method, path string, input any) pact.MCPTool {
return pact.MCPTool{Name: name, Title: title, Description: desc, Guard: "user", Method: method, Path: ManagePrefix + path, Input: input}
@@ -34,6 +34,10 @@ func fileTools() []pact.MCPTool {
"Attaches one file sent as standard base64 (at most 3000000 bytes decoded; for larger files use bm_manage_create_material_upload) to a material, after its files; a material holds at most 20. The extension and the content are checked; a sent mime must match the content. Returns the material with its files (id, absolute link, name, size and type) and a warning; 404 when the material is unknown, 422 when the file is refused. "+
api.MaterialFileWarning+" "+MCPMaterialPrivacy+writeMaterials,
http.MethodPost, "/materials/{id}/files/inline", attachMaterialFileInput{}),
tool("manage_create_material_upload", "Get an upload URL for a material file",
"Returns a one-time upload URL for one file of this material, valid for 10 minutes, bound to this material and to you, and usable once: send the file with the returned curl_example (curl -F 'file=@<path>' '<upload_url>') from a shell, so the bytes never pass through MCP. At most 50 MB, one of the listed accept extensions; the answer also has expires_at, max_bytes and a warning. A used, expired or forged URL gets 403. 404 when the material is unknown, 503 when uploads are not configured. "+
api.MaterialFileWarning+" "+MCPMaterialPrivacy+writeMaterials,
http.MethodPost, "/materials/{id}/files/upload-url", idInput{}),
tool("manage_detach_material_file", "Detach a file from a material",
"Deletes one file of a material, its row and its stored blob, so its link stops working; 404 when the material or the file is unknown or the file belongs to another material."+deleteForever+" "+
api.MaterialFileWarning+" "+MCPMaterialPrivacy+writeMaterials,

View File

@@ -1,6 +1,8 @@
package bm
import (
"crypto/sha256"
"encoding/hex"
"net/http"
"strconv"
"time"
@@ -10,10 +12,12 @@ import (
userclasses "git.golem15.com/golem15/sm-user-plugin/classes"
usermiddleware "git.golem15.com/golem15/sm-user-plugin/middleware"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/cooler"
"git.golem15.com/golem15/summercms/modules/lighthouse"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"git.golem15.com/golem15/summercms/modules/surf"
"git.golem15.com/jakub/sm-bm-plugin/classes"
"git.golem15.com/jakub/sm-bm-plugin/controllers"
"git.golem15.com/jakub/sm-bm-plugin/controllers/api"
"git.golem15.com/jakub/sm-bm-plugin/models"
@@ -45,11 +49,16 @@ const (
// (it runs after user.api_token), or per client IP for a request
// without one.
bucketToken = "throttle:jz.bm.token"
// bucketUploadIP and bucketUploadTicket throttle the signed material
// upload per client IP and per ticket: it carries no bearer, so the
// ticket in its path is the only credential (D-12).
bucketUploadIP = "throttle:jz.bm.upload-ip"
bucketUploadTicket = "throttle:jz.bm.upload-ticket"
)
// Buckets declares the rate limits of the public calendar feed, the
// management API, the token twins of the student API, the realtime routes
// and the BM chat routes.
// management API, the token twins of the student API, the signed material
// upload, the realtime routes and the BM chat routes.
func (p *Plugin) Buckets() map[string]surf.Bucket {
trusted := surf.TrustedProxies(nil)
if p.app != nil {
@@ -83,6 +92,20 @@ func (p *Plugin) Buckets() map[string]surf.Bucket {
return "jz.bm.manage.ip|" + surf.ClientIP(r, trusted)
},
},
"jz.bm.upload-ip": {
Max: 10,
Decay: time.Minute,
Key: func(r *http.Request) string { return "jz.bm.upload.ip|" + surf.ClientIP(r, trusted) },
},
"jz.bm.upload-ticket": {
Max: 3,
Decay: time.Minute,
Key: func(r *http.Request) string {
// A digest, so the ticket itself is not kept as a limiter key.
sum := sha256.Sum256([]byte(r.PathValue("ticket")))
return "jz.bm.upload.ticket|" + hex.EncodeToString(sum[:])[:32]
},
},
"jz.bm.realtime": {
Max: 120,
Decay: time.Minute,
@@ -174,6 +197,8 @@ func (p *Plugin) Routes(r pact.Router) error {
g.Where("id", "[0-9]+")
g.Post("/materials/{id}/files/inline", h.AttachMaterialInline, "user.scope:write", "user.permission:"+controllers.PermMaterials, "body.limit:"+strconv.Itoa(api.MaterialInlineBodyLimit))
g.Where("id", "[0-9]+")
g.Post("/materials/{id}/files/upload-url", h.CreateMaterialUpload, "user.scope:write", "user.permission:"+controllers.PermMaterials)
g.Where("id", "[0-9]+")
g.Delete("/materials/{id}/files/{file}", h.DetachMaterialFile, "user.scope:write", "user.permission:"+controllers.PermMaterials)
g.Where("id", "[0-9]+")
g.Where("file", "[0-9]+")
@@ -200,6 +225,15 @@ func (p *Plugin) Routes(r pact.Router) error {
"user.permission:"+controllers.PermSchedule,
"user.permission:"+controllers.PermMaterials)
})
// The signed material upload (D-10, D-12): no bearer, because curl sends
// the file without one; the ticket in the path is the credential. It is
// bound to one material and one user, expires and works once, and the
// handler re-checks the user's jz.bm.access_materials permission.
// Throttled per IP and per ticket; the body is capped at one material
// file plus the multipart framing.
r.Group(APIPrefix+"/uploads", surf.Use(bucketUploadIP, bucketUploadTicket), func(g pact.Router) {
g.Post("/{ticket}", h.SignedMaterialUpload, "body.limit:"+strconv.Itoa(api.MaterialFilesBodyLimit))
})
// The calendar feed: public, because calendar clients fetch it without
// credentials; the token in the path is the credential. ServeMux cannot
// match a suffix inside a segment, so {token} carries the ".ics" and the
@@ -302,6 +336,16 @@ func (p *Plugin) handlers() *api.Handlers {
h.DashboardLimit = p.app.Config.Int("jz.bm.dashboard_limit")
h.PublicURL = p.app.Config.String("jz.bm.public_url")
h.AppURL = p.app.Config.String("app.url")
h.UploadKey = func() []byte { return classes.UploadKey(p.app.Config.String("app.key")) }
}
if p.app != nil {
h.Cache = func() cooler.Cache {
cache, err := cooler.FromApp(p.app)
if err != nil {
return nil
}
return cache
}
}
return h
}