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