1<?php 2 3namespace Sabre\DAV\Browser; 4 5use Sabre\DAV; 6use Sabre\DAV\MkCol; 7use Sabre\HTTP\URLUtil; 8use Sabre\HTTP\RequestInterface; 9use Sabre\HTTP\ResponseInterface; 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) 2007-2015 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 Sabre\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 $this->server->httpResponse->setHeader('Content-Security-Policy', "img-src 'self'; style-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 ($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]); 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', "img-src 'self'; style-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 void 418 */ 419 function generateHeader($title, $path = null) { 420 421 $version = DAV\Version::VERSION; 422 423 $vars = [ 424 'title' => $this->escapeHTML($title), 425 'favicon' => $this->escapeHTML($this->getAssetUrl('favicon.ico')), 426 'style' => $this->escapeHTML($this->getAssetUrl('sabredav.css')), 427 'iconstyle' => $this->escapeHTML($this->getAssetUrl('openiconic/open-iconic.css')), 428 'logo' => $this->escapeHTML($this->getAssetUrl('sabredav.png')), 429 'baseUrl' => $this->server->getBaseUri(), 430 ]; 431 432 $html = <<<HTML 433<!DOCTYPE html> 434<html> 435<head> 436 <title>$vars[title] - sabre/dav $version</title> 437 <link rel="shortcut icon" href="$vars[favicon]" type="image/vnd.microsoft.icon" /> 438 <link rel="stylesheet" href="$vars[style]" type="text/css" /> 439 <link rel="stylesheet" href="$vars[iconstyle]" type="text/css" /> 440 441</head> 442<body> 443 <header> 444 <div class="logo"> 445 <a href="$vars[baseUrl]"><img src="$vars[logo]" alt="sabre/dav" /> $vars[title]</a> 446 </div> 447 </header> 448 449 <nav> 450HTML; 451 452 // If the path is empty, there's no parent. 453 if ($path) { 454 list($parentUri) = URLUtil::splitPath($path); 455 $fullPath = $this->server->getBaseUri() . URLUtil::encodePath($parentUri); 456 $html .= '<a href="' . $fullPath . '" class="btn">⇤ Go to parent</a>'; 457 } else { 458 $html .= '<span class="btn disabled">⇤ Go to parent</span>'; 459 } 460 461 $html .= ' <a href="?sabreAction=plugins" class="btn"><span class="oi" data-glyph="puzzle-piece"></span> Plugins</a>'; 462 463 $html .= "</nav>"; 464 465 return $html; 466 467 } 468 469 /** 470 * Generates the page footer. 471 * 472 * Returns html. 473 * 474 * @return string 475 */ 476 function generateFooter() { 477 478 $version = DAV\Version::VERSION; 479 return <<<HTML 480<footer>Generated by SabreDAV $version (c)2007-2015 <a href="http://sabre.io/">http://sabre.io/</a></footer> 481</body> 482</html> 483HTML; 484 485 } 486 487 /** 488 * This method is used to generate the 'actions panel' output for 489 * collections. 490 * 491 * This specifically generates the interfaces for creating new files, and 492 * creating new directories. 493 * 494 * @param DAV\INode $node 495 * @param mixed $output 496 * @return void 497 */ 498 function htmlActionsPanel(DAV\INode $node, &$output) { 499 500 if (!$node instanceof DAV\ICollection) 501 return; 502 503 // We also know fairly certain that if an object is a non-extended 504 // SimpleCollection, we won't need to show the panel either. 505 if (get_class($node) === 'Sabre\\DAV\\SimpleCollection') 506 return; 507 508 ob_start(); 509 echo '<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> 522 '; 523 524 $output .= ob_get_clean(); 525 526 } 527 528 /** 529 * This method takes a path/name of an asset and turns it into url 530 * suiteable for http access. 531 * 532 * @param string $assetName 533 * @return string 534 */ 535 protected function getAssetUrl($assetName) { 536 537 return $this->server->getBaseUri() . '?sabreAction=asset&assetName=' . urlencode($assetName); 538 539 } 540 541 /** 542 * This method returns a local pathname to an asset. 543 * 544 * @param string $assetName 545 * @return string 546 * @throws DAV\Exception\NotFound 547 */ 548 protected function getLocalAssetPath($assetName) { 549 550 $assetDir = __DIR__ . '/assets/'; 551 $path = $assetDir . $assetName; 552 553 // Making sure people aren't trying to escape from the base path. 554 $path = str_replace('\\', '/', $path); 555 if (strpos($path, '/../') !== false || strrchr($path, '/') === '/..') { 556 throw new DAV\Exception\NotFound('Path does not exist, or escaping from the base path was detected'); 557 } 558 if (strpos(realpath($path), realpath($assetDir)) === 0 && 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 * @return void 569 */ 570 protected function serveAsset($assetName) { 571 572 $assetPath = $this->getLocalAssetPath($assetName); 573 574 // Rudimentary mime type detection 575 $mime = 'application/octet-stream'; 576 $map = [ 577 'ico' => 'image/vnd.microsoft.icon', 578 'png' => 'image/png', 579 'css' => 'text/css', 580 ]; 581 582 $ext = substr($assetName, strrpos($assetName, '.') + 1); 583 if (isset($map[$ext])) { 584 $mime = $map[$ext]; 585 } 586 587 $this->server->httpResponse->setHeader('Content-Type', $mime); 588 $this->server->httpResponse->setHeader('Content-Length', filesize($assetPath)); 589 $this->server->httpResponse->setHeader('Cache-Control', 'public, max-age=1209600'); 590 $this->server->httpResponse->setStatus(200); 591 $this->server->httpResponse->setBody(fopen($assetPath, 'r')); 592 593 } 594 595 /** 596 * Sort helper function: compares two directory entries based on type and 597 * display name. Collections sort above other types. 598 * 599 * @param array $a 600 * @param array $b 601 * @return int 602 */ 603 protected function compareNodes($a, $b) { 604 605 $typeA = (isset($a['{DAV:}resourcetype'])) 606 ? (in_array('{DAV:}collection', $a['{DAV:}resourcetype']->getValue())) 607 : false; 608 609 $typeB = (isset($b['{DAV:}resourcetype'])) 610 ? (in_array('{DAV:}collection', $b['{DAV:}resourcetype']->getValue())) 611 : false; 612 613 // If same type, sort alphabetically by filename: 614 if ($typeA === $typeB) { 615 return strnatcasecmp($a['displayPath'], $b['displayPath']); 616 } 617 return (($typeA < $typeB) ? 1 : -1); 618 619 } 620 621 /** 622 * Maps a resource type to a human-readable string and icon. 623 * 624 * @param array $resourceTypes 625 * @param INode $node 626 * @return array 627 */ 628 private function mapResourceType(array $resourceTypes, $node) { 629 630 if (!$resourceTypes) { 631 if ($node instanceof DAV\IFile) { 632 return [ 633 'string' => 'File', 634 'icon' => 'file', 635 ]; 636 } else { 637 return [ 638 'string' => 'Unknown', 639 'icon' => 'cog', 640 ]; 641 } 642 } 643 644 $types = [ 645 '{http://calendarserver.org/ns/}calendar-proxy-write' => [ 646 'string' => 'Proxy-Write', 647 'icon' => 'people', 648 ], 649 '{http://calendarserver.org/ns/}calendar-proxy-read' => [ 650 'string' => 'Proxy-Read', 651 'icon' => 'people', 652 ], 653 '{urn:ietf:params:xml:ns:caldav}schedule-outbox' => [ 654 'string' => 'Outbox', 655 'icon' => 'inbox', 656 ], 657 '{urn:ietf:params:xml:ns:caldav}schedule-inbox' => [ 658 'string' => 'Inbox', 659 'icon' => 'inbox', 660 ], 661 '{urn:ietf:params:xml:ns:caldav}calendar' => [ 662 'string' => 'Calendar', 663 'icon' => 'calendar', 664 ], 665 '{http://calendarserver.org/ns/}shared-owner' => [ 666 'string' => 'Shared', 667 'icon' => 'calendar', 668 ], 669 '{http://calendarserver.org/ns/}subscribed' => [ 670 'string' => 'Subscription', 671 'icon' => 'calendar', 672 ], 673 '{urn:ietf:params:xml:ns:carddav}directory' => [ 674 'string' => 'Directory', 675 'icon' => 'globe', 676 ], 677 '{urn:ietf:params:xml:ns:carddav}addressbook' => [ 678 'string' => 'Address book', 679 'icon' => 'book', 680 ], 681 '{DAV:}principal' => [ 682 'string' => 'Principal', 683 'icon' => 'person', 684 ], 685 '{DAV:}collection' => [ 686 'string' => 'Collection', 687 'icon' => 'folder', 688 ], 689 ]; 690 691 $info = [ 692 'string' => [], 693 'icon' => 'cog', 694 ]; 695 foreach ($resourceTypes as $k => $resourceType) { 696 if (isset($types[$resourceType])) { 697 $info['string'][] = $types[$resourceType]['string']; 698 } else { 699 $info['string'][] = $resourceType; 700 } 701 } 702 foreach ($types as $key => $resourceInfo) { 703 if (in_array($key, $resourceTypes)) { 704 $info['icon'] = $resourceInfo['icon']; 705 break; 706 } 707 } 708 $info['string'] = implode(', ', $info['string']); 709 710 return $info; 711 712 } 713 714 /** 715 * Draws a table row for a property 716 * 717 * @param string $name 718 * @param mixed $value 719 * @return string 720 */ 721 private function drawPropertyRow($name, $value) { 722 723 $html = new HtmlOutputHelper( 724 $this->server->getBaseUri(), 725 $this->server->xml->namespaceMap 726 ); 727 728 return "<tr><th>" . $html->xmlName($name) . "</th><td>" . $this->drawPropertyValue($html, $value) . "</td></tr>"; 729 730 } 731 732 /** 733 * Draws a table row for a property 734 * 735 * @param HtmlOutputHelper $html 736 * @param mixed $value 737 * @return string 738 */ 739 private function drawPropertyValue($html, $value) { 740 741 if (is_scalar($value)) { 742 return $html->h($value); 743 } elseif ($value instanceof HtmlOutput) { 744 return $value->toHtml($html); 745 } elseif ($value instanceof \Sabre\Xml\XmlSerializable) { 746 747 // There's no default html output for this property, we're going 748 // to output the actual xml serialization instead. 749 $xml = $this->server->xml->write('{DAV:}root', $value, $this->server->getBaseUri()); 750 // removing first and last line, as they contain our root 751 // element. 752 $xml = explode("\n", $xml); 753 $xml = array_slice($xml, 2, -2); 754 return "<pre>" . $html->h(implode("\n", $xml)) . "</pre>"; 755 756 } else { 757 return "<em>unknown</em>"; 758 } 759 760 } 761 762 /** 763 * Returns a plugin name. 764 * 765 * Using this name other plugins will be able to access other plugins; 766 * using \Sabre\DAV\Server::getPlugin 767 * 768 * @return string 769 */ 770 function getPluginName() { 771 772 return 'browser'; 773 774 } 775 776 /** 777 * Returns a bunch of meta-data about the plugin. 778 * 779 * Providing this information is optional, and is mainly displayed by the 780 * Browser plugin. 781 * 782 * The description key in the returned array may contain html and will not 783 * be sanitized. 784 * 785 * @return array 786 */ 787 function getPluginInfo() { 788 789 return [ 790 'name' => $this->getPluginName(), 791 'description' => 'Generates HTML indexes and debug information for your sabre/dav server', 792 'link' => 'http://sabre.io/dav/browser-plugin/', 793 ]; 794 795 } 796 797} 798