diff --git a/README.md b/README.md index c9e4b71..ba74398 100644 --- a/README.md +++ b/README.md @@ -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' ''`. 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||||` (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. diff --git a/classes/uploads.go b/classes/uploads.go index 608dbc7..03b840a 100644 --- a/classes/uploads.go +++ b/classes/uploads.go @@ -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 "||||", 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 } diff --git a/controllers/api/uploads.go b/controllers/api/uploads.go index 3410022..55da342 100644 --- a/controllers/api/uploads.go +++ b/controllers/api/uploads.go @@ -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=@' '" + 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=@'." + +// 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 } diff --git a/mcp_files.go b/mcp_files.go index 0bd2ebb..9741a22 100644 --- a/mcp_files.go +++ b/mcp_files.go @@ -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=@' '') 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, diff --git a/routes.go b/routes.go index 59579a5..51d52af 100644 --- a/routes.go +++ b/routes.go @@ -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 }