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

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