RecordOriginConstraint::addProperties()   B
last analyzed

Complexity

Conditions 6
Paths 2

Size

Total Lines 25

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 42

Importance

Changes 0
Metric Value
dl 0
loc 25
ccs 0
cts 23
cp 0
rs 8.8977
c 0
b 0
f 0
cc 6
nc 2
nop 2
crap 42
1
<?php
2
/**
3
 * Schema constraint that validates the rules of recordOrigin (and possible exceptions)
4
 */
5
6
namespace Graviton\SchemaBundle\Constraint;
7
8
use Graviton\JsonSchemaBundle\Validator\Constraint\Event\ConstraintEventSchema;
9
use Symfony\Component\PropertyAccess\PropertyAccess;
10
use JsonSchema\Rfc3339;
11
12
/**
13
 * @author   List of contributors <https://github.com/libgraviton/graviton/graphs/contributors>
14
 * @license  https://opensource.org/licenses/MIT MIT License
15
 * @link     http://swisscom.ch
16
 */
17
class RecordOriginConstraint
18
{
19
20
    /**
21
     * @var string
22
     */
23
    private $recordOriginField;
24
25
    /**
26
     * @var array
27
     */
28
    private $recordOriginBlacklist;
29
30
    /**
31
     * @var array
32
     */
33
    private $exceptionFieldMap;
34
35
    /**
36
     * @var array
37
     */
38
    private $changedObjectPaths = [];
39
40
    /**
41
     * RecordOriginConstraint constructor.
42
     *
43
     * @param ConstraintUtils $utils                 Utils
44
     * @param string          $recordOriginField     name of the recordOrigin field
45
     * @param array           $recordOriginBlacklist list of recordOrigin values that cannot be modified
46
     * @param array           $exceptionFieldMap     field map from compiler pass with excluded fields
47
     */
48
    public function __construct(
49
        ConstraintUtils $utils,
50
        $recordOriginField,
51
        array $recordOriginBlacklist,
52
        array $exceptionFieldMap
53
    ) {
54
        $this->utils = $utils;
55
        $this->recordOriginField = $recordOriginField;
56
        $this->recordOriginBlacklist = $recordOriginBlacklist;
57
        $this->exceptionFieldMap = $exceptionFieldMap;
58
    }
59
60
    /**
61
     * Checks the recordOrigin rules and sets error in event if needed
62
     *
63
     * @param ConstraintEventSchema $event event class
64
     *
65
     * @return void
66
     */
67
    public function checkRecordOrigin(ConstraintEventSchema $event)
68
    {
69
        $currentRecord = $this->utils->getCurrentEntity([$this->recordOriginField]);
70
        $data = $event->getElement();
71
72
        // if no recordorigin set on saved record; we let it through
73
        if (is_null($currentRecord) || !isset($currentRecord->{$this->recordOriginField})) {
74
            // we have no current record.. but make sure user doesn't want to send the banned recordOrigin
75
            if (isset($data->{$this->recordOriginField}) &&
76
                !is_null($data->{$this->recordOriginField}) &&
77
                in_array($data->{$this->recordOriginField}, $this->recordOriginBlacklist)
78
            ) {
79
                $event->addError(
80
                    sprintf(
81
                        'Creating documents with the %s field having a value of %s is not permitted.',
82
                        $this->recordOriginField,
83
                        implode(', ', $this->recordOriginBlacklist)
84
                    ),
85
                    $this->recordOriginField
86
                );
87
                return;
88
            }
89
90
            return;
91
        }
92
93
        $recordOrigin = $currentRecord->{$this->recordOriginField};
94
95
        // not in the blacklist? can also go through..
96
        if (!in_array($recordOrigin, $this->recordOriginBlacklist)) {
97
            return;
98
        }
99
100
        // ok, user is trying to modify an object with blacklist recordorigin.. let's check fields
101
        $schema = $event->getSchema();
102
        $isAllowed = true;
103
104
        if (!isset($schema->{'x-documentClass'})) {
105
            // this should never happen but we need to check. if schema has no information to *check* our rules, we
106
            // MUST deny it in that case..
107
            $event->addError(
108
                'Internal error, not enough schema information to validate recordOrigin rules.',
109
                $this->recordOriginField
110
            );
111
            return;
112
        }
113
114
        $documentClass = $schema->{'x-documentClass'};
115
116
        if (!isset($this->exceptionFieldMap[$documentClass])) {
117
            // if he wants to edit on blacklist, but we have no exceptions, also deny..
118
            $isAllowed = false;
119
        } else {
120
            // so to check our exceptions, we remove it from both documents (the stored and the clients) and compare
121
            $exceptions = $this->exceptionFieldMap[$documentClass];
122
123
            $accessor = PropertyAccess::createPropertyAccessorBuilder()
124
                ->enableMagicCall()
125
                ->getPropertyAccessor();
126
127
            // now really get the whole object
128
            $storedObject = $this->utils->getCurrentEntity();
129
            $userObject = clone $data;
130
131
            // convert all datetimes to UTC so we compare eggs with eggs
132
            $userObject = $this->convertDatetimeToUTC($userObject, $schema, new \DateTimeZone('UTC'));
133
            $storedObject = $this->convertDatetimeToUTC($storedObject, $schema, new \DateTimeZone('UTC'));
134
135
            foreach ($exceptions as $fieldName) {
136 View Code Duplication
                if ($accessor->isWritable($storedObject, $fieldName)) {
137
                    $accessor->setValue($storedObject, $fieldName, null);
138
                } else {
139
                    $this->addProperties($fieldName, $storedObject);
140
                }
141 View Code Duplication
                if ($accessor->isWritable($userObject, $fieldName)) {
142
                    $accessor->setValue($userObject, $fieldName, null);
143
                } else {
144
                    $this->addProperties($fieldName, $userObject);
145
                }
146
            }
147
148
            // so now all unimportant fields were set to null on both - they should match if rest is untouched ;-)
149
            if ($userObject != $storedObject) {
150
                $isAllowed = false;
151
                $this->changedObjectPaths = [];
152
                $this->getChangedObjectPaths($userObject, $storedObject);
153
                $this->getChangedObjectPaths($storedObject, $userObject);
154
                $this->changedObjectPaths = array_keys($this->changedObjectPaths);
155
            }
156
        }
157
158
        if (!$isAllowed) {
159
            $error = sprintf(
160
                'Prohibited modification attempt on record with %s of %s.',
161
                $this->recordOriginField,
162
                implode(', ', $this->recordOriginBlacklist)
163
            );
164
            // if there are recordCoreExceptions we can be more explicit
165
            if (isset($this->exceptionFieldMap[$documentClass]) && !empty($this->changedObjectPaths)) {
166
                $error.= sprintf(
167
                    ' You tried to change (%s), but you can only change (%s) by recordOriginException.',
168
                    implode(', ', $this->changedObjectPaths),
169
                    implode(', ', $this->exceptionFieldMap[$documentClass])
170
                );
171
            }
172
            $event->addError($error, $this->recordOriginField);
173
        }
174
175
        return;
176
    }
177
178
    /**
179
     * Recursive convert date time to UTC
180
     * @param object        $object   Form data to be verified
181
     * @param object        $schema   Entity schema
182
     * @param \DateTimeZone $timezone to be converted to
183
     * @return object
184
     */
185
    private function convertDatetimeToUTC($object, $schema, \DateTimeZone $timezone)
186
    {
187
        foreach ($schema->properties as $field => $property) {
188
            if (isset($property->format) && $property->format == 'date-time' && isset($object->{$field})) {
189
                $dateTime = Rfc3339::createFromString($object->{$field});
190
                $dateTime->setTimezone($timezone);
191
                $object->{$field} = $dateTime->format(\DateTime::ISO8601);
192
            } elseif (isset($property->properties) && isset($object->{$field})) {
193
                $object->{$field} = $this->convertDatetimeToUTC($object->{$field}, $property, $timezone);
194
            }
195
        }
196
        return $object;
197
    }
198
199
    /**
200
     * recursive helperfunction that walks through two arrays/objects of the same structure,
201
     * compares the values and writes the paths containining differences into the $this->changedObjectPaths
202
     *
203
     * @param mixed $object  the first of the datastructures to compare
204
     * @param mixed $compare the second of the datastructures to compare
205
     * @param array $path    array of current path
206
     * @return void
207
     */
208
    private function getChangedObjectPaths($object, $compare, $path = [])
209
    {
210
        $compare = (array) $compare;
211
        $object = (array) $object;
212
        foreach ($object as $fieldName => $value) {
213
            $path[] = $fieldName;
214
            if ((is_object($value) || is_array($value)) && array_key_exists($fieldName, $compare)) {
215
                $this->getChangedObjectPaths($value, $compare[$fieldName], $path);
216
            } elseif (!array_key_exists($fieldName, $compare) || $value!=$compare[$fieldName]) {
217
                $this->changedObjectPaths[implode('.', $path)] = $value;
218
            }
219
            array_pop($path);
220
        }
221
    }
222
223
    /**
224
     * if the user provides properties that are in the exception list but not on the currently saved
225
     * object, we try here to synthetically add them to our representation. and yes, this won't support
226
     * exclusions in an array structure for the moment, but that is also not needed for now.
227
     *
228
     * @param string $expression the expression
229
     * @param object $obj        the object
230
     *
231
     * @return object the modified object
232
     */
233
    private function addProperties($expression, $obj)
234
    {
235
        $val = &$obj;
236
        $parts = explode('.', $expression);
237
        $numParts = count($parts);
238
239
        if ($numParts == 1) {
240
            $val->{$parts[0]} = null;
241
        } else {
242
            $iteration = 1;
243
            foreach ($parts as $part) {
244
                if ($iteration < $numParts) {
245
                    if (!isset($val->{$part}) || !is_object($val->{$part})) {
246
                        $val->{$part} = new \stdClass();
247
                    }
248
                    $val = &$val->{$part};
249
                } else {
250
                    $val->{$part} = null;
251
                }
252
                $iteration++;
253
            }
254
        }
255
256
        return $val;
257
    }
258
}
259