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