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