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