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