1<?php 2 3namespace Sabre\DAV\PartialUpdate; 4 5use Sabre\DAV; 6use Sabre\HTTP\RequestInterface; 7use Sabre\HTTP\ResponseInterface; 8 9/** 10 * Partial update plugin (Patch method) 11 * 12 * This plugin provides a way to modify only part of a target resource 13 * It may bu used to update a file chunk, upload big a file into smaller 14 * chunks or resume an upload. 15 * 16 * $patchPlugin = new \Sabre\DAV\PartialUpdate\Plugin(); 17 * $server->addPlugin($patchPlugin); 18 * 19 * @copyright Copyright (C) fruux GmbH (https://fruux.com/) 20 * @author Jean-Tiare LE BIGOT (http://www.jtlebi.fr/) 21 * @license http://sabre.io/license/ Modified BSD License 22 */ 23class Plugin extends DAV\ServerPlugin { 24 25 const RANGE_APPEND = 1; 26 const RANGE_START = 2; 27 const RANGE_END = 3; 28 29 /** 30 * Reference to server 31 * 32 * @var DAV\Server 33 */ 34 protected $server; 35 36 /** 37 * Initializes the plugin 38 * 39 * This method is automatically called by the Server class after addPlugin. 40 * 41 * @param DAV\Server $server 42 * @return void 43 */ 44 function initialize(DAV\Server $server) { 45 46 $this->server = $server; 47 $server->on('method:PATCH', [$this, 'httpPatch']); 48 49 } 50 51 /** 52 * Returns a plugin name. 53 * 54 * Using this name other plugins will be able to access other plugins 55 * using DAV\Server::getPlugin 56 * 57 * @return string 58 */ 59 function getPluginName() { 60 61 return 'partialupdate'; 62 63 } 64 65 /** 66 * Use this method to tell the server this plugin defines additional 67 * HTTP methods. 68 * 69 * This method is passed a uri. It should only return HTTP methods that are 70 * available for the specified uri. 71 * 72 * We claim to support PATCH method (partirl update) if and only if 73 * - the node exist 74 * - the node implements our partial update interface 75 * 76 * @param string $uri 77 * @return array 78 */ 79 function getHTTPMethods($uri) { 80 81 $tree = $this->server->tree; 82 83 if ($tree->nodeExists($uri)) { 84 $node = $tree->getNodeForPath($uri); 85 if ($node instanceof IPatchSupport) { 86 return ['PATCH']; 87 } 88 } 89 return []; 90 91 } 92 93 /** 94 * Returns a list of features for the HTTP OPTIONS Dav: header. 95 * 96 * @return array 97 */ 98 function getFeatures() { 99 100 return ['sabredav-partialupdate']; 101 102 } 103 104 /** 105 * Patch an uri 106 * 107 * The WebDAV patch request can be used to modify only a part of an 108 * existing resource. If the resource does not exist yet and the first 109 * offset is not 0, the request fails 110 * 111 * @param RequestInterface $request 112 * @param ResponseInterface $response 113 * @return void 114 */ 115 function httpPatch(RequestInterface $request, ResponseInterface $response) { 116 117 $path = $request->getPath(); 118 119 // Get the node. Will throw a 404 if not found 120 $node = $this->server->tree->getNodeForPath($path); 121 if (!$node instanceof IPatchSupport) { 122 throw new DAV\Exception\MethodNotAllowed('The target resource does not support the PATCH method.'); 123 } 124 125 $range = $this->getHTTPUpdateRange($request); 126 127 if (!$range) { 128 throw new DAV\Exception\BadRequest('No valid "X-Update-Range" found in the headers'); 129 } 130 131 $contentType = strtolower( 132 $request->getHeader('Content-Type') 133 ); 134 135 if ($contentType != 'application/x-sabredav-partialupdate') { 136 throw new DAV\Exception\UnsupportedMediaType('Unknown Content-Type header "' . $contentType . '"'); 137 } 138 139 $len = $this->server->httpRequest->getHeader('Content-Length'); 140 if (!$len) throw new DAV\Exception\LengthRequired('A Content-Length header is required'); 141 142 switch ($range[0]) { 143 case self::RANGE_START : 144 // Calculate the end-range if it doesn't exist. 145 if (!$range[2]) { 146 $range[2] = $range[1] + $len - 1; 147 } else { 148 if ($range[2] < $range[1]) { 149 throw new DAV\Exception\RequestedRangeNotSatisfiable('The end offset (' . $range[2] . ') is lower than the start offset (' . $range[1] . ')'); 150 } 151 if ($range[2] - $range[1] + 1 != $len) { 152 throw new DAV\Exception\RequestedRangeNotSatisfiable('Actual data length (' . $len . ') is not consistent with begin (' . $range[1] . ') and end (' . $range[2] . ') offsets'); 153 } 154 } 155 break; 156 } 157 158 if (!$this->server->emit('beforeWriteContent', [$path, $node, null])) 159 return; 160 161 $body = $this->server->httpRequest->getBody(); 162 163 164 $etag = $node->patch($body, $range[0], isset($range[1]) ? $range[1] : null); 165 166 $this->server->emit('afterWriteContent', [$path, $node]); 167 168 $response->setHeader('Content-Length', '0'); 169 if ($etag) $response->setHeader('ETag', $etag); 170 $response->setStatus(204); 171 172 // Breaks the event chain 173 return false; 174 175 } 176 177 /** 178 * Returns the HTTP custom range update header 179 * 180 * This method returns null if there is no well-formed HTTP range request 181 * header. It returns array(1) if it was an append request, array(2, 182 * $start, $end) if it's a start and end range, lastly it's array(3, 183 * $endoffset) if the offset was negative, and should be calculated from 184 * the end of the file. 185 * 186 * Examples: 187 * 188 * null - invalid 189 * [1] - append 190 * [2,10,15] - update bytes 10, 11, 12, 13, 14, 15 191 * [2,10,null] - update bytes 10 until the end of the patch body 192 * [3,-5] - update from 5 bytes from the end of the file. 193 * 194 * @param RequestInterface $request 195 * @return array|null 196 */ 197 function getHTTPUpdateRange(RequestInterface $request) { 198 199 $range = $request->getHeader('X-Update-Range'); 200 if (is_null($range)) return null; 201 202 // Matching "Range: bytes=1234-5678: both numbers are optional 203 204 if (!preg_match('/^(append)|(?:bytes=([0-9]+)-([0-9]*))|(?:bytes=(-[0-9]+))$/i', $range, $matches)) return null; 205 206 if ($matches[1] === 'append') { 207 return [self::RANGE_APPEND]; 208 } elseif (strlen($matches[2]) > 0) { 209 return [self::RANGE_START, $matches[2], $matches[3] ?: null]; 210 } else { 211 return [self::RANGE_END, $matches[4]]; 212 } 213 214 } 215} 216