1: <?php
2:
3: /*****************************************************************************************
4: * X2Engine Open Source Edition is a customer relationship management program developed by
5: * X2Engine, Inc. Copyright (C) 2011-2016 X2Engine Inc.
6: *
7: * This program is free software; you can redistribute it and/or modify it under
8: * the terms of the GNU Affero General Public License version 3 as published by the
9: * Free Software Foundation with the addition of the following permission added
10: * to Section 15 as permitted in Section 7(a): FOR ANY PART OF THE COVERED WORK
11: * IN WHICH THE COPYRIGHT IS OWNED BY X2ENGINE, X2ENGINE DISCLAIMS THE WARRANTY
12: * OF NON INFRINGEMENT OF THIRD PARTY RIGHTS.
13: *
14: * This program is distributed in the hope that it will be useful, but WITHOUT
15: * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
16: * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
17: * details.
18: *
19: * You should have received a copy of the GNU Affero General Public License along with
20: * this program; if not, see http://www.gnu.org/licenses or write to the Free
21: * Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
22: * 02110-1301 USA.
23: *
24: * You can contact X2Engine, Inc. P.O. Box 66752, Scotts Valley,
25: * California 95067, USA. or at email address contact@x2engine.com.
26: *
27: * The interactive user interfaces in modified source and object code versions
28: * of this program must display Appropriate Legal Notices, as required under
29: * Section 5 of the GNU Affero General Public License version 3.
30: *
31: * In accordance with Section 7(b) of the GNU Affero General Public License version 3,
32: * these Appropriate Legal Notices must retain the display of the "Powered by
33: * X2Engine" logo. If the display of the logo is not reasonably feasible for
34: * technical reasons, the Appropriate Legal Notices must display the words
35: * "Powered by X2Engine".
36: *****************************************************************************************/
37:
38: /**
39: * Handles External API subscriptions (for pushing data).
40: *
41: * This model record is for managing "subscriptions" to events in X2Engine. The
42: * purpose behind this all is to allow a means to enact changes to other systems
43: * in response to events in X2Engine without the need for polling.
44: *
45: * Each time {@link X2Flow::trigger} is called, all hooks matching the name of
46: * the triggering event will also be called. Then, POST requests will be sent to
47: * the URL specified by the "target_url" attribute, and the payload will be
48: * either arbitrary data, or a URL within the REST API at which to retrieve the
49: * payload (if the payload is an instance of {@link X2Model}). Each record of
50: * this model type is thus in effect a request to either send data to a remote
51: * service, or notify that remote service that it needs to fetch data from
52: * X2Engine at a given resource location.
53: *
54: * @package application.models
55: * @author Demitri Morgan <demitri@x2engine.com>
56: */
57: class ApiHook extends CActiveRecord {
58:
59: /**
60: * No more than this number of remote hooks can run at a time.
61: */
62: const MAX_API_HOOK_BATCH = 3;
63:
64: /**
65: * Hook timeout after 3 seconds
66: */
67: const MAX_WAIT_TIME = 3;
68:
69: /**
70: * Response data, or false if no requests have yet been sent.
71: * @var mixed
72: */
73: public $sent = false;
74:
75: /**
76: * Most-recently-created CURL session handle
77: * @var type
78: */
79: private $_ch;
80:
81: public function attributeLabels() {
82: return array(
83: 'id' => Yii::t('app','ID'),
84: 'target_url' => Yii::t('app','Target URL'),
85: 'event' => Yii::t('app','Event')
86: );
87: }
88:
89: /**
90: * Sends a deletion request to the "subscription" URL
91: */
92: public function beforeDelete(){
93: if($this->scenario != 'delete.remote')
94: $this->send('DELETE');
95: return parent::beforeDelete();
96: }
97:
98: /**
99: * Composes and returns a {@link CDbCriteria}-compatible property array for
100: * querying hooks for any given event.
101: *
102: * Creates the criteria properties for fetching all hooks for a specified
103: * model name, event name and user ID.
104: *
105: * @param string $event Event name
106: * @param string $modelName Model name associated with the hook; used for
107: * distinguishing generic events such as "a record was created"
108: * @param integer $userId run hooks for a user with this ID.
109: * @return array
110: */
111: public static function criteria($event,$modelName,$userId) {
112: $criteria = array(
113: 'condition' => "`t`.`event`=:event "
114: . "AND `t`.`userId`".($userId != X2_PRIMARY_ADMIN_ID
115: ? " IN (".X2_PRIMARY_ADMIN_ID.",:userId)" // User hooks + system hooks
116: : "=:userId"), // Admin's hooks (also apply system-wide)
117: 'params' => array(
118: ':event' => $event,
119: ':userId' => $userId
120: ),
121: 'alias' => 't'
122: );
123: if(!empty($modelName)) {
124: $criteria['condition'] .= ' AND `t`.`modelName`=:modelName';
125: $criteria['params'][':modelName'] = $modelName;
126: }
127: return $criteria;
128: }
129:
130: /**
131: * Getter w/stripped code (Platinum-only settings) for the maximum number of
132: * API hooks to send.
133: *
134: * @return type
135: */
136: public function getMaxNHooks() {
137: $max = self::MAX_API_HOOK_BATCH;
138:
139: return $max;
140: }
141:
142: /**
143: * Returns the last status code
144: */
145: public function getStatus() {
146: if(isset($this->_ch)) {
147: return curl_getinfo($this->_ch,CURLINFO_HTTP_CODE);
148: }
149: return 0;
150: }
151:
152: public function getTimeout() {
153: $timeout = self::MAX_WAIT_TIME;
154:
155: return $timeout;
156: }
157:
158: public function insert($attributes = null){
159: $this->createDate = time();
160: return parent::insert($attributes);
161: }
162:
163: /**
164: * Validator for limiting the number of hooks on a given action.
165: *
166: * @param type $attribute
167: * @param type $params
168: */
169: public function maxBatchSize($attribute,$params=array()) {
170: $max = $this->getMaxNHooks();
171: $params = compact($attribute);
172: $criteria = self::criteria($this->$attribute, $this->modelName,
173: $this->userId);
174: if(self::model()->count($criteria)>=$max) {
175: $this->addError($attribute,Yii::t('app','The maximum number of '
176: . 'hooks ({n}) has been reached for events of this type.',
177: array('{n}'=>$max)));
178: }
179: }
180:
181: public static function model($className = __CLASS__){
182: return parent::model($className);
183: }
184:
185: public function rules() {
186: return array(
187: array('event','maxBatchSize'),
188: array('directPayload','boolean','allowEmpty'=>true),
189: array('target_url','required')
190: );
191: }
192:
193: /**
194: * Runs the API hook; sends data to the third-party service.
195: *
196: * @param X2Model $output The relevant model object
197: * @return \ApiHook
198: */
199: public function run($output) {
200: $this->send('POST',$output);
201: return $this;
202: }
203:
204: /**
205: * Runs all API hooks corresponding to an event, a model (or arbitrary
206: * payload), and a user.
207: *
208: * @param string $event Name of the event
209: * @param mixed $output The relevant model object
210: * @param integer $userId the ID of the acting user in running the API hook
211: */
212: public static function runAll($event,$output=null,$userId=null) {
213: $modelName = (isset($output['model']) && $output['model'] instanceof X2Model)
214: ? get_class($output['model'])
215: : null;
216: $userId = $userId === null
217: ? Yii::app()->getSuId()
218: : $userId;
219:
220: // Cache the current query and use the cached value if the number of
221: // records and last updated time haven't changed
222: $cacheDep = new CDbCacheDependency('SELECT MAX(`createDate`),COUNT(*) '
223: . 'FROM `'.self::model()->tableName().'`');
224: $hookCriteria = self::criteria($event, $modelName, $userId);
225: return array_map(function($h)use($output){
226: return $h->run($output);
227: },self::model()->cache(86400,$cacheDep)->findAll($hookCriteria));
228: }
229:
230: /**
231: * Sends a request to pull data from X2Engine, or to delete/unsubscribe.
232: *
233: * @param string $method Request method to use
234: * @param array $data an array to JSON-encode and send
235: */
236: public function send($method,$data) {
237: if(!extension_loaded('curl'))
238: return;
239: // Compose the body of the request to send
240: $payload = json_encode($this->walkData($data));
241:
242: // Start a cURL session and configure the request
243: $this->_ch = curl_init($this->target_url);
244: curl_setopt_array($this->_ch,array(
245: CURLOPT_CUSTOMREQUEST => $method,
246: CURLOPT_RETURNTRANSFER => true,
247: CURLOPT_TIMEOUT => $this->getTimeout(),
248: CURLOPT_HTTPHEADER => array('Content-Type: application/json; charset=utf-8'),
249: CURLOPT_HTTP200ALIASES => array_keys(ResponseUtil::getStatusMessages()),
250: ));
251: if(!empty($payload))
252: curl_setopt($this->_ch,CURLOPT_POSTFIELDS,$payload);
253:
254: // Send the request
255: $this->sent = curl_exec($this->_ch);
256:
257: // If the remote end is no longer listening, we can stop sending data
258: if($this->getStatus() == 410) {
259: $this->setScenario('delete.remote');
260: $this->delete();
261: }
262: }
263:
264: public function tableName() {
265: return 'x2_api_hooks';
266: }
267:
268: /**
269: * Recursively walk a payload variable, converting it appropriately to an
270: * array so that it can be JSON-encoded.
271: *
272: * @param type $payload The data to be converted to an array.
273: * @param type $key An array key at any level if the recursion depth is
274: * nonzero; null otherwise.
275: * @return array If the key is not null, it will return a 2-element array
276: * with its first element being the key (or a different one) and the second
277: * the converted value; $key === null implies the first level of recursion
278: */
279: private function walkData($payload,$key=null){
280: switch(gettype($payload)){
281: case 'array':
282: $output = array();
283: foreach($payload as $nestedKey=>$value) {
284: list($outKey,$outValue) = $this->walkData($value,$nestedKey);
285: $output[$outKey] = $outValue;
286: }
287: return $key === null
288: ? $output // Top level of recursion
289: : array($key,$output); // A nested array at key $key
290: case 'object':
291: if($payload instanceof CActiveRecord){
292: // Send a resource URL for the remote end to retrieve, if
293: // if it's X2Model we're working with, or direct payload is
294: // disabled. Otherwise, send the attributes directly.
295: $direct = $this->directPayload || !($payload instanceof X2Model);
296: $extraFields = $payload instanceof Actions
297: ? array('actionDescription'=>$payload->getActionDescription())
298: : array();
299: return array(
300: $direct ? $key : 'resource_url',
301: $direct ? array_merge($payload->attributes,$extraFields)
302: : Yii::app()->createExternalUrl('/api2/model', array(
303: '_class' => get_class($payload),
304: '_id' => $payload->id
305: )));
306: } else {
307: return array($key,'Object');
308: }
309: case 'resource':
310: return array($key,'Resource');
311: default:
312: return array($key,$payload);
313: }
314: }
315:
316: }
317:
318: ?>
319: