Files
foundry-core/internal/platform/assets.go
T
codex-bot 0d7fc4dbe5
Foundry Go checks / check (push) Successful in 2m56s
Foundry Go checks / check (pull_request) Successful in 2m49s
Preserve staged resource identities and expose upload receipts
2026-11-07 23:18:20 +08:00

397 lines
12 KiB
Go

package platform
import (
"bytes"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"image"
_ "image/gif"
_ "image/jpeg"
_ "image/png"
"io"
"net/http"
"net/url"
"strconv"
"strings"
_ "golang.org/x/image/webp"
)
const assetLimit = 20 << 20
const assetPixelLimit = 40_000_000
type assetInfo struct {
ID string `json:"id"`
MIME string `json:"mime_type"`
Bytes int `json:"bytes"`
Width int `json:"width"`
Height int `json:"height"`
URL string `json:"url"`
}
func (s *service) assetURL(project, workspace, id string) string {
return s.origin + "/projects/" + url.PathEscape(project) + "/workspaces/" + url.PathEscape(workspace) + "/assets/" + id
}
// Decode headers first and bound allocation before decoding the actual image.
// The MIME comes from the decoder, never from the filename or upload header.
func inspectAsset(encoded []byte) (string, int, int, error) {
config, format, err := image.DecodeConfig(bytes.NewReader(encoded))
if err != nil {
return "", 0, 0, err
}
if !canvasChoice(format, "png", "jpeg", "gif", "webp") || config.Width <= 0 || config.Height <= 0 || config.Width > assetPixelLimit || config.Height > assetPixelLimit || int64(config.Width)*int64(config.Height) > assetPixelLimit {
return "", 0, 0, errors.New("invalid_image_dimensions")
}
decoded, actual, err := image.Decode(bytes.NewReader(encoded))
if err != nil {
return "", 0, 0, err
}
if actual != format || decoded.Bounds().Dx() != config.Width || decoded.Bounds().Dy() != config.Height {
return "", 0, 0, errors.New("invalid_image")
}
return "image/" + format, config.Width, config.Height, nil
}
func (s *service) uploadAsset(w http.ResponseWriter, r *http.Request, project, account string) error {
if r.Method != http.MethodPost {
reject(w, 405, "method_not_allowed")
return nil
}
key := canvasIdempotencyKey(r)
if !uuidSlugPattern.MatchString(key) {
reject(w, 400, "invalid_idempotency_key")
return nil
}
resourceID := strings.ToLower(r.Header.Get("X-Resource-Id"))
if resourceID != "" && !uuidSlugPattern.MatchString(resourceID) {
reject(w, 400, "invalid_resource_id")
return nil
}
respond := func(info assetInfo) {
if resourceID == "" {
reply(w, 201, info)
} else {
reply(w, 201, response{"protocol_version": 2, "request_id": key, "status": "applied", "asset": info})
}
}
tx, err := s.db.BeginTx(r.Context(), nil)
if err != nil {
return err
}
defer tx.Rollback()
workspace := r.PathValue("workspace")
_, role, err := lockWorkspace(r.Context(), tx, project, workspace, account)
if err != nil {
return err
}
if role == "viewer" {
reject(w, 403, "forbidden")
return nil
}
// Do not hold a workspace lock during a slow multipart upload. Recheck below.
if err = tx.Rollback(); err != nil {
return err
}
r.Body = http.MaxBytesReader(w, r.Body, assetLimit+(64<<10))
reader, err := r.MultipartReader()
if err != nil {
reject(w, 400, "invalid_asset")
return nil
}
var encoded []byte
count := 0
for {
part, e := reader.NextPart()
if e == io.EOF {
break
}
if e != nil {
reject(w, 413, "asset_too_large")
return nil
}
if part.FormName() != "image" && part.FormName() != "file" {
part.Close()
reject(w, 400, "invalid_asset")
return nil
}
count++
if count > 1 {
part.Close()
reject(w, 400, "invalid_asset")
return nil
}
encoded, e = io.ReadAll(io.LimitReader(part, assetLimit+1))
part.Close()
if e != nil || len(encoded) > assetLimit {
reject(w, 413, "asset_too_large")
return nil
}
}
if count != 1 || len(encoded) == 0 {
reject(w, 400, "invalid_asset")
return nil
}
mime, width, height, err := inspectAsset(encoded)
if err != nil {
reject(w, 400, "invalid_asset")
return nil
}
tx, err = s.db.BeginTx(r.Context(), nil)
if err != nil {
return err
}
defer tx.Rollback()
_, role, err = lockWorkspace(r.Context(), tx, project, workspace, account)
if err != nil {
return err
}
if role == "viewer" {
reject(w, 403, "forbidden")
return nil
}
digest := sha256.Sum256(encoded)
hash := hex.EncodeToString(digest[:])
info := assetInfo{MIME: mime, Bytes: len(encoded), Width: width, Height: height}
var previous string
err = tx.QueryRowContext(r.Context(), `SELECT id::text,digest FROM canvas_assets WHERE project=$1 AND workspace_id=$2 AND uploaded_by=$3 AND idempotency_key=$4`, project, workspace, account, key).Scan(&info.ID, &previous)
if err == nil {
if previous != hash || resourceID != "" && resourceID != info.ID {
reject(w, 409, "idempotency_conflict")
return nil
}
info.URL = s.assetURL(project, workspace, info.ID)
respond(info)
return nil
}
if err != sql.ErrNoRows {
return err
}
if resourceID != "" {
var occupied bool
if err = tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM canvas_assets WHERE project=$1 AND workspace_id=$2 AND id=$3)`, project, workspace, resourceID).Scan(&occupied); err != nil {
return err
}
if occupied {
reject(w, 409, "resource_identity_conflict")
return nil
}
}
err = tx.QueryRowContext(r.Context(), `INSERT INTO canvas_assets(project,workspace_id,uploaded_by,mime_type,bytes,width,height,digest,idempotency_key,id) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,COALESCE(NULLIF($10,'')::uuid,gen_random_uuid())) RETURNING id::text`, project, workspace, account, mime, encoded, width, height, hash, key, resourceID).Scan(&info.ID)
if err != nil {
return err
}
if err = tx.Commit(); err != nil {
return err
}
info.URL = s.assetURL(project, workspace, info.ID)
respond(info)
return nil
}
// A missing receipt is unknown: callers may only retry the same resource/key/bytes.
func (s *service) assetOperation(w http.ResponseWriter, r *http.Request, project, account string) error {
if r.Method != http.MethodGet {
reject(w, 405, "method_not_allowed")
return nil
}
key, workspace := r.PathValue("request"), r.PathValue("workspace")
if !uuidSlugPattern.MatchString(key) {
reject(w, 400, "invalid_idempotency_key")
return nil
}
tx, err := s.db.BeginTx(r.Context(), nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, _, err = lockWorkspace(r.Context(), tx, project, workspace, account); err != nil {
return err
}
var info assetInfo
err = tx.QueryRowContext(r.Context(), `SELECT id::text,mime_type,octet_length(bytes),width,height FROM canvas_assets WHERE project=$1 AND workspace_id=$2 AND uploaded_by=$3 AND idempotency_key=$4`, project, workspace, account, key).Scan(&info.ID, &info.MIME, &info.Bytes, &info.Width, &info.Height)
if err == sql.ErrNoRows {
reply(w, 404, response{"protocol_version": 2, "request_id": key, "status": "unknown", "error": "asset_receipt_unknown"})
return nil
}
if err != nil {
return err
}
info.URL = s.assetURL(project, workspace, info.ID)
reply(w, 200, response{"protocol_version": 2, "request_id": key, "status": "applied", "asset": info})
return nil
}
func assetReadable(r *http.Request, tx *sql.Tx, project, workspace, id, account string) (bool, error) {
var uploader string
err := tx.QueryRowContext(r.Context(), `SELECT uploaded_by::text FROM canvas_assets WHERE project=$1 AND workspace_id=$2 AND id=$3`, project, workspace, id).Scan(&uploader)
if err != nil {
return false, err
}
var bindings int
err = tx.QueryRowContext(r.Context(), `SELECT count(*) FROM canvas_asset_bindings WHERE project=$1 AND workspace_id=$2 AND asset_id=$3`, project, workspace, id).Scan(&bindings)
if err != nil {
return false, err
}
if bindings == 0 {
if uploader == account {
return true, nil
}
var admin bool
err = tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM workspace_members WHERE project=$1 AND workspace_id=$2 AND account_id=$3 AND role='admin')`, project, workspace, account).Scan(&admin)
return admin, err
}
rows, err := tx.QueryContext(r.Context(), `SELECT board_id::text FROM canvas_asset_bindings WHERE project=$1 AND workspace_id=$2 AND asset_id=$3 ORDER BY board_id`, project, workspace, id)
if err != nil {
return false, err
}
boards := []string{}
for rows.Next() {
var board string
if err = rows.Scan(&board); err != nil {
rows.Close()
return false, err
}
boards = append(boards, board)
}
err = rows.Err()
rows.Close()
if err != nil {
return false, err
}
for _, board := range boards {
_, _, err := lockMaterial(r.Context(), tx, project, workspace, board, account)
if err == nil {
return true, nil
}
if err != sql.ErrNoRows {
return false, err
}
}
return false, nil
}
func (s *service) getAsset(w http.ResponseWriter, r *http.Request, project, account string) error {
if r.Method != http.MethodGet {
reject(w, 405, "method_not_allowed")
return nil
}
width, variantErr := previewWidth(r.URL.Query().Get("variant"), r.URL.Query().Get("width"))
if variantErr != nil {
reject(w, 400, "invalid_asset_variant")
return nil
}
id := r.PathValue("asset")
if !uuidSlugPattern.MatchString(id) {
reject(w, 404, "not_found")
return nil
}
workspace := r.PathValue("workspace")
tx, err := s.db.BeginTx(r.Context(), nil)
if err != nil {
return err
}
defer tx.Rollback()
// Serialize with permission and binding changes before making the read decision.
var locked string
err = tx.QueryRowContext(r.Context(), `SELECT id::text FROM workspaces WHERE project=$1 AND id=$2 FOR UPDATE`, project, workspace).Scan(&locked)
if err != nil {
return err
}
allowed, err := assetReadable(r, tx, project, workspace, id, account)
if err != nil {
return err
}
if !allowed {
reject(w, 404, "not_found")
return nil
}
var encoded []byte
var mime string
err = tx.QueryRowContext(r.Context(), `SELECT mime_type,bytes FROM canvas_assets WHERE project=$1 AND workspace_id=$2 AND id=$3`, project, workspace, id).Scan(&mime, &encoded)
if err != nil {
return err
}
// Permission and immutable bytes are now read consistently. Release locks
// before resizing or waiting for a slow public client to consume the body.
if err = tx.Rollback(); err != nil {
return err
}
variant := "original"
if width > 0 {
preview, e := cachedAssetPreview(r.Context(), encoded, mime, width)
if e != nil {
return e
}
encoded, mime = preview.data, preview.mime
variant = "preview"
}
w.Header().Set("X-Asset-Variant", variant)
w.Header().Set("Content-Type", mime)
w.Header().Set("Content-Length", strconv.Itoa(len(encoded)))
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Cache-Control", "private, no-store")
w.WriteHeader(200)
_, err = w.Write(encoded)
return err
}
func (s *service) attachCanvasAssets(r *http.Request, tx *sql.Tx, project, workspace, board string, raw json.RawMessage) error {
for _, data := range canvasNodeData(raw) {
id, _ := data["assetId"].(string)
if id == "" {
src, _ := data["src"].(string)
prefix := s.assetURL(project, workspace, "")
if strings.HasPrefix(src, prefix) {
candidate := strings.TrimPrefix(src, prefix)
if uuidSlugPattern.MatchString(candidate) {
id = candidate
}
}
}
if id == "" {
continue
}
_, err := tx.ExecContext(r.Context(), `INSERT INTO canvas_asset_bindings(project,workspace_id,asset_id,board_id) VALUES($1,$2,$3,$4) ON CONFLICT DO NOTHING`, project, workspace, id, board)
if err != nil {
return err
}
}
return nil
}
func (s *service) validateCanvasAssets(r *http.Request, tx *sql.Tx, project, workspace, account string, raw json.RawMessage) (bool, error) {
for _, data := range canvasNodeData(raw) {
id, explicit := data["assetId"].(string)
if !explicit {
src, _ := data["src"].(string)
prefix := s.assetURL(project, workspace, "")
if !strings.HasPrefix(src, prefix) {
continue
}
id = strings.TrimPrefix(src, prefix)
if !uuidSlugPattern.MatchString(id) {
return false, nil
}
}
if data["src"] != s.assetURL(project, workspace, id) {
return false, nil
}
allowed, err := assetReadable(r, tx, project, workspace, id, account)
if err == sql.ErrNoRows {
return false, nil
}
if err != nil {
return false, err
}
if !allowed {
return false, nil
}
// assetId is optional. Stable managed URLs acquire the same durable binding.
_, err = tx.ExecContext(r.Context(), `INSERT INTO canvas_asset_bindings(project,workspace_id,asset_id,board_id) VALUES($1,$2,$3,$4) ON CONFLICT DO NOTHING`, project, workspace, id, r.PathValue("material"))
if err != nil {
return false, err
}
}
return true, nil
}