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