1<?php 2 3namespace Sabre\DAV\Locks; 4 5use Sabre\DAV; 6use Sabre\HTTP\RequestInterface; 7use Sabre\HTTP\ResponseInterface; 8 9/** 10 * Locking plugin 11 * 12 * This plugin provides locking support to a WebDAV server. 13 * The easiest way to get started, is by hooking it up as such: 14 * 15 * $lockBackend = new Sabre\DAV\Locks\Backend\File('./mylockdb'); 16 * $lockPlugin = new Sabre\DAV\Locks\Plugin($lockBackend); 17 * $server->addPlugin($lockPlugin); 18 * 19 * @copyright Copyright (C) fruux GmbH (https://fruux.com/) 20 * @author Evert Pot (http://evertpot.com/) 21 * @license http://sabre.io/license/ Modified BSD License 22 */ 23class Plugin extends DAV\ServerPlugin { 24 25 /** 26 * locksBackend 27 * 28 * @var Backend\BackendInterface 29 */ 30 protected $locksBackend; 31 32 /** 33 * server 34 * 35 * @var DAV\Server 36 */ 37 protected $server; 38 39 /** 40 * __construct 41 * 42 * @param Backend\BackendInterface $locksBackend 43 */ 44 function __construct(Backend\BackendInterface $locksBackend) { 45 46 $this->locksBackend = $locksBackend; 47 48 } 49 50 /** 51 * Initializes the plugin 52 * 53 * This method is automatically called by the Server class after addPlugin. 54 * 55 * @param DAV\Server $server 56 * @return void 57 */ 58 function initialize(DAV\Server $server) { 59 60 $this->server = $server; 61 62 $this->server->xml->elementMap['{DAV:}lockinfo'] = 'Sabre\\DAV\\Xml\\Request\\Lock'; 63 64 $server->on('method:LOCK', [$this, 'httpLock']); 65 $server->on('method:UNLOCK', [$this, 'httpUnlock']); 66 $server->on('validateTokens', [$this, 'validateTokens']); 67 $server->on('propFind', [$this, 'propFind']); 68 $server->on('afterUnbind', [$this, 'afterUnbind']); 69 70 } 71 72 /** 73 * Returns a plugin name. 74 * 75 * Using this name other plugins will be able to access other plugins 76 * using Sabre\DAV\Server::getPlugin 77 * 78 * @return string 79 */ 80 function getPluginName() { 81 82 return 'locks'; 83 84 } 85 86 /** 87 * This method is called after most properties have been found 88 * it allows us to add in any Lock-related properties 89 * 90 * @param DAV\PropFind $propFind 91 * @param DAV\INode $node 92 * @return void 93 */ 94 function propFind(DAV\PropFind $propFind, DAV\INode $node) { 95 96 $propFind->handle('{DAV:}supportedlock', function() { 97 return new DAV\Xml\Property\SupportedLock(); 98 }); 99 $propFind->handle('{DAV:}lockdiscovery', function() use ($propFind) { 100 return new DAV\Xml\Property\LockDiscovery( 101 $this->getLocks($propFind->getPath()) 102 ); 103 }); 104 105 } 106 107 /** 108 * Use this method to tell the server this plugin defines additional 109 * HTTP methods. 110 * 111 * This method is passed a uri. It should only return HTTP methods that are 112 * available for the specified uri. 113 * 114 * @param string $uri 115 * @return array 116 */ 117 function getHTTPMethods($uri) { 118 119 return ['LOCK','UNLOCK']; 120 121 } 122 123 /** 124 * Returns a list of features for the HTTP OPTIONS Dav: header. 125 * 126 * In this case this is only the number 2. The 2 in the Dav: header 127 * indicates the server supports locks. 128 * 129 * @return array 130 */ 131 function getFeatures() { 132 133 return [2]; 134 135 } 136 137 /** 138 * Returns all lock information on a particular uri 139 * 140 * This function should return an array with Sabre\DAV\Locks\LockInfo objects. If there are no locks on a file, return an empty array. 141 * 142 * Additionally there is also the possibility of locks on parent nodes, so we'll need to traverse every part of the tree 143 * If the $returnChildLocks argument is set to true, we'll also traverse all the children of the object 144 * for any possible locks and return those as well. 145 * 146 * @param string $uri 147 * @param bool $returnChildLocks 148 * @return array 149 */ 150 function getLocks($uri, $returnChildLocks = false) { 151 152 return $this->locksBackend->getLocks($uri, $returnChildLocks); 153 154 } 155 156 /** 157 * Locks an uri 158 * 159 * The WebDAV lock request can be operated to either create a new lock on a file, or to refresh an existing lock 160 * If a new lock is created, a full XML body should be supplied, containing information about the lock such as the type 161 * of lock (shared or exclusive) and the owner of the lock 162 * 163 * If a lock is to be refreshed, no body should be supplied and there should be a valid If header containing the lock 164 * 165 * Additionally, a lock can be requested for a non-existent file. In these case we're obligated to create an empty file as per RFC4918:S7.3 166 * 167 * @param RequestInterface $request 168 * @param ResponseInterface $response 169 * @return bool 170 */ 171 function httpLock(RequestInterface $request, ResponseInterface $response) { 172 173 $uri = $request->getPath(); 174 175 $existingLocks = $this->getLocks($uri); 176 177 if ($body = $request->getBodyAsString()) { 178 // This is a new lock request 179 180 $existingLock = null; 181 // Checking if there's already non-shared locks on the uri. 182 foreach ($existingLocks as $existingLock) { 183 if ($existingLock->scope === LockInfo::EXCLUSIVE) { 184 throw new DAV\Exception\ConflictingLock($existingLock); 185 } 186 } 187 188 $lockInfo = $this->parseLockRequest($body); 189 $lockInfo->depth = $this->server->getHTTPDepth(); 190 $lockInfo->uri = $uri; 191 if ($existingLock && $lockInfo->scope != LockInfo::SHARED) 192 throw new DAV\Exception\ConflictingLock($existingLock); 193 194 } else { 195 196 // Gonna check if this was a lock refresh. 197 $existingLocks = $this->getLocks($uri); 198 $conditions = $this->server->getIfConditions($request); 199 $found = null; 200 201 foreach ($existingLocks as $existingLock) { 202 foreach ($conditions as $condition) { 203 foreach ($condition['tokens'] as $token) { 204 if ($token['token'] === 'opaquelocktoken:' . $existingLock->token) { 205 $found = $existingLock; 206 break 3; 207 } 208 } 209 } 210 } 211 212 // If none were found, this request is in error. 213 if (is_null($found)) { 214 if ($existingLocks) { 215 throw new DAV\Exception\Locked(reset($existingLocks)); 216 } else { 217 throw new DAV\Exception\BadRequest('An xml body is required for lock requests'); 218 } 219 220 } 221 222 // This must have been a lock refresh 223 $lockInfo = $found; 224 225 // The resource could have been locked through another uri. 226 if ($uri != $lockInfo->uri) $uri = $lockInfo->uri; 227 228 } 229 230 if ($timeout = $this->getTimeoutHeader()) $lockInfo->timeout = $timeout; 231 232 $newFile = false; 233 234 // If we got this far.. we should go check if this node actually exists. If this is not the case, we need to create it first 235 try { 236 $this->server->tree->getNodeForPath($uri); 237 238 // We need to call the beforeWriteContent event for RFC3744 239 // Edit: looks like this is not used, and causing problems now. 240 // 241 // See Issue 222 242 // $this->server->emit('beforeWriteContent',array($uri)); 243 244 } catch (DAV\Exception\NotFound $e) { 245 246 // It didn't, lets create it 247 $this->server->createFile($uri, fopen('php://memory', 'r')); 248 $newFile = true; 249 250 } 251 252 $this->lockNode($uri, $lockInfo); 253 254 $response->setHeader('Content-Type', 'application/xml; charset=utf-8'); 255 $response->setHeader('Lock-Token', '<opaquelocktoken:' . $lockInfo->token . '>'); 256 $response->setStatus($newFile ? 201 : 200); 257 $response->setBody($this->generateLockResponse($lockInfo)); 258 259 // Returning false will interrupt the event chain and mark this method 260 // as 'handled'. 261 return false; 262 263 } 264 265 /** 266 * Unlocks a uri 267 * 268 * This WebDAV method allows you to remove a lock from a node. The client should provide a valid locktoken through the Lock-token http header 269 * The server should return 204 (No content) on success 270 * 271 * @param RequestInterface $request 272 * @param ResponseInterface $response 273 * @return void 274 */ 275 function httpUnlock(RequestInterface $request, ResponseInterface $response) { 276 277 $lockToken = $request->getHeader('Lock-Token'); 278 279 // If the locktoken header is not supplied, we need to throw a bad request exception 280 if (!$lockToken) throw new DAV\Exception\BadRequest('No lock token was supplied'); 281 282 $path = $request->getPath(); 283 $locks = $this->getLocks($path); 284 285 // Windows sometimes forgets to include < and > in the Lock-Token 286 // header 287 if ($lockToken[0] !== '<') $lockToken = '<' . $lockToken . '>'; 288 289 foreach ($locks as $lock) { 290 291 if ('<opaquelocktoken:' . $lock->token . '>' == $lockToken) { 292 293 $this->unlockNode($path, $lock); 294 $response->setHeader('Content-Length', '0'); 295 $response->setStatus(204); 296 297 // Returning false will break the method chain, and mark the 298 // method as 'handled'. 299 return false; 300 301 } 302 303 } 304 305 // If we got here, it means the locktoken was invalid 306 throw new DAV\Exception\LockTokenMatchesRequestUri(); 307 308 } 309 310 /** 311 * This method is called after a node is deleted. 312 * 313 * We use this event to clean up any locks that still exist on the node. 314 * 315 * @param string $path 316 * @return void 317 */ 318 function afterUnbind($path) { 319 320 $locks = $this->getLocks($path, $includeChildren = true); 321 foreach ($locks as $lock) { 322 $this->unlockNode($path, $lock); 323 } 324 325 } 326 327 /** 328 * Locks a uri 329 * 330 * All the locking information is supplied in the lockInfo object. The object has a suggested timeout, but this can be safely ignored 331 * It is important that if the existing timeout is ignored, the property is overwritten, as this needs to be sent back to the client 332 * 333 * @param string $uri 334 * @param LockInfo $lockInfo 335 * @return bool 336 */ 337 function lockNode($uri, LockInfo $lockInfo) { 338 339 if (!$this->server->emit('beforeLock', [$uri, $lockInfo])) return; 340 return $this->locksBackend->lock($uri, $lockInfo); 341 342 } 343 344 /** 345 * Unlocks a uri 346 * 347 * This method removes a lock from a uri. It is assumed all the supplied information is correct and verified 348 * 349 * @param string $uri 350 * @param LockInfo $lockInfo 351 * @return bool 352 */ 353 function unlockNode($uri, LockInfo $lockInfo) { 354 355 if (!$this->server->emit('beforeUnlock', [$uri, $lockInfo])) return; 356 return $this->locksBackend->unlock($uri, $lockInfo); 357 358 } 359 360 361 /** 362 * Returns the contents of the HTTP Timeout header. 363 * 364 * The method formats the header into an integer. 365 * 366 * @return int 367 */ 368 function getTimeoutHeader() { 369 370 $header = $this->server->httpRequest->getHeader('Timeout'); 371 372 if ($header) { 373 374 if (stripos($header, 'second-') === 0) $header = (int)(substr($header, 7)); 375 elseif (stripos($header, 'infinite') === 0) $header = LockInfo::TIMEOUT_INFINITE; 376 else throw new DAV\Exception\BadRequest('Invalid HTTP timeout header'); 377 378 } else { 379 380 $header = 0; 381 382 } 383 384 return $header; 385 386 } 387 388 /** 389 * Generates the response for successful LOCK requests 390 * 391 * @param LockInfo $lockInfo 392 * @return string 393 */ 394 protected function generateLockResponse(LockInfo $lockInfo) { 395 396 return $this->server->xml->write('{DAV:}prop', [ 397 '{DAV:}lockdiscovery' => 398 new DAV\Xml\Property\LockDiscovery([$lockInfo]) 399 ]); 400 } 401 402 /** 403 * The validateTokens event is triggered before every request. 404 * 405 * It's a moment where this plugin can check all the supplied lock tokens 406 * in the If: header, and check if they are valid. 407 * 408 * In addition, it will also ensure that it checks any missing lokens that 409 * must be present in the request, and reject requests without the proper 410 * tokens. 411 * 412 * @param RequestInterface $request 413 * @param mixed $conditions 414 * @return void 415 */ 416 function validateTokens(RequestInterface $request, &$conditions) { 417 418 // First we need to gather a list of locks that must be satisfied. 419 $mustLocks = []; 420 $method = $request->getMethod(); 421 422 // Methods not in that list are operations that doesn't alter any 423 // resources, and we don't need to check the lock-states for. 424 switch ($method) { 425 426 case 'DELETE' : 427 $mustLocks = array_merge($mustLocks, $this->getLocks( 428 $request->getPath(), 429 true 430 )); 431 break; 432 case 'MKCOL' : 433 case 'MKCALENDAR' : 434 case 'PROPPATCH' : 435 case 'PUT' : 436 case 'PATCH' : 437 $mustLocks = array_merge($mustLocks, $this->getLocks( 438 $request->getPath(), 439 false 440 )); 441 break; 442 case 'MOVE' : 443 $mustLocks = array_merge($mustLocks, $this->getLocks( 444 $request->getPath(), 445 true 446 )); 447 $mustLocks = array_merge($mustLocks, $this->getLocks( 448 $this->server->calculateUri($request->getHeader('Destination')), 449 false 450 )); 451 break; 452 case 'COPY' : 453 $mustLocks = array_merge($mustLocks, $this->getLocks( 454 $this->server->calculateUri($request->getHeader('Destination')), 455 false 456 )); 457 break; 458 case 'LOCK' : 459 //Temporary measure.. figure out later why this is needed 460 // Here we basically ignore all incoming tokens... 461 foreach ($conditions as $ii => $condition) { 462 foreach ($condition['tokens'] as $jj => $token) { 463 $conditions[$ii]['tokens'][$jj]['validToken'] = true; 464 } 465 } 466 return; 467 468 } 469 470 // It's possible that there's identical locks, because of shared 471 // parents. We're removing the duplicates here. 472 $tmp = []; 473 foreach ($mustLocks as $lock) $tmp[$lock->token] = $lock; 474 $mustLocks = array_values($tmp); 475 476 foreach ($conditions as $kk => $condition) { 477 478 foreach ($condition['tokens'] as $ii => $token) { 479 480 // Lock tokens always start with opaquelocktoken: 481 if (substr($token['token'], 0, 16) !== 'opaquelocktoken:') { 482 continue; 483 } 484 485 $checkToken = substr($token['token'], 16); 486 // Looping through our list with locks. 487 foreach ($mustLocks as $jj => $mustLock) { 488 489 if ($mustLock->token == $checkToken) { 490 491 // We have a match! 492 // Removing this one from mustlocks 493 unset($mustLocks[$jj]); 494 495 // Marking the condition as valid. 496 $conditions[$kk]['tokens'][$ii]['validToken'] = true; 497 498 // Advancing to the next token 499 continue 2; 500 501 } 502 503 } 504 505 // If we got here, it means that there was a 506 // lock-token, but it was not in 'mustLocks'. 507 // 508 // This is an edge-case, as it could mean that token 509 // was specified with a url that was not 'required' to 510 // check. So we're doing one extra lookup to make sure 511 // we really don't know this token. 512 // 513 // This also gets triggered when the user specified a 514 // lock-token that was expired. 515 $oddLocks = $this->getLocks($condition['uri']); 516 foreach ($oddLocks as $oddLock) { 517 518 if ($oddLock->token === $checkToken) { 519 520 // We have a hit! 521 $conditions[$kk]['tokens'][$ii]['validToken'] = true; 522 continue 2; 523 524 } 525 } 526 527 // If we get all the way here, the lock-token was 528 // really unknown. 529 530 531 } 532 533 } 534 535 // If there's any locks left in the 'mustLocks' array, it means that 536 // the resource was locked and we must block it. 537 if ($mustLocks) { 538 539 throw new DAV\Exception\Locked(reset($mustLocks)); 540 541 } 542 543 } 544 545 /** 546 * Parses a webdav lock xml body, and returns a new Sabre\DAV\Locks\LockInfo object 547 * 548 * @param string $body 549 * @return LockInfo 550 */ 551 protected function parseLockRequest($body) { 552 553 $result = $this->server->xml->expect( 554 '{DAV:}lockinfo', 555 $body 556 ); 557 558 $lockInfo = new LockInfo(); 559 560 $lockInfo->owner = $result->owner; 561 $lockInfo->token = DAV\UUIDUtil::getUUID(); 562 $lockInfo->scope = $result->scope; 563 564 return $lockInfo; 565 566 } 567 568 /** 569 * Returns a bunch of meta-data about the plugin. 570 * 571 * Providing this information is optional, and is mainly displayed by the 572 * Browser plugin. 573 * 574 * The description key in the returned array may contain html and will not 575 * be sanitized. 576 * 577 * @return array 578 */ 579 function getPluginInfo() { 580 581 return [ 582 'name' => $this->getPluginName(), 583 'description' => 'The locks plugin turns this server into a class-2 WebDAV server and adds support for LOCK and UNLOCK', 584 'link' => 'http://sabre.io/dav/locks/', 585 ]; 586 587 } 588 589} 590