Completed
Push — master ( ace5fb...9c2de1 )
by
unknown
20:35 queued 07:41
created

internalPublishCustomUrlAliasForLocation()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 25

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
nc 4
nop 2
dl 0
loc 25
rs 9.52
c 0
b 0
f 0
1
<?php
2
3
/**
4
 * File containing the UrlAlias Handler.
5
 *
6
 * @copyright Copyright (C) eZ Systems AS. All rights reserved.
7
 * @license For full copyright and license information view LICENSE file distributed with this source code.
8
 */
9
namespace eZ\Publish\Core\Persistence\Legacy\Content\UrlAlias;
10
11
use eZ\Publish\Core\Base\Exceptions\BadStateException;
12
use eZ\Publish\Core\Base\Exceptions\InvalidArgumentException;
13
use eZ\Publish\Core\Persistence\Legacy\Content\Language\MaskGenerator;
14
use eZ\Publish\Core\Persistence\Legacy\Content\UrlAlias\DTO\SwappedLocationProperties;
15
use eZ\Publish\Core\Persistence\Legacy\Content\UrlAlias\DTO\UrlAliasForSwappedLocation;
16
use eZ\Publish\SPI\Persistence\Content\Language;
17
use eZ\Publish\SPI\Persistence\Content\UrlAlias\Handler as UrlAliasHandlerInterface;
18
use eZ\Publish\SPI\Persistence\Content\Language\Handler as LanguageHandler;
19
use eZ\Publish\Core\Persistence\Legacy\Content\Gateway as ContentGateway;
20
use eZ\Publish\Core\Persistence\Legacy\Content\Location\Gateway as LocationGateway;
21
use eZ\Publish\Core\Base\Exceptions\NotFoundException;
22
use eZ\Publish\Core\Base\Exceptions\ForbiddenException;
23
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
24
use eZ\Publish\SPI\Persistence\TransactionHandler;
25
26
/**
27
 * The UrlAlias Handler provides nice urls management.
28
 *
29
 * Its methods operate on a representation of the url alias data structure held
30
 * inside a storage engine.
31
 */
32
class Handler implements UrlAliasHandlerInterface
33
{
34
    const ROOT_LOCATION_ID = 1;
35
36
    /**
37
     * This is intentionally hardcoded for now as:
38
     * 1. We don't implement this configuration option.
39
     * 2. Such option should not be in this layer, should be handled higher up.
40
     *
41
     * @deprecated
42
     */
43
    const CONTENT_REPOSITORY_ROOT_LOCATION_ID = 2;
44
45
    /**
46
     * The maximum level of alias depth.
47
     */
48
    const MAX_URL_ALIAS_DEPTH_LEVEL = 60;
49
50
    /**
51
     * UrlAlias Gateway.
52
     *
53
     * @var \eZ\Publish\Core\Persistence\Legacy\Content\UrlAlias\Gateway
54
     */
55
    protected $gateway;
56
57
    /**
58
     * Gateway for handling location data.
59
     *
60
     * @var \eZ\Publish\Core\Persistence\Legacy\Content\Location\Gateway
61
     */
62
    protected $locationGateway;
63
64
    /**
65
     * UrlAlias Mapper.
66
     *
67
     * @var \eZ\Publish\Core\Persistence\Legacy\Content\UrlAlias\Mapper
68
     */
69
    protected $mapper;
70
71
    /**
72
     * Caching language handler.
73
     *
74
     * @var \eZ\Publish\Core\Persistence\Legacy\Content\Language\CachingHandler
75
     */
76
    protected $languageHandler;
77
78
    /**
79
     * URL slug converter.
80
     *
81
     * @var \eZ\Publish\Core\Persistence\Legacy\Content\UrlAlias\SlugConverter
82
     */
83
    protected $slugConverter;
84
85
    /**
86
     * Gateway for handling content data.
87
     *
88
     * @var \eZ\Publish\Core\Persistence\Legacy\Content\Gateway
89
     */
90
    protected $contentGateway;
91
92
    /**
93
     * Language mask generator.
94
     *
95
     * @var \eZ\Publish\Core\Persistence\Legacy\Content\Language\MaskGenerator
96
     */
97
    protected $maskGenerator;
98
99
    /** @var \eZ\Publish\SPI\Persistence\TransactionHandler */
100
    private $transactionHandler;
101
102
    /**
103
     * Creates a new UrlAlias Handler.
104
     *
105
     * @param \eZ\Publish\Core\Persistence\Legacy\Content\UrlAlias\Gateway $gateway
106
     * @param \eZ\Publish\Core\Persistence\Legacy\Content\UrlAlias\Mapper $mapper
107
     * @param \eZ\Publish\Core\Persistence\Legacy\Content\Location\Gateway $locationGateway
108
     * @param \eZ\Publish\SPI\Persistence\Content\Language\Handler $languageHandler
109
     * @param \eZ\Publish\Core\Persistence\Legacy\Content\UrlAlias\SlugConverter $slugConverter
110
     * @param \eZ\Publish\Core\Persistence\Legacy\Content\Gateway $contentGateway
111
     * @param \eZ\Publish\Core\Persistence\Legacy\Content\Language\MaskGenerator $maskGenerator
112
     * @param \eZ\Publish\SPI\Persistence\TransactionHandler $transactionHandler
113
     */
114
    public function __construct(
115
        Gateway $gateway,
116
        Mapper $mapper,
117
        LocationGateway $locationGateway,
118
        LanguageHandler $languageHandler,
119
        SlugConverter $slugConverter,
120
        ContentGateway $contentGateway,
121
        MaskGenerator $maskGenerator,
122
        TransactionHandler $transactionHandler
123
    ) {
124
        $this->gateway = $gateway;
125
        $this->mapper = $mapper;
126
        $this->locationGateway = $locationGateway;
127
        $this->languageHandler = $languageHandler;
0 ignored issues
show
Documentation Bug introduced by
$languageHandler is of type object<eZ\Publish\SPI\Pe...ntent\Language\Handler>, but the property $languageHandler was declared to be of type object<eZ\Publish\Core\P...anguage\CachingHandler>. Are you sure that you always receive this specific sub-class here, or does it make sense to add an instanceof check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a given class or a super-class is assigned to a property that is type hinted more strictly.

Either this assignment is in error or an instanceof check should be added for that assignment.

class Alien {}

class Dalek extends Alien {}

class Plot
{
    /** @var  Dalek */
    public $villain;
}

$alien = new Alien();
$plot = new Plot();
if ($alien instanceof Dalek) {
    $plot->villain = $alien;
}
Loading history...
128
        $this->slugConverter = $slugConverter;
129
        $this->contentGateway = $contentGateway;
130
        $this->maskGenerator = $maskGenerator;
131
        $this->transactionHandler = $transactionHandler;
132
    }
133
134
    public function publishUrlAliasForLocation(
135
        $locationId,
136
        $parentLocationId,
137
        $name,
138
        $languageCode,
139
        $alwaysAvailable = false,
140
        $updatePathIdentificationString = false
141
    ) {
142
        $languageId = $this->languageHandler->loadByLanguageCode($languageCode)->id;
143
144
        $this->internalPublishUrlAliasForLocation(
145
            $locationId,
146
            $parentLocationId,
147
            $name,
148
            $languageId,
149
            $alwaysAvailable,
150
            $updatePathIdentificationString
151
        );
152
    }
153
154
    /**
155
     * Internal publish method, accepting language ID instead of language code and optionally
156
     * new alias ID (used when swapping Locations).
157
     *
158
     * @see \eZ\Publish\Core\Persistence\Legacy\Content\UrlAlias\Handler::locationSwapped()
159
     *
160
     * @param int $locationId
161
     * @param int $parentLocationId
162
     * @param string $name
163
     * @param int $languageId
164
     * @param bool $alwaysAvailable
165
     * @param bool $updatePathIdentificationString legacy storage specific for updating ezcontentobject_tree.path_identification_string
166
     * @param int $newId
167
     */
168
    private function internalPublishUrlAliasForLocation(
169
        $locationId,
170
        $parentLocationId,
171
        $name,
172
        $languageId,
173
        $alwaysAvailable = false,
174
        $updatePathIdentificationString = false,
175
        $newId = null
176
    ) {
177
        $parentId = $this->getRealAliasId($parentLocationId);
178
        $name = $this->slugConverter->convert($name, 'location_' . $locationId);
179
        $uniqueCounter = $this->slugConverter->getUniqueCounterValue($name, $parentId == 0);
180
        $languageMask = $languageId | (int)$alwaysAvailable;
181
        $action = 'eznode:' . $locationId;
182
        $cleanup = false;
183
184
        // Exiting the loop with break;
185
        while (true) {
186
            $newText = '';
187
            if ($locationId != self::CONTENT_REPOSITORY_ROOT_LOCATION_ID) {
0 ignored issues
show
Deprecated Code introduced by
The constant eZ\Publish\Core\Persiste...SITORY_ROOT_LOCATION_ID has been deprecated.

This class constant has been deprecated.

Loading history...
188
                $newText = $name . ($name == '' || $uniqueCounter > 1 ? $uniqueCounter : '');
189
            }
190
            $newTextMD5 = $this->getHash($newText);
191
192
            // Try to load existing entry
193
            $row = $this->gateway->loadRow($parentId, $newTextMD5);
194
195
            // If nothing was returned insert new entry
196
            if (empty($row)) {
197
                // Check for existing active location entry on this level and reuse it's id
198
                $existingLocationEntry = $this->gateway->loadAutogeneratedEntry($action, $parentId);
199
                if (!empty($existingLocationEntry)) {
200
                    $cleanup = true;
201
                    $newId = $existingLocationEntry['id'];
202
                }
203
204
                try {
205
                    $newId = $this->gateway->insertRow(
206
                        [
207
                            'id' => $newId,
208
                            'link' => $newId,
209
                            'parent' => $parentId,
210
                            'action' => $action,
211
                            'lang_mask' => $languageMask,
212
                            'text' => $newText,
213
                            'text_md5' => $newTextMD5,
214
                        ]
215
                    );
216
                } catch (\RuntimeException $e) {
217
                    while ($e->getPrevious() !== null) {
218
                        $e = $e->getPrevious();
219
                        if ($e instanceof UniqueConstraintViolationException) {
220
                            // Concurrency! someone else inserted the same row that we where going to.
221
                            // let's do another loop pass
222
                            $uniqueCounter += 1;
223
                            continue 2;
224
                        }
225
                    }
226
227
                    throw $e;
228
                }
229
230
                break;
231
            }
232
233
            // Row exists, check if it is reusable. There are 3 cases when this is possible:
234
            // 1. NOP entry
235
            // 2. existing location or custom alias entry
236
            // 3. history entry
237
            if ($row['action'] == 'nop:' || $row['action'] == $action || $row['is_original'] == 0) {
238
                // Check for existing location entry on this level, if it exists and it's id differs from reusable
239
                // entry id then reusable entry should be updated with the existing location entry id.
240
                // Note: existing location entry may be downgraded and relinked later, depending on its language.
241
                $existingLocationEntry = $this->gateway->loadAutogeneratedEntry($action, $parentId);
242
243
                if (!empty($existingLocationEntry)) {
244
                    // Always cleanup when active autogenerated entry exists on the same level
245
                    $cleanup = true;
246
                    $newId = $existingLocationEntry['id'];
247
                    if ($existingLocationEntry['id'] == $row['id']) {
248
                        // If we are reusing existing location entry merge existing language mask
249
                        $languageMask |= ($row['lang_mask'] & ~1);
250
                    }
251
                } elseif ($newId === null) {
252
                    // Use reused row ID only if publishing normally, else use given $newId
253
                    $newId = $row['id'];
254
                }
255
256
                $this->gateway->updateRow(
257
                    $parentId,
258
                    $newTextMD5,
259
                    [
260
                        'action' => $action,
261
                        // In case when NOP row was reused
262
                        'action_type' => 'eznode',
263
                        'lang_mask' => $languageMask,
264
                        // Updating text ensures that letter case changes are stored
265
                        'text' => $newText,
266
                        // Set "id" and "link" for case when reusable entry is history
267
                        'id' => $newId,
268
                        'link' => $newId,
269
                        // Entry should be active location entry (original and not alias).
270
                        // Note: this takes care of taking over custom alias entry for the location on the same level
271
                        // and with same name and action.
272
                        'alias_redirects' => 1,
273
                        'is_original' => 1,
274
                        'is_alias' => 0,
275
                    ]
276
                );
277
278
                break;
279
            }
280
281
            // If existing row is not reusable, increment $uniqueCounter and try again
282
            $uniqueCounter += 1;
283
        }
284
285
        /* @var $newText */
286
        if ($updatePathIdentificationString) {
287
            $this->locationGateway->updatePathIdentificationString(
288
                $locationId,
289
                $parentLocationId,
290
                $this->slugConverter->convert($newText, 'node_' . $locationId, 'urlalias_compat')
0 ignored issues
show
Bug introduced by
The variable $newText does not seem to be defined for all execution paths leading up to this point.

If you define a variable conditionally, it can happen that it is not defined for all execution paths.

Let’s take a look at an example:

function myFunction($a) {
    switch ($a) {
        case 'foo':
            $x = 1;
            break;

        case 'bar':
            $x = 2;
            break;
    }

    // $x is potentially undefined here.
    echo $x;
}

In the above example, the variable $x is defined if you pass “foo” or “bar” as argument for $a. However, since the switch statement has no default case statement, if you pass any other value, the variable $x would be undefined.

Available Fixes

  1. Check for existence of the variable explicitly:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        if (isset($x)) { // Make sure it's always set.
            echo $x;
        }
    }
    
  2. Define a default value for the variable:

    function myFunction($a) {
        $x = ''; // Set a default which gets overridden for certain paths.
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        echo $x;
    }
    
  3. Add a value for the missing path:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
    
            // We add support for the missing case.
            default:
                $x = '';
                break;
        }
    
        echo $x;
    }
    
Loading history...
291
            );
292
        }
293
294
        /* @var $newId */
295
        /* @var $newTextMD5 */
296
        // Note: cleanup does not touch custom and global entries
297
        if ($cleanup) {
298
            $this->gateway->cleanupAfterPublish($action, $languageId, $newId, $parentId, $newTextMD5);
0 ignored issues
show
Bug introduced by
The variable $newTextMD5 does not seem to be defined for all execution paths leading up to this point.

If you define a variable conditionally, it can happen that it is not defined for all execution paths.

Let’s take a look at an example:

function myFunction($a) {
    switch ($a) {
        case 'foo':
            $x = 1;
            break;

        case 'bar':
            $x = 2;
            break;
    }

    // $x is potentially undefined here.
    echo $x;
}

In the above example, the variable $x is defined if you pass “foo” or “bar” as argument for $a. However, since the switch statement has no default case statement, if you pass any other value, the variable $x would be undefined.

Available Fixes

  1. Check for existence of the variable explicitly:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        if (isset($x)) { // Make sure it's always set.
            echo $x;
        }
    }
    
  2. Define a default value for the variable:

    function myFunction($a) {
        $x = ''; // Set a default which gets overridden for certain paths.
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        echo $x;
    }
    
  3. Add a value for the missing path:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
    
            // We add support for the missing case.
            default:
                $x = '';
                break;
        }
    
        echo $x;
    }
    
Loading history...
299
        }
300
    }
301
302
    /**
303
     * Create a user chosen $alias pointing to $locationId in $languageCode.
304
     *
305
     * If $languageCode is null the $alias is created in the system's default
306
     * language. $alwaysAvailable makes the alias available in all languages.
307
     *
308
     * @throws \eZ\Publish\API\Repository\Exceptions\BadStateException
309
     * @throws \eZ\Publish\API\Repository\Exceptions\NotFoundException
310
     * @throws \eZ\Publish\API\Repository\Exceptions\ForbiddenException
311
     *
312
     * @param mixed $locationId
313
     * @param string $path
314
     * @param bool $forwarding
315
     * @param string $languageCode
316
     * @param bool $alwaysAvailable
317
     *
318
     * @return \eZ\Publish\SPI\Persistence\Content\UrlAlias
319
     */
320
    public function createCustomUrlAlias($locationId, $path, $forwarding = false, $languageCode = null, $alwaysAvailable = false)
321
    {
322
        return $this->createUrlAlias(
323
            'eznode:' . $locationId,
324
            $path,
325
            $forwarding,
326
            $languageCode,
327
            $alwaysAvailable
328
        );
329
    }
330
331
    /**
332
     * Create a user chosen $alias pointing to a resource in $languageCode.
333
     * This method does not handle location resources - if a user enters a location target
334
     * the createCustomUrlAlias method has to be used.
335
     *
336
     * If $languageCode is null the $alias is created in the system's default
337
     * language. $alwaysAvailable makes the alias available in all languages.
338
     *
339
     * @throws \eZ\Publish\API\Repository\Exceptions\ForbiddenException if the path already exists for the given language
340
     * @throws \eZ\Publish\API\Repository\Exceptions\BadStateException if the path is broken
341
     * @throws \eZ\Publish\API\Repository\Exceptions\NotFoundException
342
     *
343
     * @param string $resource
344
     * @param string $path
345
     * @param bool $forwarding
346
     * @param string $languageCode
347
     * @param bool $alwaysAvailable
348
     *
349
     * @return \eZ\Publish\SPI\Persistence\Content\UrlAlias
350
     */
351
    public function createGlobalUrlAlias($resource, $path, $forwarding = false, $languageCode = null, $alwaysAvailable = false)
352
    {
353
        return $this->createUrlAlias(
354
            $resource,
355
            $path,
356
            $forwarding,
357
            $languageCode,
358
            $alwaysAvailable
359
        );
360
    }
361
362
    /**
363
     * Internal method for creating global or custom URL alias (these are handled in the same way).
364
     *
365
     * @throws \eZ\Publish\Core\Base\Exceptions\ForbiddenException if the path already exists for the given language
366
     * @throws \eZ\Publish\API\Repository\Exceptions\NotFoundException
367
     * @throws \eZ\Publish\API\Repository\Exceptions\BadStateException
368
     *
369
     * @param string $action
370
     * @param string $path
371
     * @param bool $forward
372
     * @param string|null $languageCode
373
     * @param bool $alwaysAvailable
374
     *
375
     * @return \eZ\Publish\SPI\Persistence\Content\UrlAlias
376
     */
377
    protected function createUrlAlias($action, $path, $forward, $languageCode, $alwaysAvailable)
378
    {
379
        $pathElements = explode('/', $path);
380
        $topElement = array_pop($pathElements);
381
        $languageId = $this->languageHandler->loadByLanguageCode($languageCode)->id;
382
        $parentId = 0;
383
384
        // Handle all path elements except topmost one
385
        $isPathNew = false;
386
        foreach ($pathElements as $level => $pathElement) {
387
            $pathElement = $this->slugConverter->convert($pathElement, 'noname' . ($level + 1));
388
            $pathElementMD5 = $this->getHash($pathElement);
389
            if (!$isPathNew) {
390
                $row = $this->gateway->loadRow($parentId, $pathElementMD5);
391
                if (empty($row)) {
392
                    $isPathNew = true;
393
                } else {
394
                    $parentId = $row['link'];
395
                }
396
            }
397
398
            if ($isPathNew) {
399
                $parentId = $this->insertNopEntry($parentId, $pathElement, $pathElementMD5);
400
            }
401
        }
402
403
        // Handle topmost path element
404
        $topElement = $this->slugConverter->convert($topElement, 'noname' . (count($pathElements) + 1));
405
406
        // If last (next to topmost) entry parent is special root entry we handle topmost entry as first level entry
407
        // That is why we need to reset $parentId to 0
408
        if ($parentId != 0 && $this->gateway->isRootEntry($parentId)) {
409
            $parentId = 0;
410
        }
411
412
        $topElementMD5 = $this->getHash($topElement);
413
        // Set common values for two cases below
414
        $data = [
415
            'action' => $action,
416
            'is_alias' => 1,
417
            'alias_redirects' => $forward ? 1 : 0,
418
            'parent' => $parentId,
419
            'text' => $topElement,
420
            'text_md5' => $topElementMD5,
421
            'is_original' => 1,
422
        ];
423
        // Try to load topmost element
424
        if (!$isPathNew) {
425
            $row = $this->gateway->loadRow($parentId, $topElementMD5);
426
        }
427
428
        // If nothing was returned perform insert
429
        if ($isPathNew || empty($row)) {
430
            $data['lang_mask'] = $languageId | (int)$alwaysAvailable;
431
            $id = $this->gateway->insertRow($data);
432
        } elseif ($row['action'] == 'nop:' || $row['is_original'] == 0) {
433
            // Row exists, check if it is reusable. There are 2 cases when this is possible:
434
            // 1. NOP entry
435
            // 2. history entry
436
            $data['lang_mask'] = $languageId | (int)$alwaysAvailable;
437
            // If history is reused move link to id
438
            $data['link'] = $id = $row['id'];
439
            $this->gateway->updateRow(
440
                $parentId,
441
                $topElementMD5,
442
                $data
443
            );
444
        } elseif ($row['action'] == $action && 0 === ((int)$row['lang_mask'] & $languageId)) {
445
            // add another language to the same custom alias
446
            $data['link'] = $id = $row['id'];
447
            $data['lang_mask'] = $row['lang_mask'] | $languageId | (int)$alwaysAvailable;
448
            $this->gateway->updateRow(
449
                $parentId,
450
                $topElementMD5,
451
                $data
452
            );
453
        } else {
454
            throw new ForbiddenException("Path '%path%' already exists for the given language", ['%path%' => $path]);
455
        }
456
457
        $data['raw_path_data'] = $this->gateway->loadPathData($id);
458
459
        return $this->mapper->extractUrlAliasFromData($data);
460
    }
461
462
    /**
463
     * Convenience method for inserting nop type row.
464
     *
465
     * @param mixed $parentId
466
     * @param string $text
467
     * @param string $textMD5
468
     *
469
     * @return mixed
470
     */
471
    protected function insertNopEntry($parentId, $text, $textMD5)
472
    {
473
        return $this->gateway->insertRow(
474
            [
475
                'lang_mask' => 1,
476
                'action' => 'nop:',
477
                'parent' => $parentId,
478
                'text' => $text,
479
                'text_md5' => $textMD5,
480
            ]
481
        );
482
    }
483
484
    /**
485
     * List of user generated or autogenerated url entries, pointing to $locationId.
486
     *
487
     * @throws \eZ\Publish\API\Repository\Exceptions\BadStateException
488
     *
489
     * @param mixed $locationId
490
     * @param bool $custom if true the user generated aliases are listed otherwise the autogenerated
491
     *
492
     * @return \eZ\Publish\SPI\Persistence\Content\UrlAlias[]
493
     */
494 View Code Duplication
    public function listURLAliasesForLocation($locationId, $custom = false)
495
    {
496
        $data = $this->gateway->loadLocationEntries($locationId, $custom);
497
        foreach ($data as &$entry) {
498
            $entry['raw_path_data'] = $this->gateway->loadPathData($entry['id']);
499
        }
500
501
        return $this->mapper->extractUrlAliasListFromData($data);
502
    }
503
504
    /**
505
     * List global aliases.
506
     *
507
     * @throws \eZ\Publish\API\Repository\Exceptions\BadStateException
508
     *
509
     * @param string|null $languageCode
510
     * @param int $offset
511
     * @param int $limit
512
     *
513
     * @return \eZ\Publish\SPI\Persistence\Content\UrlAlias[]
514
     */
515 View Code Duplication
    public function listGlobalURLAliases($languageCode = null, $offset = 0, $limit = -1)
516
    {
517
        $data = $this->gateway->listGlobalEntries($languageCode, $offset, $limit);
518
        foreach ($data as &$entry) {
519
            $entry['raw_path_data'] = $this->gateway->loadPathData($entry['id']);
520
        }
521
522
        return $this->mapper->extractUrlAliasListFromData($data);
523
    }
524
525
    /**
526
     * Removes url aliases.
527
     *
528
     * Autogenerated aliases are not removed by this method.
529
     *
530
     * @param \eZ\Publish\SPI\Persistence\Content\UrlAlias[] $urlAliases
531
     *
532
     * @return bool
533
     */
534
    public function removeURLAliases(array $urlAliases)
535
    {
536
        foreach ($urlAliases as $urlAlias) {
537
            if ($urlAlias->isCustom) {
538
                list($parentId, $textMD5) = explode('-', $urlAlias->id);
539
                if (!$this->gateway->removeCustomAlias($parentId, $textMD5)) {
540
                    return false;
541
                }
542
            }
543
        }
544
545
        return true;
546
    }
547
548
    /**
549
     * Looks up a url alias for the given url.
550
     *
551
     * @throws \eZ\Publish\API\Repository\Exceptions\NotFoundException
552
     * @throws \eZ\Publish\API\Repository\Exceptions\InvalidArgumentException
553
     * @throws \eZ\Publish\API\Repository\Exceptions\BadStateException
554
     *
555
     * @param string $url
556
     *
557
     * @return \eZ\Publish\SPI\Persistence\Content\UrlAlias
558
     */
559
    public function lookup($url)
560
    {
561
        $urlHashes = [];
562
        foreach (explode('/', $url) as $level => $text) {
563
            $urlHashes[$level] = $this->getHash($text);
564
        }
565
566
        $pathDepth = count($urlHashes);
567
        if ($pathDepth > self::MAX_URL_ALIAS_DEPTH_LEVEL) {
568
            throw new InvalidArgumentException('$urlHashes', 'Exceeded maximum depth level for content URL alias.');
569
        }
570
571
        $data = $this->gateway->loadUrlAliasData($urlHashes);
572
        if (empty($data)) {
573
            throw new NotFoundException('URLAlias', $url);
574
        }
575
576
        $hierarchyData = [];
577
        $isPathHistory = false;
578
        for ($level = 0; $level < $pathDepth; ++$level) {
579
            $prefix = $level === $pathDepth - 1 ? '' : 'ezurlalias_ml' . $level . '_';
580
            $isPathHistory = $isPathHistory ?: ($data[$prefix . 'link'] != $data[$prefix . 'id']);
581
            $hierarchyData[$level] = [
582
                'id' => $data[$prefix . 'id'],
583
                'parent' => $data[$prefix . 'parent'],
584
                'action' => $data[$prefix . 'action'],
585
            ];
586
        }
587
588
        $data['is_path_history'] = $isPathHistory;
589
        $data['raw_path_data'] = ($data['action_type'] == 'eznode' && !$data['is_alias'])
590
            ? $this->gateway->loadPathDataByHierarchy($hierarchyData)
591
            : $this->gateway->loadPathData($data['id']);
592
593
        return $this->mapper->extractUrlAliasFromData($data);
594
    }
595
596
    /**
597
     * Loads URL alias by given $id.
598
     *
599
     * @throws \eZ\Publish\API\Repository\Exceptions\NotFoundException
600
     * @throws \eZ\Publish\API\Repository\Exceptions\BadStateException
601
     *
602
     * @param string $id
603
     *
604
     * @return \eZ\Publish\SPI\Persistence\Content\UrlAlias
605
     */
606
    public function loadUrlAlias($id)
607
    {
608
        list($parentId, $textMD5) = explode('-', $id);
609
        $data = $this->gateway->loadRow((int)$parentId, $textMD5);
610
611
        if (empty($data)) {
612
            throw new NotFoundException('URLAlias', $id);
613
        }
614
615
        $data['raw_path_data'] = $this->gateway->loadPathData((int)$data['id']);
616
617
        return $this->mapper->extractUrlAliasFromData($data);
618
    }
619
620
    /**
621
     * Notifies the underlying engine that a location has moved.
622
     *
623
     * This method triggers the change of the autogenerated aliases.
624
     *
625
     * @param mixed $locationId
626
     * @param mixed $oldParentId
627
     * @param mixed $newParentId
628
     */
629
    public function locationMoved($locationId, $oldParentId, $newParentId)
630
    {
631
        // @todo optimize: $newLocationAliasId is already available in self::publishUrlAliasForLocation() as $newId
632
        $newParentLocationAliasId = $this->getRealAliasId($newParentId);
633
        $newLocationAlias = $this->gateway->loadAutogeneratedEntry(
634
            'eznode:' . $locationId,
635
            $newParentLocationAliasId
636
        );
637
638
        $oldParentLocationAliasId = $this->getRealAliasId($oldParentId);
639
        $oldLocationAlias = $this->gateway->loadAutogeneratedEntry(
640
            'eznode:' . $locationId,
641
            $oldParentLocationAliasId
642
        );
643
644
        // Historize alias for old location
645
        $this->gateway->historizeId($oldLocationAlias['id'], $newLocationAlias['id']);
646
        // Reparent subtree of old location to new location
647
        $this->gateway->reparent($oldLocationAlias['id'], $newLocationAlias['id']);
648
    }
649
650
    /**
651
     * Notifies the underlying engine that a location was copied.
652
     *
653
     * This method triggers the creation of the autogenerated aliases for the copied locations
654
     *
655
     * @param mixed $locationId
656
     * @param mixed $newLocationId
657
     * @param mixed $newParentId
658
     */
659
    public function locationCopied($locationId, $newLocationId, $newParentId)
660
    {
661
        $newParentAliasId = $this->getRealAliasId($newLocationId);
662
        $oldParentAliasId = $this->getRealAliasId($locationId);
663
664
        $actionMap = $this->getCopiedLocationsMap($locationId, $newLocationId);
665
666
        $this->copySubtree(
667
            $actionMap,
668
            $oldParentAliasId,
669
            $newParentAliasId
670
        );
671
    }
672
673
    /**
674
     * Notify the underlying engine that a Location has been swapped.
675
     *
676
     * This method triggers the change of the autogenerated aliases.
677
     *
678
     * @param int $location1Id
679
     * @param int $location1ParentId
680
     * @param int $location2Id
681
     * @param int $location2ParentId
682
     * @throws \eZ\Publish\Core\Base\Exceptions\NotFoundException
683
     */
684
    public function locationSwapped($location1Id, $location1ParentId, $location2Id, $location2ParentId)
685
    {
686
        $location1 = new SwappedLocationProperties($location1Id, $location1ParentId);
687
        $location2 = new SwappedLocationProperties($location2Id, $location2ParentId);
688
689
        $location1->entries = $this->gateway->loadAllLocationEntries($location1Id);
690
        $location2->entries = $this->gateway->loadAllLocationEntries($location2Id);
691
692
        $location1->mainLanguageId = $this->gateway->getLocationContentMainLanguageId($location1Id);
693
        $location2->mainLanguageId = $this->gateway->getLocationContentMainLanguageId($location2Id);
694
695
        // Load autogenerated entries to find alias ID
696
        $location1->autogeneratedId = $this->gateway->loadAutogeneratedEntry("eznode:{$location1Id}")['id'];
697
        $location2->autogeneratedId = $this->gateway->loadAutogeneratedEntry("eznode:{$location2Id}")['id'];
698
699
        $contentInfo1 = $this->contentGateway->loadContentInfoByLocationId($location1Id);
700
        $contentInfo2 = $this->contentGateway->loadContentInfoByLocationId($location2Id);
701
702
        $names1 = $this->getNamesForAllLanguages($contentInfo1);
703
        $names2 = $this->getNamesForAllLanguages($contentInfo2);
704
705
        $location1->isAlwaysAvailable = $this->maskGenerator->isAlwaysAvailable($contentInfo1['language_mask']);
706
        $location2->isAlwaysAvailable = $this->maskGenerator->isAlwaysAvailable($contentInfo2['language_mask']);
707
708
        $languages = $this->languageHandler->loadAll();
709
710
        // Historize everything first to avoid name conflicts in case swapped Locations are siblings
711
        $this->historizeBeforeSwap($location1->entries, $location2->entries);
712
713
        foreach ($languages as $languageCode => $language) {
714
            $location1->name = isset($names1[$languageCode]) ? $names1[$languageCode] : null;
715
            $location2->name = isset($names2[$languageCode]) ? $names2[$languageCode] : null;
716
            $urlAliasesForSwappedLocations = $this->getUrlAliasesForSwappedLocations(
717
                $language,
718
                $location1,
719
                $location2
720
            );
721
            foreach ($urlAliasesForSwappedLocations as $urlAliasForLocation) {
722
                $this->internalPublishUrlAliasForLocation(
723
                    $urlAliasForLocation->id,
724
                    $urlAliasForLocation->parentId,
725
                    $urlAliasForLocation->name,
726
                    $language->id,
727
                    $urlAliasForLocation->isAlwaysAvailable,
728
                    $urlAliasForLocation->isPathIdentificationStringModified,
729
                    $urlAliasForLocation->newId
730
                );
731
            }
732
        }
733
734
        $this->internalPublishCustomUrlAliasForLocation($location1, $contentInfo1['language_mask']);
735
        $this->internalPublishCustomUrlAliasForLocation($location2, $contentInfo2['language_mask']);
736
    }
737
738
    /**
739
     * @param array $contentInfo
740
     *
741
     * @return array
742
     */
743
    private function getNamesForAllLanguages(array $contentInfo)
744
    {
745
        $nameDataArray = $this->contentGateway->loadVersionedNameData([
746
            [
747
                'id' => $contentInfo['id'],
748
                'version' => $contentInfo['current_version'],
749
            ],
750
        ]);
751
752
        $namesForAllLanguages = [];
753
        foreach ($nameDataArray as $nameData) {
754
            $namesForAllLanguages[$nameData['ezcontentobject_name_content_translation']]
755
                = $nameData['ezcontentobject_name_name'];
756
        }
757
758
        return $namesForAllLanguages;
759
    }
760
761
    /**
762
     * Historizes given existing active entries for two swapped Locations.
763
     *
764
     * This should be done before republishing URL aliases, in order to avoid unnecessary
765
     * conflicts when swapped Locations are siblings.
766
     *
767
     * We need to historize everything separately per language (mask), in case the entries
768
     * remain history future publishing reusages need to be able to take them over cleanly.
769
     *
770
     * @see \eZ\Publish\Core\Persistence\Legacy\Content\UrlAlias\Handler::locationSwapped()
771
     *
772
     * @param array $location1Entries
773
     * @param array $location2Entries
774
     */
775
    private function historizeBeforeSwap($location1Entries, $location2Entries)
776
    {
777
        foreach ($location1Entries as $row) {
778
            $this->gateway->historizeBeforeSwap($row['action'], $row['lang_mask']);
779
        }
780
781
        foreach ($location2Entries as $row) {
782
            $this->gateway->historizeBeforeSwap($row['action'], $row['lang_mask']);
783
        }
784
    }
785
786
    /**
787
     * Decides if UrlAlias for $location2 should be published first.
788
     *
789
     * The order in which Locations are published only matters if swapped Locations are siblings and they have the same
790
     * name in a given language. In this case, the UrlAlias for Location which previously had lower number at the end of
791
     * its UrlAlias text (or no number at all) should be published first. This ensures that the number still stays lower
792
     * for this Location after the swap. If it wouldn't stay lower, then swapping Locations in conjunction with swapping
793
     * UrlAliases would effectively cancel each other.
794
     *
795
     * @param array $location1Entries
796
     * @param int $location1ParentId
797
     * @param string $name1
798
     * @param array $location2Entries
799
     * @param int $location2ParentId
800
     * @param string $name2
801
     * @param int $languageId
802
     *
803
     * @return bool
804
     */
805
    private function shouldUrlAliasForSecondLocationBePublishedFirst(
806
        array $location1Entries,
807
        $location1ParentId,
808
        $name1,
809
        array $location2Entries,
810
        $location2ParentId,
811
        $name2,
812
        $languageId
813
    ) {
814
        if ($location1ParentId === $location2ParentId && $name1 === $name2) {
815
            $locationEntry1 = $this->getLocationEntryInLanguage($location1Entries, $languageId);
816
            $locationEntry2 = $this->getLocationEntryInLanguage($location2Entries, $languageId);
817
818
            if ($locationEntry1 === null || $locationEntry2 === null) {
819
                return false;
820
            }
821
822
            if ($locationEntry2['text'] < $locationEntry1['text']) {
823
                return true;
824
            }
825
        }
826
827
        return false;
828
    }
829
830
    /**
831
     * Get in a proper order - to be published - a list of URL aliases for swapped Locations.
832
     *
833
     * @see shouldUrlAliasForSecondLocationBePublishedFirst
834
     *
835
     * @param \eZ\Publish\SPI\Persistence\Content\Language $language
836
     * @param \eZ\Publish\Core\Persistence\Legacy\Content\UrlAlias\DTO\SwappedLocationProperties $location1
837
     * @param \eZ\Publish\Core\Persistence\Legacy\Content\UrlAlias\DTO\SwappedLocationProperties $location2
838
     *
839
     * @return \eZ\Publish\Core\Persistence\Legacy\Content\UrlAlias\DTO\UrlAliasForSwappedLocation[]
840
     */
841
    private function getUrlAliasesForSwappedLocations(
842
        Language $language,
843
        SwappedLocationProperties $location1,
844
        SwappedLocationProperties $location2
845
    ) {
846
        $isMainLanguage1 = $language->id == $location1->mainLanguageId;
847
        $isMainLanguage2 = $language->id == $location2->mainLanguageId;
848
        $urlAliases = [];
849
        if (isset($location1->name)) {
850
            $urlAliases[] = new UrlAliasForSwappedLocation(
851
                $location1->id,
852
                $location1->parentId,
853
                $location1->name,
854
                $isMainLanguage2 && $location1->isAlwaysAvailable,
855
                $isMainLanguage2,
856
                $location1->autogeneratedId
857
            );
858
        }
859
860
        if (isset($location2->name)) {
861
            $urlAliases[] = new UrlAliasForSwappedLocation(
862
                $location2->id,
863
                $location2->parentId,
864
                $location2->name,
865
                $isMainLanguage1 && $location2->isAlwaysAvailable,
866
                $isMainLanguage1,
867
                $location2->autogeneratedId
868
            );
869
870
            if (isset($location1->name) && $this->shouldUrlAliasForSecondLocationBePublishedFirst(
871
                    $location1->entries,
872
                    $location1->parentId,
873
                    $location1->name,
874
                    $location2->entries,
875
                    $location2->parentId,
876
                    $location2->name,
877
                    $language->id
878
                )) {
879
                $urlAliases = array_reverse($urlAliases);
880
            }
881
        }
882
883
        return $urlAliases;
884
    }
885
886
    /**
887
     * @param array $locationEntries
888
     * @param int $languageId
889
     *
890
     * @return array|null
891
     */
892
    private function getLocationEntryInLanguage(array $locationEntries, $languageId)
893
    {
894
        $entries = array_filter(
895
            $locationEntries,
896
            function (array $row) use ($languageId) {
897
                return (bool) ($row['lang_mask'] & $languageId);
898
            }
899
        );
900
901
        return !empty($entries) ? array_shift($entries) : null;
902
    }
903
904
    /**
905
     * Returns possibly corrected alias id for given $locationId !! For use as parent id in logic.
906
     *
907
     * First level entries must have parent id set to 0 instead of their parent location alias id.
908
     * There are two cases when alias id needs to be corrected:
909
     * 1) location is special location without URL alias (location with id=1 in standard installation)
910
     * 2) location is site root location, having special root entry in the ezurlalias_ml table (location with id=2
911
     *    in standard installation)
912
     *
913
     * @param mixed $locationId
914
     *
915
     * @return mixed
916
     */
917
    protected function getRealAliasId($locationId)
918
    {
919
        // Absolute root location does have a url alias entry so we can skip lookup
920
        if ($locationId == self::ROOT_LOCATION_ID) {
921
            return 0;
922
        }
923
924
        $data = $this->gateway->loadAutogeneratedEntry('eznode:' . $locationId);
925
926
        // Root entries (URL wise) can return 0 as the returned value is used as parent (parent is 0 for root entries)
927
        if (empty($data) || ($data['id'] != 0 && $data['parent'] == 0 && strlen($data['text']) == 0)) {
928
            $id = 0;
929
        } else {
930
            $id = $data['id'];
931
        }
932
933
        return $id;
934
    }
935
936
    /**
937
     * Recursively copies aliases from old parent under new parent.
938
     *
939
     * @param array $actionMap
940
     * @param mixed $oldParentAliasId
941
     * @param mixed $newParentAliasId
942
     */
943
    protected function copySubtree($actionMap, $oldParentAliasId, $newParentAliasId)
944
    {
945
        $rows = $this->gateway->loadAutogeneratedEntries($oldParentAliasId);
946
        $newIdsMap = [];
947
        foreach ($rows as $row) {
948
            $oldParentAliasId = $row['id'];
949
950
            // Ensure that same action entries remain grouped by the same id
951
            if (!isset($newIdsMap[$oldParentAliasId])) {
952
                $newIdsMap[$oldParentAliasId] = $this->gateway->getNextId();
953
            }
954
955
            $row['action'] = $actionMap[$row['action']];
956
            $row['parent'] = $newParentAliasId;
957
            $row['id'] = $row['link'] = $newIdsMap[$oldParentAliasId];
958
            $this->gateway->insertRow($row);
959
960
            $this->copySubtree(
961
                $actionMap,
962
                $oldParentAliasId,
963
                $row['id']
964
            );
965
        }
966
    }
967
968
    /**
969
     * @param mixed $oldParentId
970
     * @param mixed $newParentId
971
     *
972
     * @return array
973
     */
974
    protected function getCopiedLocationsMap($oldParentId, $newParentId)
975
    {
976
        $originalLocations = $this->locationGateway->getSubtreeContent($oldParentId);
977
        $copiedLocations = $this->locationGateway->getSubtreeContent($newParentId);
978
979
        $map = [];
980
        foreach ($originalLocations as $index => $originalLocation) {
981
            $map['eznode:' . $originalLocation['node_id']] = 'eznode:' . $copiedLocations[$index]['node_id'];
982
        }
983
984
        return $map;
985
    }
986
987
    public function locationDeleted($locationId): array
988
    {
989
        $action = 'eznode:' . $locationId;
990
        $entry = $this->gateway->loadAutogeneratedEntry($action);
991
        $entryId = $entry['id'];
992
993
        $this->removeSubtree($entryId, $action, $entry['is_original']);
994
995
        // after location-delete process completed
996
        // check if entry had children; if yes - then restore them as nop-type
997
        // for historical aliases relates to that entry
998
        $notDeletedChildrenAliases = $this->gateway->getAllChildrenAliases($entryId);
999
        if (count($notDeletedChildrenAliases) > 0) {
1000
            $this->insertAliasEntryAsNop($entry);
1001
        }
1002
1003
        return $notDeletedChildrenAliases;
1004
    }
1005
1006
    /**
1007
     * Notifies the underlying engine that Locations Content Translation was removed.
1008
     *
1009
     * @param int[] $locationIds all Locations of the Content that got Translation removed
1010
     * @param string $languageCode language code of the removed Translation
1011
     */
1012
    public function translationRemoved(array $locationIds, $languageCode)
1013
    {
1014
        $languageId = $this->languageHandler->loadByLanguageCode($languageCode)->id;
1015
1016
        $actions = [];
1017
        foreach ($locationIds as $locationId) {
1018
            $actions[] = 'eznode:' . $locationId;
1019
        }
1020
        $this->gateway->bulkRemoveTranslation($languageId, $actions);
1021
    }
1022
1023
    /**
1024
     * Recursively removes aliases by given $id and $action.
1025
     *
1026
     * $original parameter is used to limit removal of moved Location aliases to history entries only.
1027
     *
1028
     * @param mixed $id
1029
     * @param string $action
1030
     * @param mixed $original
1031
     */
1032
    protected function removeSubtree($id, $action, $original)
1033
    {
1034
        // Remove first to avoid unnecessary recursion.
1035
        if ($original) {
1036
            // If entry is original remove all for action (history and custom entries included).
1037
            $this->gateway->remove($action);
1038
        } else {
1039
            // Else entry is history, so remove only for action with the id.
1040
            // This means $id grouped history entries are removed, other history, active autogenerated
1041
            // and custom are left alone.
1042
            $this->gateway->remove($action, $id);
1043
        }
1044
1045
        // Load all autogenerated for parent $id, including history.
1046
        $entries = $this->gateway->loadAutogeneratedEntries($id, true);
1047
1048
        foreach ($entries as $entry) {
1049
            $this->removeSubtree($entry['id'], $entry['action'], $entry['is_original']);
1050
        }
1051
    }
1052
1053
    /**
1054
     * @param string $text
1055
     *
1056
     * @return string
1057
     */
1058
    protected function getHash($text)
1059
    {
1060
        return md5(mb_strtolower($text, 'UTF-8'));
1061
    }
1062
1063
    /**
1064
     * {@inheritdoc}
1065
     */
1066
    public function archiveUrlAliasesForDeletedTranslations($locationId, $parentLocationId, array $languageCodes)
1067
    {
1068
        $parentId = $this->getRealAliasId($parentLocationId);
1069
1070
        $data = $this->gateway->loadLocationEntries($locationId);
1071
        // filter removed Translations
1072
        $removedLanguages = array_diff(
1073
            $this->mapper->extractLanguageCodesFromData($data),
1074
            $languageCodes
1075
        );
1076
1077
        if (empty($removedLanguages)) {
1078
            return;
1079
        }
1080
1081
        // map languageCodes to their IDs
1082
        $languageIds = array_map(
1083
            function ($languageCode) {
1084
                return $this->languageHandler->loadByLanguageCode($languageCode)->id;
1085
            },
1086
            $removedLanguages
1087
        );
1088
1089
        $this->gateway->archiveUrlAliasesForDeletedTranslations($locationId, $parentId, $languageIds);
1090
    }
1091
1092
    /**
1093
     * Remove corrupted URL aliases (global, custom and system).
1094
     *
1095
     * @return int Number of removed URL aliases
1096
     *
1097
     * @throws \Exception
1098
     */
1099
    public function deleteCorruptedUrlAliases()
1100
    {
1101
        $this->transactionHandler->beginTransaction();
1102
        try {
1103
            $totalCount = $this->gateway->deleteUrlAliasesWithoutLocation();
1104
            $totalCount += $this->gateway->deleteUrlAliasesWithoutParent();
1105
            $totalCount += $this->gateway->deleteUrlAliasesWithBrokenLink();
1106
            $totalCount += $this->gateway->deleteUrlNopAliasesWithoutChildren();
1107
1108
            $this->transactionHandler->commit();
1109
1110
            return $totalCount;
1111
        } catch (\Exception $e) {
1112
            $this->transactionHandler->rollback();
1113
            throw $e;
1114
        }
1115
    }
1116
1117
    /**
1118
     * Attempt repairing auto-generated URL aliases for the given Location (including history).
1119
     *
1120
     * Note: it is assumed that at this point original, working, URL Alias for Location is published.
1121
     *
1122
     * @param int $locationId
1123
     *
1124
     * @throws \eZ\Publish\Core\Base\Exceptions\BadStateException
1125
     */
1126
    public function repairBrokenUrlAliasesForLocation(int $locationId)
1127
    {
1128
        try {
1129
            $this->gateway->repairBrokenUrlAliasesForLocation($locationId);
1130
        } catch (\RuntimeException $e) {
1131
            throw new BadStateException('locationId', $e->getMessage(), $e);
1132
        }
1133
    }
1134
1135
    private function insertAliasEntryAsNop(array $aliasEntry): void
1136
    {
1137
        $aliasEntry['action'] = 'nop:';
1138
        $aliasEntry['action_type'] = 'nop';
1139
1140
        $this->gateway->insertRow($aliasEntry);
1141
    }
1142
1143
    /**
1144
     * Internal publish custom aliases method, accepting language mask to set correct language mask on url aliases
1145
     * new alias ID (used when swapping Locations).
1146
     */
1147
    private function internalPublishCustomUrlAliasForLocation(SwappedLocationProperties $location, int $languageMask)
1148
    {
1149
        foreach ($location->entries as $entry) {
1150
            if ((int)$entry['is_alias'] === 0) {
1151
                continue;
1152
            }
1153
1154
            $mask = (int)$entry['lang_mask'] & $languageMask;
1155
1156
            if ($mask <= 1) {
1157
                continue;
1158
            }
1159
1160
            $this->gateway->updateRow(
1161
                (int)$entry['parent'],
1162
                $entry['text_md5'],
1163
                [
1164
                    'id' => (int)$entry['id'],
1165
                    'is_original' => 1,
1166
                    'is_alias' => 1,
1167
                    'lang_mask' => $mask,
1168
                ]
1169
            );
1170
        }
1171
    }
1172
}
1173