Plugin.php 18 KB

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