0011666: Some fixes for Expressodriver
[tine20] / tine20 / Expressodriver / Backend / Storage / Adapter / Webdav.php
1 <?php
2
3 /**
4  * Tine 2.0
5  *
6  * @package     Expressodriver
7  * @subpackage  Backend
8  * @license     http://www.gnu.org/licenses/agpl.html AGPL Version 3
9  * @copyright   Copyright (c) 2007-2014 Metaways Infosystems GmbH (http://www.metaways.de)
10  * @copyright   Copyright (c) 2014 Serpro (http://www.serpro.gov.br)
11  * @author      Marcelo Teixeira <marcelo.teixeira@serpro.gov.br>
12  * @author      Edgar de Lucca <edgar.lucca@serpro.gov.br>
13  *
14  */
15
16 /**
17  * Webdav adapter class
18  */
19 class Expressodriver_Backend_Storage_Adapter_Webdav extends Expressodriver_Backend_Storage_Abstract implements Expressodriver_Backend_Storage_Adapter_Interface, Expressodriver_Backend_Storage_Capabilities
20 {
21     /**
22      * Constant for Expresso drive cache key
23      */
24     const GETEXPRESSODRIVEETAGS = 'getExpressodriveEtags';
25
26     /**
27      * Constant for Expresso drive cache entry tag
28      */
29     const EXPRESSODRIVEETAGS = 'expressodriverEtags';
30
31     /**
32      * @var string password
33      */
34     protected $password;
35
36     /**
37      * @var string user name
38      */
39     protected $user;
40
41     /**
42      * @var string host
43      */
44     protected $host;
45
46     /**
47      * @var boolean is use https of host
48      */
49     protected $secure;
50
51     /**
52      * @var string root folder
53      */
54     protected $root;
55
56     /**
57      * @var path of certificates
58      */
59     protected $certPath;
60
61     /**
62      * @var boolean is ready
63      */
64     protected $ready;
65
66     /**
67      * @var string adapter name
68      */
69     protected $name;
70
71     /**
72      * @var \Sabre\DAV\Client
73      */
74     private $client;
75
76     /**
77      * @var array of files
78      */
79     private static $tempFiles = array();
80
81     /**
82      * @var boolean use cache for folder and files metadata
83      */
84     private $useCache = true;
85
86     /**
87      * @var integer cache lifetime in milisecs
88      */
89     private $cacheLifetime = 86400; // one day
90
91     /**
92      * @var string user locale/timezone
93      */
94     private $timezone;
95
96     /**
97      * the constructor
98      *
99      * @param array $options
100      */
101     public function __construct(array $options)
102     {
103         if (isset($options['host']) && isset($options['user']) && isset($options['password'])) {
104             $host = $options['host'];
105             if (substr($host, 0, 8) == "https://")
106                 $host = substr($host, 8);
107             else if (substr($host, 0, 7) == "http://")
108                 $host = substr($host, 7);
109             $this->host = $host;
110             $this->user = $options['user'];
111             $this->password = $options['password'];
112             $this->name = $options['name'];
113             if (isset($options['secure'])) {
114                 if (is_string($options['secure'])) {
115                     $this->secure = ($options['secure'] === 'true');
116                 } else {
117                     $this->secure = (bool) $options['secure'];
118                 }
119             } else {
120                 $this->secure = false;
121             }
122
123             $this->root = isset($options['root']) ? $options['root'] : '/';
124             if (!$this->root || $this->root[0] != '/') {
125                 $this->root = '/' . $this->root;
126             }
127             if (substr($this->root, -1, 1) != '/') {
128                 $this->root .= '/';
129             }
130
131             $this->timezone = Tinebase_Core::getUserTimezone();
132             $this->useCache = $options['useCache'];
133             $this->cacheLifetime = $options['cacheLifetime'];
134
135         } else {
136             Tinebase_Core::getLogger()->err(__METHOD__ . '::' . __LINE__ . 'Webdav config error. Please check your Expressodriver settings');
137             throw new Exception('Webdav config error. Please check your Expressodriver settings');
138         }
139     }
140
141     /*
142      * Initialize webdav server connection
143      */
144     private function init()
145     {
146         if ($this->ready) {
147             return;
148         }
149         $this->ready = true;
150
151         $settings = array(
152             'baseUri' => $this->createBaseUri(),
153             'userName' => $this->user,
154             'password' => $this->password,
155         );
156         $this->client = new \Sabre\DAV\Client($settings);
157         $this->client->setVerifyPeer(false);
158     }
159
160     /**
161      * verify if file exists
162      *
163      * @param  string $path path
164      * @return boolean of exists file in path
165      */
166     public function fileExists($path)
167     {
168         $this->init();
169         $cleanPath = $this->cleanPath($path);
170         try {
171             $this->client->propfind($this->encodePath($cleanPath), array('{DAV:}resourcetype'));
172             return true;
173         } catch (Exception $e) {
174             return false;
175         }
176     }
177
178     /**
179      * open file in to path
180      *
181      * @param  string $_path
182      * @param  string $_mode
183      */
184     public function fopen($_path, $_mode)
185     {
186         $this->init();
187         $path = $this->cleanPath($_path);
188         switch ($_mode) {
189             case 'r':
190             case 'rb':
191                 if (!$this->fileExists($path)) {
192                     return false;
193                 }
194                 //straight up curl instead of sabredav here, sabredav put's the entire get result in memory
195                 $curl = curl_init();
196                 $fp = fopen('php://temp', 'r+');
197                 curl_setopt($curl, CURLOPT_USERPWD, $this->user . ':' . $this->password);
198                 curl_setopt($curl, CURLOPT_URL, $this->createBaseUri() . $this->encodePath($path));
199                 curl_setopt($curl, CURLOPT_FILE, $fp);
200                 curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true);
201                 curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, false);
202
203                 if ($this->secure === true) {
204                     // @todo: verify certificates
205                     curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, 0);
206                     curl_setopt($curl, CURLOPT_SSL_VERIFYHOST, 0);
207                     if ($this->certPath) {
208                         curl_setopt($curl, CURLOPT_CAINFO, $this->certPath);
209                     }
210                 }
211
212                 curl_exec($curl);
213                 $statusCode = curl_getinfo($curl, CURLINFO_HTTP_CODE);
214                 if ($statusCode !== 200) {
215                     if (Tinebase_Core::isLogLevel(Zend_Log::DEBUG))
216                         Tinebase_Core::getLogger()->debug(__METHOD__ . '::' . __LINE__
217                                 . ' fopen error ' . $path);
218                 }
219                 curl_close($curl);
220                 rewind($fp);
221                 return $fp;
222             case 'w':
223             case 'wb':
224             case 'a':
225             case 'ab':
226             case 'r+':
227             case 'w+':
228             case 'wb+':
229             case 'a+':
230             case 'x':
231             case 'x+':
232             case 'c':
233             case 'c+':
234                 //emulate these
235                 if (strrpos($path, '.') !== false) {
236                     $ext = substr($path, strrpos($path, '.'));
237                 } else {
238                     $ext = '';
239                 }
240                 if ($this->fileExists($path)) {
241                     if (!$this->isUpdatable($path)) {
242                         return false;
243                     }
244                     $tmpFile = $this->getCachedFile($path);
245                 } else {
246                     if (!$this->isCreatable(dirname($path))) {
247                         return false;
248                     }
249                 }
250                 self::$tempFiles[$tmpFile] = $path;
251                 return fopen('close://' . $tmpFile, $_mode);
252         }
253     }
254
255     /**
256      * return the free space of user folder in webdav
257      *
258      * @param string $path
259      * @return array of quota used and available bytes
260      */
261     public function freeSpace($path)
262     {
263         $response = $this->client->propfind($this->encodePath($path), array('{DAV:}quota-available-bytes', '{DAV:}quota-used-bytes'), 0);
264         return array(
265             'quota-available-bytes' => $response['{DAV:}quota-available-bytes'],
266             'quota-used-bytes' => $response['{DAV:}quota-used-bytes']
267         );
268     }
269
270     /**
271      * get content type
272      *
273      * @param string $path
274      * @return string of ContentType
275      */
276     public function getContentType($path)
277     {
278         $response = $this->client->propfind($this->encodePath($path), array('{DAV:}getcontenttype'), 0);
279         return $response['{DAV:}getcontenttype'];
280     }
281
282     /**
283      * get ETag from path
284      *
285      * @param string $path
286      * @return string eTag hash
287      */
288     public function getEtag($path)
289     {
290         $response = $this->client->propfind($this->encodePath($path), array('{DAV:}getetag'), 0);
291         return $response['{DAV:}getetag'];
292     }
293
294     /**
295      * get the time of last modified path node
296      *
297      * @param string $path
298      * @return Tinebase_DateTime
299      */
300     public function getMtime($path)
301     {
302         $response = $this->client->propfind($this->encodePath($path), array('{DAV:}getlastmodified'), 0);
303         return Tinebase_DateTime($response['{DAV:}getlastmodified'], $this->timezone);
304     }
305
306     /**
307      * create folder in webdav
308      *
309      * @param string $_path
310      * @return boolean success
311      */
312     public function mkdir($_path)
313     {
314         $this->init();
315         $path = $this->cleanPath($_path);
316         return $this->simpleResponse('MKCOL', $path, null, 201);
317     }
318
319
320     /**
321      * rename folder or file
322      *
323      * @param string $_oldPath
324      * @param string $_newPath
325      * @return boolean success
326      */
327     public function rename($_oldPath, $_newPath)
328     {
329         $this->init();
330         $oldPath = $this->encodePath($this->cleanPath($_oldPath));
331         $newPath = $this->createBaseUri() . $this->encodePath($this->cleanPath($_newPath));
332         try {
333             $this->client->request('MOVE', $oldPath, null, array('Destination' => $newPath));
334             return true;
335         } catch (Exception $e) {
336             return false;
337         }
338     }
339
340     /**
341      * remove folder
342      *
343      * @param string $path
344      * @param boolean $recursive
345      * @return boolean success
346      */
347     public function rmdir($path, $recursive = FALSE)
348     {
349         $this->init();
350         $path = $this->cleanPath($path) . '/';
351         // FIXME: some WebDAV impl return 403 when trying to DELETE
352         // a non-empty folder
353         return $this->simpleResponse('DELETE', $path, null, 204);
354     }
355
356     /**
357      * get node of path
358      *
359      * @param string $_path
360      * @return array of nodes the files and folders
361      */
362     public function stat($_path)
363     {
364         $this->init();
365         try {
366             $response = $this->getNodes($_path);
367             if (count($response) > 0) {
368                 return $response[0];
369             } else {
370                 return array();
371             }
372         } catch (Exception $ex) {
373             return array();
374         }
375     }
376
377     /**
378      * serch files and folder of path
379      *
380      * @param string $query
381      * @param string $_path
382      * @return nodes of files and folders
383      */
384     public function search($query, $_path = '')
385     {
386         $this->init();
387         try {
388             $result = $this->getNodes($_path);
389             array_shift($result); //the first entry is the current directory
390             $trimQuery = trim($query);
391             if (!empty($trimQuery)) { // filter query
392                 $resultFilter = array();
393                 foreach ($result as $file) {
394                     if (strstr(strtolower($file['name']), strtolower($query)) !== false || empty($query)) {
395                         $resultFilter[] = $file;
396                     }
397                 }
398                 $result = $resultFilter;
399             }
400             return $result; //$response;
401         } catch (Exception $e) {
402             return false;
403         }
404     }
405
406     /**
407      * delete the file or folder in server
408      *
409      * @param string $path
410      * @return boolean if successful
411      */
412     public function unlink($path)
413     {
414         $this->init();
415         return $this->simpleResponse('DELETE', $path, null, 204);
416     }
417
418
419     /**
420      * Return an associative array of capabilities (booleans) of the backend
421      *
422      * @return array associative of with capabilities
423      */
424     public function getCapabilities()
425     {
426         return array(
427         );
428     }
429
430     /**
431      * create base URL from config
432      *
433      * @return string url of server webdav
434      */
435     protected function createBaseUri()
436     {
437         $baseUri = 'http';
438         if ($this->secure) {
439             $baseUri .= 's';
440         }
441         $baseUri .= '://' . $this->host . $this->root;
442         return $baseUri;
443     }
444
445     /**
446      * upload file to path
447      *
448      * @param  string $path
449      * @param  string $target
450      */
451     public function uploadFile($path, $target)
452     {
453         $this->init();
454         $source = fopen($path, 'r');
455
456         $curl = curl_init();
457         curl_setopt($curl, CURLOPT_USERPWD, $this->user . ':' . $this->password);
458         curl_setopt($curl, CURLOPT_URL, $this->createBaseUri() . $this->encodePath($target));
459         curl_setopt($curl, CURLOPT_BINARYTRANSFER, true);
460         curl_setopt($curl, CURLOPT_INFILE, $source); // file pointer
461         curl_setopt($curl, CURLOPT_INFILESIZE, filesize($path));
462         curl_setopt($curl, CURLOPT_PUT, true);
463         curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
464         if ($this->secure === true) {
465             curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, 0);
466             curl_setopt($curl, CURLOPT_SSL_VERIFYHOST, 0);
467             if ($this->certPath) {
468                 curl_setopt($curl, CURLOPT_CAINFO, $this->certPath);
469             }
470         }
471         curl_exec($curl);
472         $statusCode = curl_getinfo($curl, CURLINFO_HTTP_CODE);
473         if ($statusCode !== 200) {
474             error_log("webdav client", 'curl GET ' . curl_getinfo($curl, CURLINFO_EFFECTIVE_URL) . ' returned status code ' . $statusCode);
475         }
476         curl_close($curl);
477         fclose($source);
478     }
479
480     /**
481      * parse permission of string
482      *
483      * @param string $_permissionsString
484      * @return array of permissions
485      */
486     protected function parsePermissions($_permissionsString)
487     {
488         $permissions = array(parent::PERMISSION_READ => true);
489         if (strpos($_permissionsString, 'R') !== false) {
490             $permissions = array_merge($permissions, array(parent::PERMISSION_SHARE => true));
491         }
492         if (strpos($_permissionsString, 'D') !== false) {
493             $permissions = array_merge($permissions, array(parent::PERMISSION_DELETE => true));
494         }
495         if (strpos($_permissionsString, 'W') !== false) {
496             $permissions = array_merge($permissions, array(parent::PERMISSION_UPDATE => true));
497         }
498         if (strpos($_permissionsString, 'CK') !== false) {
499             $permissions = array_merge($permissions, array(parent::PERMISSION_CREATE => true, parent::PERMISSION_UPDATE => true));
500         }
501         return $permissions;
502     }
503
504     /**
505      * verify update permission
506      *
507      * @param string $_path
508      * @return boolean of permission
509      */
510     public function isUpdatable($_path)
511     {
512         return (bool) ($this->getPermissions($_path) & parent::PERMISSION_UPDATE);
513     }
514
515     /**
516      * verify creatable permission
517      *
518      * @param string $_path
519      * @return boolean of permission
520      */
521     public function isCreatable($_path)
522     {
523         return (bool) ($this->getPermissions($_path) & parent::PERMISSION_CREATE);
524     }
525
526     /**
527      * verify sharable permission
528      *
529      * @param string $_path
530      * @return boolean of permission
531      */
532     public function isSharable($_path)
533     {
534         return (bool) ($this->getPermissions($_path) & parent::PERMISSION_SHARE);
535     }
536
537     /**
538      * verify delete permission
539      *
540      * @param string $_path
541      * @return boolean of permission
542      */
543     public function isDeletable($_path)
544     {
545         return (bool) ($this->getPermissions($_path) & parent::PERMISSION_DELETE);
546     }
547
548     /**
549      * get grants of node
550      *
551      * @param node $_node
552      * @return array of permission
553      */
554     private function getGrants($_node)
555     {
556         if (isset($_node['{http://owncloud.org/ns}permissions'])) {
557             return $this->parsePermissions($_node['{http://owncloud.org/ns}permissions']);
558         } else if (!isset($_node['{DAV:}getcontenttype'])) { // folder
559             return array('readGrant' => true, 'addGrant' => true, 'editGrant' => true, 'deleteGrant' => true, 'shareGrant' => true);
560         } else if (isset($_node['{DAV:}getcontenttype'])) { // file
561             return array('readGrant' => true, 'editGrant' => true, 'deleteGrant' => true, 'shareGrant' => true);
562         } else {
563             return array();
564         }
565     }
566
567     /**
568      * get permission of files and folders
569      *
570      * @param string $_path
571      * @return array of [permission]
572      */
573     public function getPermissions($_path)
574     {
575         $this->init();
576         $path = $this->cleanPath($_path);
577         $response = $this->client->propfind($this->encodePath($path), array('{http://owncloud.org/ns}permissions'));
578         if (isset($response['{http://owncloud.org/ns}permissions'])) {
579             return $this->parsePermissions($response['{http://owncloud.org/ns}permissions']);
580         } else if ($this->isDir($path)) {
581             return array('readGrant' => true, 'addGrant' => true, 'editGrant' => true, 'deleteGrant' => true, 'shareGrant' => true);
582         } else if ($this->fileExists($path)) {
583             return array('readGrant' => true, 'editGrant' => true, 'deleteGrant' => true, 'shareGrant' => true);
584         } else {
585             return 0;
586         }
587     }
588
589     /**
590      * request method of server webdav
591      *
592      * @param string $method
593      * @param string $path
594      * @param integer $expected status code
595      * @return boolean request successful
596      */
597     private function simpleResponse($_method, $_path, $_body, $_expected)
598     {
599         $path = $this->cleanPath($_path);
600         try {
601             $response = $this->client->request($_method, $this->encodePath($path), $_body);
602             return $response['statusCode'] == $_expected;
603         } catch (Exception $e) {
604             error_log($e->getMessage());
605             return false;
606         }
607     }
608
609     /**
610      * URL encodes the given path but keeps the slashes
611      *
612      * @param string $path to encode
613      * @return string encoded path
614      */
615     private function encodePath($path)
616     {
617         // slashes need to stay
618         return str_replace('%2F', '/', rawurlencode($path));
619     }
620
621     /**
622      * check if curl is installed
623      * @return boolean true or [curl] if not exists
624      */
625     public static function checkDependencies()
626     {
627         if (function_exists('curl_init')) {
628             return true;
629         } else {
630             return array('curl');
631         }
632     }
633
634     /**
635      * get filetype of the path node
636      *
637      * @param string $path
638      * @return string dir or file
639      */
640     public function filetype($path)
641     {
642         $this->init();
643         $_path = $this->cleanPath($path);
644         try {
645             $response = $this->client->propfind($this->encodePath($_path), array('{DAV:}resourcetype'));
646             $responseType = array();
647             if (isset($response["{DAV:}resourcetype"])) {
648                 $responseType = $response["{DAV:}resourcetype"]->resourceType;
649             }
650             return (count($responseType) > 0 and $responseType[0] == "{DAV:}collection") ? 'dir' : 'file';
651         } catch (Exception $e) {
652             return false;
653         }
654     }
655
656     /**
657      * clean and remove unwanted slash of a path
658      *
659      * @param string $path
660      * @return string path
661      */
662     public function cleanPath($path)
663     {
664         $_path = Expressodriver_Backend_Storage_Abstract::normalizePath($path);
665         // remove leading slash
666         return substr($_path, 1);
667     }
668
669     /**
670      * get nodes of a path
671      * NOTE: getNodes returns path root node and its childreen:
672      * [0]          -> path root node (used by stat)
673      * [1] .. [N]   -> childreen nodes (used by search)
674      *
675      * @param string $path path
676      * @return array of nodes
677      */
678     private function getNodes($path)
679     {
680         $_path = $this->cleanPath($path);
681         if ($this->useCache) {
682             $response = $this->client->propfind($this->encodePath($_path), array('{DAV:}getetag'), 0);
683             $result = $this->getNodesFromCache($_path, $response['{DAV:}getetag']);
684         } else {
685             $result = $this->getNodesFromBackend($_path);
686         }
687         return $result;
688     }
689
690     /**
691      * get nodes from cache
692      * if cache miss or cache etag is outdated, updates cache with nodes from backend
693      *
694      * @param string $path path
695      * @param string $etag hash etag
696      * @return array of nodes
697      */
698     private function getNodesFromCache($path, $etag)
699     {
700         $cache = Tinebase_Core::get('cache');
701         $cacheId = Tinebase_Helper::arrayToCacheId(
702                 array(
703                     self::GETEXPRESSODRIVEETAGS,
704                     sha1(Tinebase_getUser()->getId()) . $this->encodePath($path)
705                 )
706             );
707         $result = $cache->load($cacheId);
708         if (!$result) {
709             $result = $this->getNodesFromBackend($path);
710             $cache->save($result, $cacheId, array(self::EXPRESSODRIVEETAGS), $this->cacheLifetime);
711         } else {
712             if ($result[0]['hash'] != $etag) {
713                 $result = $this->getNodesFromBackend($path);
714                 $cache->save($result, $cacheId, array(self::EXPRESSODRIVEETAGS), $this->cacheLifetime);
715             }
716         }
717         return $result;
718     }
719
720     /**
721      * get nodes from adapter backend
722      *
723      * @param string $path
724      * @return array nodes
725      */
726     private function getNodesFromBackend($path)
727     {
728         $response = $this->client->propfind($this->encodePath($path), array(), 1);
729         $result = array();
730         $statNode = true;
731         $path = $path === false ? '' : $path;
732         foreach ($response as $key => $value) {
733             if($statNode) {
734                 $nodePath = $path;
735                 $statNode = false;
736             } else {
737                 $nodePath = $path . '/' . urldecode(basename($key));
738             }
739             $result[] = $this->rawDataToNode($nodePath, $value);
740         }
741         return $result;
742     }
743
744     /**
745      * converts raw data from adapter into a node array
746      *
747      * @param string $path
748      * @param array $file
749      * @return array of nodes
750      */
751     private function rawDataToNode($path, $file)
752     {
753         $filetmp = array(
754             'name' => urldecode(basename($path)),
755             'path' => $path,
756             'hash' => $file['{DAV:}getetag'],
757             'last_modified_time' => new Tinebase_DateTime($file['{DAV:}getlastmodified'], $this->timezone),
758             'size' => $file['{DAV:}getcontentlength'],
759             'type' => isset($file['{DAV:}getcontenttype']) ? Tinebase_Model_Tree_Node::TYPE_FILE : Tinebase_Model_Tree_Node::TYPE_FOLDER,
760             'resourcetype' => $file['{DAV:}resourcetype'],
761             'contenttype' => $file['{DAV:}getcontenttype'],
762             'account_grants' => $this->getGrants($file)
763         );
764         return $filetmp;
765     }
766
767     /**
768      * check if webdav credentials are valid
769      *
770      * @param string $url
771      * @param string $username
772      * @param string $password
773      * @return boolean
774      */
775     public function checkCredentials($url, $username, $password)
776     {
777         $arr = explode('://', $url, 2);
778         list($webdavauth_protocol, $webdavauth_url_path) = $arr;
779         $url = $webdavauth_protocol.'://'.urlencode($username).':'.urlencode($password).'@'.$webdavauth_url_path;
780
781         $headers = get_headers($url);
782         if ($headers == false) {
783             return false;
784         }
785         $returncode = substr($headers[0], 9, 3);
786
787         return substr($returncode, 0, 1) === '2';
788     }
789 }