0012088: remove invalid chars from cache IDs
[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 = Tinebase_Helper::convertCacheId('_handle_' . sha1(Zend_Json_Encoder::encode($classes)) . '_' .
214                     (self::userIsRegistered() ? Tinebase_Core::getUser()->getId() : 'anon'));
215                 
216                 $server = $cache->load($cacheId);
217                 if ($server instanceof Zend_Json_Server) {
218                     return $server;
219                 }
220                 
221             } catch (Zend_Cache_Exception $zce) {
222                 if (Tinebase_Core::isLogLevel(Zend_Log::NOTICE)) Tinebase_Core::getLogger()->notice(__METHOD__ . '::' . __LINE__
223                     . " Failed to create cache. Exception: \n". $zce);
224             }
225         }
226         
227         $server = new Zend_Json_Server();
228         $server->setAutoEmitResponse(false);
229         $server->setAutoHandleExceptions(false);
230         
231         if (is_array($classes)) {
232             foreach ($classes as $class => $namespace) {
233                 try {
234                     $server->setClass($class, $namespace);
235                 } catch (Exception $e) {
236                     if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG)) Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
237                         . " Failed to add JSON API for '$class' => '$namespace' Exception: \n". $e);
238                 }
239             }
240         }
241
242         if (self::userIsRegistered()) {
243             $definitions = self::_getModelConfigMethods();
244             $server->loadFunctions($definitions);
245         }
246         
247         if (isset($cache)) {
248             $cache->save($server, $cacheId, array(), null);
249         }
250
251         return $server;
252     }
253
254     /**
255      * handler for JSON api requests
256      * @todo session expire handling
257      * 
258      * @param $request
259      * @return JSON
260      */
261     protected function _handle($request)
262     {
263         try {
264             $method = $request->getMethod();
265             Tinebase_Core::getLogger()->INFO(__METHOD__ . '::' . __LINE__ .' is JSON request. method: ' . $method);
266             
267             $jsonKey = (isset($_SERVER['HTTP_X_TINE20_JSONKEY'])) ? $_SERVER['HTTP_X_TINE20_JSONKEY'] : '';
268             $this->_checkJsonKey($method, $jsonKey);
269             
270             if (empty($method)) {
271                 // SMD request
272                 return self::getServiceMap();
273             }
274             
275             $this->_methods[] = $method;
276
277             $classes = self::_getServerClasses();
278             $server = self::_getServer($classes);
279
280             $response = $server->handle($request);
281             if ($response->isError()) {
282                 Tinebase_Core::getLogger()->err(__METHOD__ . '::' . __LINE__ . ' Got response error: '
283                     . print_r($response->getError()->toArray(), true));
284             }
285             return $response;
286             
287         } catch (Exception $exception) {
288             return $this->_handleException($request, $exception);
289         }
290     }
291     
292     /**
293      * handle exceptions
294      * 
295      * @param Zend_Json_Server_Request_Http $request
296      * @param Exception $exception
297      * @return Zend_Json_Server_Response
298      */
299     protected function _handleException($request, $exception)
300     {
301         $server = self::_getServer();
302         
303         $exceptionData = method_exists($exception, 'toArray')? $exception->toArray() : array();
304         $exceptionData['message'] = htmlentities($exception->getMessage(), ENT_COMPAT, 'UTF-8');
305         $exceptionData['code']    = $exception->getCode();
306         
307         if ($exception instanceof Tinebase_Exception) {
308             $exceptionData['appName'] = $exception->getAppName();
309             $exceptionData['title'] = $exception->getTitle();
310         }
311         
312         Tinebase_Core::getLogger()->warn(__METHOD__ . '::' . __LINE__ . ' ' . get_class($exception) . ' -> ' . $exception->getMessage());
313         
314         $suppressTrace = Tinebase_Core::getConfig()->suppressExceptionTraces;
315         if ($suppressTrace !== TRUE) {
316             $exceptionData['trace'] = Tinebase_Exception::getTraceAsArray($exception);
317         }
318         
319         Tinebase_Exception::log($exception, $suppressTrace);
320         
321         $server->fault($exceptionData['message'], $exceptionData['code'], $exceptionData);
322         
323         $response = $server->getResponse();
324         if (null !== ($id = $request->getId())) {
325             $response->setId($id);
326         }
327         if (null !== ($version = $request->getVersion())) {
328             $response->setVersion($version);
329         }
330     
331         return $response;
332     }
333     
334     /**
335      * return service map
336      * 
337      * @return Zend_Json_Server_Smd
338      */
339     public static function getServiceMap()
340     {
341         $classes = self::_getServerClasses();
342         $server = self::_getServer($classes);
343         
344         $server->setTarget('index.php')
345                ->setEnvelope(Zend_Json_Server_Smd::ENV_JSONRPC_2);
346             
347         $smd = $server->getServiceMap();
348
349         return $smd;
350     }
351
352     /**
353      * get frontend classes for json server
354      *
355      * @return array
356      */
357     protected static function _getServerClasses()
358     {
359         $classes = array();
360
361         $classes['Tinebase_Frontend_Json'] = 'Tinebase';
362
363         if (self::userIsRegistered()) {
364             $classes['Tinebase_Frontend_Json_Container'] = 'Tinebase_Container';
365             $classes['Tinebase_Frontend_Json_PersistentFilter'] = 'Tinebase_PersistentFilter';
366
367             $userApplications = Tinebase_Core::getUser()->getApplications(TRUE);
368             foreach ($userApplications as $application) {
369                 $jsonAppName = $application->name . '_Frontend_Json';
370                 if (class_exists($jsonAppName)) {
371                     $classes[$jsonAppName] = $application->name;
372                 }
373             }
374         }
375
376         if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__
377             . ' Got frontend classes: ' . print_r($classes, true));
378
379         return $classes;
380     }
381
382     /**
383      * get default modelconfig methods
384      *
385      * @return array of Zend_Server_Method_Definition
386      */
387     protected static function _getModelConfigMethods()
388     {
389         // get all apps user has RUN right for
390         $userApplications = Tinebase_Core::getUser()->getApplications();
391
392         $definitions = array();
393         foreach ($userApplications as $application) {
394             try {
395                 $controller = Tinebase_Core::getApplicationInstance($application->name);
396                 $models = $controller->getModels();
397                 if (!$models) {
398                     continue;
399                 }
400             } catch (Exception $e) {
401                 Tinebase_Exception::log($e);
402                 continue;
403             }
404
405             foreach ($models as $model) {
406                 $config = $model::getConfiguration();
407                 if ($config && $config->exposeJsonApi) {
408                     // TODO replace this with generic method
409                     $simpleModelName = str_replace($application->name . '_Model_', '', $model);
410
411                     $commonJsonApiMethods = array(
412                         'get' => array(
413                             'params' => array(
414                                 new Zend_Server_Method_Parameter(array(
415                                     'type' => 'string',
416                                     'name' => 'id',
417                                 )),
418                             ),
419                             'help'   => 'get one ' . $simpleModelName . ' identified by $id',
420                             'plural' => false,
421                         ),
422                         'search' => array(
423                             'params' => array(
424                                 new Zend_Server_Method_Parameter(array(
425                                     'type' => 'array',
426                                     'name' => 'filter',
427                                 )),
428                                 new Zend_Server_Method_Parameter(array(
429                                     'type' => 'array',
430                                     'name' => 'pagination',
431                                 )),
432                             ),
433                             'help'   => 'Search for ' . $simpleModelName . 's matching given arguments',
434                             'plural' => true,
435                         ),
436                         'save' => array(
437                             'params' => array(
438                                 new Zend_Server_Method_Parameter(array(
439                                     'type' => 'array',
440                                     'name' => 'recordData',
441                                 )),
442                             ),
443                             'help'   => 'Save ' . $simpleModelName . '',
444                             'plural' => false,
445                         ),
446                         'delete' => array(
447                             'params' => array(
448                                 new Zend_Server_Method_Parameter(array(
449                                     'type' => 'array',
450                                     'name' => 'ids',
451                                 )),
452                             ),
453                             'help'   => 'Delete multiple ' . $simpleModelName . 's',
454                             'plural' => true,
455                         ),
456                     );
457
458                     foreach ($commonJsonApiMethods as $name => $method) {
459                         $key = $application->name . '.' . $name . $simpleModelName . ($method['plural'] ? 's' : '');
460                         $appJsonFrontendClass = $application->name . '_Frontend_Json';
461                         if (class_exists($appJsonFrontendClass)) {
462                             $class = $appJsonFrontendClass;
463                             $object = null;
464                         } else {
465                             $class = 'Tinebase_Frontend_Json_Generic';
466                             $object = new Tinebase_Frontend_Json_Generic($application->name);
467                         }
468                         $definitions[$key] = new Zend_Server_Method_Definition(array(
469                             'name'            => $key,
470                             'prototypes'      => array(array(
471                                 'returnType' => 'array',
472                                 'parameters' => $method['params']
473                             )),
474                             'methodHelp'      => $method['help'],
475                             'invokeArguments' => array(),
476                             'object'          => $object,
477                             'callback'        => array(
478                                 'type'   => 'instance',
479                                 'class'  => $class,
480                                 'method' => $name . $simpleModelName . ($method['plural'] ? 's' : '')
481                             ),
482                         ));
483                     }
484                 }
485             }
486         }
487
488         if (Tinebase_Core::isLogLevel(Zend_Log::TRACE)) Tinebase_Core::getLogger()->trace(__METHOD__ . '::' . __LINE__
489             . ' Got MC definitions: ' . print_r(array_keys($definitions), true));
490
491         return $definitions;
492     }
493
494     /**
495      * check json key
496      *
497      * @param string $method
498      * @param string $jsonKey
499      */
500     protected function _checkJsonKey($method, $jsonKey)
501     {
502         $anonymnousMethods = array(
503             '', //empty method
504             'Tinebase.getRegistryData',
505             'Tinebase.getAllRegistryData',
506             'Tinebase.authenticate',
507             'Tinebase.login',
508             'Tinebase.getAvailableTranslations',
509             'Tinebase.getTranslations',
510             'Tinebase.setLocale'
511         );
512         
513         // check json key for all methods but some exceptions
514         if ( !(in_array($method, $anonymnousMethods)) && $jsonKey !== Tinebase_Core::get('jsonKey')) {
515         
516             if (! self::userIsRegistered()) {
517                 Tinebase_Core::getLogger()->INFO(__METHOD__ . '::' . __LINE__ .
518                     ' Attempt to request a privileged Json-API method (' . $method . ') without authorisation from "' .
519                     $_SERVER['REMOTE_ADDR'] . '". (session timeout?)');
520             } else {
521                 Tinebase_Core::getLogger()->WARN(__METHOD__ . '::' . __LINE__ . ' Fatal: got wrong json key! (' . $jsonKey . ') Possible CSRF attempt!' .
522                     ' affected account: ' . print_r(Tinebase_Core::getUser()->toArray(), true) .
523                     ' request: ' . print_r($_REQUEST, true)
524                 );
525             }
526             
527             throw new Tinebase_Exception_AccessDenied('Not Authorised', 401);
528         }
529     }
530     
531     /**
532     * returns request method
533     *
534     * @return string|NULL
535     */
536     public function getRequestMethod()
537     {
538         return (! empty($this->_methods)) ? implode('|', $this->_methods) : NULL;
539     }
540
541     public static function userIsRegistered()
542     {
543         return Tinebase_Core::isRegistered(Tinebase_Core::USER)
544             && is_object(Tinebase_Core::getUser());
545     }
546 }