Completed
Push — master ( f4a0a7...1f0805 )
by
unknown
16:09
created

RecordOriginConstraint::convertDatetimeToUTC()   B

Complexity

Conditions 7
Paths 4

Size

Total Lines 13
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 56

Importance

Changes 0
Metric Value
dl 0
loc 13
ccs 0
cts 13
cp 0
rs 8.2222
c 0
b 0
f 0
cc 7
eloc 9
nc 4
nop 3
crap 56
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  http://opensource.org/licenses/gpl-license.php GNU Public 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();
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
            $storedObject = clone $currentRecord;
128
            $userObject = clone $data;
129
130
            // convert all datetimes to UTC so we compare eggs with eggs
131
            $userObject = $this->convertDatetimeToUTC($userObject, $schema, new \DateTimeZone('UTC'));
132
133
            foreach ($exceptions as $fieldName) {
134 View Code Duplication
                if ($accessor->isWritable($storedObject, $fieldName)) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
135
                    $accessor->setValue($storedObject, $fieldName, null);
136
                } else {
137
                    $this->addProperties($fieldName, $storedObject);
138
                }
139 View Code Duplication
                if ($accessor->isWritable($userObject, $fieldName)) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
140
                    $accessor->setValue($userObject, $fieldName, null);
141
                } else {
142
                    $this->addProperties($fieldName, $userObject);
143
                }
144
            }
145
146
            // so now all unimportant fields were set to null on both - they should match if rest is untouched ;-)
147
            if ($userObject != $storedObject) {
148
                $isAllowed = false;
149
                $this->getChangedObjectPaths($userObject, $storedObject);
150
            }
151
        }
152
153
        if (!$isAllowed) {
154
            $error = sprintf(
155
                'Prohibited modification attempt on record with %s of %s.',
156
                $this->recordOriginField,
157
                implode(', ', $this->recordOriginBlacklist)
158
            );
159
            // if there are recordCoreExceptions we can be more explicit
160
            if (isset($this->exceptionFieldMap[$documentClass]) && !empty($this->changedObjectPaths)) {
161
                $error.= sprintf(
162
                    ' You tried to change (%s), but you can only change (%s) by recordOriginException.',
163
                    implode(', ', $this->changedObjectPaths),
164
                    implode(', ', $this->exceptionFieldMap[$documentClass])
165
                );
166
            }
167
            $event->addError($error, $this->recordOriginField);
168
        }
169
170
        return;
171
    }
172
173
    /**
174
     * Recursive convert date time to UTC
175
     * @param object        $object   Form data to be verified
176
     * @param object        $schema   Entity schema
177
     * @param \DateTimeZone $timezone to be converted to
178
     * @return object
179
     */
180
    private function convertDatetimeToUTC($object, $schema, \DateTimeZone $timezone)
181
    {
182
        foreach ($schema->properties as $field => $property) {
183
            if (isset($property->format) && $property->format == 'date-time' && isset($object->{$field})) {
184
                $dateTime = Rfc3339::createFromString($object->{$field});
185
                $dateTime->setTimezone($timezone);
186
                $object->{$field} = $dateTime->format(\DateTime::ISO8601);
187
            } elseif (isset($property->properties) && isset($object->{$field})) {
188
                $object->{$field} = $this->convertDatetimeToUTC($object->{$field}, $property, $timezone);
189
            }
190
        }
191
        return $object;
192
    }
193
194
    /**
195
     * recursive helperfunction that walks through two arrays/objects of the same structure,
196
     * compares the values and writes the paths containining differences into the $this->changedObjectPaths
197
     *
198
     * @param   mixed $object        the first of the datastructures to compare
199
     * @param   mixed $compareObject the second of the datastructures to compare
200
     * @param   array $path          the array holding the current path
201
     * @return  array                returning the child nodes in an array
202
     */
203
    private function getChangedObjectPaths($object, $compareObject, $path = [])
204
    {
205
        $return = [];
206
        foreach ($object as $fieldName => $value) {
207
            if (is_object($compareObject)) {
208
                $compareValue = $compareObject->$fieldName;
209
            } else {
210
                $compareValue = $compareObject[$fieldName];
211
            }
212
            $path[]=$fieldName;
213
            if (is_object($value) || is_array($value)) {
214
                $return[$fieldName] = $this->getChangedObjectPaths($value, $compareValue, $path);
215
            } else {
216
                if ($value!=$compareValue) {
217
                    $this->changedObjectPaths[] = implode('.', $path);
218
                }
219
            }
220
            array_pop($path);
221
        }
222
        return $return;
223
    }
224
225
    /**
226
     * if the user provides properties that are in the exception list but not on the currently saved
227
     * object, we try here to synthetically add them to our representation. and yes, this won't support
228
     * exclusions in an array structure for the moment, but that is also not needed for now.
229
     *
230
     * @param string $expression the expression
231
     * @param object $obj        the object
232
     *
233
     * @return object the modified object
234
     */
235
    private function addProperties($expression, $obj)
236
    {
237
        $val = &$obj;
238
        $parts = explode('.', $expression);
239
        $numParts = count($parts);
240
241
        if ($numParts == 1) {
242
            $val->{$parts[0]} = null;
243
        } else {
244
            $iteration = 1;
245
            foreach ($parts as $part) {
246
                if ($iteration < $numParts) {
247
                    if (!isset($val->{$part}) || !is_object($val->{$part})) {
248
                        $val->{$part} = new \stdClass();
249
                    }
250
                    $val = &$val->{$part};
251
                } else {
252
                    $val->{$part} = null;
253
                }
254
                $iteration++;
255
            }
256
        }
257
258
        return $val;
259
    }
260
}
261