1<?php
2
3declare(strict_types=1);
4
5namespace Sabre\DAV\Sync;
6
7use Sabre\DAV;
8use Sabre\DAV\Xml\Request\SyncCollectionReport;
9use Sabre\HTTP\RequestInterface;
10
11/**
12 * This plugin all WebDAV-sync capabilities to the Server.
13 *
14 * WebDAV-sync is defined by rfc6578
15 *
16 * The sync capabilities only work with collections that implement
17 * Sabre\DAV\Sync\ISyncCollection.
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     * Reference to server object.
27     *
28     * @var DAV\Server
29     */
30    protected $server;
31
32    const SYNCTOKEN_PREFIX = 'http://sabre.io/ns/sync/';
33
34    /**
35     * Returns a plugin name.
36     *
37     * Using this name other plugins will be able to access other plugins
38     * using \Sabre\DAV\Server::getPlugin
39     *
40     * @return string
41     */
42    public function getPluginName()
43    {
44        return 'sync';
45    }
46
47    /**
48     * Initializes the plugin.
49     *
50     * This is when the plugin registers it's hooks.
51     */
52    public function initialize(DAV\Server $server)
53    {
54        $this->server = $server;
55        $server->xml->elementMap['{DAV:}sync-collection'] = 'Sabre\\DAV\\Xml\\Request\\SyncCollectionReport';
56
57        $self = $this;
58
59        $server->on('report', function ($reportName, $dom, $uri) use ($self) {
60            if ('{DAV:}sync-collection' === $reportName) {
61                $this->server->transactionType = 'report-sync-collection';
62                $self->syncCollection($uri, $dom);
63
64                return false;
65            }
66        });
67
68        $server->on('propFind', [$this, 'propFind']);
69        $server->on('validateTokens', [$this, 'validateTokens']);
70    }
71
72    /**
73     * Returns a list of reports this plugin supports.
74     *
75     * This will be used in the {DAV:}supported-report-set property.
76     * Note that you still need to subscribe to the 'report' event to actually
77     * implement them
78     *
79     * @param string $uri
80     *
81     * @return array
82     */
83    public function getSupportedReportSet($uri)
84    {
85        $node = $this->server->tree->getNodeForPath($uri);
86        if ($node instanceof ISyncCollection && $node->getSyncToken()) {
87            return [
88                '{DAV:}sync-collection',
89            ];
90        }
91
92        return [];
93    }
94
95    /**
96     * This method handles the {DAV:}sync-collection HTTP REPORT.
97     *
98     * @param string $uri
99     */
100    public function syncCollection($uri, SyncCollectionReport $report)
101    {
102        // Getting the data
103        $node = $this->server->tree->getNodeForPath($uri);
104        if (!$node instanceof ISyncCollection) {
105            throw new DAV\Exception\ReportNotSupported('The {DAV:}sync-collection REPORT is not supported on this url.');
106        }
107        $token = $node->getSyncToken();
108        if (!$token) {
109            throw new DAV\Exception\ReportNotSupported('No sync information is available at this node');
110        }
111
112        $syncToken = $report->syncToken;
113        if (!is_null($syncToken)) {
114            // Sync-token must start with our prefix
115            if (self::SYNCTOKEN_PREFIX !== substr($syncToken, 0, strlen(self::SYNCTOKEN_PREFIX))) {
116                throw new DAV\Exception\InvalidSyncToken('Invalid or unknown sync token');
117            }
118
119            $syncToken = substr($syncToken, strlen(self::SYNCTOKEN_PREFIX));
120        }
121        $changeInfo = $node->getChanges($syncToken, $report->syncLevel, $report->limit);
122
123        if (is_null($changeInfo)) {
124            throw new DAV\Exception\InvalidSyncToken('Invalid or unknown sync token');
125        }
126
127        // Encoding the response
128        $this->sendSyncCollectionResponse(
129            $changeInfo['syncToken'],
130            $uri,
131            $changeInfo['added'],
132            $changeInfo['modified'],
133            $changeInfo['deleted'],
134            $report->properties
135        );
136    }
137
138    /**
139     * Sends the response to a sync-collection request.
140     *
141     * @param string $syncToken
142     * @param string $collectionUrl
143     */
144    protected function sendSyncCollectionResponse($syncToken, $collectionUrl, array $added, array $modified, array $deleted, array $properties)
145    {
146        $fullPaths = [];
147
148        // Pre-fetching children, if this is possible.
149        foreach (array_merge($added, $modified) as $item) {
150            $fullPath = $collectionUrl.'/'.$item;
151            $fullPaths[] = $fullPath;
152        }
153
154        $responses = [];
155        foreach ($this->server->getPropertiesForMultiplePaths($fullPaths, $properties) as $fullPath => $props) {
156            // The 'Property_Response' class is responsible for generating a
157            // single {DAV:}response xml element.
158            $responses[] = new DAV\Xml\Element\Response($fullPath, $props);
159        }
160
161        // Deleted items also show up as 'responses'. They have no properties,
162        // and a single {DAV:}status element set as 'HTTP/1.1 404 Not Found'.
163        foreach ($deleted as $item) {
164            $fullPath = $collectionUrl.'/'.$item;
165            $responses[] = new DAV\Xml\Element\Response($fullPath, [], 404);
166        }
167        $multiStatus = new DAV\Xml\Response\MultiStatus($responses, self::SYNCTOKEN_PREFIX.$syncToken);
168
169        $this->server->httpResponse->setStatus(207);
170        $this->server->httpResponse->setHeader('Content-Type', 'application/xml; charset=utf-8');
171        $this->server->httpResponse->setBody(
172            $this->server->xml->write('{DAV:}multistatus', $multiStatus, $this->server->getBaseUri())
173        );
174    }
175
176    /**
177     * This method is triggered whenever properties are requested for a node.
178     * We intercept this to see if we must return a {DAV:}sync-token.
179     */
180    public function propFind(DAV\PropFind $propFind, DAV\INode $node)
181    {
182        $propFind->handle('{DAV:}sync-token', function () use ($node) {
183            if (!$node instanceof ISyncCollection || !$token = $node->getSyncToken()) {
184                return;
185            }
186
187            return self::SYNCTOKEN_PREFIX.$token;
188        });
189    }
190
191    /**
192     * The validateTokens event is triggered before every request.
193     *
194     * It's a moment where this plugin can check all the supplied lock tokens
195     * in the If: header, and check if they are valid.
196     *
197     * @param array $conditions
198     */
199    public function validateTokens(RequestInterface $request, &$conditions)
200    {
201        foreach ($conditions as $kk => $condition) {
202            foreach ($condition['tokens'] as $ii => $token) {
203                // Sync-tokens must always start with our designated prefix.
204                if (self::SYNCTOKEN_PREFIX !== substr($token['token'], 0, strlen(self::SYNCTOKEN_PREFIX))) {
205                    continue;
206                }
207
208                // Checking if the token is a match.
209                $node = $this->server->tree->getNodeForPath($condition['uri']);
210
211                if (
212                    $node instanceof ISyncCollection &&
213                    $node->getSyncToken() == substr($token['token'], strlen(self::SYNCTOKEN_PREFIX))
214                ) {
215                    $conditions[$kk]['tokens'][$ii]['validToken'] = true;
216                }
217            }
218        }
219    }
220
221    /**
222     * Returns a bunch of meta-data about the plugin.
223     *
224     * Providing this information is optional, and is mainly displayed by the
225     * Browser plugin.
226     *
227     * The description key in the returned array may contain html and will not
228     * be sanitized.
229     *
230     * @return array
231     */
232    public function getPluginInfo()
233    {
234        return [
235            'name' => $this->getPluginName(),
236            'description' => 'Adds support for WebDAV Collection Sync (rfc6578)',
237            'link' => 'http://sabre.io/dav/sync/',
238        ];
239    }
240}
241