Passed
Push — develop ( a6ed4d...77e1ed )
by Andrew
04:27
created

Redirects::findRedirectMatch()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 28
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 15
dl 0
loc 28
rs 9.7666
c 0
b 0
f 0
cc 4
nc 4
nop 2
1
<?php
2
/**
3
 * Retour plugin for Craft CMS 3.x
4
 *
5
 * Retour allows you to intelligently redirect legacy URLs, so that you don't
6
 * lose SEO value when rebuilding & restructuring a website
7
 *
8
 * @link      https://nystudio107.com/
9
 * @copyright Copyright (c) 2018 nystudio107
10
 */
11
12
namespace nystudio107\retour\services;
13
14
use craft\base\Plugin;
15
use nystudio107\retour\Retour;
16
use nystudio107\retour\models\StaticRedirects as StaticRedirectsModel;
17
18
use Craft;
19
use craft\base\Component;
20
use craft\db\Query;
21
use craft\errors\SiteNotFoundException;
22
use craft\helpers\Db;
23
use craft\helpers\UrlHelper;
24
25
use yii\base\ExitException;
26
use yii\base\InvalidConfigException;
27
use yii\caching\TagDependency;
28
use yii\db\Exception;
29
30
/** @noinspection MissingPropertyAnnotationsInspection */
31
32
/**
33
 * @author    nystudio107
34
 * @package   Retour
35
 * @since     3.0.0
36
 */
37
class Redirects extends Component
38
{
39
    // Constants
40
    // =========================================================================
41
42
    const CACHE_KEY = 'retour_redirect_';
43
44
    const GLOBAL_REDIRECTS_CACHE_TAG = 'retour_redirects';
45
46
    // Protected Properties
47
    // =========================================================================
48
49
    /**
50
     * @var null|array
51
     */
52
    protected $cachedStaticRedirects;
53
54
    // Public Methods
55
    // =========================================================================
56
57
    /**
58
     * Handle 404s by looking for redirects
59
     */
60
    public function handle404()
61
    {
62
        Craft::info(
63
            Craft::t(
64
                'retour',
65
                'A 404 exception occurred'
66
            ),
67
            __METHOD__
68
        );
69
        $request = Craft::$app->getRequest();
70
        // We only want site requests that are not live preview or console requests
71
        if ($request->getIsSiteRequest() && !$request->getIsLivePreview() && !$request->getIsConsoleRequest()) {
72
            // See if we should redirect
73
            try {
74
                $fullUrl = urldecode($request->getAbsoluteUrl());
75
                $pathOnly = urldecode($request->getUrl());
76
            } catch (InvalidConfigException $e) {
77
                Craft::error(
78
                    $e->getMessage(),
79
                    __METHOD__
80
                );
81
                $pathOnly = '';
82
                $fullUrl = '';
83
            }
84
            // Strip the query string if `alwaysStripQueryString` is set
85
            if (Retour::$settings->alwaysStripQueryString) {
86
                $fullUrl = UrlHelper::stripQueryString($fullUrl);
87
                $pathOnly = UrlHelper::stripQueryString($pathOnly);
88
            }
89
            Craft::info(
90
                Craft::t(
91
                    'retour',
92
                    '404 full URL: {fullUrl}, 404 path only: {pathOnly}',
93
                    ['fullUrl' => $fullUrl, 'pathOnly' => $pathOnly]
94
                ),
95
                __METHOD__
96
            );
97
            // Redirect if we find a match, otherwise let Craft handle it
98
            $redirect = $this->findRedirectMatch($fullUrl, $pathOnly);
99
            if (!$this->doRedirect($fullUrl, $pathOnly, $redirect) && !Retour::$settings->alwaysStripQueryString) {
100
                // Try it again without the query string
101
                $fullUrl = UrlHelper::stripQueryString($fullUrl);
102
                $pathOnly = UrlHelper::stripQueryString($pathOnly);
103
                $redirect = $this->findRedirectMatch($fullUrl, $pathOnly);
104
                $this->doRedirect($fullUrl, $pathOnly, $redirect);
105
            }
106
            // Increment the stats
107
            Retour::$plugin->statistics->incrementStatistics($pathOnly, false);
108
        }
109
    }
110
111
    /**
112
     * Do the redirect
113
     *
114
     * @param string     $fullUrl
115
     * @param string     $pathOnly
116
     * @param null|array $redirect
117
     *
118
     * @return bool false if not redirected
119
     */
120
    public function doRedirect(string $fullUrl, string $pathOnly, $redirect): bool
121
    {
122
        $response = Craft::$app->getResponse();
123
        if ($redirect !== null) {
124
            // Figure out what type of source matching was done
125
            $redirectSrcMatch = $redirect['redirectSrcMatch'] ?? 'pathonly';
126
            switch ($redirectSrcMatch) {
127
                case 'pathonly':
128
                    $url = $pathOnly;
129
                    break;
130
                case 'fullurl':
131
                    $url = $fullUrl;
132
                    break;
133
                default:
134
                    $url = $pathOnly;
135
                    break;
136
            }
137
            $dest = $redirect['redirectDestUrl'];
138
            $status = $redirect['redirectHttpCode'];
139
            Craft::info(
140
                Craft::t(
141
                    'retour',
142
                    'Redirecting {url} to {dest} with status {status}',
143
                    ['url' => $url, 'dest' => $dest, 'status' => $status]
144
                ),
145
                __METHOD__
146
            );
147
            // Increment the stats
148
            Retour::$plugin->statistics->incrementStatistics($url, true);
149
            // Redirect the request away
150
            $response->redirect($dest, $status)->send();
151
            try {
152
                Craft::$app->end();
153
            } catch (ExitException $e) {
154
                Craft::error($e->getMessage(), __METHOD__);
155
            }
156
        }
157
158
        return false;
159
    }
160
161
    /**
162
     * @param string $fullUrl
163
     * @param string $pathOnly
164
     *
165
     * @return array|null
166
     */
167
    public function findRedirectMatch(string $fullUrl, string $pathOnly)
168
    {
169
        // Try getting the full URL redirect from the cache
170
        $redirect = $this->getRedirectFromCache($fullUrl);
171
        if ($redirect) {
172
            $this->incrementRedirectHitCount($redirect);
173
            $this->saveRedirectToCache($fullUrl, $redirect);
174
175
            return $redirect;
176
        }
177
178
        // Try getting the path only redirect from the cache
179
        $redirect = $this->getRedirectFromCache($pathOnly);
180
        if ($redirect) {
181
            $this->incrementRedirectHitCount($redirect);
182
            $this->saveRedirectToCache($pathOnly, $redirect);
183
184
            return $redirect;
185
        }
186
187
        // Resolve static redirects
188
        $redirects = $this->getAllStaticRedirects();
189
        $redirect = $this->resolveRedirect($fullUrl, $pathOnly, $redirects);
190
        if ($redirect) {
191
            return $redirect;
192
        }
193
194
        return null;
195
    }
196
197
    /**
198
     * @param $url
199
     *
200
     * @return bool|array
201
     */
202
    public function getRedirectFromCache($url)
203
    {
204
        $cache = Craft::$app->getCache();
205
        $cacheKey = $this::CACHE_KEY.md5($url);
206
        $redirect = $cache->get($cacheKey);
207
        Craft::info(
208
            Craft::t(
209
                'retour',
210
                'Cached redirect hit for {url}',
211
                ['url' => $url]
212
            ),
213
            __METHOD__
214
        );
215
216
        return $redirect;
217
    }
218
219
    /**
220
     * @param  string $url
221
     * @param  array  $redirect
222
     */
223
    public function saveRedirectToCache($url, $redirect)
224
    {
225
        $cacheKey = $this::CACHE_KEY.md5($url);
226
        $cache = Craft::$app->getCache();
227
        // Get the current site id
228
        $sites = Craft::$app->getSites();
229
        try {
230
            $siteId = $sites->getCurrentSite()->id;
231
        } catch (SiteNotFoundException $e) {
232
            $siteId = 1;
233
        }
234
        // Create the dependency tags
235
        $dependency = new TagDependency([
236
            'tags' => [
237
                $this::GLOBAL_REDIRECTS_CACHE_TAG,
238
                $this::GLOBAL_REDIRECTS_CACHE_TAG.$siteId,
239
            ],
240
        ]);
241
        $cache->set($cacheKey, $redirect, Retour::$cacheDuration, $dependency);
242
        Craft::info(
243
            Craft::t(
244
                'retour',
245
                'Cached redirect saved for {url}',
246
                ['url' => $url]
247
            ),
248
            __METHOD__
249
        );
250
    }
251
252
    /**
253
     * @param string $fullUrl
254
     * @param string $pathOnly
255
     * @param array  $redirects
256
     *
257
     * @return array|null
258
     */
259
    public function resolveRedirect(string $fullUrl, string $pathOnly, array $redirects)
260
    {
261
        $result = null;
262
        foreach ($redirects as $redirect) {
263
            // Figure out what type of source matching to do
264
            $redirectSrcMatch = $redirect['redirectSrcMatch'] ?? 'pathonly';
265
            switch ($redirectSrcMatch) {
266
                case 'pathonly':
267
                    $url = $pathOnly;
268
                    break;
269
                case 'fullurl':
270
                    $url = $fullUrl;
271
                    break;
272
                default:
273
                    $url = $pathOnly;
274
                    break;
275
            }
276
            $redirectMatchType = $redirect['redirectMatchType'] ?? 'notfound';
277
            switch ($redirectMatchType) {
278
                // Do a straight up match
279
                case 'exactmatch':
280
                    if (strcasecmp($redirect['redirectSrcUrlParsed'], $url) === 0) {
281
                        $this->incrementRedirectHitCount($redirect);
282
                        $this->saveRedirectToCache($url, $redirect);
283
284
                        return $redirect;
285
                    }
286
                    break;
287
288
                // Do a regex match
289
                case 'regexmatch':
290
                    $matchRegEx = '`'.$redirect['redirectSrcUrlParsed'].'`i';
291
                    if (preg_match($matchRegEx, $url) === 1) {
292
                        $this->incrementRedirectHitCount($redirect);
293
                        // If we're not associated with an EntryID, handle capture group replacement
294
                        if ((int)$redirect['associatedElementId'] === 0) {
295
                            $redirect['redirectDestUrl'] = preg_replace(
296
                                $matchRegEx,
297
                                $redirect['redirectDestUrl'],
298
                                $url
299
                            );
300
                        }
301
                        $this->saveRedirectToCache($url, $redirect);
302
303
                        return $redirect;
304
                    }
305
                    break;
306
307
                // Otherwise try to look up a plugin's method by and call it for the match
308
                default:
309
                    $plugin = $redirectMatchType ? Craft::$app->getPlugins()->getPlugin($redirectMatchType) : null;
310
                    if ($plugin && method_exists($plugin, 'retourMatch')) {
311
                        $args = [
312
                            [
313
                                'redirect' => &$redirect,
314
                            ],
315
                        ];
316
                        $result = \call_user_func_array([$plugin, 'retourMatch'], $args);
317
                        if ($result) {
318
                            $this->incrementRedirectHitCount($redirect);
319
                            $this->saveRedirectToCache($url, $redirect);
320
321
                            return $redirect;
322
                        }
323
                    }
324
                    break;
325
            }
326
        }
327
        Craft::info(
328
            Craft::t(
329
                'retour',
330
                'Not handled-> full URL: {fullUrl}, path only: {pathOnly}',
331
                ['fullUrl' => $fullUrl, 'pathOnly' => $pathOnly]
332
            ),
333
            __METHOD__
334
        );
335
336
        return $result;
337
    }
338
339
    /**
340
     * Returns the list of matching schemes
341
     *
342
     * @return  array
343
     */
344
    public function getMatchesList(): array
345
    {
346
        $result = [
347
            'exactmatch' => Craft::t('retour', 'Exact Match'),
348
            'regexmatch' => Craft::t('retour', 'RegEx Match'),
349
        ];
350
351
        // Add any plugins that offer the retourMatch() method
352
        foreach (Craft::$app->getPlugins()->getAllPlugins() as $plugin) {
353
            /** @var Plugin $plugin */
354
            if (method_exists($plugin, 'retourMatch')) {
355
                $result[$plugin->getHandle()] = $plugin->name.Craft::t('retour', ' Match');
356
            }
357
        }
358
359
        return $result;
360
    }
361
362
    /**
363
     * @param null|int $limit
364
     *
365
     * @return array All of the statistics
366
     */
367
    public function getAllStaticRedirects($limit = null): array
368
    {
369
        // Cache it in our class; no need to fetch it more than once
370
        if ($this->cachedStaticRedirects !== null) {
371
            return $this->cachedStaticRedirects;
372
        }
373
        // Query the db table
374
        $query = (new Query())
375
            ->from(['{{%retour_static_redirects}}'])
376
            ->orderBy('redirectMatchType ASC, hitCount DESC');
377
        if ($limit) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $limit of type null|integer is loosely compared to true; this is ambiguous if the integer can be 0. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
378
            $query->limit($limit);
379
        }
380
        $redirects = $query->all();
381
        // Cache for future accesses
382
        $this->cachedStaticRedirects = $redirects;
383
384
        return $redirects;
385
    }
386
387
    /**
388
     * Return a redirect by id
389
     *
390
     * @param int $id
391
     *
392
     * @return null|array The static redirect
393
     */
394
    public function getRedirectById(int $id)
395
    {
396
        // Query the db table
397
        $redirect = (new Query())
398
            ->from(['{{%retour_static_redirects}}'])
399
            ->where(['id' => $id])
400
            ->one();
401
402
        return $redirect;
403
    }
404
405
    /**
406
     * Return a redirect by redirectSrcUrl
407
     *
408
     * @param string $redirectSrcUrl
409
     *
410
     * @return null|array
411
     */
412
    public function getRedirectByRedirectSrcUrl(string $redirectSrcUrl)
413
    {
414
        // Query the db table
415
        $redirect = (new Query())
416
            ->from(['{{%retour_static_redirects}}'])
417
            ->where(['redirectSrcUrl' => $redirectSrcUrl])
418
            ->one();
419
420
        return $redirect;
421
    }
422
423
    /**
424
     * Delete a redirect by id
425
     *
426
     * @param int $id
427
     *
428
     * @return int The result
429
     */
430
    public function deleteRedirectById(int $id): int
431
    {
432
        $db = Craft::$app->getDb();
433
        // Delete a row from the db table
434
        try {
435
            $result = $db->createCommand()->delete(
436
                '{{%retour_static_redirects}}',
437
                [
438
                    'id' => $id,
439
                ]
440
            )->execute();
441
        } catch (Exception $e) {
442
            Craft::error($e->getMessage(), __METHOD__);
443
            $result = 0;
444
        }
445
446
        return $result;
447
    }
448
449
    /**
450
     * Increment the retour_static_redirects record
451
     *
452
     * @param $redirectConfig
453
     */
454
    public function incrementRedirectHitCount(&$redirectConfig)
455
    {
456
        if ($redirectConfig !== null) {
457
            $db = Craft::$app->getDb();
458
            $redirectConfig['hitCount']++;
459
            $redirectConfig['hitLastTime'] = Db::prepareDateForDb(new \DateTime());
460
            Craft::debug(
461
                Craft::t(
462
                    'retour',
463
                    'Incrementing statistics for: {redirect}',
464
                    ['redirect' => print_r($redirectConfig, true)]
465
                ),
466
                __METHOD__
467
            );
468
            // Update the existing record
469
            try {
470
                $rowsAffected = $db->createCommand()->update(
471
                    '{{%retour_static_redirects}}',
472
                    [
473
                        'hitCount' => $redirectConfig['hitCount'],
474
                        'hitLastTime' => $redirectConfig['hitLastTime'],
475
                    ],
476
                    [
477
                        'id' => $redirectConfig['id'],
478
                    ]
479
                )->execute();
480
                Craft::debug('Rows affected: '.$rowsAffected, __METHOD__);
481
            } catch (Exception $e) {
482
                Craft::error($e->getMessage(), __METHOD__);
483
            }
484
        }
485
    }
486
487
    /**
488
     * @param array $redirectConfig
489
     */
490
    public function saveRedirect(array $redirectConfig)
491
    {
492
        // Validate the model before saving it to the db
493
        $redirect = new StaticRedirectsModel($redirectConfig);
494
        if ($redirect->validate() === false) {
495
            Craft::error(
496
                Craft::t(
497
                    'retour',
498
                    'Error validating redirect {id}: {errors}',
499
                    ['id' => $redirect->id, 'errors' => print_r($redirect->getErrors(), true)]
500
                ),
501
                __METHOD__
502
            );
503
504
            return;
505
        }
506
        // Get the validated model attributes and save them to the db
507
        $redirectConfig = $redirect->getAttributes();
508
        $db = Craft::$app->getDb();
509
        // See if a redirect exists with this source URL already
510
        if ((int)$redirectConfig['id'] === 0) {
511
            // Query the db table
512
            $redirect = (new Query())
513
                ->from(['{{%retour_static_redirects}}'])
514
                ->where(['redirectSrcUrlParsed' => $redirectConfig['redirectSrcUrlParsed']])
515
                ->one();
516
            // If it exists, update it rather than having duplicates
517
            if (!empty($redirect)) {
518
                $redirectConfig['id'] = $redirect['id'];
519
            }
520
        }
521
        if ((int)$redirectConfig['id'] !== 0) {
522
            Craft::debug(
523
                Craft::t(
524
                    'retour',
525
                    'Updating existing redirect: {redirect}',
526
                    ['redirect' => print_r($redirectConfig, true)]
527
                ),
528
                __METHOD__
529
            );
530
            // Update the existing record
531
            try {
532
                $db->createCommand()->update(
533
                    '{{%retour_static_redirects}}',
534
                    $redirectConfig,
535
                    [
536
                        'id' => $redirectConfig['id'],
537
                    ]
538
                )->execute();
539
            } catch (Exception $e) {
540
                Craft::error($e->getMessage(), __METHOD__);
541
            }
542
        } else {
543
            Craft::debug(
544
                Craft::t(
545
                    'retour',
546
                    'Creating new redirect: {redirect}',
547
                    ['redirect' => print_r($redirectConfig, true)]
548
                ),
549
                __METHOD__
550
            );
551
            unset($redirectConfig['id']);
552
            // Create a new record
553
            try {
554
                $db->createCommand()->insert(
555
                    '{{%retour_static_redirects}}',
556
                    $redirectConfig
557
                )->execute();
558
            } catch (Exception $e) {
559
                Craft::error($e->getMessage(), __METHOD__);
560
            }
561
        }
562
        // To prevent redirect loops, see if any static redirects have our redirectDestUrl as their redirectSrcUrl
563
        $testRedirectConfig = $this->getRedirectByRedirectSrcUrl($redirectConfig['redirectDestUrl']);
564
        if ($testRedirectConfig !== null) {
565
            Craft::debug(
566
                Craft::t(
567
                    'retour',
568
                    'Deleting redirect to prevent a loop: {redirect}',
569
                    ['redirect' => print_r($testRedirectConfig, true)]
570
                ),
571
                __METHOD__
572
            );
573
            // Delete the redirect that has a redirectSrcUrl the same as this record's redirectDestUrl
574
            try {
575
                $db->createCommand()->delete(
576
                    '{{%retour_static_redirects}}',
577
                    ['id' => $testRedirectConfig['id']]
578
                )->execute();
579
            } catch (Exception $e) {
580
                Craft::error($e->getMessage(), __METHOD__);
581
            }
582
        }
583
    }
584
585
    /**
586
     * Invalidate all of the redirects caches
587
     */
588
    public function invalidateCaches()
589
    {
590
        $cache = Craft::$app->getCache();
591
        TagDependency::invalidate($cache, $this::GLOBAL_REDIRECTS_CACHE_TAG);
592
        Craft::info(
593
            Craft::t(
594
                'retour',
595
                'All redirect caches cleared'
596
            ),
597
            __METHOD__
598
        );
599
    }
600
}
601