1<?php
2
3declare(strict_types=1);
4
5namespace Sabre\DAV;
6
7use UnexpectedValueException;
8
9/**
10 * This class represents a set of properties that are going to be updated.
11 *
12 * Usually this is simply a PROPPATCH request, but it can also be used for
13 * internal updates.
14 *
15 * Property updates must always be atomic. This means that a property update
16 * must either completely succeed, or completely fail.
17 *
18 * @copyright Copyright (C) fruux GmbH (https://fruux.com/)
19 * @author Evert Pot (http://evertpot.com/)
20 * @license http://sabre.io/license/ Modified BSD License
21 */
22class PropPatch
23{
24    /**
25     * Properties that are being updated.
26     *
27     * This is a key-value list. If the value is null, the property is supposed
28     * to be deleted.
29     *
30     * @var array
31     */
32    protected $mutations;
33
34    /**
35     * A list of properties and the result of the update. The result is in the
36     * form of a HTTP status code.
37     *
38     * @var array
39     */
40    protected $result = [];
41
42    /**
43     * This is the list of callbacks when we're performing the actual update.
44     *
45     * @var array
46     */
47    protected $propertyUpdateCallbacks = [];
48
49    /**
50     * This property will be set to true if the operation failed.
51     *
52     * @var bool
53     */
54    protected $failed = false;
55
56    /**
57     * Constructor.
58     *
59     * @param array $mutations A list of updates
60     */
61    public function __construct(array $mutations)
62    {
63        $this->mutations = $mutations;
64    }
65
66    /**
67     * Call this function if you wish to handle updating certain properties.
68     * For instance, your class may be responsible for handling updates for the
69     * {DAV:}displayname property.
70     *
71     * In that case, call this method with the first argument
72     * "{DAV:}displayname" and a second argument that's a method that does the
73     * actual updating.
74     *
75     * It's possible to specify more than one property as an array.
76     *
77     * The callback must return a boolean or an it. If the result is true, the
78     * operation was considered successful. If it's false, it's consided
79     * failed.
80     *
81     * If the result is an integer, we'll use that integer as the http status
82     * code associated with the operation.
83     *
84     * @param string|string[] $properties
85     */
86    public function handle($properties, callable $callback)
87    {
88        $usedProperties = [];
89        foreach ((array) $properties as $propertyName) {
90            if (array_key_exists($propertyName, $this->mutations) && !isset($this->result[$propertyName])) {
91                $usedProperties[] = $propertyName;
92                // HTTP Accepted
93                $this->result[$propertyName] = 202;
94            }
95        }
96
97        // Only registering if there's any unhandled properties.
98        if (!$usedProperties) {
99            return;
100        }
101        $this->propertyUpdateCallbacks[] = [
102            // If the original argument to this method was a string, we need
103            // to also make sure that it stays that way, so the commit function
104            // knows how to format the arguments to the callback.
105            is_string($properties) ? $properties : $usedProperties,
106            $callback,
107        ];
108    }
109
110    /**
111     * Call this function if you wish to handle _all_ properties that haven't
112     * been handled by anything else yet. Note that you effectively claim with
113     * this that you promise to process _all_ properties that are coming in.
114     */
115    public function handleRemaining(callable $callback)
116    {
117        $properties = $this->getRemainingMutations();
118        if (!$properties) {
119            // Nothing to do, don't register callback
120            return;
121        }
122
123        foreach ($properties as $propertyName) {
124            // HTTP Accepted
125            $this->result[$propertyName] = 202;
126
127            $this->propertyUpdateCallbacks[] = [
128                $properties,
129                $callback,
130            ];
131        }
132    }
133
134    /**
135     * Sets the result code for one or more properties.
136     *
137     * @param string|string[] $properties
138     * @param int             $resultCode
139     */
140    public function setResultCode($properties, $resultCode)
141    {
142        foreach ((array) $properties as $propertyName) {
143            $this->result[$propertyName] = $resultCode;
144        }
145
146        if ($resultCode >= 400) {
147            $this->failed = true;
148        }
149    }
150
151    /**
152     * Sets the result code for all properties that did not have a result yet.
153     *
154     * @param int $resultCode
155     */
156    public function setRemainingResultCode($resultCode)
157    {
158        $this->setResultCode(
159            $this->getRemainingMutations(),
160            $resultCode
161        );
162    }
163
164    /**
165     * Returns the list of properties that don't have a result code yet.
166     *
167     * This method returns a list of property names, but not its values.
168     *
169     * @return string[]
170     */
171    public function getRemainingMutations()
172    {
173        $remaining = [];
174        foreach ($this->mutations as $propertyName => $propValue) {
175            if (!isset($this->result[$propertyName])) {
176                $remaining[] = $propertyName;
177            }
178        }
179
180        return $remaining;
181    }
182
183    /**
184     * Returns the list of properties that don't have a result code yet.
185     *
186     * This method returns list of properties and their values.
187     *
188     * @return array
189     */
190    public function getRemainingValues()
191    {
192        $remaining = [];
193        foreach ($this->mutations as $propertyName => $propValue) {
194            if (!isset($this->result[$propertyName])) {
195                $remaining[$propertyName] = $propValue;
196            }
197        }
198
199        return $remaining;
200    }
201
202    /**
203     * Performs the actual update, and calls all callbacks.
204     *
205     * This method returns true or false depending on if the operation was
206     * successful.
207     *
208     * @return bool
209     */
210    public function commit()
211    {
212        // First we validate if every property has a handler
213        foreach ($this->mutations as $propertyName => $value) {
214            if (!isset($this->result[$propertyName])) {
215                $this->failed = true;
216                $this->result[$propertyName] = 403;
217            }
218        }
219
220        foreach ($this->propertyUpdateCallbacks as $callbackInfo) {
221            if ($this->failed) {
222                break;
223            }
224            if (is_string($callbackInfo[0])) {
225                $this->doCallbackSingleProp($callbackInfo[0], $callbackInfo[1]);
226            } else {
227                $this->doCallbackMultiProp($callbackInfo[0], $callbackInfo[1]);
228            }
229        }
230
231        /*
232         * If anywhere in this operation updating a property failed, we must
233         * update all other properties accordingly.
234         */
235        if ($this->failed) {
236            foreach ($this->result as $propertyName => $status) {
237                if (202 === $status) {
238                    // Failed dependency
239                    $this->result[$propertyName] = 424;
240                }
241            }
242        }
243
244        return !$this->failed;
245    }
246
247    /**
248     * Executes a property callback with the single-property syntax.
249     *
250     * @param string $propertyName
251     */
252    private function doCallBackSingleProp($propertyName, callable $callback)
253    {
254        $result = $callback($this->mutations[$propertyName]);
255        if (is_bool($result)) {
256            if ($result) {
257                if (is_null($this->mutations[$propertyName])) {
258                    // Delete
259                    $result = 204;
260                } else {
261                    // Update
262                    $result = 200;
263                }
264            } else {
265                // Fail
266                $result = 403;
267            }
268        }
269        if (!is_int($result)) {
270            throw new UnexpectedValueException('A callback sent to handle() did not return an int or a bool');
271        }
272        $this->result[$propertyName] = $result;
273        if ($result >= 400) {
274            $this->failed = true;
275        }
276    }
277
278    /**
279     * Executes a property callback with the multi-property syntax.
280     */
281    private function doCallBackMultiProp(array $propertyList, callable $callback)
282    {
283        $argument = [];
284        foreach ($propertyList as $propertyName) {
285            $argument[$propertyName] = $this->mutations[$propertyName];
286        }
287
288        $result = $callback($argument);
289
290        if (is_array($result)) {
291            foreach ($propertyList as $propertyName) {
292                if (!isset($result[$propertyName])) {
293                    $resultCode = 500;
294                } else {
295                    $resultCode = $result[$propertyName];
296                }
297                if ($resultCode >= 400) {
298                    $this->failed = true;
299                }
300                $this->result[$propertyName] = $resultCode;
301            }
302        } elseif (true === $result) {
303            // Success
304            foreach ($argument as $propertyName => $propertyValue) {
305                $this->result[$propertyName] = is_null($propertyValue) ? 204 : 200;
306            }
307        } elseif (false === $result) {
308            // Fail :(
309            $this->failed = true;
310            foreach ($propertyList as $propertyName) {
311                $this->result[$propertyName] = 403;
312            }
313        } else {
314            throw new UnexpectedValueException('A callback sent to handle() did not return an array or a bool');
315        }
316    }
317
318    /**
319     * Returns the result of the operation.
320     *
321     * @return array
322     */
323    public function getResult()
324    {
325        return $this->result;
326    }
327
328    /**
329     * Returns the full list of mutations.
330     *
331     * @return array
332     */
333    public function getMutations()
334    {
335        return $this->mutations;
336    }
337}
338