1e31bbbc914073622792a180a8a8ab1bfea375d8
[tine20] / tine20 / Tinebase / Server / Json.php
1 <?php
2 /**
3  * Tine 2.0
4  * 
5  * @package     Tinebase
6  * @subpackage  Server
7  * @license     http://www.gnu.org/licenses/agpl.html AGPL Version 3
8  * @copyright   Copyright (c) 2007-2013 Metaways Infosystems GmbH (http://www.metaways.de)
9  * @author      Philipp Schüle <p.schuele@metaways.de>
10  * 
11  */
12
13 /**
14  * JSON Server class with handle() function
15  * 
16  * @package     Tinebase
17  * @subpackage  Server
18  */
19 class Tinebase_Server_Json extends Tinebase_Server_Abstract implements Tinebase_Server_Interface
20 {
21     /**
22      * handled request methods
23      * 
24      * @var array
25      */
26     protected $_methods = array();
27     
28     /**
29      * 
30      * @var boolean
31      */
32     protected $_supportsSessions = true;
33     
34     /**
35      * (non-PHPdoc)
36      * @see Tinebase_Server_Interface::handle()
37      */
38     public function handle(\Zend\Http\Request $request = null, $body = null)
39     {
40         $this->_request = $request instanceof \Zend\Http\Request ? $request : Tinebase_Core::get(Tinebase_Core::REQUEST);
41         $this->_body    = $body !== null ? $body : fopen('php://input', 'r');
42         
43         $request = $request instanceof \Zend\Http\Request ? $request : new \Zend\Http\PhpEnvironment\Request();
44
45         // only for debugging
46         //Tinebase_Core::getLogger()->DEBUG(__METHOD__ . '::' . __LINE__ . " raw request: " . $request->__toString());
47         
48         // handle CORS requests
49         if ($request->getHeaders()->has('ORIGIN') && !$request->getHeaders()->has('X-FORWARDED-HOST')) {
50             /**
51              * First the client sends a preflight request
52              * 
53              * METHOD: OPTIONS
54              * Access-Control-Request-Headers:x-requested-with, content-type
55              * Access-Control-Request-Method:POST
56              * Origin:http://other.site
57              * Referer:http://other.site/example.html
58              * User-Agent:Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/38.0.2125.111 Safari/537.36
59              * 
60              * We have to respond with
61              * 
62              * Access-Control-Allow-Credentials:true
63              * Access-Control-Allow-Headers:x-requested-with, x-tine20-request-type, content-type, x-tine20-jsonkey
64              * Access-Control-Allow-Methods:POST
65              * Access-Control-Allow-Origin:http://other.site
66              * 
67              * Then the client sends the standard JSON request with two additional headers
68              * 
69              * METHOD: POST
70              * Origin:http://other.site
71              * Referer:http://other.site/example.html
72              * Standard-JSON-Rquest-Headers...
73              * 
74              * We have to add two additional headers to our standard response
75              * 
76              * Access-Control-Allow-Credentials:true
77              * Access-Control-Allow-Origin:http://other.site
78              */
79             $origin = $request->getHeaders('ORIGIN')->getFieldValue();
80             $uri    = \Zend\Uri\UriFactory::factory($origin);
81             
82             if (in_array($uri->getScheme(), array('http', 'https'))) {
83                 $allowedOrigins = array_merge(
84                     (array) Tinebase_Core::getConfig()->get(Tinebase_Config::ALLOWEDJSONORIGINS, array()),
85                     array($this->_request->getServer('SERVER_NAME'))
86                 );
87                 
88                 if (in_array($uri->getHost(), $allowedOrigins)) {
89                     // this headers have to be sent, for any CORS'ed JSON request
90                     header('Access-Control-Allow-Origin: ' . $origin);
91                     header('Access-Control-Allow-Credentials: true');
92                 }
93                 
94                 // check for CORS preflight request
95                 if ($request->getMethod() == \Zend\Http\Request::METHOD_OPTIONS &&
96                     $request->getHeaders()->has('ACCESS-CONTROL-REQUEST-METHOD')
97                 ) {
98                     $this->_methods = array('handleCors');
99                     
100                     if (in_array($uri->getHost(), $allowedOrigins)) {
101                         header('Access-Control-Allow-Methods: POST');
102                         header('Access-Control-Allow-Headers: x-requested-with, x-tine20-request-type, content-type, x-tine20-jsonkey');
103                         header('Access-Control-Max-Age: 3600'); // cache result of OPTIONS request for 1 hour
104                         
105                     } else {
106                         Tinebase_Core::getLogger()->WARN (__METHOD__ . '::' . __LINE__ . " unhandled CORS preflight request from $origin");
107                         Tinebase_Core::getLogger()->INFO (__METHOD__ . '::' . __LINE__ . " you may want to set \"'allowedJsonOrigins' => array('{$uri->getHost()}'),\" to config.inc.php");
108                         Tinebase_Core::getLogger()->DEBUG(__METHOD__ . '::' . __LINE__ . " allowed origins: " . print_r($allowedOrigins, TRUE));
109                     }
110                     
111                     // stop further processing => is OPTIONS request
112                     return;
113                 }
114             }
115         }
116         
117         $exception = false;
118         
119         if (Tinebase_Session::sessionExists()) {
120             try {
121                 Tinebase_Core::startCoreSession();
122             } catch (Zend_Session_Exception $zse) {
123                 $exception = new Tinebase_Exception_AccessDenied('Not Authorised', 401);
124                 
125                 // expire session cookie for client
126                 Tinebase_Session::expireSessionCookie();
127             }
128         }
129         
130         if ($exception === false) {
131             try {
132                 Tinebase_Core::initFramework();
133             } catch (Exception $exception) {
134                 if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(
135                     __METHOD__ . '::' . __LINE__ .' initFramework exception: ' . $exception);
136             }
137         }
138         
139         $json = $request->getContent();
140         $json = Tinebase_Core::filterInputForDatabase($json);
141
142         if (substr($json, 0, 1) == '[') {
143             if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
144                 . ' batched request');
145             $isBatchedRequest = true;
146             $requests = Zend_Json::decode($json);
147         } else {
148             $isBatchedRequest = false;
149             $requests = array(Zend_Json::decode($json));
150         }
151         
152         if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) {
153             $_requests = $requests;
154             foreach (array('password', 'oldPassword', 'newPassword') as $field) {
155                 if (isset($requests[0]["params"][$field])) {
156                     $_requests[0]["params"][$field] = "*******";
157                 }
158             }
159             if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
160                 . ' is JSON request. rawdata: ' . print_r($_requests, true));
161         } 
162         
163         $response = array();
164         foreach ($requests as $requestOptions) {
165             if ($requestOptions !== NULL) {
166                 $request = new Zend_Json_Server_Request();
167                 $request->setOptions($requestOptions);
168                 
169                 $response[] = $exception ?
170                    $this->_handleException($request, $exception) :
171                    $this->_handle($request);
172             } else {
173                 if (Tinebase_Core::isLogLevel(Zend_Log::NOTICE)) Tinebase_Core::getLogger()->notice(__METHOD__ . '::' . __LINE__
174                     . ' Got empty request options: skip request.');
175                 $response[] = NULL;
176             }
177         }
178
179         if (! headers_sent()) {
180             header('Content-type: application/json');
181         }
182         echo $isBatchedRequest ? '['. implode(',', $response) .']' : $response[0];
183     }
184     
185     /**
186      * get JSON from cache or new instance
187      * 
188      * @param array $classes for Zend_Cache_Frontend_File
189      * @return Zend_Json_Server
190      */
191     protected static function _getServer($classes = null)
192     {
193         // setup cache if available
194         if (is_array($classes) && Tinebase_Core::getCache()) {
195             $masterFiles = array();
196             
197             $dirname = dirname(__FILE__) . '/../../';
198             foreach ($classes as $class => $namespace) {
199                 $masterFiles[] = $dirname . str_replace('_', '/', $class) . '.php';
200             }
201             
202             try {
203                 $cache = new Zend_Cache_Frontend_File(array(
204                     'master_files'              => $masterFiles,
205                     'lifetime'                  => null,
206                     'automatic_serialization'   => true, // turn that off for more speed
207                     'automatic_cleaning_factor' => 0,    // no garbage collection as this is done by a scheduler task
208                     'write_control'             => false, // don't read cache entry after it got written
209                     'logging'                   => (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)),
210                     'logger'                    => Tinebase_Core::getLogger(),
211                 ));
212                 $cache->setBackend(Tinebase_Core::getCache()->getBackend());
213                 $cacheId = '_handle_' . sha1(Zend_Json_Encoder::encode($classes));
214                 
215                 $server = $cache->load($cacheId);
216                 if ($server instanceof Zend_Json_Server) {
217                     return $server;
218                 }
219                 
220             } catch (Zend_Cache_Exception $zce) {
221                 if (Tinebase_Core::isLogLevel(Zend_Log::NOTICE)) Tinebase_Core::getLogger()->notice(__METHOD__ . '::' . __LINE__
222                     . " Failed to create cache. Exception: \n". $zce);
223             }
224         }
225         
226         $server = new Zend_Json_Server();
227         $server->setAutoEmitResponse(false);
228         $server->setAutoHandleExceptions(false);
229         
230         if (is_array($classes)) {
231             foreach ($classes as $class => $namespace) {
232                 try {
233                     $server->setClass($class, $namespace);
234                 } catch (Exception $e) {
235                     if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
236                         . " Failed to add JSON API for '$class' => '$namespace' Exception: \n". $e);
237                 }
238             }
239         }
240
241         if (Tinebase_Core::isRegistered(Tinebase_Core::USER)) {
242             // TODO pass classes here??
243             self::_addModelConfigMethods($server);
244         }
245         
246         if (isset($cache)) {
247             $cache->save($server, $cacheId, array(), null);
248         }
249
250         return $server;
251     }
252
253     protected static function _addModelConfigMethods(Zend_Json_Server $server)
254     {
255         $definitions = self::_getModelConfigMethods();
256         $server->loadFunctions($definitions);
257     }
258     
259     /**
260      * handler for JSON api requests
261      * @todo session expire handling
262      * 
263      * @param $request
264      * @return JSON
265      */
266     protected function _handle($request)
267     {
268         try {
269             $method = $request->getMethod();
270             Tinebase_Core::getLogger()->INFO(__METHOD__ . '::' . __LINE__ .' is JSON request. method: ' . $method);
271             
272             $jsonKey = (isset($_SERVER['HTTP_X_TINE20_JSONKEY'])) ? $_SERVER['HTTP_X_TINE20_JSONKEY'] : '';
273             $this->_checkJsonKey($method, $jsonKey);
274             
275             if (empty($method)) {
276                 // SMD request
277                 return self::getServiceMap();
278             }
279             
280             $this->_methods[] = $method;
281             
282             $classes = array();
283             
284             // add json apis which require no auth
285             $classes['Tinebase_Frontend_Json'] = 'Tinebase';
286             
287             // register additional Json apis only available for authorised users
288             if (Tinebase_Session::isStarted() && Zend_Auth::getInstance()->hasIdentity()) {
289                 
290                 $applicationParts = explode('.', $method);
291                 $applicationName = ucfirst($applicationParts[0]);
292                 
293                 switch($applicationName) {
294                     // additional Tinebase json apis
295                     case 'Tinebase_Container':
296                         $classes['Tinebase_Frontend_Json_Container'] = 'Tinebase_Container';
297                         break;
298                     case 'Tinebase_PersistentFilter':
299                         $classes['Tinebase_Frontend_Json_PersistentFilter'] = 'Tinebase_PersistentFilter';
300                         break;
301                         
302                     default;
303                         if(Tinebase_Core::getUser() && Tinebase_Core::getUser()->hasRight($applicationName, Tinebase_Acl_Rights_Abstract::RUN)) {
304                             $classes[$applicationName.'_Frontend_Json'] = $applicationName;
305                         }
306                         break;
307                 }
308             }
309             
310             $server = self::_getServer($classes);
311
312             $response = $server->handle($request);
313             if ($response->isError()) {
314                 Tinebase_Core::getLogger()->err(__METHOD__ . '::' . __LINE__ . ' Got response error: '
315                     . print_r($response->getError()->toArray(), true));
316             }
317             return $response;
318             
319         } catch (Exception $exception) {
320             return $this->_handleException($request, $exception);
321         }
322     }
323     
324     /**
325      * handle exceptions
326      * 
327      * @param Zend_Json_Server_Request_Http $request
328      * @param Exception $exception
329      * @return Zend_Json_Server_Response
330      */
331     protected function _handleException($request, $exception)
332     {
333         $server = self::_getServer();
334         
335         $exceptionData = method_exists($exception, 'toArray')? $exception->toArray() : array();
336         $exceptionData['message'] = htmlentities($exception->getMessage(), ENT_COMPAT, 'UTF-8');
337         $exceptionData['code']    = $exception->getCode();
338         
339         if ($exception instanceof Tinebase_Exception) {
340             $exceptionData['appName'] = $exception->getAppName();
341             $exceptionData['title'] = $exception->getTitle();
342         }
343         
344         Tinebase_Core::getLogger()->warn(__METHOD__ . '::' . __LINE__ . ' ' . get_class($exception) . ' -> ' . $exception->getMessage());
345         
346         $suppressTrace = Tinebase_Core::getConfig()->suppressExceptionTraces;
347         if ($suppressTrace !== TRUE) {
348             $exceptionData['trace'] = Tinebase_Exception::getTraceAsArray($exception);
349         }
350         
351         Tinebase_Exception::log($exception, $suppressTrace);
352         
353         $server->fault($exceptionData['message'], $exceptionData['code'], $exceptionData);
354         
355         $response = $server->getResponse();
356         if (null !== ($id = $request->getId())) {
357             $response->setId($id);
358         }
359         if (null !== ($version = $request->getVersion())) {
360             $response->setVersion($version);
361         }
362     
363         return $response;
364     }
365     
366     /**
367      * return service map
368      * 
369      * @return Zend_Json_Server_Smd
370      */
371     public static function getServiceMap()
372     {
373         $classes = array();
374         
375         $classes['Tinebase_Frontend_Json'] = 'Tinebase';
376         
377         if (Tinebase_Core::isRegistered(Tinebase_Core::USER)) {
378             $classes['Tinebase_Frontend_Json_Container'] = 'Tinebase_Container';
379             $classes['Tinebase_Frontend_Json_PersistentFilter'] = 'Tinebase_PersistentFilter';
380             
381             $userApplications = Tinebase_Core::getUser()->getApplications(TRUE);
382             foreach($userApplications as $application) {
383                 $jsonAppName = $application->name . '_Frontend_Json';
384                 $classes[$jsonAppName] = $application->name;
385             }
386         }
387         
388         $server = self::_getServer($classes);
389         
390         $server->setTarget('index.php')
391                ->setEnvelope(Zend_Json_Server_Smd::ENV_JSONRPC_2);
392             
393         $smd = $server->getServiceMap();
394
395         return $smd;
396     }
397
398     /**
399      * get default modelconfig methods
400      *
401      * @return array of Zend_Server_Method_Definition
402      */
403     protected static function _getModelConfigMethods()
404     {
405         $userApplications = Tinebase_Core::getUser()->getApplications(/* $_anyRight */ TRUE);
406
407         $definitions = array();
408         foreach ($userApplications as $application) {
409             try {
410                 $controller = Tinebase_Core::getApplicationInstance($application->name);
411                 $models = $controller->getModels();
412                 if (!$models) {
413                     continue;
414                 }
415             } catch (Tinebase_Exception_NotFound $tenf) {
416                 continue;
417             }
418
419             foreach ($models as $model) {
420                 $config = $model::getConfiguration();
421                 if ($config && $config->exposeJsonApi) {
422                     // TODO replace this with generic method
423                     $simpleModelName = str_replace($application->name . '_Model_', '', $model);
424
425                     $commonJsonApiMethods = array(
426                         'get' => array(
427                             'params' => array('string'),
428                             'help'   => 'get one ' . $simpleModelName . ' identified by $id',
429                             'plural' => false,
430                         ),
431                         'search' => array(
432                             'params' => array('array', 'array'),
433                             'help'   => 'Search for ' . $simpleModelName . 's matching given arguments',
434                             'plural' => true,
435                         ),
436                         'save' => array(
437                             'params' => array('array'),
438                             'help'   => 'Save ' . $simpleModelName . '',
439                             'plural' => false,
440                         ),
441                         'delete' => array(
442                             'params' => array('array'),
443                             'help'   => 'Delete multiple ' . $simpleModelName . 's',
444                             'plural' => true,
445                         ),
446                     );
447
448                     foreach ($commonJsonApiMethods as $name => $method) {
449                         $key = $application->name . '.' . $name . $simpleModelName . ($method['plural'] ? 's' : '');
450                         $definitions[$key] = new Zend_Server_Method_Definition(array(
451                             'name'            => $key,
452                             'prototypes'      => array(array(
453                                 'returnType' => 'array',
454                                 'parameters' => $method['params']
455                             )),
456                             'methodHelp'      => $method['help'],
457                             'invokeArguments' => array(),
458                             'object'          => null,
459                             'callback'        => array(
460                                 'type'   => 'instance',
461                                 'class'  => $application->name . '_Frontend_Json',
462                                 'method' => $name . $simpleModelName . ($method['plural'] ? 's' : '')
463                             ),
464                         ));
465                     }
466                 }
467             }
468         }
469
470         return $definitions;
471     }
472
473     /**
474      * check json key
475      *
476      * @param string $method
477      * @param string $jsonKey
478      */
479     protected function _checkJsonKey($method, $jsonKey)
480     {
481         $anonymnousMethods = array(
482             '', //empty method
483             'Tinebase.getRegistryData',
484             'Tinebase.getAllRegistryData',
485             'Tinebase.authenticate',
486             'Tinebase.login',
487             'Tinebase.getAvailableTranslations',
488             'Tinebase.getTranslations',
489             'Tinebase.setLocale'
490         );
491         
492         // check json key for all methods but some exceptions
493         if ( !(in_array($method, $anonymnousMethods)) && $jsonKey !== Tinebase_Core::get('jsonKey')) {
494         
495             if (! Tinebase_Core::isRegistered(Tinebase_Core::USER)) {
496                 Tinebase_Core::getLogger()->INFO(__METHOD__ . '::' . __LINE__ .
497                     ' Attempt to request a privileged Json-API method (' . $method . ') without authorisation from "' .
498                     $_SERVER['REMOTE_ADDR'] . '". (session timeout?)');
499             } else {
500                 Tinebase_Core::getLogger()->WARN(__METHOD__ . '::' . __LINE__ . ' Fatal: got wrong json key! (' . $jsonKey . ') Possible CSRF attempt!' .
501                     ' affected account: ' . print_r(Tinebase_Core::getUser()->toArray(), true) .
502                     ' request: ' . print_r($_REQUEST, true)
503                 );
504             }
505             
506             throw new Tinebase_Exception_AccessDenied('Not Authorised', 401);
507         }
508     }
509     
510     /**
511     * returns request method
512     *
513     * @return string|NULL
514     */
515     public function getRequestMethod()
516     {
517         return (! empty($this->_methods)) ? implode('|', $this->_methods) : NULL;
518     }
519 }