1<?php
2
3namespace Sabre\DAV\Browser;
4
5use Sabre\DAV;
6use Sabre\DAV\MkCol;
7use Sabre\HTTP\RequestInterface;
8use Sabre\HTTP\ResponseInterface;
9use Sabre\HTTP\URLUtil;
10
11/**
12 * Browser Plugin
13 *
14 * This plugin provides a html representation, so that a WebDAV server may be accessed
15 * using a browser.
16 *
17 * The class intercepts GET requests to collection resources and generates a simple
18 * html index.
19 *
20 * @copyright Copyright (C) fruux GmbH (https://fruux.com/)
21 * @author Evert Pot (http://evertpot.com/)
22 * @license http://sabre.io/license/ Modified BSD License
23 */
24class Plugin extends DAV\ServerPlugin {
25
26    /**
27     * reference to server class
28     *
29     * @var DAV\Server
30     */
31    protected $server;
32
33    /**
34     * enablePost turns on the 'actions' panel, which allows people to create
35     * folders and upload files straight from a browser.
36     *
37     * @var bool
38     */
39    protected $enablePost = true;
40
41    /**
42     * A list of properties that are usually not interesting. This can cut down
43     * the browser output a bit by removing the properties that most people
44     * will likely not want to see.
45     *
46     * @var array
47     */
48    public $uninterestingProperties = [
49        '{DAV:}supportedlock',
50        '{DAV:}acl-restrictions',
51//        '{DAV:}supported-privilege-set',
52        '{DAV:}supported-method-set',
53    ];
54
55    /**
56     * Creates the object.
57     *
58     * By default it will allow file creation and uploads.
59     * Specify the first argument as false to disable this
60     *
61     * @param bool $enablePost
62     */
63    function __construct($enablePost = true) {
64
65        $this->enablePost = $enablePost;
66
67    }
68
69    /**
70     * Initializes the plugin and subscribes to events
71     *
72     * @param DAV\Server $server
73     * @return void
74     */
75    function initialize(DAV\Server $server) {
76
77        $this->server = $server;
78        $this->server->on('method:GET', [$this, 'httpGetEarly'], 90);
79        $this->server->on('method:GET', [$this, 'httpGet'], 200);
80        $this->server->on('onHTMLActionsPanel', [$this, 'htmlActionsPanel'], 200);
81        if ($this->enablePost) $this->server->on('method:POST', [$this, 'httpPOST']);
82    }
83
84    /**
85     * This method intercepts GET requests that have ?sabreAction=info
86     * appended to the URL
87     *
88     * @param RequestInterface $request
89     * @param ResponseInterface $response
90     * @return bool
91     */
92    function httpGetEarly(RequestInterface $request, ResponseInterface $response) {
93
94        $params = $request->getQueryParameters();
95        if (isset($params['sabreAction']) && $params['sabreAction'] === 'info') {
96            return $this->httpGet($request, $response);
97        }
98
99    }
100
101    /**
102     * This method intercepts GET requests to collections and returns the html
103     *
104     * @param RequestInterface $request
105     * @param ResponseInterface $response
106     * @return bool
107     */
108    function httpGet(RequestInterface $request, ResponseInterface $response) {
109
110        // We're not using straight-up $_GET, because we want everything to be
111        // unit testable.
112        $getVars = $request->getQueryParameters();
113
114        // CSP headers
115        $response->setHeader('Content-Security-Policy', "default-src 'none'; img-src 'self'; style-src 'self'; font-src 'self';");
116
117        $sabreAction = isset($getVars['sabreAction']) ? $getVars['sabreAction'] : null;
118
119        switch ($sabreAction) {
120
121            case 'asset' :
122                // Asset handling, such as images
123                $this->serveAsset(isset($getVars['assetName']) ? $getVars['assetName'] : null);
124                return false;
125            default :
126            case 'info' :
127                try {
128                    $this->server->tree->getNodeForPath($request->getPath());
129                } catch (DAV\Exception\NotFound $e) {
130                    // We're simply stopping when the file isn't found to not interfere
131                    // with other plugins.
132                    return;
133                }
134
135                $response->setStatus(200);
136                $response->setHeader('Content-Type', 'text/html; charset=utf-8');
137
138                $response->setBody(
139                    $this->generateDirectoryIndex($request->getPath())
140                );
141
142                return false;
143
144            case 'plugins' :
145                $response->setStatus(200);
146                $response->setHeader('Content-Type', 'text/html; charset=utf-8');
147
148                $response->setBody(
149                    $this->generatePluginListing()
150                );
151
152                return false;
153
154        }
155
156    }
157
158    /**
159     * Handles POST requests for tree operations.
160     *
161     * @param RequestInterface $request
162     * @param ResponseInterface $response
163     * @return bool
164     */
165    function httpPOST(RequestInterface $request, ResponseInterface $response) {
166
167        $contentType = $request->getHeader('Content-Type');
168        list($contentType) = explode(';', $contentType);
169        if ($contentType !== 'application/x-www-form-urlencoded' &&
170            $contentType !== 'multipart/form-data') {
171                return;
172        }
173        $postVars = $request->getPostData();
174
175        if (!isset($postVars['sabreAction']))
176            return;
177
178        $uri = $request->getPath();
179
180        if ($this->server->emit('onBrowserPostAction', [$uri, $postVars['sabreAction'], $postVars])) {
181
182            switch ($postVars['sabreAction']) {
183
184                case 'mkcol' :
185                    if (isset($postVars['name']) && trim($postVars['name'])) {
186                        // Using basename() because we won't allow slashes
187                        list(, $folderName) = URLUtil::splitPath(trim($postVars['name']));
188
189                        if (isset($postVars['resourceType'])) {
190                            $resourceType = explode(',', $postVars['resourceType']);
191                        } else {
192                            $resourceType = ['{DAV:}collection'];
193                        }
194
195                        $properties = [];
196                        foreach ($postVars as $varName => $varValue) {
197                            // Any _POST variable in clark notation is treated
198                            // like a property.
199                            if ($varName[0] === '{') {
200                                // PHP will convert any dots to underscores.
201                                // This leaves us with no way to differentiate
202                                // the two.
203                                // Therefore we replace the string *DOT* with a
204                                // real dot. * is not allowed in uris so we
205                                // should be good.
206                                $varName = str_replace('*DOT*', '.', $varName);
207                                $properties[$varName] = $varValue;
208                            }
209                        }
210
211                        $mkCol = new MkCol(
212                            $resourceType,
213                            $properties
214                        );
215                        $this->server->createCollection($uri . '/' . $folderName, $mkCol);
216                    }
217                    break;
218
219                // @codeCoverageIgnoreStart
220                case 'put' :
221
222                    if ($_FILES) $file = current($_FILES);
223                    else break;
224
225                    list(, $newName) = URLUtil::splitPath(trim($file['name']));
226                    if (isset($postVars['name']) && trim($postVars['name']))
227                        $newName = trim($postVars['name']);
228
229                    // Making sure we only have a 'basename' component
230                    list(, $newName) = URLUtil::splitPath($newName);
231
232                    if (is_uploaded_file($file['tmp_name'])) {
233                        $this->server->createFile($uri . '/' . $newName, fopen($file['tmp_name'], 'r'));
234                    }
235                    break;
236                // @codeCoverageIgnoreEnd
237
238            }
239
240        }
241        $response->setHeader('Location', $request->getUrl());
242        $response->setStatus(302);
243        return false;
244
245    }
246
247    /**
248     * Escapes a string for html.
249     *
250     * @param string $value
251     * @return string
252     */
253    function escapeHTML($value) {
254
255        return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
256
257    }
258
259    /**
260     * Generates the html directory index for a given url
261     *
262     * @param string $path
263     * @return string
264     */
265    function generateDirectoryIndex($path) {
266
267        $html = $this->generateHeader($path ? $path : '/', $path);
268
269        $node = $this->server->tree->getNodeForPath($path);
270        if ($node instanceof DAV\ICollection) {
271
272            $html .= "<section><h1>Nodes</h1>\n";
273            $html .= "<table class=\"nodeTable\">";
274
275            $subNodes = $this->server->getPropertiesForChildren($path, [
276                '{DAV:}displayname',
277                '{DAV:}resourcetype',
278                '{DAV:}getcontenttype',
279                '{DAV:}getcontentlength',
280                '{DAV:}getlastmodified',
281            ]);
282
283            foreach ($subNodes as $subPath => $subProps) {
284
285                $subNode = $this->server->tree->getNodeForPath($subPath);
286                $fullPath = $this->server->getBaseUri() . URLUtil::encodePath($subPath);
287                list(, $displayPath) = URLUtil::splitPath($subPath);
288
289                $subNodes[$subPath]['subNode'] = $subNode;
290                $subNodes[$subPath]['fullPath'] = $fullPath;
291                $subNodes[$subPath]['displayPath'] = $displayPath;
292            }
293            uasort($subNodes, [$this, 'compareNodes']);
294
295            foreach ($subNodes as $subProps) {
296                $type = [
297                    'string' => 'Unknown',
298                    'icon'   => 'cog',
299                ];
300                if (isset($subProps['{DAV:}resourcetype'])) {
301                    $type = $this->mapResourceType($subProps['{DAV:}resourcetype']->getValue(), $subProps['subNode']);
302                }
303
304                $html .= '<tr>';
305                $html .= '<td class="nameColumn"><a href="' . $this->escapeHTML($subProps['fullPath']) . '"><span class="oi" data-glyph="' . $this->escapeHTML($type['icon']) . '"></span> ' . $this->escapeHTML($subProps['displayPath']) . '</a></td>';
306                $html .= '<td class="typeColumn">' . $this->escapeHTML($type['string']) . '</td>';
307                $html .= '<td>';
308                if (isset($subProps['{DAV:}getcontentlength'])) {
309                    $html .= $this->escapeHTML($subProps['{DAV:}getcontentlength'] . ' bytes');
310                }
311                $html .= '</td><td>';
312                if (isset($subProps['{DAV:}getlastmodified'])) {
313                    $lastMod = $subProps['{DAV:}getlastmodified']->getTime();
314                    $html .= $this->escapeHTML($lastMod->format('F j, Y, g:i a'));
315                }
316                $html .= '</td>';
317
318                $buttonActions = '';
319                if ($subProps['subNode'] instanceof DAV\IFile) {
320                    $buttonActions = '<a href="' . $this->escapeHTML($subProps['fullPath']) . '?sabreAction=info"><span class="oi" data-glyph="info"></span></a>';
321                }
322                $this->server->emit('browserButtonActions', [$subProps['fullPath'], $subProps['subNode'], &$buttonActions]);
323
324                $html .= '<td>' . $buttonActions . '</td>';
325                $html .= '</tr>';
326            }
327
328            $html .= '</table>';
329
330        }
331
332        $html .= "</section>";
333        $html .= "<section><h1>Properties</h1>";
334        $html .= "<table class=\"propTable\">";
335
336        // Allprops request
337        $propFind = new PropFindAll($path);
338        $properties = $this->server->getPropertiesByNode($propFind, $node);
339
340        $properties = $propFind->getResultForMultiStatus()[200];
341
342        foreach ($properties as $propName => $propValue) {
343            if (!in_array($propName, $this->uninterestingProperties)) {
344                $html .= $this->drawPropertyRow($propName, $propValue);
345            }
346
347        }
348
349
350        $html .= "</table>";
351        $html .= "</section>";
352
353        /* Start of generating actions */
354
355        $output = '';
356        if ($this->enablePost) {
357            $this->server->emit('onHTMLActionsPanel', [$node, &$output, $path]);
358        }
359
360        if ($output) {
361
362            $html .= "<section><h1>Actions</h1>";
363            $html .= "<div class=\"actions\">\n";
364            $html .= $output;
365            $html .= "</div>\n";
366            $html .= "</section>\n";
367        }
368
369        $html .= $this->generateFooter();
370
371        $this->server->httpResponse->setHeader('Content-Security-Policy', "default-src 'none'; img-src 'self'; style-src 'self'; font-src 'self';");
372
373        return $html;
374
375    }
376
377    /**
378     * Generates the 'plugins' page.
379     *
380     * @return string
381     */
382    function generatePluginListing() {
383
384        $html = $this->generateHeader('Plugins');
385
386        $html .= "<section><h1>Plugins</h1>";
387        $html .= "<table class=\"propTable\">";
388        foreach ($this->server->getPlugins() as $plugin) {
389            $info = $plugin->getPluginInfo();
390            $html .= '<tr><th>' . $info['name'] . '</th>';
391            $html .= '<td>' . $info['description'] . '</td>';
392            $html .= '<td>';
393            if (isset($info['link']) && $info['link']) {
394                $html .= '<a href="' . $this->escapeHTML($info['link']) . '"><span class="oi" data-glyph="book"></span></a>';
395            }
396            $html .= '</td></tr>';
397        }
398        $html .= "</table>";
399        $html .= "</section>";
400
401        /* Start of generating actions */
402
403        $html .= $this->generateFooter();
404
405        return $html;
406
407    }
408
409    /**
410     * Generates the first block of HTML, including the <head> tag and page
411     * header.
412     *
413     * Returns footer.
414     *
415     * @param string $title
416     * @param string $path
417     * @return string
418     */
419    function generateHeader($title, $path = null) {
420
421        $version = '';
422        if (DAV\Server::$exposeVersion) {
423            $version = DAV\Version::VERSION;
424        }
425
426        $vars = [
427            'title'     => $this->escapeHTML($title),
428            'favicon'   => $this->escapeHTML($this->getAssetUrl('favicon.ico')),
429            'style'     => $this->escapeHTML($this->getAssetUrl('sabredav.css')),
430            'iconstyle' => $this->escapeHTML($this->getAssetUrl('openiconic/open-iconic.css')),
431            'logo'      => $this->escapeHTML($this->getAssetUrl('sabredav.png')),
432            'baseUrl'   => $this->server->getBaseUri(),
433        ];
434
435        $html = <<<HTML
436<!DOCTYPE html>
437<html>
438<head>
439    <title>$vars[title] - sabre/dav $version</title>
440    <link rel="shortcut icon" href="$vars[favicon]"   type="image/vnd.microsoft.icon" />
441    <link rel="stylesheet"    href="$vars[style]"     type="text/css" />
442    <link rel="stylesheet"    href="$vars[iconstyle]" type="text/css" />
443
444</head>
445<body>
446    <header>
447        <div class="logo">
448            <a href="$vars[baseUrl]"><img src="$vars[logo]" alt="sabre/dav" /> $vars[title]</a>
449        </div>
450    </header>
451
452    <nav>
453HTML;
454
455        // If the path is empty, there's no parent.
456        if ($path)  {
457            list($parentUri) = URLUtil::splitPath($path);
458            $fullPath = $this->server->getBaseUri() . URLUtil::encodePath($parentUri);
459            $html .= '<a href="' . $fullPath . '" class="btn">⇤ Go to parent</a>';
460        } else {
461            $html .= '<span class="btn disabled">⇤ Go to parent</span>';
462        }
463
464        $html .= ' <a href="?sabreAction=plugins" class="btn"><span class="oi" data-glyph="puzzle-piece"></span> Plugins</a>';
465
466        $html .= "</nav>";
467
468        return $html;
469
470    }
471
472    /**
473     * Generates the page footer.
474     *
475     * Returns html.
476     *
477     * @return string
478     */
479    function generateFooter() {
480
481        $version = '';
482        if (DAV\Server::$exposeVersion) {
483            $version = DAV\Version::VERSION;
484        }
485        return <<<HTML
486<footer>Generated by SabreDAV $version (c)2007-2016 <a href="http://sabre.io/">http://sabre.io/</a></footer>
487</body>
488</html>
489HTML;
490
491    }
492
493    /**
494     * This method is used to generate the 'actions panel' output for
495     * collections.
496     *
497     * This specifically generates the interfaces for creating new files, and
498     * creating new directories.
499     *
500     * @param DAV\INode $node
501     * @param mixed $output
502     * @param string $path
503     * @return void
504     */
505    function htmlActionsPanel(DAV\INode $node, &$output, $path) {
506
507        if (!$node instanceof DAV\ICollection)
508            return;
509
510        // We also know fairly certain that if an object is a non-extended
511        // SimpleCollection, we won't need to show the panel either.
512        if (get_class($node) === 'Sabre\\DAV\\SimpleCollection')
513            return;
514
515        $output .= <<<HTML
516<form method="post" action="">
517<h3>Create new folder</h3>
518<input type="hidden" name="sabreAction" value="mkcol" />
519<label>Name:</label> <input type="text" name="name" /><br />
520<input type="submit" value="create" />
521</form>
522<form method="post" action="" enctype="multipart/form-data">
523<h3>Upload file</h3>
524<input type="hidden" name="sabreAction" value="put" />
525<label>Name (optional):</label> <input type="text" name="name" /><br />
526<label>File:</label> <input type="file" name="file" /><br />
527<input type="submit" value="upload" />
528</form>
529HTML;
530
531    }
532
533    /**
534     * This method takes a path/name of an asset and turns it into url
535     * suiteable for http access.
536     *
537     * @param string $assetName
538     * @return string
539     */
540    protected function getAssetUrl($assetName) {
541
542        return $this->server->getBaseUri() . '?sabreAction=asset&assetName=' . urlencode($assetName);
543
544    }
545
546    /**
547     * This method returns a local pathname to an asset.
548     *
549     * @param string $assetName
550     * @throws DAV\Exception\NotFound
551     * @return string
552     */
553    protected function getLocalAssetPath($assetName) {
554
555        $assetDir = __DIR__ . '/assets/';
556        $path = $assetDir . $assetName;
557
558        // Making sure people aren't trying to escape from the base path.
559        $path = str_replace('\\', '/', $path);
560        if (strpos($path, '/../') !== false || strrchr($path, '/') === '/..') {
561            throw new DAV\Exception\NotFound('Path does not exist, or escaping from the base path was detected');
562        }
563        if (strpos(realpath($path), realpath($assetDir)) === 0 && file_exists($path)) {
564            return $path;
565        }
566        throw new DAV\Exception\NotFound('Path does not exist, or escaping from the base path was detected');
567    }
568
569    /**
570     * This method reads an asset from disk and generates a full http response.
571     *
572     * @param string $assetName
573     * @return void
574     */
575    protected function serveAsset($assetName) {
576
577        $assetPath = $this->getLocalAssetPath($assetName);
578
579        // Rudimentary mime type detection
580        $mime = 'application/octet-stream';
581        $map = [
582            'ico' => 'image/vnd.microsoft.icon',
583            'png' => 'image/png',
584            'css' => 'text/css',
585        ];
586
587        $ext = substr($assetName, strrpos($assetName, '.') + 1);
588        if (isset($map[$ext])) {
589            $mime = $map[$ext];
590        }
591
592        $this->server->httpResponse->setHeader('Content-Type', $mime);
593        $this->server->httpResponse->setHeader('Content-Length', filesize($assetPath));
594        $this->server->httpResponse->setHeader('Cache-Control', 'public, max-age=1209600');
595        $this->server->httpResponse->setStatus(200);
596        $this->server->httpResponse->setBody(fopen($assetPath, 'r'));
597
598    }
599
600    /**
601     * Sort helper function: compares two directory entries based on type and
602     * display name. Collections sort above other types.
603     *
604     * @param array $a
605     * @param array $b
606     * @return int
607     */
608    protected function compareNodes($a, $b) {
609
610        $typeA = (isset($a['{DAV:}resourcetype']))
611            ? (in_array('{DAV:}collection', $a['{DAV:}resourcetype']->getValue()))
612            : false;
613
614        $typeB = (isset($b['{DAV:}resourcetype']))
615            ? (in_array('{DAV:}collection', $b['{DAV:}resourcetype']->getValue()))
616            : false;
617
618        // If same type, sort alphabetically by filename:
619        if ($typeA === $typeB) {
620            return strnatcasecmp($a['displayPath'], $b['displayPath']);
621        }
622        return (($typeA < $typeB) ? 1 : -1);
623
624    }
625
626    /**
627     * Maps a resource type to a human-readable string and icon.
628     *
629     * @param array $resourceTypes
630     * @param DAV\INode $node
631     * @return array
632     */
633    private function mapResourceType(array $resourceTypes, $node) {
634
635        if (!$resourceTypes) {
636            if ($node instanceof DAV\IFile) {
637                return [
638                    'string' => 'File',
639                    'icon'   => 'file',
640                ];
641            } else {
642                return [
643                    'string' => 'Unknown',
644                    'icon'   => 'cog',
645                ];
646            }
647        }
648
649        $types = [
650            '{http://calendarserver.org/ns/}calendar-proxy-write' => [
651                'string' => 'Proxy-Write',
652                'icon'   => 'people',
653            ],
654            '{http://calendarserver.org/ns/}calendar-proxy-read' => [
655                'string' => 'Proxy-Read',
656                'icon'   => 'people',
657            ],
658            '{urn:ietf:params:xml:ns:caldav}schedule-outbox' => [
659                'string' => 'Outbox',
660                'icon'   => 'inbox',
661            ],
662            '{urn:ietf:params:xml:ns:caldav}schedule-inbox' => [
663                'string' => 'Inbox',
664                'icon'   => 'inbox',
665            ],
666            '{urn:ietf:params:xml:ns:caldav}calendar' => [
667                'string' => 'Calendar',
668                'icon'   => 'calendar',
669            ],
670            '{http://calendarserver.org/ns/}shared-owner' => [
671                'string' => 'Shared',
672                'icon'   => 'calendar',
673            ],
674            '{http://calendarserver.org/ns/}subscribed' => [
675                'string' => 'Subscription',
676                'icon'   => 'calendar',
677            ],
678            '{urn:ietf:params:xml:ns:carddav}directory' => [
679                'string' => 'Directory',
680                'icon'   => 'globe',
681            ],
682            '{urn:ietf:params:xml:ns:carddav}addressbook' => [
683                'string' => 'Address book',
684                'icon'   => 'book',
685            ],
686            '{DAV:}principal' => [
687                'string' => 'Principal',
688                'icon'   => 'person',
689            ],
690            '{DAV:}collection' => [
691                'string' => 'Collection',
692                'icon'   => 'folder',
693            ],
694        ];
695
696        $info = [
697            'string' => [],
698            'icon'   => 'cog',
699        ];
700        foreach ($resourceTypes as $k => $resourceType) {
701            if (isset($types[$resourceType])) {
702                $info['string'][] = $types[$resourceType]['string'];
703            } else {
704                $info['string'][] = $resourceType;
705            }
706        }
707        foreach ($types as $key => $resourceInfo) {
708            if (in_array($key, $resourceTypes)) {
709                $info['icon'] = $resourceInfo['icon'];
710                break;
711            }
712        }
713        $info['string'] = implode(', ', $info['string']);
714
715        return $info;
716
717    }
718
719    /**
720     * Draws a table row for a property
721     *
722     * @param string $name
723     * @param mixed $value
724     * @return string
725     */
726    private function drawPropertyRow($name, $value) {
727
728        $html = new HtmlOutputHelper(
729            $this->server->getBaseUri(),
730            $this->server->xml->namespaceMap
731        );
732
733        return "<tr><th>" . $html->xmlName($name) . "</th><td>" . $this->drawPropertyValue($html, $value) . "</td></tr>";
734
735    }
736
737    /**
738     * Draws a table row for a property
739     *
740     * @param HtmlOutputHelper $html
741     * @param mixed $value
742     * @return string
743     */
744    private function drawPropertyValue($html, $value) {
745
746        if (is_scalar($value)) {
747            return $html->h($value);
748        } elseif ($value instanceof HtmlOutput) {
749            return $value->toHtml($html);
750        } elseif ($value instanceof \Sabre\Xml\XmlSerializable) {
751
752            // There's no default html output for this property, we're going
753            // to output the actual xml serialization instead.
754            $xml = $this->server->xml->write('{DAV:}root', $value, $this->server->getBaseUri());
755            // removing first and last line, as they contain our root
756            // element.
757            $xml = explode("\n", $xml);
758            $xml = array_slice($xml, 2, -2);
759            return "<pre>" . $html->h(implode("\n", $xml)) . "</pre>";
760
761        } else {
762            return "<em>unknown</em>";
763        }
764
765    }
766
767    /**
768     * Returns a plugin name.
769     *
770     * Using this name other plugins will be able to access other plugins;
771     * using \Sabre\DAV\Server::getPlugin
772     *
773     * @return string
774     */
775    function getPluginName() {
776
777        return 'browser';
778
779    }
780
781    /**
782     * Returns a bunch of meta-data about the plugin.
783     *
784     * Providing this information is optional, and is mainly displayed by the
785     * Browser plugin.
786     *
787     * The description key in the returned array may contain html and will not
788     * be sanitized.
789     *
790     * @return array
791     */
792    function getPluginInfo() {
793
794        return [
795            'name'        => $this->getPluginName(),
796            'description' => 'Generates HTML indexes and debug information for your sabre/dav server',
797            'link'        => 'http://sabre.io/dav/browser-plugin/',
798        ];
799
800    }
801
802}
803