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