Passed
Push — v3 ( 2b006a...43f239 )
by Andrew
33:34 queued 16:32
created

src/services/Redirects.php (1 issue)

an empty catch block is always commented.

Coding Style Comprehensibility Informational
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 nystudio107\retour\Retour;
15
use nystudio107\retour\events\RedirectEvent;
16
use nystudio107\retour\events\ResolveRedirectEvent;
17
use nystudio107\retour\events\RedirectResolvedEvent;
18
use nystudio107\retour\helpers\UrlHelper;
19
use nystudio107\retour\models\StaticRedirects as StaticRedirectsModel;
20
21
use Craft;
22
use craft\base\Component;
23
use craft\base\Plugin;
24
use craft\db\Query;
25
use craft\errors\SiteNotFoundException;
26
use craft\helpers\Db;
27
28
use yii\base\ExitException;
29
use yii\base\InvalidConfigException;
30
use yii\base\InvalidRouteException;
31
use yii\caching\TagDependency;
32
use yii\db\Exception;
33
34
/** @noinspection MissingPropertyAnnotationsInspection */
35
36
/**
37
 * @author    nystudio107
38
 * @package   Retour
39
 * @since     3.0.0
40
 */
41
class Redirects extends Component
42
{
43
    // Constants
44
    // =========================================================================
45
46
    const CACHE_KEY = 'retour_redirect_';
47
48
    const GLOBAL_REDIRECTS_CACHE_TAG = 'retour_redirects';
49
50
    const EVENT_REDIRECT_ID = 0;
51
52
    /**
53
     * @event RedirectEvent The event that is triggered before the redirect is saved
54
     * You may set [[RedirectEvent::isValid]] to `false` to prevent the redirect from getting saved.
55
     *
56
     * ```php
57
     * use nystudio107\retour\services\Redirects;
58
     * use nystudio107\retour\events\RedirectEvent;
59
     *
60
     * Event::on(Redirects::class,
61
     *     Redirects::EVENT_BEFORE_SAVE_REDIRECT,
62
     *     function(RedirectEvent $event) {
63
     *         // potentially set $event->isValid;
64
     *     }
65
     * );
66
     * ```
67
     */
68
    const EVENT_BEFORE_SAVE_REDIRECT = 'beforeSaveRedirect';
69
70
    /**
71
     * @event RedirectEvent The event that is triggered after the redirect is saved
72
     *
73
     * ```php
74
     * use nystudio107\retour\services\Redirects;
75
     * use nystudio107\retour\events\RedirectEvent;
76
     *
77
     * Event::on(Redirects::class,
78
     *     Redirects::EVENT_AFTER_SAVE_REDIRECT,
79
     *     function(RedirectEvent $event) {
80
     *         // the redirect was saved
81
     *     }
82
     * );
83
     * ```
84
     */
85
    const EVENT_AFTER_SAVE_REDIRECT = 'afterSaveRedirect';
86
87
    /**
88
     * @event ResolveRedirectEvent The event that is triggered before Retour has attempted
89
     *        to resolve redirects. You may set [[ResolveRedirectEvent::redirectDestUrl]] to
90
     *        to the URL that it should redirect to, or null if no redirect should happen
91
     *
92
     * ```php
93
     * use nystudio107\retour\services\Redirects;
94
     * use nystudio107\retour\events\ResolveRedirectEvent;
95
     *
96
     * Event::on(Redirects::class,
97
     *     Redirects::EVENT_AFTER_SAVE_REDIRECT,
98
     *     function(ResolveRedirectEvent $event) {
99
     *         // potentially set $event->redirectDestUrl;
100
     *     }
101
     * );
102
     * ```
103
     */
104
    const EVENT_BEFORE_RESOLVE_REDIRECT = 'beforeResolveRedirect';
105
106
    /**
107
     * @event ResolveRedirectEvent The event that is triggered after Retour has attempted
108
     *        to resolve redirects. You may set [[ResolveRedirectEvent::redirectDestUrl]] to
109
     *        to the URL that it should redirect to, or null if no redirect should happen
110
     *
111
     * ```php
112
     * use nystudio107\retour\services\Redirects;
113
     * use nystudio107\retour\events\ResolveRedirectEvent;
114
     *
115
     * Event::on(Redirects::class,
116
     *     Redirects::EVENT_AFTER_RESOLVE_REDIRECT,
117
     *     function(ResolveRedirectEvent $event) {
118
     *         // potentially set $event->redirectDestUrl;
119
     *     }
120
     * );
121
     * ```
122
     */
123
    const EVENT_AFTER_RESOLVE_REDIRECT = 'afterResolveRedirect';
124
125
    /**
126
     * @event RedirectResolvedEvent The event that is triggered once Retour has resolved
127
     *        a redirect. [[RedirectResolvedEvent::redirect]] will be set to the redirect
128
     *        that was resolved. You may set [[RedirectResolvedEvent::redirectDestUrl]] to
129
     *        to a different URL that it should redirect to, or leave it null if the
130
     *        redirect should happen as resolved.
131
     *
132
     * ```php
133
     * use nystudio107\retour\services\Redirects;
134
     * use nystudio107\retour\events\RedirectResolvedEvent;
135
     *
136
     * Event::on(Redirects::class,
137
     *     Redirects::EVENT_REDIRECT_RESOLVED,
138
     *     function(RedirectResolvedEvent $event) {
139
     *         // potentially set $event->redirectDestUrl;
140
     *     }
141
     * );
142
     * ```
143
     */
144
    const EVENT_REDIRECT_RESOLVED = 'redirectResolved';
145
146
    // Protected Properties
147
    // =========================================================================
148
149
    /**
150
     * @var null|array
151
     */
152
    protected $cachedStaticRedirects;
153
154
    // Public Methods
155
    // =========================================================================
156
157
    /**
158
     * Handle 404s by looking for redirects
159
     */
160
    public function handle404()
161
    {
162
        Craft::info(
163
            Craft::t(
164
                'retour',
165
                'A 404 exception occurred'
166
            ),
167
            __METHOD__
168
        );
169
        $request = Craft::$app->getRequest();
170
        // We only want site requests that are not live preview or console requests
171
        if ($request->getIsSiteRequest() && !$this->isPreview($request) && !$request->getIsConsoleRequest()) {
172
            // See if we should redirect
173
            try {
174
                $fullUrl = urldecode($request->getAbsoluteUrl());
175
                $pathOnly = urldecode($request->getUrl());
176
            } catch (InvalidConfigException $e) {
177
                Craft::error(
178
                    $e->getMessage(),
179
                    __METHOD__
180
                );
181
                $pathOnly = '';
182
                $fullUrl = '';
183
            }
184
            // Strip the query string if `alwaysStripQueryString` is set
185
            if (Retour::$settings->alwaysStripQueryString) {
186
                $fullUrl = UrlHelper::stripQueryString($fullUrl);
187
                $pathOnly = UrlHelper::stripQueryString($pathOnly);
188
            }
189
            // Stash the $pathOnly for use when incrementing the statistics
190
            $originalPathOnly = $pathOnly;
191
            Craft::info(
192
                Craft::t(
193
                    'retour',
194
                    '404 full URL: {fullUrl}, 404 path only: {pathOnly}',
195
                    ['fullUrl' => $fullUrl, 'pathOnly' => $pathOnly]
196
                ),
197
                __METHOD__
198
            );
199
            if (!$this->excludeUri($pathOnly)) {
200
                // Redirect if we find a match, otherwise let Craft handle it
201
                $redirect = $this->findRedirectMatch($fullUrl, $pathOnly);
202
                if (!$this->doRedirect($fullUrl, $pathOnly, $redirect) && !Retour::$settings->alwaysStripQueryString) {
203
                    // Try it again without the query string
204
                    $fullUrl = UrlHelper::stripQueryString($fullUrl);
205
                    $pathOnly = UrlHelper::stripQueryString($pathOnly);
206
                    $redirect = $this->findRedirectMatch($fullUrl, $pathOnly);
207
                    $this->doRedirect($fullUrl, $pathOnly, $redirect);
208
                }
209
                // Increment the stats
210
                Retour::$plugin->statistics->incrementStatistics($originalPathOnly, false);
211
            }
212
        }
213
    }
214
215
    /**
216
     * Do the redirect
217
     *
218
     * @param string     $fullUrl
219
     * @param string     $pathOnly
220
     * @param null|array $redirect
221
     *
222
     * @return bool false if not redirected
223
     */
224
    public function doRedirect(string $fullUrl, string $pathOnly, $redirect): bool
225
    {
226
        $response = Craft::$app->getResponse();
227
        if ($redirect !== null) {
228
            // Figure out what type of source matching was done
229
            $redirectSrcMatch = $redirect['redirectSrcMatch'] ?? 'pathonly';
230
            switch ($redirectSrcMatch) {
231
                case 'pathonly':
232
                    $url = $pathOnly;
233
                    break;
234
                case 'fullurl':
235
                    $url = $fullUrl;
236
                    break;
237
                default:
238
                    $url = $pathOnly;
239
                    break;
240
            }
241
            $dest = $redirect['redirectDestUrl'];
242
            // If this isn't a full URL, make it one based on the appropriate site
243
            if (!UrlHelper::isFullUrl($dest)) {
244
                try {
245
                    $siteId = $redirect['siteId'] ?? null;
246
                    if ($siteId !== null) {
247
                        $siteId = (int)$siteId;
248
                    }
249
                    $dest = UrlHelper::siteUrl($dest, null, null, $siteId);
250
                } catch (\yii\base\Exception $e) {
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment why this CATCH block is empty.
Loading history...
251
                }
252
            }
253
            if (Retour::$settings->preserveQueryString) {
254
                $request = Craft::$app->getRequest();
255
                if (!empty($request->getQueryStringWithoutPath())) {
256
                    $dest .= '?' . $request->getQueryStringWithoutPath();
257
                }
258
            }
259
            $redirectMatchType = $redirect['redirectMatchType'] ?? 'notfound';
260
            // Parse reference tags for exact matches
261
            if ($redirectMatchType === 'exactmatch') {
262
                $dest = Craft::$app->elements->parseRefs($dest, $redirect['siteId'] ?? null);
263
            }
264
            $status = $redirect['redirectHttpCode'];
265
            Craft::info(
266
                Craft::t(
267
                    'retour',
268
                    'Redirecting {url} to {dest} with status {status}',
269
                    ['url' => $url, 'dest' => $dest, 'status' => $status]
270
                ),
271
                __METHOD__
272
            );
273
            // Increment the stats
274
            Retour::$plugin->statistics->incrementStatistics($url, true);
275
            // Handle a Retour return status > 400 to render the actual error template
276
            if ($status >= 400) {
277
                Retour::$currentException->statusCode = $status;
278
                $errorHandler = Craft::$app->getErrorHandler();
279
                $errorHandler->exception = Retour::$currentException;
280
                try {
281
                    $response = Craft::$app->runAction('templates/render-error');
282
                } catch (InvalidRouteException $e) {
283
                    Craft::error($e->getMessage(), __METHOD__);
284
                } catch (\yii\console\Exception $e) {
285
                    Craft::error($e->getMessage(), __METHOD__);
286
                }
287
            }
288
            // Sanitize the URL
289
            $dest = UrlHelper::sanitizeUrl($dest);
290
            // Add any additional headers (existing ones will be replaced)
291
            if (!empty(Retour::$settings->additionalHeaders)) {
292
                foreach (Retour::$settings->additionalHeaders as $additionalHeader) {
293
                    $response->headers->set($additionalHeader['name'], $additionalHeader['value']);
294
                }
295
            }
296
            // Redirect the request away;
297
            $response->redirect($dest, $status)->send();
298
            try {
299
                Craft::$app->end();
300
            } catch (ExitException $e) {
301
                Craft::error($e->getMessage(), __METHOD__);
302
            }
303
        }
304
305
        return false;
306
    }
307
308
    /**
309
     * @param string $fullUrl
310
     * @param string $pathOnly
311
     * @param null   $siteId
312
     *
313
     * @return array|null
314
     */
315
    public function findRedirectMatch(string $fullUrl, string $pathOnly, $siteId = null)
316
    {
317
        // Get the current site
318
        if ($siteId === null) {
319
            $currentSite = Craft::$app->getSites()->currentSite;
320
            if ($currentSite) {
321
                $siteId = $currentSite->id;
322
            } else {
323
                $primarySite = Craft::$app->getSites()->primarySite;
324
                if ($currentSite) {
325
                    $siteId = $primarySite->id;
326
                }
327
            }
328
        }
329
        // Try getting the full URL redirect from the cache
330
        $redirect = $this->getRedirectFromCache($fullUrl, $siteId);
331
        if ($redirect) {
332
            $this->incrementRedirectHitCount($redirect);
333
            $this->saveRedirectToCache($fullUrl, $redirect);
334
335
            return $redirect;
336
        }
337
        // Try getting the path only redirect from the cache
338
        $redirect = $this->getRedirectFromCache($pathOnly, $siteId);
339
        if ($redirect) {
340
            $this->incrementRedirectHitCount($redirect);
341
            $this->saveRedirectToCache($pathOnly, $redirect);
342
343
            return $redirect;
344
        }
345
        // Resolve static redirects
346
        $redirects = $this->getAllStaticRedirects(null, $siteId);
347
        $redirect = $this->resolveRedirect($fullUrl, $pathOnly, $redirects);
348
        if ($redirect) {
349
            return $redirect;
350
        }
351
352
        return null;
353
    }
354
355
    /**
356
     * @param          $url
357
     * @param int|null $siteId
358
     *
359
     * @return bool|array
360
     */
361
    public function getRedirectFromCache($url, int $siteId = 0)
362
    {
363
        $cache = Craft::$app->getCache();
364
        $cacheKey = $this::CACHE_KEY.md5($url).$siteId;
365
        $redirect = $cache->get($cacheKey);
366
        Craft::info(
367
            Craft::t(
368
                'retour',
369
                'Cached redirect hit for {url}',
370
                ['url' => $url]
371
            ),
372
            __METHOD__
373
        );
374
375
        return $redirect;
376
    }
377
378
    /**
379
     * @param  string $url
380
     * @param  array  $redirect
381
     */
382
    public function saveRedirectToCache($url, $redirect)
383
    {
384
        $cache = Craft::$app->getCache();
385
        // Get the current site id
386
        $sites = Craft::$app->getSites();
387
        try {
388
            $siteId = $sites->getCurrentSite()->id;
389
        } catch (SiteNotFoundException $e) {
390
            $siteId = 1;
391
        }
392
        $cacheKey = $this::CACHE_KEY.md5($url).$siteId;
393
        // Create the dependency tags
394
        $dependency = new TagDependency([
395
            'tags' => [
396
                $this::GLOBAL_REDIRECTS_CACHE_TAG,
397
                $this::GLOBAL_REDIRECTS_CACHE_TAG.$siteId,
398
            ],
399
        ]);
400
        $cache->set($cacheKey, $redirect, Retour::$cacheDuration, $dependency);
401
        Craft::info(
402
            Craft::t(
403
                'retour',
404
                'Cached redirect saved for {url}',
405
                ['url' => $url]
406
            ),
407
            __METHOD__
408
        );
409
    }
410
411
    /**
412
     * @param string $fullUrl
413
     * @param string $pathOnly
414
     * @param array  $redirects
415
     *
416
     * @return array|null
417
     */
418
    public function resolveRedirect(string $fullUrl, string $pathOnly, array $redirects)
419
    {
420
        $result = null;
421
        // Throw the Redirects::EVENT_BEFORE_RESOLVE_REDIRECT event
422
        $event = new ResolveRedirectEvent([
423
            'fullUrl' => $fullUrl,
424
            'pathOnly' => $pathOnly,
425
            'redirectDestUrl' => null,
426
            'redirectHttpCode' => 301,
427
        ]);
428
        $this->trigger(self::EVENT_BEFORE_RESOLVE_REDIRECT, $event);
429
        if ($event->redirectDestUrl !== null) {
430
            return $this->resolveEventRedirect($event);
431
        }
432
        // Iterate through the redirects
433
        foreach ($redirects as $redirect) {
434
            // Figure out what type of source matching to do
435
            $redirectSrcMatch = $redirect['redirectSrcMatch'] ?? 'pathonly';
436
            $redirectEnabled = (bool)$redirect['enabled'];
437
            if ($redirectEnabled === true) {
438
                switch ($redirectSrcMatch) {
439
                    case 'pathonly':
440
                        $url = $pathOnly;
441
                        break;
442
                    case 'fullurl':
443
                        $url = $fullUrl;
444
                        break;
445
                    default:
446
                        $url = $pathOnly;
447
                        break;
448
                }
449
                $redirectMatchType = $redirect['redirectMatchType'] ?? 'notfound';
450
                switch ($redirectMatchType) {
451
                    // Do a straight up match
452
                    case 'exactmatch':
453
                        if (strcasecmp($redirect['redirectSrcUrlParsed'], $url) === 0) {
454
                            $this->incrementRedirectHitCount($redirect);
455
                            $this->saveRedirectToCache($url, $redirect);
456
457
                            // Throw the Redirects::EVENT_REDIRECT_RESOLVED event
458
                            $event = new RedirectResolvedEvent([
459
                                'fullUrl' => $fullUrl,
460
                                'pathOnly' => $pathOnly,
461
                                'redirectDestUrl' => null,
462
                                'redirectHttpCode' => 301,
463
                                'redirect' => $redirect,
464
                            ]);
465
                            $this->trigger(self::EVENT_REDIRECT_RESOLVED, $event);
466
                            if ($event->redirectDestUrl !== null) {
467
                                return $this->resolveEventRedirect($event, $url, $redirect);
468
                            }
469
470
                            return $redirect;
471
                        }
472
                        break;
473
474
                    // Do a regex match
475
                    case 'regexmatch':
476
                        $matchRegEx = '`'.$redirect['redirectSrcUrlParsed'].'`i';
477
                        try {
478
                            if (preg_match($matchRegEx, $url) === 1) {
479
                                $this->incrementRedirectHitCount($redirect);
480
                                // If we're not associated with an EntryID, handle capture group replacement
481
                                if ((int)$redirect['associatedElementId'] === 0) {
482
                                    $redirect['redirectDestUrl'] = preg_replace(
483
                                        $matchRegEx,
484
                                        $redirect['redirectDestUrl'],
485
                                        $url
486
                                    );
487
                                }
488
                                $url = preg_replace('/([^:])(\/{2,})/', '$1/', $url);
489
                                $this->saveRedirectToCache($url, $redirect);
490
491
                                // Throw the Redirects::EVENT_REDIRECT_RESOLVED event
492
                                $event = new RedirectResolvedEvent([
493
                                    'fullUrl' => $fullUrl,
494
                                    'pathOnly' => $pathOnly,
495
                                    'redirectDestUrl' => null,
496
                                    'redirectHttpCode' => 301,
497
                                    'redirect' => $redirect,
498
                                ]);
499
                                $this->trigger(self::EVENT_REDIRECT_RESOLVED, $event);
500
                                if ($event->redirectDestUrl !== null) {
501
                                    return $this->resolveEventRedirect($event, $url, $redirect);
502
                                }
503
504
                                return $redirect;
505
                            }
506
                        } catch (\Exception $e) {
507
                            // That's fine
508
                            Craft::error('Invalid Redirect Regex: '.$matchRegEx, __METHOD__);
509
                        }
510
511
                        break;
512
513
                    // Otherwise try to look up a plugin's method by and call it for the match
514
                    default:
515
                        $plugin = $redirectMatchType ? Craft::$app->getPlugins()->getPlugin($redirectMatchType) : null;
516
                        if ($plugin && method_exists($plugin, 'retourMatch')) {
517
                            $args = [
518
                                [
519
                                    'redirect' => &$redirect,
520
                                ],
521
                            ];
522
                            $result = \call_user_func_array([$plugin, 'retourMatch'], $args);
523
                            if ($result) {
524
                                $this->incrementRedirectHitCount($redirect);
525
                                $this->saveRedirectToCache($url, $redirect);
526
527
                                // Throw the Redirects::EVENT_REDIRECT_RESOLVED event
528
                                $event = new RedirectResolvedEvent([
529
                                    'fullUrl' => $fullUrl,
530
                                    'pathOnly' => $pathOnly,
531
                                    'redirectDestUrl' => null,
532
                                    'redirectHttpCode' => 301,
533
                                    'redirect' => $redirect,
534
                                ]);
535
                                $this->trigger(self::EVENT_REDIRECT_RESOLVED, $event);
536
                                if ($event->redirectDestUrl !== null) {
537
                                    return $this->resolveEventRedirect($event, $url, $redirect);
538
                                }
539
540
                                return $redirect;
541
                            }
542
                        }
543
                        break;
544
                }
545
            }
546
        }
547
        // Throw the Redirects::EVENT_AFTER_RESOLVE_REDIRECT event
548
        $event = new ResolveRedirectEvent([
549
            'fullUrl' => $fullUrl,
550
            'pathOnly' => $pathOnly,
551
            'redirectDestUrl' => null,
552
            'redirectHttpCode' => 301,
553
        ]);
554
        $this->trigger(self::EVENT_AFTER_RESOLVE_REDIRECT, $event);
555
        if ($event->redirectDestUrl !== null) {
556
            return $this->resolveEventRedirect($event);
557
        }
558
        Craft::info(
559
            Craft::t(
560
                'retour',
561
                'Not handled-> full URL: {fullUrl}, path only: {pathOnly}',
562
                ['fullUrl' => $fullUrl, 'pathOnly' => $pathOnly]
563
            ),
564
            __METHOD__
565
        );
566
567
        return $result;
568
    }
569
570
    /**
571
     * @param ResolveRedirectEvent $event
572
     *
573
     * @return null|array
574
     */
575
    public function resolveEventRedirect(ResolveRedirectEvent $event, $url = null, $redirect = null)
576
    {
577
        $result = null;
578
579
        if ($event->redirectDestUrl !== null) {
580
            $resolvedRedirect = new StaticRedirectsModel([
581
                'id' => self::EVENT_REDIRECT_ID,
582
                'redirectDestUrl' => $event->redirectDestUrl,
583
                'redirectHttpCode' => $event->redirectHttpCode,
584
            ]);
585
            $result = $resolvedRedirect->toArray();
586
587
            if ($url !== null && $redirect !== null) {
588
                // Save the modified redirect to the cache
589
                $redirect['redirectDestUrl'] = $event->redirectDestUrl;
590
                $redirect['redirectHttpCode'] = $event->redirectHttpCode;
591
                $this->saveRedirectToCache($url, $redirect);
592
            }
593
        }
594
595
        return $result;
596
    }
597
598
    /**
599
     * Returns the list of matching schemes
600
     *
601
     * @return  array
602
     */
603
    public function getMatchesList(): array
604
    {
605
        $result = [
606
            'exactmatch' => Craft::t('retour', 'Exact Match'),
607
            'regexmatch' => Craft::t('retour', 'RegEx Match'),
608
        ];
609
610
        // Add any plugins that offer the retourMatch() method
611
        foreach (Craft::$app->getPlugins()->getAllPlugins() as $plugin) {
612
            /** @var Plugin $plugin */
613
            if (method_exists($plugin, 'retourMatch')) {
614
                $result[$plugin->getHandle()] = $plugin->name.Craft::t('retour', ' Match');
615
            }
616
        }
617
618
        return $result;
619
    }
620
621
    /**
622
     * @param null|int $limit
623
     * @param int|null $siteId
624
     *
625
     * @return array All of the statistics
626
     */
627
    public function getAllStaticRedirects($limit = null, int $siteId = null): array
628
    {
629
        // Cache it in our class; no need to fetch it more than once
630
        if ($this->cachedStaticRedirects !== null) {
631
            return $this->cachedStaticRedirects;
632
        }
633
        // Query the db table
634
        $query = (new Query())
635
            ->from(['{{%retour_static_redirects}}'])
636
            ->orderBy('redirectMatchType ASC, redirectSrcMatch ASC, hitCount DESC');
637
        if ($siteId) {
638
            $query
639
                ->where(['siteId' => $siteId])
640
                ->orWhere(['siteId' => null]);
641
        }
642
        if ($limit) {
643
            $query->limit($limit);
644
        }
645
        $redirects = $query->all();
646
        // Cache for future accesses
647
        $this->cachedStaticRedirects = $redirects;
648
649
        return $redirects;
650
    }
651
652
    /**
653
     * Return a redirect by id
654
     *
655
     * @param int $id
656
     *
657
     * @return null|array The static redirect
658
     */
659
    public function getRedirectById(int $id)
660
    {
661
        // Query the db table
662
        $redirect = (new Query())
663
            ->from(['{{%retour_static_redirects}}'])
664
            ->where(['id' => $id])
665
            ->one();
666
667
        return $redirect;
668
    }
669
670
    /**
671
     * Return a redirect by redirectSrcUrl
672
     *
673
     * @param string   $redirectSrcUrl
674
     * @param int|null $siteId
675
     *
676
     * @return null|array
677
     */
678
    public function getRedirectByRedirectSrcUrl(string $redirectSrcUrl, int $siteId = null)
679
    {
680
        // Query the db table
681
        $query = (new Query())
682
            ->from(['{{%retour_static_redirects}}'])
683
            ->where(['redirectSrcUrl' => $redirectSrcUrl])
684
            ;
685
        if ($siteId) {
686
            $query
687
                ->andWhere(['or', [
688
                    'siteId' => $siteId,
689
                ], [
690
                    'siteId' => null,
691
                ]]);
692
        }
693
        $redirect = $query->one();
694
695
        return $redirect;
696
    }
697
698
    /**
699
     * Delete a redirect by id
700
     *
701
     * @param int $id
702
     *
703
     * @return int The result
704
     */
705
    public function deleteRedirectById(int $id): int
706
    {
707
        $db = Craft::$app->getDb();
708
        // Delete a row from the db table
709
        try {
710
            $result = $db->createCommand()->delete(
711
                '{{%retour_static_redirects}}',
712
                [
713
                    'id' => $id,
714
                ]
715
            )->execute();
716
        } catch (Exception $e) {
717
            Craft::error($e->getMessage(), __METHOD__);
718
            $result = 0;
719
        }
720
721
        return $result;
722
    }
723
724
    /**
725
     * Increment the retour_static_redirects record
726
     *
727
     * @param $redirectConfig
728
     */
729
    public function incrementRedirectHitCount(&$redirectConfig)
730
    {
731
        if ($redirectConfig !== null) {
732
            $db = Craft::$app->getDb();
733
            $redirectConfig['hitCount']++;
734
            $redirectConfig['hitLastTime'] = Db::prepareDateForDb(new \DateTime());
735
            Craft::debug(
736
                Craft::t(
737
                    'retour',
738
                    'Incrementing statistics for: {redirect}',
739
                    ['redirect' => print_r($redirectConfig, true)]
740
                ),
741
                __METHOD__
742
            );
743
            // Update the existing record
744
            try {
745
                $rowsAffected = $db->createCommand()->update(
746
                    '{{%retour_static_redirects}}',
747
                    [
748
                        'hitCount' => $redirectConfig['hitCount'],
749
                        'hitLastTime' => $redirectConfig['hitLastTime'],
750
                    ],
751
                    [
752
                        'id' => $redirectConfig['id'],
753
                    ]
754
                )->execute();
755
                Craft::debug('Rows affected: '.$rowsAffected, __METHOD__);
756
            } catch (\Exception $e) {
757
                Craft::error($e->getMessage(), __METHOD__);
758
            }
759
        }
760
    }
761
762
    /**
763
     * @param array $redirectConfig
764
     */
765
    public function saveRedirect(array $redirectConfig)
766
    {
767
        // Handle URL encoded URLs by decoding them before saving them
768
        if ($redirectConfig['redirectMatchType'] === 'exactmatch') {
769
            $redirectConfig['redirectSrcUrl'] = urldecode($redirectConfig['redirectSrcUrl'] ?? '');
770
            $redirectConfig['redirectSrcUrlParsed'] = urldecode($redirectConfig['redirectSrcUrlParsed'] ?? '');
771
        }
772
        // Validate the model before saving it to the db
773
        $redirect = new StaticRedirectsModel($redirectConfig);
774
        if ($redirect->validate() === false) {
775
            Craft::error(
776
                Craft::t(
777
                    'retour',
778
                    'Error validating redirect {id}: {errors}',
779
                    ['id' => $redirect->id, 'errors' => print_r($redirect->getErrors(), true)]
780
                ),
781
                __METHOD__
782
            );
783
784
            return;
785
        }
786
        // Get the validated model attributes and save them to the db
787
        $redirectConfig = $redirect->getAttributes();
788
        // 0 for a siteId needs to be converted to null
789
        if (empty($redirectConfig['siteId']) || (int)$redirectConfig['siteId'] === 0) {
790
            $redirectConfig['siteId'] = null;
791
        }
792
        // Throw an event to before saving the redirect
793
        $db = Craft::$app->getDb();
794
        // See if a redirect exists with this source URL already
795
        if ((int)$redirectConfig['id'] === 0) {
796
            // Query the db table
797
            $redirect = (new Query())
798
                ->from(['{{%retour_static_redirects}}'])
799
                ->where(['redirectSrcUrlParsed' => $redirectConfig['redirectSrcUrlParsed']])
800
                ->andWhere(['siteId' => $redirectConfig['siteId']])
801
                ->one();
802
            // If it exists, update it rather than having duplicates
803
            if (!empty($redirect)) {
804
                $redirectConfig['id'] = $redirect['id'];
805
            }
806
        }
807
        // Trigger a 'beforeSaveRedirect' event
808
        $isNew = (int)$redirectConfig['id'] === 0;
809
        $event = new RedirectEvent([
810
            'isNew' => $isNew,
811
            'legacyUrl' => $redirectConfig['redirectSrcUrlParsed'],
812
            'destinationUrl' => $redirectConfig['redirectDestUrl'],
813
            'matchType' => $redirectConfig['redirectSrcMatch'],
814
            'redirectType' => $redirectConfig['redirectHttpCode'],
815
        ]);
816
        $this->trigger(self::EVENT_BEFORE_SAVE_REDIRECT, $event);
817
        if (!$event->isValid) {
818
            return;
819
        }
820
        // See if this is an existing redirect
821
        if (!$isNew) {
822
            Craft::debug(
823
                Craft::t(
824
                    'retour',
825
                    'Updating existing redirect: {redirect}',
826
                    ['redirect' => print_r($redirectConfig, true)]
827
                ),
828
                __METHOD__
829
            );
830
            // Update the existing record
831
            try {
832
                $db->createCommand()->update(
833
                    '{{%retour_static_redirects}}',
834
                    $redirectConfig,
835
                    [
836
                        'id' => $redirectConfig['id'],
837
                    ]
838
                )->execute();
839
            } catch (Exception $e) {
840
                Craft::error($e->getMessage(), __METHOD__);
841
            }
842
        } else {
843
            Craft::debug(
844
                Craft::t(
845
                    'retour',
846
                    'Creating new redirect: {redirect}',
847
                    ['redirect' => print_r($redirectConfig, true)]
848
                ),
849
                __METHOD__
850
            );
851
            unset($redirectConfig['id']);
852
            // Create a new record
853
            try {
854
                $db->createCommand()->insert(
855
                    '{{%retour_static_redirects}}',
856
                    $redirectConfig
857
                )->execute();
858
            } catch (Exception $e) {
859
                Craft::error($e->getMessage(), __METHOD__);
860
            }
861
        }
862
        // To prevent redirect loops, see if any static redirects have our redirectDestUrl as their redirectSrcUrl
863
        $testRedirectConfig = $this->getRedirectByRedirectSrcUrl(
864
            $redirectConfig['redirectDestUrl'],
865
            $redirectConfig['siteId']
866
        );
867
        if ($testRedirectConfig !== null) {
868
            Craft::debug(
869
                Craft::t(
870
                    'retour',
871
                    'Deleting redirect to prevent a loop: {redirect}',
872
                    ['redirect' => print_r($testRedirectConfig, true)]
873
                ),
874
                __METHOD__
875
            );
876
            // Delete the redirect that has a redirectSrcUrl the same as this record's redirectDestUrl
877
            try {
878
                $db->createCommand()->delete(
879
                    '{{%retour_static_redirects}}',
880
                    ['id' => $testRedirectConfig['id']]
881
                )->execute();
882
            } catch (Exception $e) {
883
                Craft::error($e->getMessage(), __METHOD__);
884
            }
885
        }
886
        // Trigger a 'afterSaveRedirect' event
887
        $this->trigger(self::EVENT_AFTER_SAVE_REDIRECT, $event);
888
889
        // Invalidate caches after saving a redirect
890
        $this->invalidateCaches();
891
    }
892
893
    /**
894
     * Invalidate all of the redirects caches
895
     */
896
    public function invalidateCaches()
897
    {
898
        $cache = Craft::$app->getCache();
899
        TagDependency::invalidate($cache, $this::GLOBAL_REDIRECTS_CACHE_TAG);
900
        // If they are using Craft 3.3 or later, clear the GraphQL caches too
901
        if (Retour::$craft33) {
902
            $gql = Craft::$app->getGql();
903
            if (method_exists($gql, 'invalidateCaches')) {
904
                $gql->invalidateCaches();
905
            }
906
        }
907
        Craft::info(
908
            Craft::t(
909
                'retour',
910
                'All redirect caches cleared'
911
            ),
912
            __METHOD__
913
        );
914
    }
915
916
    /**
917
     * @param $uri
918
     *
919
     * @return bool
920
     */
921
    public function excludeUri($uri): bool
922
    {
923
        $uri = '/'.ltrim($uri, '/');
924
        if (!empty(Retour::$settings->excludePatterns)) {
925
            foreach (Retour::$settings->excludePatterns as $excludePattern) {
926
                $pattern = '`'.$excludePattern['pattern'].'`i';
927
                try {
928
                    if (preg_match($pattern, $uri) === 1) {
929
                        return true;
930
                    }
931
                } catch (\Exception $e) {
932
                    // That's fine
933
                    Craft::error('Invalid exclude URI Regex: '.$pattern, __METHOD__);
934
                }
935
            }
936
        }
937
938
        return false;
939
    }
940
941
    /**
942
     * Return whether this is a preview request of any kind
943
     *
944
     * @return bool
945
     */
946
    public function isPreview($request): bool
947
    {
948
        $isPreview = false;
949
        if (Retour::$craft32) {
950
            $isPreview = $request->getIsPreview();
951
        }
952
        $isLivePreview = $request->getIsLivePreview();
953
954
        return ($isPreview || $isLivePreview);
955
    }
956
}
957