Completed
Push — 4.0 ( b59aea...80f83b )
by Loz
52s queued 21s
created

InheritedPermissions::setGlobalEditPermissions()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 2
nc 1
nop 1
dl 0
loc 4
rs 10
c 0
b 0
f 0
1
<?php
2
3
namespace SilverStripe\Security;
4
5
use InvalidArgumentException;
6
use SilverStripe\Core\Injector\Injectable;
7
use SilverStripe\ORM\DataList;
8
use SilverStripe\ORM\DataObject;
9
use SilverStripe\ORM\Hierarchy\Hierarchy;
10
use SilverStripe\Versioned\Versioned;
11
12
/**
13
 * Calculates batch permissions for nested objects for:
14
 *  - canView: Supports 'Anyone' type
15
 *  - canEdit
16
 *  - canDelete: Includes special logic for ensuring parent objects can only be deleted if their children can
17
 *    be deleted also.
18
 */
19
class InheritedPermissions implements PermissionChecker
20
{
21
    use Injectable;
22
23
    /**
24
     * Delete permission
25
     */
26
    const DELETE = 'delete';
27
28
    /**
29
     * View permission
30
     */
31
    const VIEW = 'view';
32
33
    /**
34
     * Edit permission
35
     */
36
    const EDIT = 'edit';
37
38
    /**
39
     * Anyone canView permission
40
     */
41
    const ANYONE = 'Anyone';
42
43
    /**
44
     * Restrict to logged in users
45
     */
46
    const LOGGED_IN_USERS = 'LoggedInUsers';
47
48
    /**
49
     * Restrict to specific groups
50
     */
51
    const ONLY_THESE_USERS = 'OnlyTheseUsers';
52
53
    /**
54
     * Inherit from parent
55
     */
56
    const INHERIT = 'Inherit';
57
58
    /**
59
     * Class name
60
     *
61
     * @var string
62
     */
63
    protected $baseClass = null;
64
65
    /**
66
     * Object for evaluating top level permissions designed as "Inherit"
67
     *
68
     * @var DefaultPermissionChecker
69
     */
70
    protected $defaultPermissions = null;
71
72
    /**
73
     * Global permissions required to edit.
74
     * If empty no global permissions are required
75
     *
76
     * @var array
77
     */
78
    protected $globalEditPermissions = [];
79
80
    /**
81
     * Cache of permissions
82
     *
83
     * @var array
84
     */
85
    protected $cachePermissions = [];
86
87
    /**
88
     * Construct new permissions object
89
     *
90
     * @param string $baseClass Base class
91
     */
92
    public function __construct($baseClass)
93
    {
94
        if (!is_a($baseClass, DataObject::class, true)) {
95
            throw new InvalidArgumentException('Invalid DataObject class: ' . $baseClass);
96
        }
97
        $this->baseClass = $baseClass;
98
        return $this;
99
    }
100
101
    /**
102
     * @param DefaultPermissionChecker $callback
103
     * @return $this
104
     */
105
    public function setDefaultPermissions(DefaultPermissionChecker $callback)
106
    {
107
        $this->defaultPermissions = $callback;
108
        return $this;
109
    }
110
111
    /**
112
     * Global permissions required to edit
113
     *
114
     * @param array $permissions
115
     * @return $this
116
     */
117
    public function setGlobalEditPermissions($permissions)
118
    {
119
        $this->globalEditPermissions = $permissions;
120
        return $this;
121
    }
122
123
    /**
124
     * @return array
125
     */
126
    public function getGlobalEditPermissions()
127
    {
128
        return $this->globalEditPermissions;
129
    }
130
131
    /**
132
     * Get root permissions handler, or null if no handler
133
     *
134
     * @return DefaultPermissionChecker|null
135
     */
136
    public function getDefaultPermissions()
137
    {
138
        return $this->defaultPermissions;
139
    }
140
141
    /**
142
     * Get base class
143
     *
144
     * @return string
145
     */
146
    public function getBaseClass()
147
    {
148
        return $this->baseClass;
149
    }
150
151
    /**
152
     * Force pre-calculation of a list of permissions for optimisation
153
     *
154
     * @param string $permission
155
     * @param array $ids
156
     */
157
    public function prePopulatePermissionCache($permission = 'edit', $ids = [])
158
    {
159
        switch ($permission) {
160
            case self::EDIT:
161
                $this->canEditMultiple($ids, Security::getCurrentUser(), false);
162
                break;
163
            case self::VIEW:
164
                $this->canViewMultiple($ids, Security::getCurrentUser(), false);
165
                break;
166
            case self::DELETE:
167
                $this->canDeleteMultiple($ids, Security::getCurrentUser(), false);
168
                break;
169
            default:
170
                throw new InvalidArgumentException("Invalid permission type $permission");
171
        }
172
    }
173
174
    /**
175
     * This method is NOT a full replacement for the individual can*() methods, e.g. {@link canEdit()}. Rather than
176
     * checking (potentially slow) PHP logic, it relies on the database group associations, e.g. the "CanEditType" field
177
     * plus the "SiteTree_EditorGroups" many-many table. By batch checking multiple records, we can combine the queries
178
     * efficiently.
179
     *
180
     * Caches based on $typeField data. To invalidate the cache, use {@link SiteTree::reset()} or set the $useCached
181
     * property to FALSE.
182
     *
183
     * @param string $type Either edit, view, or create
184
     * @param array $ids Array of IDs
185
     * @param Member $member Member
186
     * @param array $globalPermission If the member doesn't have this permission code, don't bother iterating deeper
187
     * @param bool $useCached Enables use of cache. Cache will be populated even if this is false.
188
     * @return array A map of permissions, keys are ID numbers, and values are boolean permission checks
189
     * ID keys to boolean values
190
     */
191
    protected function batchPermissionCheck(
192
        $type,
193
        $ids,
194
        Member $member = null,
195
        $globalPermission = [],
196
        $useCached = true
197
    ) {
198
        // Validate ids
199
        $ids = array_filter($ids, 'is_numeric');
200
        if (empty($ids)) {
201
            return [];
202
        }
203
204
        // Default result: nothing editable
205
        $result = array_fill_keys($ids, false);
206
207
        // Validate member permission
208
        // Only VIEW allows anonymous (Anyone) permissions
209
        $memberID = $member ? (int)$member->ID : 0;
210
        if (!$memberID && $type !== self::VIEW) {
211
            return $result;
212
        }
213
214
        // Look in the cache for values
215
        $cacheKey = "{$type}-{$memberID}";
216
        if ($useCached && isset($this->cachePermissions[$cacheKey])) {
217
            $cachedValues = array_intersect_key($this->cachePermissions[$cacheKey], $result);
218
219
            // If we can't find everything in the cache, then look up the remainder separately
220
            $uncachedIDs = array_keys(array_diff_key($result, $this->cachePermissions[$cacheKey]));
221
            if ($uncachedIDs) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $uncachedIDs of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
222
                $uncachedValues = $this->batchPermissionCheck($type, $uncachedIDs, $member, $globalPermission, false);
223
                return $cachedValues + $uncachedValues;
224
            }
225
            return $cachedValues;
226
        }
227
228
        // If a member doesn't have a certain permission then they can't edit anything
229
        if ($globalPermission && !Permission::checkMember($member, $globalPermission)) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $globalPermission of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
230
            return $result;
231
        }
232
233
        // Get the groups that the given member belongs to
234
        $groupIDsSQLList = '0';
235
        if ($memberID) {
236
            $groupIDs = $member->Groups()->column("ID");
0 ignored issues
show
Bug introduced by
The method Groups() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

236
            $groupIDs = $member->/** @scrutinizer ignore-call */ Groups()->column("ID");

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
237
            $groupIDsSQLList = implode(", ", $groupIDs) ?: '0';
238
        }
239
240
        // Check if record is versioned
241
        if ($this->isVersioned()) {
242
            // Check all records for each stage and merge
243
            $combinedStageResult = [];
244
            foreach ([ Versioned::DRAFT, Versioned::LIVE ] as $stage) {
245
                $stageRecords = Versioned::get_by_stage($this->getBaseClass(), $stage)
246
                    ->byIDs($ids);
247
                // Exclude previously calculated records from later stage calculations
248
                if ($combinedStageResult) {
249
                    $stageRecords = $stageRecords->exclude('ID', array_keys($combinedStageResult));
250
                }
251
                $stageResult = $this->batchPermissionCheckForStage(
252
                    $type,
253
                    $globalPermission,
254
                    $stageRecords,
255
                    $groupIDsSQLList,
256
                    $member
257
                );
258
                // Note: Draft stage takes precedence over live, but only if draft exists
259
                $combinedStageResult = $combinedStageResult + $stageResult;
260
            }
261
        } else {
262
            // Unstaged result
263
            $stageRecords = DataObject::get($this->getBaseClass())->byIDs($ids);
264
            $combinedStageResult = $this->batchPermissionCheckForStage(
265
                $type,
266
                $globalPermission,
267
                $stageRecords,
268
                $groupIDsSQLList,
269
                $member
270
            );
271
        }
272
273
        // Cache the results
274
        if (empty($this->cachePermissions[$cacheKey])) {
275
            $this->cachePermissions[$cacheKey] = [];
276
        }
277
        if ($combinedStageResult) {
278
            $this->cachePermissions[$cacheKey] = $combinedStageResult + $this->cachePermissions[$cacheKey];
279
        }
280
        return $combinedStageResult;
281
    }
282
283
    /**
284
     * @param string $type
285
     * @param array $globalPermission List of global permissions
286
     * @param DataList $stageRecords List of records to check for this stage
287
     * @param string $groupIDsSQLList Group IDs this member belongs to
288
     * @param Member $member
289
     * @return array
290
     */
291
    protected function batchPermissionCheckForStage(
292
        $type,
293
        $globalPermission,
294
        DataList $stageRecords,
295
        $groupIDsSQLList,
296
        Member $member = null
297
    ) {
298
        // Initialise all IDs to false
299
        $result = array_fill_keys($stageRecords->column('ID'), false);
300
301
        // Get the uninherited permissions
302
        $typeField = $this->getPermissionField($type);
303
        if ($member && $member->ID) {
304
            // Determine if this member matches any of the group or other rules
305
            $groupJoinTable = $this->getJoinTable($type);
306
            $baseTable = DataObject::getSchema()->baseDataTable($this->getBaseClass());
307
            $uninheritedPermissions = $stageRecords
308
                ->where([
309
                    "(\"$typeField\" IN (?, ?) OR " . "(\"$typeField\" = ? AND \"$groupJoinTable\".\"{$baseTable}ID\" IS NOT NULL))"
310
                    => [
311
                        self::ANYONE,
312
                        self::LOGGED_IN_USERS,
313
                        self::ONLY_THESE_USERS
314
                    ]
315
                ])
316
                ->leftJoin(
317
                    $groupJoinTable,
318
                    "\"$groupJoinTable\".\"{$baseTable}ID\" = \"{$baseTable}\".\"ID\" AND " . "\"$groupJoinTable\".\"GroupID\" IN ($groupIDsSQLList)"
319
                )->column('ID');
320
        } else {
321
            // Only view pages with ViewType = Anyone if not logged in
322
            $uninheritedPermissions = $stageRecords
323
                ->filter($typeField, self::ANYONE)
324
                ->column('ID');
325
        }
326
327
        if ($uninheritedPermissions) {
328
            // Set all the relevant items in $result to true
329
            $result = array_fill_keys($uninheritedPermissions, true) + $result;
330
        }
331
332
        // Group $potentiallyInherited by ParentID; we'll look at the permission of all those parents and
333
        // then see which ones the user has permission on
334
        $groupedByParent = [];
335
        $potentiallyInherited = $stageRecords->filter($typeField, self::INHERIT);
336
        foreach ($potentiallyInherited as $item) {
337
            /** @var DataObject|Hierarchy $item */
338
            if ($item->ParentID) {
0 ignored issues
show
Bug Best Practice introduced by
The property ParentID does not exist on SilverStripe\ORM\DataObject. Since you implemented __get, consider adding a @property annotation.
Loading history...
339
                if (!isset($groupedByParent[$item->ParentID])) {
340
                    $groupedByParent[$item->ParentID] = [];
341
                }
342
                $groupedByParent[$item->ParentID][] = $item->ID;
0 ignored issues
show
Bug introduced by
The property ID does not seem to exist on SilverStripe\ORM\Hierarchy\Hierarchy.
Loading history...
343
            } else {
344
                // Fail over to default permission check for Inherit and ParentID = 0
345
                $result[$item->ID] = $this->checkDefaultPermissions($type, $member);
346
            }
347
        }
348
349
        // Copy permissions from parent to child
350
        if ($groupedByParent) {
351
            $actuallyInherited = $this->batchPermissionCheck(
352
                $type,
353
                array_keys($groupedByParent),
354
                $member,
355
                $globalPermission
356
            );
357
            if ($actuallyInherited) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $actuallyInherited of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
358
                $parentIDs = array_keys(array_filter($actuallyInherited));
359
                foreach ($parentIDs as $parentID) {
360
                    // Set all the relevant items in $result to true
361
                    $result = array_fill_keys($groupedByParent[$parentID], true) + $result;
362
                }
363
            }
364
        }
365
        return $result;
366
    }
367
368
    public function canEditMultiple($ids, Member $member = null, $useCached = true)
369
    {
370
        return $this->batchPermissionCheck(
371
            self::EDIT,
372
            $ids,
373
            $member,
374
            $this->getGlobalEditPermissions(),
375
            $useCached
376
        );
377
    }
378
379
    public function canViewMultiple($ids, Member $member = null, $useCached = true)
380
    {
381
        return $this->batchPermissionCheck(self::VIEW, $ids, $member, [], $useCached);
382
    }
383
384
    public function canDeleteMultiple($ids, Member $member = null, $useCached = true)
385
    {
386
        // Validate ids
387
        $ids = array_filter($ids, 'is_numeric');
388
        if (empty($ids)) {
389
            return [];
390
        }
391
        $result = array_fill_keys($ids, false);
392
393
        // Validate member permission
394
        if (!$member || !$member->ID) {
395
            return $result;
396
        }
397
        $deletable = [];
398
399
        // Look in the cache for values
400
        $cacheKey = "delete-{$member->ID}";
401
        if ($useCached && isset($this->cachePermissions[$cacheKey])) {
402
            $cachedValues = array_intersect_key($this->cachePermissions[$cacheKey], $result);
403
404
            // If we can't find everything in the cache, then look up the remainder separately
405
            $uncachedIDs = array_keys(array_diff_key($result, $this->cachePermissions[$cacheKey]));
406
            if ($uncachedIDs) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $uncachedIDs of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
407
                $uncachedValues = $this->canDeleteMultiple($uncachedIDs, $member, false);
408
                return $cachedValues + $uncachedValues;
409
            }
410
            return $cachedValues;
411
        }
412
413
        // You can only delete pages that you can edit
414
        $editableIDs = array_keys(array_filter($this->canEditMultiple($ids, $member)));
415
        if ($editableIDs) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $editableIDs of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
416
            // You can only delete pages whose children you can delete
417
            $childRecords = DataObject::get($this->baseClass)
418
                ->filter('ParentID', $editableIDs);
419
420
            // Find out the children that can be deleted
421
            $children = $childRecords->map("ID", "ParentID");
422
            $childIDs = $children->keys();
423
            if ($childIDs) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $childIDs of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
424
                $deletableChildren = $this->canDeleteMultiple($childIDs, $member);
425
426
                // Get a list of all the parents that have no undeletable children
427
                $deletableParents = array_fill_keys($editableIDs, true);
428
                foreach ($deletableChildren as $id => $canDelete) {
429
                    if (!$canDelete) {
430
                        unset($deletableParents[$children[$id]]);
431
                    }
432
                }
433
434
                // Use that to filter the list of deletable parents that have children
435
                $deletableParents = array_keys($deletableParents);
436
437
                // Also get the $ids that don't have children
438
                $parents = array_unique($children->values());
439
                $deletableLeafNodes = array_diff($editableIDs, $parents);
440
441
                // Combine the two
442
                $deletable = array_merge($deletableParents, $deletableLeafNodes);
443
            } else {
444
                $deletable = $editableIDs;
445
            }
446
        }
447
448
        // Convert the array of deletable IDs into a map of the original IDs with true/false as the value
449
        return array_fill_keys($deletable, true) + array_fill_keys($ids, false);
450
    }
451
452
    public function canDelete($id, Member $member = null)
453
    {
454
        // No ID: Check default permission
455
        if (!$id) {
456
            return $this->checkDefaultPermissions(self::DELETE, $member);
457
        }
458
459
        // Regular canEdit logic is handled by canEditMultiple
460
        $results = $this->canDeleteMultiple(
461
            [ $id ],
462
            $member
463
        );
464
465
        // Check if in result
466
        return isset($results[$id]) ? $results[$id] : false;
467
    }
468
469
    public function canEdit($id, Member $member = null)
470
    {
471
        // No ID: Check default permission
472
        if (!$id) {
473
            return $this->checkDefaultPermissions(self::EDIT, $member);
474
        }
475
476
        // Regular canEdit logic is handled by canEditMultiple
477
        $results = $this->canEditMultiple(
478
            [ $id ],
479
            $member
480
        );
481
482
        // Check if in result
483
        return isset($results[$id]) ? $results[$id] : false;
484
    }
485
486
    public function canView($id, Member $member = null)
487
    {
488
        // No ID: Check default permission
489
        if (!$id) {
490
            return $this->checkDefaultPermissions(self::VIEW, $member);
491
        }
492
493
        // Regular canView logic is handled by canViewMultiple
494
        $results = $this->canViewMultiple(
495
            [ $id ],
496
            $member
497
        );
498
499
        // Check if in result
500
        return isset($results[$id]) ? $results[$id] : false;
501
    }
502
503
    /**
504
     * Get field to check for permission type for the given check.
505
     * Defaults to those provided by {@see InheritedPermissionsExtension)
506
     *
507
     * @param string $type
508
     * @return string
509
     */
510
    protected function getPermissionField($type)
511
    {
512
        switch ($type) {
513
            case self::DELETE:
514
                // Delete uses edit type - Drop through
515
            case self::EDIT:
516
                return 'CanEditType';
517
            case self::VIEW:
518
                return 'CanViewType';
519
            default:
520
                throw new InvalidArgumentException("Invalid argument type $type");
521
        }
522
    }
523
524
    /**
525
     * Get join table for type
526
     * Defaults to those provided by {@see InheritedPermissionsExtension)
527
     *
528
     * @param string $type
529
     * @return string
530
     */
531
    protected function getJoinTable($type)
532
    {
533
        switch ($type) {
534
            case self::DELETE:
535
                // Delete uses edit type - Drop through
536
            case self::EDIT:
537
                return $this->getEditorGroupsTable();
538
            case self::VIEW:
539
                return $this->getViewerGroupsTable();
540
            default:
541
                throw new InvalidArgumentException("Invalid argument type $type");
542
        }
543
    }
544
545
    /**
546
     * Determine default permission for a givion check
547
     *
548
     * @param string $type Method to check
549
     * @param Member $member
550
     * @return bool
551
     */
552
    protected function checkDefaultPermissions($type, Member $member = null)
553
    {
554
        $defaultPermissions = $this->getDefaultPermissions();
555
        if (!$defaultPermissions) {
0 ignored issues
show
introduced by
$defaultPermissions is of type SilverStripe\Security\DefaultPermissionChecker, thus it always evaluated to true.
Loading history...
556
            return false;
557
        }
558
        switch ($type) {
559
            case self::VIEW:
560
                return $defaultPermissions->canView($member);
561
            case self::EDIT:
562
                return $defaultPermissions->canEdit($member);
563
            case self::DELETE:
564
                return $defaultPermissions->canDelete($member);
565
            default:
566
                return false;
567
        }
568
    }
569
570
    /**
571
     * Check if this model has versioning
572
     *
573
     * @return bool
574
     */
575
    protected function isVersioned()
576
    {
577
        if (!class_exists(Versioned::class)) {
578
            return false;
579
        }
580
        /** @var Versioned|DataObject $singleton */
581
        $singleton = DataObject::singleton($this->getBaseClass());
582
        return $singleton->hasExtension(Versioned::class) && $singleton->hasStages();
0 ignored issues
show
Bug introduced by
The method hasExtension() does not exist on SilverStripe\Versioned\Versioned. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

582
        return $singleton->/** @scrutinizer ignore-call */ hasExtension(Versioned::class) && $singleton->hasStages();

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
Bug introduced by
The method hasStages() does not exist on SilverStripe\ORM\DataObject. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

582
        return $singleton->hasExtension(Versioned::class) && $singleton->/** @scrutinizer ignore-call */ hasStages();
Loading history...
583
    }
584
585
    public function clearCache()
586
    {
587
        $this->cachePermissions = [];
588
        return $this;
589
    }
590
591
    /**
592
     * Get table to use for editor groups relation
593
     *
594
     * @return string
595
     */
596
    protected function getEditorGroupsTable()
597
    {
598
        $table = DataObject::getSchema()->tableName($this->baseClass);
599
        return "{$table}_EditorGroups";
600
    }
601
602
    /**
603
     * Get table to use for viewer groups relation
604
     *
605
     * @return string
606
     */
607
    protected function getViewerGroupsTable()
608
    {
609
        $table = DataObject::getSchema()->tableName($this->baseClass);
610
        return "{$table}_ViewerGroups";
611
    }
612
}
613