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