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:
16
README.md
16
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' '<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.
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
10
mcp_files.go
10
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=@<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,
|
||||
|
||||
48
routes.go
48
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user