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 } 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 } 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 } 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 { reject(w, 409, "idempotency_conflict") return nil } info.URL = s.assetURL(project, workspace, info.ID) reply(w, 201, info) return nil } if err != sql.ErrNoRows { return err } err = tx.QueryRowContext(r.Context(), `INSERT INTO canvas_assets(project,workspace_id,uploaded_by,mime_type,bytes,width,height,digest,idempotency_key) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id::text`, project, workspace, account, mime, encoded, width, height, hash, key).Scan(&info.ID) if err != nil { return err } if err = tx.Commit(); err != nil { return err } info.URL = s.assetURL(project, workspace, info.ID) reply(w, 201, 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 } 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 } 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 }