├── controllers ├── redirects │ ├── _field_tab_logs.php │ ├── index.php │ ├── request-log │ │ ├── _modal.php │ │ ├── columns.yaml │ │ ├── config_list.yaml │ │ └── _list_toolbar.php │ ├── _sparkline.php │ ├── config_relation.yaml │ ├── _warning.php │ ├── config_reorder.yaml │ ├── config_import_export.yaml │ ├── _redirect_test_result.php │ ├── config_form.yaml │ ├── config_list.yaml │ ├── config_filter.yaml │ ├── _status_code_info.php │ ├── _popup_actions.php │ ├── _field_statistics.php │ ├── _redirect_test.php │ └── _list_toolbar.php ├── statistics │ ├── _loading-indicator.php │ ├── _top-crawlers-this-month.php │ ├── _redirect-hits-per-month.php │ ├── _top-redirects-this-month.php │ ├── _hits-per-day.php │ ├── index.php │ └── _score-board.php ├── categories │ ├── _list_toolbar.php │ ├── config_form.yaml │ └── config_list.yaml ├── testlab │ ├── _test_button.php │ ├── _tester_failed.php │ ├── _tester_result.php │ ├── index.php │ └── _tester_result_items.php ├── logs │ ├── config_filter.yaml │ ├── config_list.yaml │ └── _list_toolbar.php ├── Categories.php ├── Logs.php ├── TestLab.php └── Statistics.php ├── models ├── client │ ├── columns.yaml │ └── fields.yaml ├── category │ ├── fields.yaml │ └── columns.yaml ├── Category.php ├── RedirectLog.php ├── Client.php ├── RedirectExport.php ├── redirectlog │ └── columns.yaml ├── settings │ └── fields.yaml ├── redirectexport │ └── columns.yaml ├── redirectimport │ └── columns.yaml ├── redirect │ └── columns.yaml ├── Settings.php └── RedirectImport.php ├── classes ├── contracts │ ├── PublishManagerInterface.php │ ├── TesterInterface.php │ ├── RedirectConditionInterface.php │ ├── CacheManagerInterface.php │ └── RedirectManagerInterface.php ├── exceptions │ ├── RulesPathNotReadable.php │ ├── RulesPathNotWritable.php │ ├── InvalidScheme.php │ ├── UnableToLoadRules.php │ ├── NoMatchForRequest.php │ └── NoMatchForRule.php ├── util │ └── Str.php ├── observers │ ├── traits │ │ └── CanBeDisabled.php │ ├── SettingsObserver.php │ └── RedirectObserver.php ├── Sparkline.php ├── TesterResult.php ├── testers │ ├── RedirectLoop.php │ ├── RedirectMatch.php │ ├── RedirectCount.php │ ├── RedirectFinalDestination.php │ └── ResponseCode.php ├── RedirectManagerSettings.php ├── RedirectConditionManager.php ├── OptionHelper.php ├── TesterBase.php ├── PublishManager.php ├── CacheManager.php ├── RedirectMiddleware.php ├── RedirectRule.php └── StatisticsHelper.php ├── reportwidgets ├── createredirect │ ├── partials │ │ └── _widget.htm │ └── fields.yaml ├── TopTenRedirects.php ├── toptenredirects │ └── partials │ │ └── _widget.htm └── CreateRedirect.php ├── console └── PublishRedirectsCommand.php ├── updates ├── 20220728_0014_add_forward_query_parameters.php ├── 20200414_0009_add_ignore_case_to_redirects_table.php ├── 20200414_0010_add_ignore_trailing_slash_to_redirects_table.php ├── 20190404_0006_add_description_to_redirects_table.php ├── 20200918_0012_add_redirect_id_to_system_request_logs_table.php ├── 20181019_0003_add_ignore_query_parameters_to_redirects_table.php ├── 20190704_0007_add_timestamp_crawler_index_on_clients_table.php ├── 20181117_0004_add_redirect_timestamp_crawler_index_on_clients_table.php ├── 20181117_0005_add_month_year_crawler_index_on_clients_table.php ├── 20200408_0008_change_column_types_from_char_to_varchar.php ├── 20200918_0011_refactor_redirects_logs_table.php ├── 20220415_0013_rename_to_winter_redirect.php ├── 20180831_0002_upgrade_from_adrenth_redirect.php └── version.yaml ├── ServiceProvider.php ├── assets ├── css │ ├── statistics.css │ ├── redirect.css │ └── test-lab.css ├── javascript │ └── test-lab.js └── images │ └── icon.svg ├── config └── config.php ├── composer.json ├── routes.php ├── DOCUMENTATION.md └── README.md /controllers/redirects/_field_tab_logs.php: -------------------------------------------------------------------------------- 1 | relationRender('logs') ?> 2 | -------------------------------------------------------------------------------- /controllers/statistics/_loading-indicator.php: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 |
6 | -------------------------------------------------------------------------------- /controllers/redirects/index.php: -------------------------------------------------------------------------------- 1 | 2 | makePartial('warning', ['warningMessage' => $warningMessage]); ?> 3 | 4 | 5 | listRender(); ?> 6 | -------------------------------------------------------------------------------- /models/client/columns.yaml: -------------------------------------------------------------------------------- 1 | # =================================== 2 | # List Column Definitions 3 | # =================================== 4 | 5 | columns: 6 | id: 7 | label: ID 8 | searchable: true -------------------------------------------------------------------------------- /models/client/fields.yaml: -------------------------------------------------------------------------------- 1 | # =================================== 2 | # Form Field Definitions 3 | # =================================== 4 | 5 | fields: 6 | id: 7 | label: ID 8 | disabled: true 9 | -------------------------------------------------------------------------------- /models/category/fields.yaml: -------------------------------------------------------------------------------- 1 | # =================================== 2 | # Form Field Definitions 3 | # =================================== 4 | 5 | fields: 6 | name: 7 | label: winter.redirect::lang.redirect.name 8 | span: left 9 | -------------------------------------------------------------------------------- /models/category/columns.yaml: -------------------------------------------------------------------------------- 1 | # =================================== 2 | # List Column Definitions 3 | # =================================== 4 | 5 | columns: 6 | name: 7 | label: winter.redirect::lang.redirect.name 8 | searchable: true 9 | -------------------------------------------------------------------------------- /models/Category.php: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /controllers/testlab/_test_button.php: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /controllers/redirects/request-log/_modal.php: -------------------------------------------------------------------------------- 1 | 5 | 8 | -------------------------------------------------------------------------------- /classes/contracts/PublishManagerInterface.php: -------------------------------------------------------------------------------- 1 | getKey()); ?>?crawler=1');"> 2 | <?= e(trans('winter.redirect::lang.redirect.sparkline_30d')); ?> 6 | 7 | -------------------------------------------------------------------------------- /controllers/testlab/_tester_failed.php: -------------------------------------------------------------------------------- 1 |
2 |
3 |

4 | 7 |
8 |
9 | -------------------------------------------------------------------------------- /models/RedirectLog.php: -------------------------------------------------------------------------------- 1 | Redirect::class, 15 | ]; 16 | 17 | protected $guarded = []; 18 | } 19 | -------------------------------------------------------------------------------- /models/Client.php: -------------------------------------------------------------------------------- 1 | Redirect::class, 15 | ]; 16 | 17 | public $timestamps = false; 18 | 19 | protected $guarded = []; 20 | } 21 | -------------------------------------------------------------------------------- /classes/exceptions/InvalidScheme.php: -------------------------------------------------------------------------------- 1 | 2 | 6 |
7 | 8 |

9 |

10 |
11 | 12 | -------------------------------------------------------------------------------- /models/RedirectExport.php: -------------------------------------------------------------------------------- 1 | get() 17 | ->toArray(); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /classes/exceptions/UnableToLoadRules.php: -------------------------------------------------------------------------------- 1 | 2 |

3 | 4 | render() ?> 5 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /classes/exceptions/NoMatchForRequest.php: -------------------------------------------------------------------------------- 1 | = ':filtered' 10 | status_code: 11 | label: winter.redirect::lang.redirect.status_code 12 | type: group 13 | modelClass: Winter\Redirect\Models\Redirect 14 | options: filterStatusCodeOptions 15 | conditions: status_code in (:filtered) 16 | -------------------------------------------------------------------------------- /controllers/redirects/request-log/config_list.yaml: -------------------------------------------------------------------------------- 1 | # =================================== 2 | # List Behavior Config 3 | # =================================== 4 | 5 | title: system::lang.request_log.menu_label 6 | list: $/winter/redirect/controllers/redirects/request-log/columns.yaml 7 | modelClass: System\Models\RequestLog 8 | noRecordsMessage: backend::lang.list.no_records 9 | recordsPerPage: 10 10 | showSetup: true 11 | showCheckboxes: true 12 | defaultSort: 13 | column: count 14 | direction: desc 15 | 16 | toolbar: 17 | buttons: request-log/list_toolbar 18 | search: 19 | prompt: backend::lang.list.search_prompt 20 | -------------------------------------------------------------------------------- /console/PublishRedirectsCommand.php: -------------------------------------------------------------------------------- 1 | name = 'winter:redirect:publish-redirects'; 15 | $this->description = 'Publish all redirects.'; 16 | 17 | parent::__construct(); 18 | } 19 | 20 | public function handle(PublishManager $publishManager): void 21 | { 22 | $publishManager->publish(); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /classes/observers/traits/CanBeDisabled.php: -------------------------------------------------------------------------------- 1 | publishManager = $publishManager; 17 | } 18 | 19 | public function saving(): void 20 | { 21 | try { 22 | $this->publishManager->publish(); 23 | } catch (Throwable $e) { 24 | // .. 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /classes/exceptions/NoMatchForRule.php: -------------------------------------------------------------------------------- 1 | getId(), 20 | $requestPath, 21 | $scheme ?: '(no scheme)' 22 | )); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /classes/contracts/TesterInterface.php: -------------------------------------------------------------------------------- 1 | addCss('/plugins/winter/redirect/assets/css/redirect.css'); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /controllers/redirects/_redirect_test_result.php: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 |
5 | 6 |
7 |
8 | 9 |
10 | 11 |
12 | (getStatusCode(); ?>) 13 |
14 |
15 | 16 | -------------------------------------------------------------------------------- /reportwidgets/createredirect/fields.yaml: -------------------------------------------------------------------------------- 1 | # =================================== 2 | # Form Field Definitions 3 | # =================================== 4 | 5 | fields: 6 | from_url: 7 | label: winter.redirect::lang.redirect.from_url 8 | placeholder: winter.redirect::lang.redirect.from_url_placeholder 9 | type: text 10 | span: left 11 | comment: winter.redirect::lang.redirect.from_url_comment 12 | required: true 13 | attributes: 14 | autofocus: '' 15 | to_url: 16 | label: winter.redirect::lang.redirect.to_url 17 | placeholder: winter.redirect::lang.redirect.to_url_placeholder 18 | type: text 19 | span: right 20 | comment: winter.redirect::lang.redirect.to_url_comment 21 | -------------------------------------------------------------------------------- /classes/Sparkline.php: -------------------------------------------------------------------------------- 1 | colorHexToRGB($color); 18 | 19 | $baseRed = $baseGreen = $baseBlue = 255; 20 | 21 | $red = (int) floor(($baseRed + $red) / 2); 22 | $green = (int) floor(($baseGreen + $green) / 2); 23 | $blue = (int) floor(($baseBlue + $blue) / 2); 24 | 25 | $this->setFillColorRGB($red, $green, $blue); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /controllers/redirects/config_form.yaml: -------------------------------------------------------------------------------- 1 | # =================================== 2 | # Form Behavior Config 3 | # =================================== 4 | 5 | # Record name 6 | name: winter.redirect::lang.redirect.redirect 7 | 8 | # Model Form Field configuration 9 | form: $/winter/redirect/models/redirect/fields.yaml 10 | 11 | # Model Class name 12 | modelClass: Winter\Redirect\Models\Redirect 13 | 14 | # Default redirect location 15 | defaultRedirect: winter/redirect/redirects 16 | 17 | # Create page 18 | create: 19 | title: winter.redirect::lang.title.create_redirect 20 | redirect: winter/redirect/redirects/update/:id 21 | redirectClose: winter/redirect/redirects 22 | 23 | # Update page 24 | update: 25 | title: winter.redirect::lang.title.edit_redirect 26 | redirect: winter/redirect/redirects 27 | redirectClose: winter/redirect/redirects 28 | -------------------------------------------------------------------------------- /controllers/categories/config_form.yaml: -------------------------------------------------------------------------------- 1 | # =================================== 2 | # Form Behavior Config 3 | # =================================== 4 | 5 | # Record name 6 | name: winter.redirect::lang.redirect.category 7 | 8 | # Model Form Field configuration 9 | form: $/winter/redirect/models/category/fields.yaml 10 | 11 | # Model Class name 12 | modelClass: Winter\Redirect\Models\Category 13 | 14 | # Default redirect location 15 | defaultRedirect: winter/redirect/categories 16 | 17 | # Create page 18 | create: 19 | title: winter.redirect::lang.title.create_category 20 | redirect: winter/redirect/categories/update/:id 21 | redirectClose: winter/redirect/categories 22 | 23 | # Update page 24 | update: 25 | title: winter.redirect::lang.title.edit_category 26 | redirect: winter/redirect/categories 27 | redirectClose: winter/redirect/categories 28 | -------------------------------------------------------------------------------- /controllers/statistics/_top-crawlers-this-month.php: -------------------------------------------------------------------------------- 1 |

10])); ?>

2 | 3 |
4 | 13 |
14 | 15 |

16 | 17 | -------------------------------------------------------------------------------- /updates/20220728_0014_add_forward_query_parameters.php: -------------------------------------------------------------------------------- 1 | boolean('forward_query_parameters') 17 | ->default(false) 18 | ->after('ignore_trailing_slash'); 19 | }); 20 | } 21 | 22 | public function down(): void 23 | { 24 | Schema::table('winter_redirect_redirects', function (Blueprint $table) { 25 | $table->dropColumn('forward_query_parameters'); 26 | }); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /controllers/statistics/_redirect-hits-per-month.php: -------------------------------------------------------------------------------- 1 |

2 | 3 |
4 | 13 |
14 | 15 |

16 | 17 | -------------------------------------------------------------------------------- /reportwidgets/TopTenRedirects.php: -------------------------------------------------------------------------------- 1 | alias = 'redirectTopTenRedirects'; 19 | 20 | parent::__construct($controller, $properties); 21 | } 22 | 23 | /** 24 | * @noinspection PhpMissingParentCallCommonInspection 25 | */ 26 | public function render() 27 | { 28 | $helper = new StatisticsHelper(); 29 | 30 | return $this->makePartial('widget', [ 31 | 'topTenRedirectsThisMonth' => $helper->getTopRedirectsThisMonth(), 32 | ]); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /ServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->bind(Contracts\RedirectManagerInterface::class, RedirectManager::class); 18 | $this->app->bind(Contracts\PublishManagerInterface::class, PublishManager::class); 19 | $this->app->bind(Contracts\CacheManagerInterface::class, CacheManager::class); 20 | 21 | $this->app->singleton(RedirectManager::class); 22 | $this->app->singleton(PublishManager::class); 23 | $this->app->singleton(CacheManager::class); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /assets/css/statistics.css: -------------------------------------------------------------------------------- 1 | .row { 2 | margin-bottom: 30px; 3 | } 4 | 5 | .row .report-widget { 6 | -webkit-box-shadow: 3px 3px 11px 0 rgba(217, 217, 217, 1); 7 | -moz-box-shadow: 3px 3px 11px 0 rgba(217, 217, 217, 1); 8 | box-shadow: 3px 3px 11px 0 rgba(217, 217, 217, 1); 9 | position: relative; 10 | min-height: 155px; 11 | } 12 | 13 | .row .report-widget h3 { 14 | padding-bottom: 5px; 15 | border-bottom: 1px solid #cae1e2; 16 | margin-bottom: 15px; 17 | } 18 | 19 | .report-widget.redirect-hits-per-day { 20 | min-height: 375px; 21 | } 22 | 23 | .report-widget.current-active-redirects { 24 | min-height: 375px; 25 | } 26 | 27 | .report-widget.general { 28 | min-height: 400px; 29 | } 30 | 31 | .report-widget div[data-control=toolbar] { 32 | height: 125px; 33 | } 34 | 35 | .report-widget .loading-indicator-container { 36 | position: absolute; 37 | left: 0; 38 | top: 0; 39 | width: 100%; 40 | height: 100%; 41 | } 42 | 43 | .report-widget .loading-indicator { 44 | background: transparent; 45 | } 46 | -------------------------------------------------------------------------------- /classes/TesterResult.php: -------------------------------------------------------------------------------- 1 | passed = $passed; 16 | $this->message = $message; 17 | $this->duration = 0; 18 | } 19 | 20 | public function isPassed(): bool 21 | { 22 | return $this->passed; 23 | } 24 | 25 | public function getMessage(): string 26 | { 27 | return $this->message; 28 | } 29 | 30 | public function setDuration(int $duration): TesterResult 31 | { 32 | $this->duration = $duration; 33 | 34 | return $this; 35 | } 36 | 37 | public function getDuration(): int 38 | { 39 | return $this->duration; 40 | } 41 | 42 | public function getStatusCssClass(): string 43 | { 44 | return $this->passed ? 'passed' : 'failed'; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /controllers/statistics/_top-redirects-this-month.php: -------------------------------------------------------------------------------- 1 |

10])); ?>

2 | 3 |
4 | 17 |
18 | 19 |

20 | 21 | -------------------------------------------------------------------------------- /classes/testers/RedirectLoop.php: -------------------------------------------------------------------------------- 1 | testUrl); 19 | 20 | $this->setDefaultCurlOptions($curlHandle); 21 | 22 | curl_setopt($curlHandle, CURLOPT_MAXREDIRS, 20); 23 | 24 | $error = null; 25 | 26 | if (curl_exec($curlHandle) === false 27 | && curl_errno($curlHandle) === CURLE_TOO_MANY_REDIRECTS) { 28 | $error = e(trans('winter.redirect::lang.test_lab.possible_loop')); 29 | } 30 | 31 | curl_close($curlHandle); 32 | 33 | $message = $error ?? e(trans('winter.redirect::lang.test_lab.no_loop')); 34 | 35 | return new TesterResult($error === null, $message); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /reportwidgets/toptenredirects/partials/_widget.htm: -------------------------------------------------------------------------------- 1 |
2 |

10])) ?>

3 | 4 |
5 | 18 |
19 | 20 |

21 | 22 |
23 | -------------------------------------------------------------------------------- /updates/20200414_0009_add_ignore_case_to_redirects_table.php: -------------------------------------------------------------------------------- 1 | boolean('ignore_case') 15 | ->default(false) 16 | ->after('ignore_query_parameters'); 17 | }); 18 | } 19 | 20 | public function down(): void 21 | { 22 | try { 23 | Schema::table('vdlp_redirect_redirects', static function (Blueprint $table) { 24 | $table->dropColumn('ignore_case'); 25 | }); 26 | } catch (Throwable $e) { 27 | resolve(LoggerInterface::class)->error(sprintf( 28 | 'Winter.Redirect: Unable to drop column `%s` from table `%s`: %s', 29 | 'ignore_case', 30 | 'vdlp_redirect_redirects', 31 | $e->getMessage() 32 | )); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /controllers/categories/config_list.yaml: -------------------------------------------------------------------------------- 1 | # =================================== 2 | # List Behavior Config 3 | # =================================== 4 | 5 | # Model List Column configuration 6 | list: $/winter/redirect/models/category/columns.yaml 7 | 8 | # Model Class name 9 | modelClass: Winter\Redirect\Models\Category 10 | 11 | # List Title 12 | title: winter.redirect::lang.title.categories 13 | 14 | # Link URL for each record 15 | recordUrl: winter/redirect/categories/update/:id 16 | 17 | # Message to display if the list is empty 18 | noRecordsMessage: backend::lang.list.no_records 19 | 20 | # Records to display per page 21 | recordsPerPage: 20 22 | 23 | # Displays the list column set up button 24 | # showSetup: true 25 | 26 | # Displays the sorting link on each column 27 | showSorting: true 28 | 29 | # Default sorting column 30 | # defaultSort: 31 | # column: created_at 32 | # direction: desc 33 | 34 | # Display checkboxes next to each record 35 | # showCheckboxes: true 36 | 37 | # Toolbar widget configuration 38 | toolbar: 39 | # Partial for toolbar buttons 40 | buttons: list_toolbar 41 | 42 | # Search widget configuration 43 | search: 44 | prompt: backend::lang.list.search_prompt 45 | -------------------------------------------------------------------------------- /assets/css/redirect.css: -------------------------------------------------------------------------------- 1 | .modal-content h3 { 2 | color: #da5700; 3 | margin-bottom: 8px; 4 | } 5 | 6 | #Form-field-Redirect-status_code-group .help-block.before-field { 7 | margin-bottom: 0; 8 | color: #2a3e51; 9 | } 10 | 11 | .status-code-info { 12 | cursor: pointer; 13 | margin-bottom: 10px; 14 | color: #da5700; 15 | } 16 | 17 | tr.special td { 18 | font-style: italic; 19 | } 20 | 21 | div.actions .btn { 22 | text-align: center; 23 | } 24 | 25 | .sparkline { 26 | width: 100px; 27 | height: 30px; 28 | background-size: 100px 30px; 29 | background-repeat: no-repeat; 30 | } 31 | 32 | .sparkline img { 33 | width: 100px; 34 | height: 30px; 35 | opacity: .8; 36 | } 37 | 38 | .form-sidebar .sparkline { 39 | width: 260px; 40 | height: 120px; 41 | background-size: 260px 120px; 42 | background-repeat: no-repeat; 43 | margin-top: 4px; 44 | } 45 | 46 | .form-sidebar .sparkline img { 47 | width: 260px; 48 | height: 120px; 49 | opacity: .8; 50 | } 51 | 52 | .scoreboard-item.title-value p { 53 | font-size: 20px; 54 | } 55 | 56 | ul.nav li a:focus, table.data tr:focus { 57 | outline:0 !important; 58 | box-shadow: none !important; 59 | } 60 | -------------------------------------------------------------------------------- /updates/20200414_0010_add_ignore_trailing_slash_to_redirects_table.php: -------------------------------------------------------------------------------- 1 | boolean('ignore_trailing_slash') 15 | ->default(false) 16 | ->after('ignore_case'); 17 | }); 18 | } 19 | 20 | public function down(): void 21 | { 22 | try { 23 | Schema::table('vdlp_redirect_redirects', static function (Blueprint $table) { 24 | $table->dropColumn('ignore_trailing_slash'); 25 | }); 26 | } catch (Throwable $e) { 27 | resolve(LoggerInterface::class)->error(sprintf( 28 | 'Winter.Redirect: Unable to drop column `%s` from table `%s`: %s', 29 | 'ignore_trailing_slash', 30 | 'vdlp_redirect_redirects', 31 | $e->getMessage() 32 | )); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /controllers/redirects/config_list.yaml: -------------------------------------------------------------------------------- 1 | # =================================== 2 | # List Behavior Config 3 | # =================================== 4 | 5 | # Model List Column configuration 6 | list: $/winter/redirect/models/redirect/columns.yaml 7 | 8 | # Model Class name 9 | modelClass: Winter\Redirect\Models\Redirect 10 | 11 | # List Title 12 | title: winter.redirect::lang.title.redirects 13 | 14 | # Link URL for each record 15 | recordUrl: winter/redirect/redirects/update/:id 16 | 17 | # Message to display if the list is empty 18 | noRecordsMessage: winter.redirect::lang.list.no_records 19 | 20 | # Records to display per page 21 | recordsPerPage: 20 22 | 23 | # Displays the list column set up button 24 | showSetup: true 25 | 26 | # Displays the sorting link on each column 27 | showSorting: true 28 | 29 | # Default sorting column 30 | defaultSort: 31 | column: sort_order 32 | direction: asc 33 | 34 | # Display checkboxes next to each record 35 | showCheckboxes: true 36 | 37 | # Toolbar widget configuration 38 | toolbar: 39 | # Partial for toolbar buttons 40 | buttons: list_toolbar 41 | 42 | # Search widget configuration 43 | search: 44 | prompt: backend::lang.list.search_prompt 45 | 46 | filter: config_filter.yaml 47 | -------------------------------------------------------------------------------- /controllers/logs/config_list.yaml: -------------------------------------------------------------------------------- 1 | # =================================== 2 | # List Behavior Config 3 | # =================================== 4 | 5 | # Model List Column configuration 6 | list: $/winter/redirect/models/redirectlog/columns.yaml 7 | 8 | filter: config_filter.yaml 9 | 10 | # Model Class name 11 | modelClass: Winter\Redirect\Models\RedirectLog 12 | 13 | # List Title 14 | title: winter.redirect::lang.title.view_redirect_log 15 | 16 | # Link URL for each record 17 | recordUrl: winter/redirect/redirects/update/:redirect_id 18 | 19 | # Message to display if the list is empty 20 | noRecordsMessage: backend::lang.list.no_records 21 | 22 | # Records to display per page 23 | recordsPerPage: 20 24 | 25 | # Displays the list column set up button 26 | showSetup: true 27 | 28 | # Displays the sorting link on each column 29 | showSorting: true 30 | 31 | # Default sorting column 32 | defaultSort: 33 | column: updated_at 34 | direction: desc 35 | 36 | # Display checkboxes next to each record 37 | showCheckboxes: true 38 | 39 | # Toolbar widget configuration 40 | toolbar: 41 | # Partial for toolbar buttons 42 | buttons: list_toolbar 43 | 44 | # Search widget configuration 45 | search: 46 | prompt: backend::lang.list.search_prompt 47 | -------------------------------------------------------------------------------- /classes/RedirectManagerSettings.php: -------------------------------------------------------------------------------- 1 | loggingEnabled = $loggingEnabled; 18 | $this->statisticsEnabled = $statisticsEnabled; 19 | $this->relativePathsEnabled = $relativePathsEnabled; 20 | } 21 | 22 | public static function createDefault(): RedirectManagerSettings 23 | { 24 | return new self( 25 | Settings::isLoggingEnabled(), 26 | Settings::isStatisticsEnabled(), 27 | Settings::isRelativePathsEnabled() 28 | ); 29 | } 30 | 31 | public function isLoggingEnabled(): bool 32 | { 33 | return $this->loggingEnabled; 34 | } 35 | 36 | public function isStatisticsEnabled(): bool 37 | { 38 | return $this->statisticsEnabled; 39 | } 40 | 41 | public function isRelativePathsEnabled(): bool 42 | { 43 | return $this->relativePathsEnabled; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /classes/testers/RedirectMatch.php: -------------------------------------------------------------------------------- 1 | getRedirectManager(); 19 | 20 | // TODO: Add scheme. 21 | try { 22 | $match = $manager->match($this->testPath, Request::getScheme()); 23 | } catch (NoMatchForRequest | InvalidScheme $e) { 24 | $match = false; 25 | } 26 | 27 | if ($match === false) { 28 | return new TesterResult(false, e(trans('winter.redirect::lang.test_lab.not_match_redirect'))); 29 | } 30 | 31 | $message = sprintf( 32 | '%s %s.', 33 | e(trans('winter.redirect::lang.test_lab.matched')), 34 | Backend::url('winter/redirect/redirects/update/' . $match->getId()), 35 | e(trans('winter.redirect::lang.test_lab.redirect')) 36 | ); 37 | 38 | return new TesterResult(true, $message); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /classes/testers/RedirectCount.php: -------------------------------------------------------------------------------- 1 | testUrl); 19 | 20 | $this->setDefaultCurlOptions($curlHandle); 21 | 22 | $error = null; 23 | 24 | if (curl_exec($curlHandle) === false) { 25 | $error = curl_error($curlHandle); 26 | } 27 | 28 | if ($error !== null) { 29 | return new TesterResult(false, e(trans('winter.redirect::lang.test_lab.result_request_failed'))); 30 | } 31 | 32 | $statusCode = (int) curl_getinfo($curlHandle, CURLINFO_HTTP_CODE); 33 | $redirectCount = (int) curl_getinfo($curlHandle, CURLINFO_REDIRECT_COUNT); 34 | 35 | curl_close($curlHandle); 36 | 37 | return new TesterResult( 38 | $redirectCount === 1 || ($redirectCount === 0 && $statusCode > 400), 39 | e(trans('winter.redirect::lang.test_lab.redirects_followed', ['count' => $redirectCount, 'limit' => 10])) 40 | ); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /updates/20190404_0006_add_description_to_redirects_table.php: -------------------------------------------------------------------------------- 1 | string('description') 22 | ->nullable() 23 | ->after('system'); 24 | }); 25 | } 26 | 27 | public function down(): void 28 | { 29 | try { 30 | Schema::table('vdlp_redirect_redirects', static function (Blueprint $table) { 31 | $table->dropColumn('description'); 32 | }); 33 | } catch (Throwable $e) { 34 | resolve(LoggerInterface::class)->error(sprintf( 35 | 'Winter.Redirect: Unable to drop index `%s` from table `%s`: %s', 36 | 'description', 37 | 'vdlp_redirect_redirects', 38 | $e->getMessage() 39 | )); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /controllers/logs/_list_toolbar.php: -------------------------------------------------------------------------------- 1 |
3 | 7 | 8 | 9 | 14 | 15 | 16 | 28 |
29 | -------------------------------------------------------------------------------- /config/config.php: -------------------------------------------------------------------------------- 1 | [ 17 | 18 | 'publish_redirects' => env('WINTER_REDIRECT_CRON_PUBLISH_REDIRECTS', '00:00'), 19 | 20 | ], 21 | 22 | /* 23 | |-------------------------------------------------------------------------- 24 | | Logging 25 | |-------------------------------------------------------------------------- 26 | | 27 | | Enable or disable specific logging information. Commonly used for 28 | | debugging purposes. 29 | | 30 | */ 31 | 32 | 'log_redirect_changes' => (bool) env('WINTER_REDIRECT_LOG_REDIRECT_CHANGES', false), 33 | 34 | /* 35 | |-------------------------------------------------------------------------- 36 | | Redirect Rules Path 37 | |-------------------------------------------------------------------------- 38 | | 39 | | The path of the redirect rules. Make sure the path is writable. 40 | | 41 | */ 42 | 43 | 'rules_path' => env('WINTER_REDIRECT_RULES_PATH', storage_path('app/redirects.csv')), 44 | 45 | ]; 46 | -------------------------------------------------------------------------------- /updates/20200918_0012_add_redirect_id_to_system_request_logs_table.php: -------------------------------------------------------------------------------- 1 | unsignedInteger('vdlp_redirect_redirect_id') 19 | ->nullable() 20 | ->after('id'); 21 | 22 | $table->foreign('vdlp_redirect_redirect_id', 'vdlp_redirect_request_log') 23 | ->references('id') 24 | ->on('vdlp_redirect_redirects') 25 | ->onDelete('set null'); 26 | }); 27 | } 28 | 29 | public function down(): void 30 | { 31 | if (Schema::hasColumn('system_request_logs', 'vdlp_redirect_redirect_id')) { 32 | Schema::table('system_request_logs', static function (Blueprint $table): void { 33 | $table->dropForeign('vdlp_redirect_request_log'); 34 | $table->dropColumn('vdlp_redirect_redirect_id'); 35 | }); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /controllers/redirects/request-log/_list_toolbar.php: -------------------------------------------------------------------------------- 1 | 14 | 24 |
25 | 37 |
38 | -------------------------------------------------------------------------------- /updates/20181019_0003_add_ignore_query_parameters_to_redirects_table.php: -------------------------------------------------------------------------------- 1 | boolean('ignore_query_parameters') 22 | ->default(false) 23 | ->after('sort_order'); 24 | }); 25 | } 26 | 27 | public function down(): void 28 | { 29 | try { 30 | Schema::table('vdlp_redirect_redirects', static function (Blueprint $table) { 31 | $table->dropColumn('ignore_query_parameters'); 32 | }); 33 | } catch (Throwable $e) { 34 | resolve(LoggerInterface::class)->error(sprintf( 35 | 'Winter.Redirect: Unable to drop column `%s` from table `%s`: %s', 36 | 'ignore_query_parameters', 37 | 'vdlp_redirect_redirects', 38 | $e->getMessage() 39 | )); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "winter/wn-redirect-plugin", 3 | "description": "Advanced redirect plugin for Winter CMS.", 4 | "license": "GPL-2.0-only", 5 | "type": "winter-plugin", 6 | "authors": [ 7 | { 8 | "name": "Van der Let & Partners", 9 | "email": "octobercms@vdlp.nl" 10 | }, 11 | { 12 | "name": "Alwin Drenth", 13 | "email": "adrenth@gmail.com" 14 | }, 15 | { 16 | "name": "Winter CMS Maintainers", 17 | "homepage": "https://wintercms.com", 18 | "role": "Maintainer" 19 | } 20 | ], 21 | "require": { 22 | "php": "^8.1", 23 | "ext-curl": "*", 24 | "ext-json": "*", 25 | "composer/installers": "^1.0 || ^2.0", 26 | "davaxi/sparkline": "^2.0", 27 | "jaybizzle/crawler-detect": "^1.2", 28 | "league/csv": "^9.0", 29 | "symfony/stopwatch": "^4.0 || ^5.0 || ^6.0 || ^7.0", 30 | "winter/wn-backend-module": "^1.2.8 || dev-develop" 31 | }, 32 | "replace": { 33 | "vdlp/oc-redirect-plugin": "<=3.1.1" 34 | }, 35 | "archive": { 36 | "exclude": [ 37 | ".gitattributes", 38 | ".github", 39 | ".gitignore", 40 | "tests", 41 | "phpunit.xml" 42 | ] 43 | }, 44 | "config": { 45 | "allow-plugins": { 46 | "composer/installers": false 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /updates/20190704_0007_add_timestamp_crawler_index_on_clients_table.php: -------------------------------------------------------------------------------- 1 | index( 22 | [ 23 | 'timestamp', 24 | 'crawler' 25 | ], 26 | 'timestamp_crawler' 27 | ); 28 | }); 29 | } 30 | 31 | public function down(): void 32 | { 33 | try { 34 | Schema::table('vdlp_redirect_clients', static function (Blueprint $table) { 35 | $table->dropIndex('timestamp_crawler'); 36 | }); 37 | } catch (Throwable $e) { 38 | resolve(LoggerInterface::class)->error(sprintf( 39 | 'Winter.Redirect: Unable to drop index `%s` from table `%s`: %s', 40 | 'timestamp_crawler', 41 | 'vdlp_redirect_clients', 42 | $e->getMessage() 43 | )); 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /classes/contracts/RedirectConditionInterface.php: -------------------------------------------------------------------------------- 1 | index( 22 | [ 23 | 'redirect_id', 24 | 'timestamp', 25 | 'crawler' 26 | ], 27 | 'redirect_timestamp_crawler' 28 | ); 29 | }); 30 | } 31 | 32 | public function down(): void 33 | { 34 | try { 35 | Schema::table('vdlp_redirect_clients', static function (Blueprint $table) { 36 | $table->dropIndex('redirect_timestamp_crawler'); 37 | }); 38 | } catch (Throwable $e) { 39 | resolve(LoggerInterface::class)->error(sprintf( 40 | 'Winter.Redirect: Unable to drop index `%s` from table `%s`: %s', 41 | 'redirect_timestamp_crawler', 42 | 'vdlp_redirect_clients', 43 | $e->getMessage() 44 | )); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /models/settings/fields.yaml: -------------------------------------------------------------------------------- 1 | # =================================== 2 | # Settings Form Field Definitions 3 | # =================================== 4 | 5 | fields: 6 | redirect_settings_section: 7 | label: winter.redirect::lang.settings.menu_label 8 | comment: winter.redirect::lang.settings.menu_description 9 | type: section 10 | span: left 11 | relative_paths_enabled: 12 | label: winter.redirect::lang.settings.relative_paths_enabled_label 13 | comment: winter.redirect::lang.settings.relative_paths_enabled_command 14 | span: left 15 | type: switch 16 | default: true 17 | logging_enabled: 18 | label: winter.redirect::lang.settings.logging_enabled_label 19 | comment: winter.redirect::lang.settings.logging_enabled_comment 20 | span: left 21 | type: switch 22 | default: false 23 | statistics_enabled: 24 | label: winter.redirect::lang.settings.statistics_enabled_label 25 | comment: winter.redirect::lang.settings.statistics_enabled_comment 26 | span: left 27 | type: switch 28 | default: false 29 | test_lab_enabled: 30 | label: winter.redirect::lang.settings.test_lab_enabled_label 31 | comment: winter.redirect::lang.settings.test_lab_enabled_comment 32 | span: left 33 | type: switch 34 | default: false 35 | caching_enabled: 36 | label: winter.redirect::lang.settings.caching_enabled_label 37 | comment: winter.redirect::lang.settings.caching_enabled_comment 38 | span: left 39 | type: switch 40 | default: false 41 | 42 | -------------------------------------------------------------------------------- /classes/testers/RedirectFinalDestination.php: -------------------------------------------------------------------------------- 1 | testUrl); 19 | 20 | $this->setDefaultCurlOptions($curlHandle); 21 | 22 | curl_setopt($curlHandle, CURLOPT_FOLLOWLOCATION, false); 23 | 24 | $error = null; 25 | 26 | if (curl_exec($curlHandle) === false) { 27 | $error = e(trans('winter.redirect::lang.test_lab.not_determinate_destination_url')); 28 | } 29 | 30 | $finalDestination = curl_getinfo($curlHandle, CURLINFO_REDIRECT_URL); 31 | $statusCode = (int) curl_getinfo($curlHandle, CURLINFO_HTTP_CODE); 32 | 33 | curl_close($curlHandle); 34 | 35 | if (empty($finalDestination) && $statusCode > 400) { 36 | $message = $error ?? e(trans('winter.redirect::lang.test_lab.no_destination_url')); 37 | } else { 38 | $finalDestination = sprintf( 39 | '%s', 40 | e($finalDestination), 41 | e($finalDestination) 42 | ); 43 | 44 | $message = $error 45 | ?? trans('winter.redirect::lang.test_lab.final_destination_is', ['destination' => $finalDestination]); 46 | } 47 | 48 | return new TesterResult($error === null, $message); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /controllers/redirects/config_filter.yaml: -------------------------------------------------------------------------------- 1 | scopes: 2 | system: 3 | label: winter.redirect::lang.list.switch_system 4 | type: switch 5 | conditions: 6 | - system <> true 7 | - system = true 8 | is_enabled: 9 | label: winter.redirect::lang.list.switch_is_enabled 10 | type: switch 11 | conditions: 12 | - is_enabled <> true 13 | - is_enabled = true 14 | match_type: 15 | label: winter.redirect::lang.redirect.match_type 16 | type: group 17 | modelClass: Winter\Redirect\Models\Redirect 18 | options: filterMatchTypeOptions 19 | conditions: match_type in (:filtered) 20 | target_type: 21 | label: winter.redirect::lang.redirect.target_type 22 | type: group 23 | modelClass: Winter\Redirect\Models\Redirect 24 | options: filterTargetTypeOptions 25 | conditions: target_type in (:filtered) 26 | status_code: 27 | label: winter.redirect::lang.redirect.status_code 28 | type: group 29 | modelClass: Winter\Redirect\Models\Redirect 30 | options: filterStatusCodeOptions 31 | conditions: status_code in (:filtered) 32 | category: 33 | label: winter.redirect::lang.redirect.category 34 | modelClass: Winter\Redirect\Models\Category 35 | conditions: category_id in (:filtered) 36 | nameFrom: name 37 | hits: 38 | label: winter.redirect::lang.redirect.has_hits 39 | type: switch 40 | conditions: 41 | - hits = 0 42 | - hits <> 0 43 | minimum_hits: 44 | label: winter.redirect::lang.redirect.minimum_hits 45 | type: number 46 | conditions: hits >= ':filtered' 47 | -------------------------------------------------------------------------------- /updates/20181117_0005_add_month_year_crawler_index_on_clients_table.php: -------------------------------------------------------------------------------- 1 | index( 22 | [ 23 | 'month', 24 | 'year', 25 | 'crawler' 26 | ], 27 | 'month_year_crawler' 28 | ); 29 | 30 | $table->index( 31 | [ 32 | 'month', 33 | 'year', 34 | ], 35 | 'month_year' 36 | ); 37 | }); 38 | } 39 | 40 | public function down(): void 41 | { 42 | try { 43 | Schema::table('vdlp_redirect_clients', static function (Blueprint $table) { 44 | $table->dropIndex('month_year_crawler'); 45 | $table->dropIndex('month_year'); 46 | }); 47 | } catch (Throwable $e) { 48 | resolve(LoggerInterface::class)->error(sprintf( 49 | 'Winter.Redirect: Unable to drop index `%s`, `%s` from table `%s`: %s', 50 | 'month_year_crawler', 51 | 'month_year', 52 | 'vdlp_redirect_clients', 53 | $e->getMessage() 54 | )); 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /controllers/statistics/_hits-per-day.php: -------------------------------------------------------------------------------- 1 | 29 |
30 |
31 |

32 |
33 |
34 | 49 |
50 |
51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /classes/RedirectConditionManager.php: -------------------------------------------------------------------------------- 1 | redirectManager = $redirectManager; 17 | } 18 | 19 | public function getEnabledConditions(RedirectRule $rule): array 20 | { 21 | $enabledConditions = []; 22 | 23 | if (!class_exists(\Winter\RedirectConditions\Models\ConditionParameter::class)) { 24 | return $enabledConditions; 25 | } 26 | 27 | $conditions = $this->redirectManager->getConditions(); 28 | 29 | if (count($conditions) === 0) { 30 | return $enabledConditions; 31 | } 32 | 33 | $conditionCodes = \Winter\RedirectConditions\Models\ConditionParameter::query() 34 | ->where('redirect_id', '=', $rule->getId()) 35 | ->whereNotNull('is_enabled') 36 | ->get(['condition_code']) 37 | ->pluck('condition_code') 38 | ->toArray(); 39 | 40 | if (count($conditionCodes) === 0) { 41 | return $enabledConditions; 42 | } 43 | 44 | foreach ($conditions as $condition) { 45 | /** @var RedirectConditionInterface $condition */ 46 | $condition = resolve($condition); 47 | 48 | if (!in_array($condition->getCode(), $conditionCodes, true)) { 49 | continue; 50 | } 51 | 52 | $enabledConditions[] = $condition; 53 | } 54 | 55 | return $enabledConditions; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /models/redirectexport/columns.yaml: -------------------------------------------------------------------------------- 1 | columns: 2 | id: ID 3 | match_type: winter.redirect::lang.import_export.match_type 4 | category_id: winter.redirect::lang.import_export.category_id 5 | target_type: winter.redirect::lang.import_export.target_type 6 | from_url: winter.redirect::lang.import_export.from_url 7 | from_scheme: winter.redirect::lang.import_export.from_scheme 8 | to_url: winter.redirect::lang.import_export.to_url 9 | to_scheme: winter.redirect::lang.import_export.to_scheme 10 | test_url: winter.redirect::lang.import_export.test_url 11 | cms_page: winter.redirect::lang.import_export.cms_page 12 | static_page: winter.redirect::lang.import_export.static_page 13 | requirements: winter.redirect::lang.import_export.requirements 14 | status_code: winter.redirect::lang.import_export.status_code 15 | hits: winter.redirect::lang.import_export.hits 16 | from_date: winter.redirect::lang.import_export.from_date 17 | to_date: winter.redirect::lang.import_export.to_date 18 | sort_order: winter.redirect::lang.import_export.sort_order 19 | ignore_query_parameters: winter.redirect::lang.import_export.ignore_query_parameters 20 | ignore_case: winter.redirect::lang.import_export.ignore_case 21 | ignore_trailing_slash: winter.redirect::lang.import_export.ignore_trailing_slash 22 | is_enabled: winter.redirect::lang.import_export.is_enabled 23 | test_lab: winter.redirect::lang.import_export.test_lab 24 | test_lab_path: winter.redirect::lang.import_export.test_lab_path 25 | system: winter.redirect::lang.import_export.system 26 | description: winter.redirect::lang.import_export.description 27 | last_used_at: winter.redirect::lang.import_export.last_used_at 28 | created_at: winter.redirect::lang.import_export.created_at 29 | updated_at: winter.redirect::lang.import_export.updated_at 30 | -------------------------------------------------------------------------------- /models/redirectimport/columns.yaml: -------------------------------------------------------------------------------- 1 | columns: 2 | id: ID 3 | match_type: winter.redirect::lang.import_export.match_type 4 | category_id: winter.redirect::lang.import_export.category_id 5 | target_type: winter.redirect::lang.import_export.target_type 6 | from_url: winter.redirect::lang.import_export.from_url 7 | from_scheme: winter.redirect::lang.import_export.from_scheme 8 | to_url: winter.redirect::lang.import_export.to_url 9 | to_scheme: winter.redirect::lang.import_export.to_scheme 10 | test_url: winter.redirect::lang.import_export.test_url 11 | cms_page: winter.redirect::lang.import_export.cms_page 12 | static_page: winter.redirect::lang.import_export.static_page 13 | requirements: winter.redirect::lang.import_export.requirements 14 | status_code: winter.redirect::lang.import_export.status_code 15 | hits: winter.redirect::lang.import_export.hits 16 | from_date: winter.redirect::lang.import_export.from_date 17 | to_date: winter.redirect::lang.import_export.to_date 18 | sort_order: winter.redirect::lang.import_export.sort_order 19 | ignore_query_parameters: winter.redirect::lang.import_export.ignore_query_parameters 20 | ignore_case: winter.redirect::lang.import_export.ignore_case 21 | ignore_trailing_slash: winter.redirect::lang.import_export.ignore_trailing_slash 22 | is_enabled: winter.redirect::lang.import_export.is_enabled 23 | test_lab: winter.redirect::lang.import_export.test_lab 24 | test_lab_path: winter.redirect::lang.import_export.test_lab_path 25 | system: winter.redirect::lang.import_export.system 26 | description: winter.redirect::lang.import_export.description 27 | last_used_at: winter.redirect::lang.import_export.last_used_at 28 | created_at: winter.redirect::lang.import_export.created_at 29 | updated_at: winter.redirect::lang.import_export.updated_at 30 | -------------------------------------------------------------------------------- /controllers/statistics/index.php: -------------------------------------------------------------------------------- 1 | 5 | 6 |
7 |
8 |
9 | makePartial('loading-indicator'); ?> 10 | 17 |
18 |
19 |
20 | 21 |
22 |
23 |
24 | makePartial('loading-indicator'); ?> 25 | 26 |
27 |
28 |
29 | 30 |
31 |
32 |
33 | makePartial('loading-indicator'); ?> 34 | 35 |
36 |
37 |
38 |
39 | makePartial('loading-indicator'); ?> 40 | 41 |
42 |
43 |
44 |
45 | makePartial('loading-indicator'); ?> 46 | 47 |
48 |
49 |
50 | -------------------------------------------------------------------------------- /controllers/testlab/_tester_result.php: -------------------------------------------------------------------------------- 1 |
2 |
3 |
to_url)): ?> to_url) ?>
4 |
5 |
6 |
7 | 28 |
29 |
30 |
31 |
32 | makePartial('tester_result_items', $testResults); ?> 33 |
34 | -------------------------------------------------------------------------------- /models/redirect/columns.yaml: -------------------------------------------------------------------------------- 1 | # =================================== 2 | # List Column Definitions 3 | # =================================== 4 | 5 | columns: 6 | from_url: 7 | label: winter.redirect::lang.redirect.from_url 8 | type: redirect_from_url 9 | searchable: true 10 | to_url: 11 | label: winter.redirect::lang.redirect.to_url 12 | type: redirect_from_url 13 | searchable: true 14 | target_type: 15 | label: winter.redirect::lang.redirect.target_type 16 | type: redirect_target_type 17 | status_code: 18 | label: winter.redirect::lang.redirect.status_code 19 | type: redirect_status_code 20 | match_type: 21 | label: winter.redirect::lang.redirect.match_type 22 | type: redirect_match_type 23 | category: 24 | label: winter.redirect::lang.redirect.category 25 | relation: category 26 | select: name 27 | default: '-' 28 | hits: 29 | label: winter.redirect::lang.redirect.hits 30 | type: number 31 | last_used_at: 32 | label: winter.redirect::lang.redirect.last_used_at 33 | type: datetime 34 | sparkline: 35 | label: winter.redirect::lang.redirect.sparkline_30d 36 | sortable: false 37 | searchable: false 38 | cssClass: column-button 39 | type: partial 40 | sort_order: 41 | label: winter.redirect::lang.redirect.priority 42 | is_enabled: 43 | label: winter.redirect::lang.redirect.enabled 44 | type: redirect_switch_color 45 | system: 46 | label: winter.redirect::lang.redirect.type 47 | width: 16px 48 | type: redirect_system 49 | sortable: false 50 | invisible: true 51 | updated_at: 52 | label: winter.redirect::lang.redirect.modified_at 53 | type: datetime 54 | invisible: true 55 | created_at: 56 | label: winter.redirect::lang.redirect.created_at 57 | type: datetime 58 | invisible: true 59 | -------------------------------------------------------------------------------- /reportwidgets/CreateRedirect.php: -------------------------------------------------------------------------------- 1 | alias = 'redirectCreateRedirect'; 28 | 29 | parent::__construct($controller, $properties); 30 | 31 | $this->redirect = resolve(Redirector::class); 32 | } 33 | 34 | /** 35 | * @noinspection PhpMissingParentCallCommonInspection 36 | */ 37 | public function render() 38 | { 39 | $widgetConfig = $this->makeConfig('~/plugins/winter/redirect/reportwidgets/createredirect/fields.yaml'); 40 | $widgetConfig->model = new Redirect; 41 | $widgetConfig->alias = $this->alias . 'Redirect'; 42 | 43 | $this->vars['formWidget'] = $this->makeWidget(Form::class, $widgetConfig); 44 | 45 | return $this->makePartial('widget'); 46 | } 47 | 48 | public function onSubmit(): RedirectResponse 49 | { 50 | $redirect = Redirect::create([ 51 | 'match_type' => Redirect::TYPE_EXACT, 52 | 'target_type' => Redirect::TARGET_TYPE_PATH_URL, 53 | 'from_url' => post('from_url'), 54 | 'from_scheme' => Redirect::SCHEME_AUTO, 55 | 'to_url' => post('to_url'), 56 | 'to_scheme' => Redirect::SCHEME_AUTO, 57 | 'test_url' => post('from_url'), 58 | 'requirements' => null, 59 | 'status_code' => 302, 60 | ]); 61 | 62 | return $this->redirect->to(Backend::url('winter/redirect/redirects/update/' . $redirect->getKey())); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /controllers/testlab/index.php: -------------------------------------------------------------------------------- 1 | 'layout']); ?> 2 | 3 | 4 | 5 |
6 | 7 |
8 |
9 |
16 |
17 |
18 |

19 | 20 |

21 |
22 |
23 | makePartial('test_button', ['redirectCount' => $redirectCount]); ?> 24 |
25 |
26 |
27 | 28 |
29 |
30 | 35 |
36 |

37 |
38 |
39 |
40 | 41 |
42 | 43 | 44 | 45 | 53 | -------------------------------------------------------------------------------- /models/Settings.php: -------------------------------------------------------------------------------- 1 | implement = [SettingsModel::class]; 30 | 31 | parent::__construct($attributes); 32 | } 33 | 34 | public static function isLoggingEnabled(): bool 35 | { 36 | try { 37 | return (bool) (new self())->get('logging_enabled', false); 38 | } catch (Throwable $exception) { 39 | return false; 40 | } 41 | } 42 | 43 | public static function isStatisticsEnabled(): bool 44 | { 45 | try { 46 | return (bool) (new self())->get('statistics_enabled', false); 47 | } catch (Throwable $exception) { 48 | return false; 49 | } 50 | } 51 | 52 | public static function isTestLabEnabled(): bool 53 | { 54 | try { 55 | return (bool) (new self())->get('test_lab_enabled', false); 56 | } catch (Throwable $exception) { 57 | return false; 58 | } 59 | } 60 | 61 | public static function isCachingEnabled(): bool 62 | { 63 | try { 64 | return (bool) (new self())->get('caching_enabled', false); 65 | } catch (Throwable $exception) { 66 | return false; 67 | } 68 | } 69 | 70 | public static function isRelativePathsEnabled(): bool 71 | { 72 | try { 73 | return (bool) (new self())->get('relative_paths_enabled', true); 74 | } catch (Throwable $exception) { 75 | return true; 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /controllers/statistics/_score-board.php: -------------------------------------------------------------------------------- 1 |
2 |
3 |
7 |
    8 | $activeRedirect): ?> 9 |
  • 10 |
  • 11 | 12 |
13 |
14 |
15 |

16 |

17 |

18 |
19 |
20 |

21 |

22 |
23 |
24 |

25 |

26 |

:

27 |
28 | 29 |
30 |

31 |

redirect->from_url) ?>

32 |

timestamp) ?>

33 |
34 | 35 |
36 |
37 | -------------------------------------------------------------------------------- /classes/OptionHelper.php: -------------------------------------------------------------------------------- 1 | 'winter.redirect::lang.redirect.target_type_none', 20 | ]; 21 | } 22 | 23 | return [ 24 | Redirect::TARGET_TYPE_PATH_URL => 'winter.redirect::lang.redirect.target_type_path_or_url', 25 | Redirect::TARGET_TYPE_CMS_PAGE => 'winter.redirect::lang.redirect.target_type_cms_page', 26 | Redirect::TARGET_TYPE_STATIC_PAGE => 'winter.redirect::lang.redirect.target_type_static_page', 27 | ]; 28 | } 29 | 30 | public static function getCmsPageOptions(): array 31 | { 32 | return ['' => '-- ' . e(trans('winter.redirect::lang.redirect.none')) . ' --' ] + Page::getNameList(); 33 | } 34 | 35 | public static function getStaticPageOptions(): array 36 | { 37 | $options = ['' => '-- ' . e(trans('winter.redirect::lang.redirect.none')) . ' --' ]; 38 | 39 | $hasPagesPlugin = PluginManager::instance()->hasPlugin('Winter.Pages'); 40 | 41 | if (!$hasPagesPlugin) { 42 | return $options; 43 | } 44 | 45 | $pages = \Winter\Pages\Classes\Page::listInTheme(Theme::getActiveTheme()); 46 | 47 | /** @var \Winter\Pages\Classes\Page $page */ 48 | foreach ($pages as $page) { 49 | if (array_key_exists('title', $page->viewBag)) { 50 | $options[$page->getBaseFileName()] = $page->viewBag['title']; 51 | } 52 | } 53 | 54 | return $options; 55 | } 56 | 57 | public static function getCategoryOptions(): array 58 | { 59 | return Category::query() 60 | ->get(['id', 'name']) 61 | ->pluck('name', 'key') 62 | ->toArray(); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /assets/javascript/test-lab.js: -------------------------------------------------------------------------------- 1 | var testerShouldStop = false; 2 | 3 | function testerExecute(offset, total, button) { 4 | if (testerShouldStop) { 5 | testerDone(); 6 | testerShouldStop = false; 7 | return; 8 | } 9 | 10 | $.request('onTest', { 11 | data: { 12 | offset: offset 13 | }, 14 | success: function (data) { 15 | if (data.result === '' || typeof data.result === 'undefined') { 16 | testerDone(); 17 | updateStatusBar(total, total); 18 | return; 19 | } 20 | 21 | $('#testerResults').prepend(data.result); 22 | 23 | updateStatusBar(total, offset); 24 | 25 | if (offset + 1 !== total) { 26 | testerExecute(offset + 1, total, button); 27 | } 28 | }, 29 | error: function() { 30 | if (offset + 1 !== total) { 31 | testerExecute(offset + 1, total, button); 32 | } 33 | } 34 | }); 35 | } 36 | 37 | function testerDone() { 38 | $('#testButton').prop('disabled', false); 39 | 40 | var loader = $('#loader'); 41 | loader.removeClass('loading'); 42 | 43 | setTimeout(function () { 44 | loader.addClass('hidden'); 45 | }, 500); 46 | } 47 | 48 | function testerStart(button) { 49 | updateStatusBar(0); 50 | 51 | $('#testerResults').html(''); 52 | 53 | button.prop('disabled', true); 54 | 55 | var loader = $('#loader'); 56 | loader.removeClass('hidden'); 57 | loader.addClass('loading'); 58 | 59 | testerExecute(0, $('#redirectCount').val(), button); 60 | } 61 | 62 | function testerStop() { 63 | testerShouldStop = true; 64 | } 65 | 66 | function updateStatusBar(total, offset) { 67 | var width = 0; 68 | 69 | if (total > 0) { 70 | width = Math.ceil(100 / total * offset); 71 | } 72 | 73 | var progress = $('#progress'); 74 | progress.html(width + '% complete (' + offset + ' of ' + total + ')'); 75 | 76 | var progressBar = $('#progressBar'); 77 | progressBar.attr('aria-valuenow', width); 78 | progressBar.css('width', width + '%'); 79 | 80 | if (width === 0) { 81 | progress.html(progress.data('initial')); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /classes/contracts/CacheManagerInterface.php: -------------------------------------------------------------------------------- 1 | getStatusCssClass(); ?>"> 2 |
3 | 4 | 1. 5 |
6 |
getMessage(); ?>
7 |
getDuration(); ?> ms
8 | 9 |
10 |
11 | 12 | 2. 13 |
14 |
getMessage(); ?>
15 |
getDuration(); ?> ms
16 |
17 |
18 |
19 | 20 | 3. 21 |
22 |
getMessage(); ?>
23 |
getDuration(); ?> ms
24 |
25 |
26 |
27 | 28 | 4. 29 |
30 |
getMessage(); ?>
31 |
getDuration(); ?> ms
32 |
33 |
34 |
35 | 36 | 5. 37 |
38 |
getMessage(); ?>
39 |
getDuration(); ?> ms
40 |
41 | -------------------------------------------------------------------------------- /classes/observers/RedirectObserver.php: -------------------------------------------------------------------------------- 1 | dispatcher = $dispatcher; 32 | $this->log = $log; 33 | } 34 | 35 | /** 36 | * @param Models\Redirect $model 37 | * @return void 38 | */ 39 | public function created(Models\Redirect $model): void 40 | { 41 | if (!self::canHandleChanges()) { 42 | return; 43 | } 44 | 45 | $this->logChange($model, 'created'); 46 | 47 | $this->dispatcher->dispatch('winter.redirect.changed', [ 48 | 'redirectIds' => Arr::wrap($model->getKey()) 49 | ]); 50 | } 51 | 52 | /** 53 | * @param Models\Redirect $model 54 | * @return void 55 | */ 56 | public function updated(Models\Redirect $model): void 57 | { 58 | if (!self::canHandleChanges()) { 59 | return; 60 | } 61 | 62 | $this->logChange($model, 'updated'); 63 | 64 | $this->dispatcher->dispatch('winter.redirect.changed', [ 65 | 'redirectIds' => Arr::wrap($model->getKey()) 66 | ]); 67 | } 68 | 69 | /** 70 | * @param Models\Redirect $model 71 | * @return void 72 | */ 73 | public function deleted(Models\Redirect $model): void 74 | { 75 | if (!self::canHandleChanges()) { 76 | return; 77 | } 78 | 79 | $this->logChange($model, 'deleted'); 80 | 81 | $this->dispatcher->dispatch('winter.redirect.changed', [ 82 | 'redirectIds' => Arr::wrap($model->getKey()) 83 | ]); 84 | } 85 | 86 | private function logChange(Models\Redirect $model, string $typeOfChange): void 87 | { 88 | if ((bool) config('winter.redirect::log_redirect_changes', false) === false) { 89 | return; 90 | } 91 | 92 | $this->log->info(sprintf( 93 | 'Winter.Redirect: Redirect %d has been %s.', 94 | $model->getKey(), 95 | $typeOfChange 96 | ), $model->getDirty()); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /controllers/Logs.php: -------------------------------------------------------------------------------- 1 | addCss('/plugins/winter/redirect/assets/css/redirect.css'); 38 | 39 | $this->request = $request; 40 | $this->translator = $translator; 41 | $this->flash = resolve('flash'); 42 | $this->log = $log; 43 | } 44 | 45 | public function onRefresh(): array 46 | { 47 | return $this->listRefresh(); 48 | } 49 | 50 | public function onEmptyLog(): array 51 | { 52 | try { 53 | RedirectLog::query()->truncate(); 54 | $this->flash->success($this->translator->trans('winter.redirect::lang.flash.truncate_success')); 55 | } catch (Throwable $e) { 56 | $this->log->warning($e); 57 | } 58 | 59 | return $this->listRefresh(); 60 | } 61 | 62 | public function onDelete(): array 63 | { 64 | if (($checkedIds = $this->request->get('checked', [])) 65 | && is_array($checkedIds) 66 | && count($checkedIds) 67 | ) { 68 | foreach ($checkedIds as $recordId) { 69 | try { 70 | /** @var RedirectLog $record */ 71 | $record = RedirectLog::query()->findOrFail($recordId); 72 | $record->delete(); 73 | } catch (Throwable $e) { 74 | $this->log->warning($e); 75 | } 76 | } 77 | 78 | $this->flash->success($this->translator->trans('winter.redirect::lang.flash.delete_selected_success')); 79 | } else { 80 | $this->flash->error($this->translator->trans('backend::lang.list.delete_selected_empty')); 81 | } 82 | 83 | return $this->listRefresh(); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /updates/20200408_0008_change_column_types_from_char_to_varchar.php: -------------------------------------------------------------------------------- 1 | getDriverName() === 'pgsql') { 21 | $database->statement(implode(' ', [ 22 | 'ALTER TABLE vdlp_redirect_redirects', 23 | 'ALTER COLUMN match_type TYPE VARCHAR(12),', 24 | 'ALTER COLUMN target_type TYPE VARCHAR(12),', 25 | 'ALTER COLUMN from_scheme TYPE VARCHAR(5),', 26 | 'ALTER COLUMN to_scheme TYPE VARCHAR(5),', 27 | 'ALTER COLUMN status_code TYPE VARCHAR(3);', 28 | ])); 29 | 30 | $database->statement(implode(' ', [ 31 | 'ALTER TABLE vdlp_redirect_redirects', 32 | "ALTER COLUMN target_type SET DEFAULT 'path_or_url',", 33 | "ALTER COLUMN from_scheme SET DEFAULT 'auto',", 34 | "ALTER COLUMN to_scheme SET DEFAULT 'auto';" 35 | ])); 36 | 37 | $database->statement(implode(' ', [ 38 | 'ALTER TABLE vdlp_redirect_redirect_logs', 39 | 'ALTER COLUMN status_code TYPE VARCHAR(3);', 40 | ])); 41 | } 42 | 43 | if ($database->getDriverName() === 'mysql') { 44 | $database->statement('ALTER TABLE `vdlp_redirect_redirects` CHANGE `match_type` `match_type` VARCHAR(12) NULL DEFAULT NULL;'); 45 | $database->statement("ALTER TABLE `vdlp_redirect_redirects` CHANGE `target_type` `target_type` VARCHAR(12) NOT NULL DEFAULT 'path_or_url';"); 46 | $database->statement("ALTER TABLE `vdlp_redirect_redirects` CHANGE `from_scheme` `from_scheme` VARCHAR(5) NOT NULL DEFAULT 'auto';"); 47 | $database->statement("ALTER TABLE `vdlp_redirect_redirects` CHANGE `to_scheme` `to_scheme` VARCHAR(5) NOT NULL DEFAULT 'auto';"); 48 | $database->statement("ALTER TABLE `vdlp_redirect_redirects` CHANGE `status_code` `status_code` VARCHAR(3) NOT NULL DEFAULT '';"); 49 | $database->statement("ALTER TABLE `vdlp_redirect_redirect_logs` CHANGE `status_code` `status_code` VARCHAR(3) NOT NULL DEFAULT '';"); 50 | } 51 | 52 | // 'sqlite' does not support the char type, so it doesn't need to be altered. 53 | } 54 | 55 | public function down(): void 56 | { 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /assets/images/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Tile 8 | 9 | 10 | A 11 | B 12 | 13 | -------------------------------------------------------------------------------- /controllers/redirects/_status_code_info.php: -------------------------------------------------------------------------------- 1 | 5 | 6 |
7 |

301 (Moved Permanently)

8 |

The 301 (Moved Permanently) status code indicates that the target 9 | resource has been assigned a new permanent URI and any future 10 | references to this resource ought to use one of the enclosed URIs. 11 | Clients with link-editing capabilities ought to automatically re-link 12 | references to the effective request URI to one or more of the new 13 | references sent by the server, where possible.

14 | 15 |

302 (Found)

16 |

The 302 (Found) status code indicates that the target resource 17 | resides temporarily under a different URI. Since the redirection 18 | might be altered on occasion, the client ought to continue to use the 19 | effective request URI for future requests. 20 |

21 | 22 |

303 (See Other)

23 |

The 303 (See Other) status code indicates that the server is 24 | redirecting the user agent to a different resource, as indicated by a 25 | URI in the Location header field, which is intended to provide an 26 | indirect response to the original request. A user agent can perform 27 | a retrieval request targeting that URI (a GET or HEAD request if 28 | using HTTP), which might also be redirected, and present the eventual 29 | result as an answer to the original request. Note that the new URI 30 | in the Location header field is not considered equivalent to the 31 | effective request URI.

32 | 33 |

404 (Not Found)

34 |

The 404 (Not Found) status code indicates that the origin server did 35 | not find a current representation for the target resource or is not 36 | willing to disclose that one exists. A 404 status code does not 37 | indicate whether this lack of representation is temporary or 38 | permanent; the 410 (Gone) status code is preferred over 404 if the 39 | origin server knows, presumably through some configurable means, that 40 | the condition is likely to be permanent.

41 | 42 |

410 (Gone)

43 |

The 410 (Gone) status code indicates that access to the target 44 | resource is no longer available at the origin server and that this 45 | condition is likely to be permanent. If the origin server does not 46 | know, or has no facility to determine, whether or not the condition 47 | is permanent, the status code 404 (Not Found) ought to be used 48 | instead.

49 |
50 | -------------------------------------------------------------------------------- /controllers/redirects/_popup_actions.php: -------------------------------------------------------------------------------- 1 | 8 | 57 | -------------------------------------------------------------------------------- /controllers/redirects/_field_statistics.php: -------------------------------------------------------------------------------- 1 | formGetModel(); 3 | $redirectId = (int) $redirect->getKey(); 4 | $latestClient = $statisticsHelper->getLatestClient($redirectId); 5 | ?> 6 | 7 |
8 |
9 |
10 |

11 |
12 | 13 |
14 |
15 |
16 |
17 | 18 |
19 |
20 |
21 |

22 |

getTotalThisMonth($redirectId), 0, '', '.') ?>

23 |

: getTotalLastMonth($redirectId), 0, '', '.') ?>

24 |
25 |
26 |
27 | 28 |
29 |
30 |
31 |

32 |

hits, 0, '', '.') ?>

33 |

34 |
35 |
36 |
37 | 38 | last_used_at, ['formatAlias' => 'dateTimeMin']) ?> 39 | 40 |
41 |
42 |
43 |

44 |

45 |

46 |
47 |
48 |
49 | 50 | updated_at, ['formatAlias' => 'dateTimeMin']) ?> 51 | 52 |
53 |
54 |
55 |

56 |

57 |

58 |
59 |
60 |
61 | 62 | systemRequestLog): ?> 63 |
64 | 65 | 66 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /models/RedirectImport.php: -------------------------------------------------------------------------------- 1 | 'required', 22 | 'match_type' => 'required|in:exact,placeholders,regex', 23 | 'target_type' => 'required|in:path_or_url,cms_page,static_page,none', 24 | 'status_code' => 'required|in:301,302,303,404,410', 25 | ]; 26 | 27 | private static array $nullableAttributes = [ 28 | 'category_id', 29 | 'from_date', 30 | 'to_date', 31 | 'last_used_at', 32 | 'to_url', 33 | 'test_url', 34 | 'cms_page', 35 | 'static_page', 36 | 'requirements', 37 | 'test_lab_path', 38 | ]; 39 | 40 | private static array $dateAttributes = [ 41 | 'from_date', 42 | 'to_date', 43 | ]; 44 | 45 | private static array $dateTimeAttributes = [ 46 | 'last_used_at', 47 | 'created_at', 48 | 'updated_at', 49 | ]; 50 | 51 | public function importData($results, $sessionKey = null) 52 | { 53 | foreach ((array) $results as $row => $data) { 54 | try { 55 | $source = Redirect::make(); 56 | 57 | $except = ['id']; 58 | 59 | foreach (array_except($data, $except) as $attribute => $value) { 60 | if ($attribute === 'requirements') { 61 | $value = json_decode($value, true, 512, JSON_THROW_ON_ERROR); 62 | } elseif (!empty($value) && in_array($attribute, self::$dateAttributes)) { 63 | $value = Argon::parse($value)->toDateString(); 64 | } elseif (!empty($value) && in_array($attribute, self::$dateTimeAttributes)) { 65 | $value = Argon::parse($value)->toDateTimeString(); 66 | } elseif (empty($value) && in_array($attribute, self::$nullableAttributes, true)) { 67 | $value = null; 68 | } 69 | 70 | $source->setAttribute($attribute, $value); 71 | } 72 | 73 | $source->save(); 74 | 75 | $this->logCreated(); 76 | } catch (Throwable $e) { 77 | $this->logError($row, $e->getMessage()); 78 | } 79 | } 80 | 81 | $createCount = $this->resultStats['created'] ?? 0; 82 | 83 | if ($createCount === 0) { 84 | return; 85 | } 86 | 87 | /** @var PublishManager $publishManager */ 88 | $publishManager = resolve(PublishManager::class); 89 | $publishManager->publish(); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /assets/css/test-lab.css: -------------------------------------------------------------------------------- 1 | .test-lab { 2 | height: 100%; 3 | width: 100%; 4 | position: absolute; 5 | top: 0; 6 | } 7 | 8 | #testerResults { 9 | font-family: Menlo, Monaco, Consolas, "Courier New", monospace; 10 | font-size: 12px; 11 | position: relative; 12 | top: 0; 13 | overflow: scroll; 14 | height: 85%; 15 | text-shadow: 0 -1px 0 #eee; 16 | outline: none; 17 | } 18 | 19 | #testButton { 20 | text-align: center; 21 | } 22 | 23 | /* 24 | * Progress Bar 25 | **********************************************************************************************************************/ 26 | 27 | .progress { 28 | height: 34px; 29 | margin-bottom: 4px; 30 | } 31 | 32 | .progress-bar { 33 | 34 | } 35 | 36 | /* 37 | * Tester Results 38 | **********************************************************************************************************************/ 39 | 40 | #testerResults a { 41 | outline: none; 42 | } 43 | 44 | #testerResults .failed i, 45 | #testerResults .failed .test { 46 | color: #ff0000; 47 | } 48 | 49 | #testerResults .passed i, 50 | #testerResults .passed .test { 51 | color: #009900; 52 | } 53 | 54 | #testerResults .row.heading .toolbar { 55 | font-family: sans-serif; 56 | } 57 | 58 | #testerResults .row.heading .toolbar .toolbar-item { 59 | margin-left: 4px; 60 | font-size: 12px; 61 | } 62 | 63 | #testerResults .row.heading { 64 | margin-bottom: 4px; 65 | } 66 | 67 | #testerResults .tester-result { 68 | background: #eee; 69 | border: 1px solid #bbb; 70 | border-radius: 4px; 71 | padding: 8px; 72 | margin-bottom: 20px; 73 | } 74 | 75 | /* 76 | * Loading Indicator 77 | **********************************************************************************************************************/ 78 | 79 | #testerResults .loading-indicator { 80 | font-size: 12px; 81 | } 82 | 83 | #testerResults .loading-indicator-container { 84 | min-height: 20px; 85 | } 86 | 87 | #testerResults .loading-indicator > span { 88 | background-size: 20px 20px; 89 | right: 0; 90 | left: auto; 91 | } 92 | 93 | #testerResults .loading-indicator-container .loading-indicator > div { 94 | right: 0; 95 | margin-right: 40px; 96 | } 97 | 98 | /* 99 | * Popup Loading Indicator 100 | **********************************************************************************************************************/ 101 | 102 | .popup-backdrop .popup-loading-indicator { 103 | width: 200px; 104 | height: 200px; 105 | margin-left: -100px; 106 | } 107 | 108 | .popup-backdrop .popup-loading-indicator:after { 109 | margin-left: 75px; 110 | } 111 | 112 | .popup-backdrop .popup-loading-indicator .btn { 113 | position: absolute; 114 | width: 100px; 115 | left: 50%; 116 | margin-left: -50px; 117 | text-align: center; 118 | top: 150px; 119 | } 120 | 121 | .popup-backdrop .popup-loading-indicator div { 122 | position: absolute; 123 | width: 200px; 124 | left: 50%; 125 | margin-left: -100px; 126 | text-align: center; 127 | top: 110px; 128 | } 129 | -------------------------------------------------------------------------------- /classes/testers/ResponseCode.php: -------------------------------------------------------------------------------- 1 | testUrl); 31 | 32 | $this->setDefaultCurlOptions($curlHandle); 33 | 34 | curl_setopt($curlHandle, CURLOPT_FOLLOWLOCATION, false); 35 | 36 | $error = null; 37 | 38 | if (curl_exec($curlHandle) === false) { 39 | $error = curl_error($curlHandle); 40 | } 41 | 42 | if ($error !== null) { 43 | return new TesterResult(false, e(trans('winter.redirect::lang.test_lab.result_request_failed'))); 44 | } 45 | 46 | $statusCode = (int) curl_getinfo($curlHandle, CURLINFO_HTTP_CODE); 47 | 48 | curl_close($curlHandle); 49 | 50 | $manager = $this->getRedirectManager(); 51 | 52 | // TODO: Add scheme 53 | try { 54 | $match = $manager->match($this->testPath, Request::getScheme()); 55 | } catch (NoMatchForRequest | InvalidScheme $e) { 56 | $match = false; 57 | } 58 | 59 | if ($match && $match->getStatusCode() !== $statusCode) { 60 | $message = e(trans('winter.redirect::lang.test_lab.matched_not_http_code', [ 61 | 'expected' => $match->getStatusCode(), 62 | 'received' => $statusCode 63 | ])); 64 | 65 | return new TesterResult(false, $message); 66 | } 67 | 68 | if ($match && $match->getStatusCode() === $statusCode) { 69 | $message = e(trans('winter.redirect::lang.test_lab.matched_http_code', [ 70 | 'code' => $statusCode, 71 | ])); 72 | 73 | return new TesterResult(true, $message); 74 | } 75 | 76 | // Should be a 301, 302, 303, 404, 410, ... 77 | if (!array_key_exists($statusCode, Redirect::$statusCodes)) { 78 | return new TesterResult( 79 | false, 80 | e(trans('winter.redirect::lang.test_lab.response_http_code_should_be')) 81 | . ' ' 82 | . implode(', ', array_keys(Redirect::$statusCodes)) 83 | ); 84 | } 85 | 86 | return new TesterResult( 87 | true, 88 | e(trans('winter.redirect::lang.test_lab.response_http_code')) . ': ' . $statusCode 89 | ); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /routes.php: -------------------------------------------------------------------------------- 1 | ['web']], static function () { 14 | Route::get('winter/redirect/sparkline/{redirectId}', static function ($redirectId) { 15 | if (!BackendAuth::check()) { 16 | return response('Forbidden', 403); 17 | } 18 | 19 | /** @var Request $request */ 20 | $request = resolve(Request::class); 21 | 22 | $crawler = $request->has('crawler'); 23 | 24 | $preset = $request->get('preset', '30d-small'); 25 | 26 | $properties = [ 27 | 'format' => '200x60', 28 | 'lineThickness' => 3, 29 | 'days' => 30, 30 | ]; 31 | 32 | if ($preset === '3m-large') { 33 | $properties = [ 34 | 'format' => '520x120', 35 | 'lineThickness' => 2, 36 | 'days' => 90, 37 | ]; 38 | } 39 | 40 | $cacheKey = sprintf('winter_redirect_%s_%d_%d', $preset, (int) $redirectId, (int) $crawler); 41 | 42 | $data = Cache::remember($cacheKey, 5 * 60, static function () use ($redirectId, $crawler, $properties) { 43 | return (new StatisticsHelper())->getRedirectHitsSparkline((int) $redirectId, $crawler, $properties['days']); 44 | }); 45 | 46 | // TODO: Generate fallback image data if generating image fails. 47 | $imageData = Cache::remember($cacheKey . '_image', 5 * 60, static function () use ($crawler, $data, $properties) { 48 | try { 49 | $primaryColor = BrandSetting::get( 50 | $crawler ? 'primary_color' : 'secondary_color', 51 | $crawler ? BrandSetting::PRIMARY_COLOR : BrandSetting::SECONDARY_COLOR 52 | ); 53 | 54 | $sparkline = new Sparkline(); 55 | $sparkline->setFormat($properties['format']); 56 | $sparkline->setPadding('2 0 0 2'); 57 | $sparkline->setData($data); 58 | $sparkline->setLineThickness($properties['lineThickness']); 59 | $sparkline->setLineColorHex($primaryColor); 60 | $sparkline->setFillColorHex($primaryColor); 61 | $sparkline->deactivateBackgroundColor(); 62 | 63 | return $sparkline->toBase64(); 64 | } catch (\Throwable $e) { 65 | // Generate a 1x1 transparent pixel as fallback. 66 | return 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+P+/HgAFDQJXep6X8QAAAABJRU5ErkJggg=='; 67 | } 68 | }); 69 | 70 | return Response::make(base64_decode($imageData), 200, [ 71 | 'Content-Type' => 'image/png', 72 | 'Content-Disposition' => 'inline; filename="' . $cacheKey . '.png"', 73 | 'Accept-Ranges' => 'none', 74 | // Leverage browser caching 75 | 'Cache-Control' => 'public, max-age=300', 76 | ]); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /classes/TesterBase.php: -------------------------------------------------------------------------------- 1 | testPath = $testPath; 31 | $this->testUrl = url($testPath); 32 | } 33 | 34 | final public function execute(): TesterResult 35 | { 36 | $stopwatch = new Stopwatch(); 37 | 38 | $stopwatch->start(__FUNCTION__); 39 | 40 | $result = $this->test(); 41 | 42 | $event = $stopwatch->stop(__FUNCTION__); 43 | 44 | $result->setDuration((int) $event->getDuration()); 45 | 46 | return $result; 47 | } 48 | 49 | public function getTestPath(): string 50 | { 51 | return $this->testPath; 52 | } 53 | 54 | public function getTestUrl(): string 55 | { 56 | return $this->testUrl; 57 | } 58 | 59 | abstract protected function test(): TesterResult; 60 | 61 | /** 62 | * @throws InvalidArgumentException 63 | */ 64 | protected function setDefaultCurlOptions($curlHandle): void 65 | { 66 | if (!is_resource($curlHandle)) { 67 | throw new InvalidArgumentException('Argument must be a valid resource type.'); 68 | } 69 | 70 | curl_setopt($curlHandle, CURLOPT_MAXREDIRS, self::MAX_REDIRECTS); 71 | curl_setopt($curlHandle, CURLOPT_CONNECTTIMEOUT, self::CONNECTION_TIMEOUT); 72 | curl_setopt($curlHandle, CURLOPT_AUTOREFERER, true); 73 | 74 | // This constant is not available when open_basedir or safe_mode are enabled. 75 | curl_setopt($curlHandle, CURLOPT_FOLLOWLOCATION, true); 76 | 77 | /** @noinspection CurlSslServerSpoofingInspection */ 78 | curl_setopt($curlHandle, CURLOPT_SSL_VERIFYHOST, false); 79 | /** @noinspection CurlSslServerSpoofingInspection */ 80 | curl_setopt($curlHandle, CURLOPT_SSL_VERIFYPEER, false); 81 | 82 | if (defined('CURLOPT_SSL_VERIFYSTATUS')) { 83 | curl_setopt($curlHandle, CURLOPT_SSL_VERIFYSTATUS, false); 84 | } 85 | 86 | curl_setopt($curlHandle, CURLOPT_RETURNTRANSFER, true); 87 | curl_setopt($curlHandle, CURLOPT_VERBOSE, false); 88 | curl_setopt($curlHandle, CURLOPT_HTTPHEADER, [ 89 | 'X-Winter-Redirect: Tester', 90 | ]); 91 | } 92 | 93 | protected function getRedirectManager(): RedirectManagerInterface 94 | { 95 | /** @var RedirectManagerInterface $manager */ 96 | $manager = resolve(RedirectManagerInterface::class); 97 | return $manager->setSettings(new RedirectManagerSettings( 98 | false, 99 | false, 100 | Settings::isRelativePathsEnabled() 101 | )); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /controllers/redirects/_redirect_test.php: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | 7 | 8 |
9 |
    10 |
  • >http
  • 11 |
  • >https
  • 12 |
13 | 14 |
15 |
16 |
17 | 20 | 26 |

27 |
28 |
29 |
30 |
31 | makePartial('redirect_test_result'); ?> 32 |
33 |
34 | 37 |
42 |
43 | 44 | 49 |
50 |
51 |

52 |
53 | 63 |
64 |
65 | 80 | -------------------------------------------------------------------------------- /updates/20200918_0011_refactor_redirects_logs_table.php: -------------------------------------------------------------------------------- 1 | getMessage(), PHP_EOL; 19 | echo 'Database table `vdlp_redirect_redirect_logs` could not be removed.', PHP_EOL; 20 | echo 'Please remove it manually and try running the database migrations again.', PHP_EOL; 21 | 22 | return; 23 | } 24 | 25 | Schema::create('vdlp_redirect_redirect_logs', static function (Blueprint $table): void { 26 | // Table MySQL configuration 27 | $table->engine = 'InnoDB'; 28 | 29 | // Columns 30 | $table->increments('id'); 31 | $table->unsignedInteger('redirect_id'); 32 | $table->string('from_to_hash', 40); 33 | $table->string('status_code', 3); 34 | $table->mediumText('from_url'); 35 | $table->mediumText('to_url'); 36 | $table->unsignedInteger('hits') 37 | ->default(0); 38 | $table->timestamps(); 39 | 40 | // Foreign keys 41 | $table->foreign('redirect_id', 'vdlp_redirect_log') 42 | ->references('id') 43 | ->on('vdlp_redirect_redirects') 44 | ->onDelete('cascade'); 45 | 46 | // Indexes 47 | $table->unique([ 48 | 'redirect_id', 49 | 'from_to_hash', 50 | 'status_code', 51 | ], 'redirect_log_unique'); 52 | }); 53 | } 54 | 55 | public function down(): void 56 | { 57 | try { 58 | Schema::disableForeignKeyConstraints(); 59 | Schema::dropIfExists('vdlp_redirect_redirect_logs'); 60 | Schema::enableForeignKeyConstraints(); 61 | } catch (Throwable $throwable) { 62 | echo 'Migration error: ' . $throwable->getMessage(), PHP_EOL; 63 | echo 'Database table `vdlp_redirect_redirect_logs` could not be removed.', PHP_EOL; 64 | echo 'Please remove it manually and try running the database migrations again.', PHP_EOL; 65 | 66 | return; 67 | } 68 | 69 | Schema::create('vdlp_redirect_redirect_logs', static function (Blueprint $table): void { 70 | // Table MySQL configuration 71 | $table->engine = 'InnoDB'; 72 | 73 | // Columns 74 | $table->increments('id'); 75 | $table->unsignedInteger('redirect_id'); 76 | $table->mediumText('from_url'); 77 | $table->mediumText('to_url'); 78 | $table->string('status_code', 3); 79 | $table->unsignedTinyInteger('day'); 80 | $table->unsignedTinyInteger('month'); 81 | $table->unsignedSmallInteger('year'); 82 | $table->dateTime('date_time'); 83 | 84 | // Indexes 85 | $table->index(['redirect_id', 'day', 'month', 'year'], 'redirect_log_dmy'); 86 | $table->index(['redirect_id', 'month', 'year'], 'redirect_log_my'); 87 | 88 | // Foreign keys 89 | $table->foreign('redirect_id', 'vdlp_redirect_log') 90 | ->references('id') 91 | ->on('vdlp_redirect_redirects') 92 | ->onDelete('cascade'); 93 | }); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /classes/PublishManager.php: -------------------------------------------------------------------------------- 1 | log = $log; 24 | $this->cacheManager = $cacheManager; 25 | } 26 | 27 | public function publish(): int 28 | { 29 | $columns = [ 30 | 'id', 31 | 'match_type', 32 | 'target_type', 33 | 'from_scheme', 34 | 'from_url', 35 | 'to_scheme', 36 | 'to_url', 37 | 'cms_page', 38 | 'static_page', 39 | 'status_code', 40 | 'requirements', 41 | 'from_date', 42 | 'to_date', 43 | 'ignore_query_parameters', 44 | 'ignore_case', 45 | 'ignore_trailing_slash', 46 | 'forward_query_parameters', 47 | ]; 48 | 49 | /** @var Collection $redirects */ 50 | $redirects = Redirect::query() 51 | ->where('is_enabled', '=', 1) 52 | ->orderBy('sort_order') 53 | ->get($columns); 54 | 55 | if ($this->cacheManager->cachingEnabledAndSupported()) { 56 | $this->publishToCache($redirects->toArray()); 57 | } else { 58 | $this->publishToFilesystem($columns, $redirects->toArray()); 59 | } 60 | 61 | $count = $redirects->count(); 62 | 63 | if ((bool) config('winter.redirect::log_redirect_changes', false) === true) { 64 | $this->log->info(sprintf( 65 | 'Winter.Redirect: Redirect engine has been updated with %s redirects.', 66 | $count 67 | )); 68 | } 69 | 70 | return $count; 71 | } 72 | 73 | private function publishToFilesystem(array $columns, array $redirects): void 74 | { 75 | $redirectsFile = config('winter.redirect::rules_path'); 76 | 77 | if (file_exists($redirectsFile)) { 78 | unlink($redirectsFile); 79 | } 80 | 81 | try { 82 | $writer = Writer::createFromPath($redirectsFile, 'w+'); 83 | $writer->insertOne($columns); 84 | 85 | foreach ($redirects as $row) { 86 | if (isset($row['requirements'])) { 87 | $row['requirements'] = json_encode($row['requirements'], JSON_THROW_ON_ERROR); 88 | } 89 | 90 | $writer->insertOne($row); 91 | } 92 | } catch (Throwable $throwable) { 93 | touch($redirectsFile); 94 | 95 | $this->log->error($throwable); 96 | } 97 | } 98 | 99 | private function publishToCache(array $redirects): void 100 | { 101 | foreach ($redirects as &$redirect) { 102 | if (isset($redirect['requirements'])) { 103 | try { 104 | $redirect['requirements'] = json_encode($redirect['requirements'], JSON_THROW_ON_ERROR); 105 | } catch (JsonException $exception) { 106 | // @ignoreException 107 | } 108 | } 109 | } 110 | 111 | unset($redirect); 112 | 113 | $this->cacheManager->flush(); 114 | $this->cacheManager->putRedirectRules($redirects); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /classes/CacheManager.php: -------------------------------------------------------------------------------- 1 | cache = $cache; 28 | $this->log = $log; 29 | } 30 | 31 | public function get(string $key) 32 | { 33 | return $this->cache->get(self::CACHE_TAG_MATCHES . '.' . $key); 34 | } 35 | 36 | public function forget(string $key): bool 37 | { 38 | return $this->cache->forget(self::CACHE_TAG_MATCHES . '.' . $key); 39 | } 40 | 41 | public function has(string $key): bool 42 | { 43 | return $this->cache->has(self::CACHE_TAG_MATCHES . '.' . $key); 44 | } 45 | 46 | public function cacheKey(string $requestPath, string $scheme): string 47 | { 48 | // Most caching backend have no limits on key lengths. 49 | // But to be sure I chose to MD5 hash the cache key. 50 | return md5($requestPath . $scheme); 51 | } 52 | 53 | public function flush(): void 54 | { 55 | foreach ([self::CACHE_TAG, self::CACHE_TAG_RULES, self::CACHE_TAG_MATCHES] as $key) { 56 | $this->cache->forget($key); 57 | } 58 | 59 | if ((bool) config('winter.redirect::log_redirect_changes', false) === true) { 60 | $this->log->info('Winter.Redirect: Redirect cache has been flushed.'); 61 | } 62 | } 63 | 64 | public function putRedirectRules(array $redirectRules): void 65 | { 66 | $this->cache->forever(self::CACHE_TAG_RULES . '.rules', $redirectRules); 67 | } 68 | 69 | public function getRedirectRules(): array 70 | { 71 | if (!$this->cache->has(self::CACHE_TAG_RULES . '.rules')) { 72 | $publishManager = resolve(PublishManagerInterface::class); 73 | $publishManager->publish(); 74 | } 75 | 76 | $data = $this->cache->get(self::CACHE_TAG_RULES . '.rules', []); 77 | 78 | if (is_array($data)) { 79 | return $data; 80 | } 81 | 82 | return []; 83 | } 84 | 85 | public function putMatch(string $cacheKey, ?RedirectRule $matchedRule = null): ?RedirectRule 86 | { 87 | if ($matchedRule === null) { 88 | $this->cache->forever(self::CACHE_TAG_MATCHES . '.' . $cacheKey, false); 89 | 90 | return null; 91 | } 92 | 93 | $matchedRuleToDate = $matchedRule->getToDate(); 94 | 95 | if ($matchedRuleToDate instanceof Carbon) { 96 | $minutes = $matchedRuleToDate->diffInMinutes(Carbon::now()); 97 | 98 | $this->cache->put(self::CACHE_TAG_MATCHES . '.' . $cacheKey, $matchedRule, $minutes); 99 | } else { 100 | $this->cache->forever(self::CACHE_TAG_MATCHES . '.' . $cacheKey, $matchedRule); 101 | } 102 | 103 | return $matchedRule; 104 | } 105 | 106 | public function cachingEnabledAndSupported(): bool 107 | { 108 | return Settings::isCachingEnabled() && !in_array(Config::get('cache.default'), ['file', 'database']); 109 | } 110 | 111 | public function cachingEnabledButNotSupported(): bool 112 | { 113 | return Settings::isCachingEnabled() && in_array(Config::get('cache.default'), ['file', 'database']); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /updates/20220415_0013_rename_to_winter_redirect.php: -------------------------------------------------------------------------------- 1 | oldPrefix . $table; 27 | $to = $this->newPrefix . $table; 28 | 29 | if (Schema::hasTable($from) && !Schema::hasTable($to)) { 30 | Schema::rename($from, $to); 31 | $this->updateIndexNames($from, $to, $to); 32 | } 33 | } 34 | 35 | if (Schema::hasColumn('system_request_logs', 'vdlp_redirect_redirect_id')) { 36 | Schema::table('system_request_logs', function (Blueprint $table) { 37 | $table->renameColumn('vdlp_redirect_redirect_id', 'winter_redirect_redirect_id'); 38 | 39 | $table->dropForeign('vdlp_redirect_request_log'); 40 | 41 | $table->foreign('winter_redirect_redirect_id', 'winter_redirect_request_log') 42 | ->references('id') 43 | ->on('winter_redirect_redirects') 44 | ->onDelete('set null'); 45 | }); 46 | } 47 | 48 | Schema::enableForeignKeyConstraints(); 49 | 50 | // Migrate the plugin settings 51 | DB::table('system_settings') 52 | ->where('item', $this->oldPrefix . 'settings') 53 | ->update(['item' => $this->newPrefix . 'settings']); 54 | } 55 | 56 | public function down() 57 | { 58 | Schema::disableForeignKeyConstraints(); 59 | 60 | foreach (self::TABLES as $table) { 61 | $from = $this->newPrefix . $table; 62 | $to = $this->oldPrefix . $table; 63 | 64 | if (Schema::hasTable($from) && !Schema::hasTable($to)) { 65 | Schema::rename($from, $to); 66 | $this->updateIndexNames($from, $to, $from); 67 | } 68 | } 69 | 70 | if (Schema::hasColumn('system_request_logs', 'winter_redirect_redirect_id')) { 71 | Schema::table('system_request_logs', function (Blueprint $table) { 72 | $table->renameColumn('winter_redirect_redirect_id', 'vdlp_redirect_redirect_id'); 73 | 74 | $table->dropForeign('winter_redirect_request_log'); 75 | 76 | $table->foreign('vdlp_redirect_redirect_id', 'vdlp_redirect_request_log') 77 | ->references('id') 78 | ->on('vdlp_redirect_redirects') 79 | ->onDelete('set null'); 80 | }); 81 | } 82 | 83 | Schema::enableForeignKeyConstraints(); 84 | 85 | // Migrate the plugin settings 86 | DB::table('system_settings') 87 | ->where('item', $this->newPrefix . 'settings') 88 | ->update(['item' => $this->oldPrefix . 'settings']); 89 | } 90 | 91 | /** 92 | * Updates index prefixes on the provided table 93 | */ 94 | public function updateIndexNames(string $from, string $to, string $table): void 95 | { 96 | $sm = Schema::getConnection()->getDoctrineSchemaManager(); 97 | 98 | $table = $sm->listTableDetails($table); 99 | 100 | foreach ($table->getIndexes() as $index) { 101 | if ($index->isPrimary()) { 102 | continue; 103 | } 104 | 105 | $old = $index->getName(); 106 | $new = str_replace($from, $to, $old); 107 | 108 | $table->renameIndex($old, $new); 109 | } 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /updates/20180831_0002_upgrade_from_adrenth_redirect.php: -------------------------------------------------------------------------------- 1 | getSchemaBuilder(); 30 | 31 | if (!$schema->hasTable('adrenth_redirect_redirects')) { 32 | // Skip upgrade migration. 33 | $log->info('No upgrade of Winter.Redirect needed. Fresh installation.'); 34 | return; 35 | } 36 | 37 | try { 38 | $database->transaction(function () use ($database) { 39 | $this->disableForeignKeyCheck($database); 40 | 41 | $mapping = [ 42 | 'adrenth_redirect_categories' => 'vdlp_redirect_categories', 43 | 'adrenth_redirect_redirects' => 'vdlp_redirect_redirects', 44 | 'adrenth_redirect_redirect_logs' => 'vdlp_redirect_redirect_logs', 45 | 'adrenth_redirect_clients' => 'vdlp_redirect_clients', 46 | ]; 47 | 48 | // language=ignore 49 | foreach ($mapping as $from => $to) { 50 | // Make sure newly created tables are empty. 51 | $database->table($to)->delete(); 52 | 53 | // Move data from old tables to new ones. 54 | $database->statement("INSERT INTO `$to` SELECT * FROM `$from`;"); 55 | } 56 | 57 | // Migrate plugin settings. 58 | $database->table('system_settings') 59 | ->where('item', '=', 'vdlp_redirect_settings') 60 | ->delete(); 61 | 62 | // language=ignore 63 | $database->statement( 64 | 'INSERT INTO `system_settings` ' 65 | . "SELECT NULL, 'vdlp_redirect_settings', `value` " 66 | . 'FROM `system_settings` ' 67 | . "WHERE `item` = 'adrenth_redirect_settings';" 68 | ); 69 | 70 | $this->enableForeignKeyCheck($database); 71 | }); 72 | } catch (Throwable $e) { 73 | $log->error(sprintf( 74 | 'Winter.Redirect: Could not upgrade plugin Winter.Redirect from Adrenth.Redirect: %s', 75 | $e->getMessage() 76 | )); 77 | } 78 | } 79 | 80 | public function down(): void 81 | { 82 | // No migrations to reverse. 83 | } 84 | 85 | /** 86 | * @param DatabaseManager $database 87 | * @return void 88 | */ 89 | private function disableForeignKeyCheck(DatabaseManager $database): void 90 | { 91 | if ($database->getDriverName() === 'sqlite') { 92 | $database->raw('PRAGMA foreign_keys = OFF;'); 93 | } 94 | 95 | if ($database->getDriverName() === 'mysql') { 96 | $database->raw('SET FOREIGN_KEY_CHECKS = 0;'); 97 | } 98 | 99 | if ($database->getDriverName() === 'pgsql') { 100 | $database->raw('SET CONSTRAINTS ALL DEFERRED;'); 101 | } 102 | } 103 | 104 | /** 105 | * @param DatabaseManager $database 106 | * @return void 107 | */ 108 | private function enableForeignKeyCheck(DatabaseManager $database): void 109 | { 110 | if ($database->getDriverName() === 'sqlite') { 111 | $database->raw('PRAGMA foreign_keys = ON;'); 112 | } 113 | 114 | if ($database->getDriverName() === 'mysql') { 115 | $database->raw('SET FOREIGN_KEY_CHECKS = 1;'); 116 | } 117 | 118 | if ($database->getDriverName() === 'pgsql') { 119 | $database->raw('PRAGMA foreign_keys = ON;'); 120 | } 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /classes/RedirectMiddleware.php: -------------------------------------------------------------------------------- 1 | redirectManager = $redirectManager; 38 | $this->redirectConditionManager = $redirectConditionManager; 39 | $this->cacheManager = $cacheManager; 40 | $this->dispatcher = $dispatcher; 41 | $this->log = $log; 42 | } 43 | 44 | /** 45 | * Run the request filter. 46 | * 47 | * @return mixed 48 | */ 49 | public function handle(Request $request, Closure $next) 50 | { 51 | // Only handle specific request methods. 52 | if ( 53 | $request->isXmlHttpRequest() 54 | || !in_array($request->method(), self::$supportedMethods, true) 55 | || Str::startsWith($request->getRequestUri(), '/winter/redirect/sparkline/') 56 | ) { 57 | return $next($request); 58 | } 59 | 60 | if ($request->header('X-Winter-Redirect') === 'Tester') { 61 | $this->redirectManager->setSettings(new RedirectManagerSettings( 62 | false, 63 | false, 64 | Settings::isRelativePathsEnabled() 65 | )); 66 | } 67 | 68 | $rule = false; 69 | 70 | $requestUri = str_replace($request->getBasePath(), '', $request->getRequestUri()); 71 | 72 | try { 73 | if ( 74 | $this->cacheManager->cachingEnabledAndSupported() 75 | && method_exists($this->redirectManager, 'matchCached') 76 | ) { 77 | $rule = $this->redirectManager->matchCached($requestUri, $request->getScheme()); 78 | } else { 79 | $rule = $this->redirectManager->match($requestUri, $request->getScheme()); 80 | } 81 | } catch (NoMatchForRequest | UnableToLoadRules | InvalidScheme $e) { 82 | $rule = false; 83 | } catch (Throwable $e) { 84 | $this->log->error(sprintf( 85 | 'Winter.Redirect: Could not perform redirect for %s (scheme: %s): %s', 86 | $requestUri, 87 | $request->getScheme(), 88 | $e->getMessage() 89 | )); 90 | } 91 | 92 | if ($rule === false || $rule === null) { 93 | return $next($request); 94 | } 95 | 96 | /* 97 | * Extensibility: 98 | * 99 | * At this point a positive match was made based on the request URI. 100 | */ 101 | $this->dispatcher->fire('winter.redirect.match', [$rule, $requestUri]); 102 | 103 | /* 104 | * Extensibility: 105 | * 106 | * Developers can add their own conditions. If a condition does not pass the redirect will be ignored. 107 | */ 108 | foreach ($this->redirectConditionManager->getEnabledConditions($rule) as $condition) { 109 | if (!$condition->passes($rule, $requestUri)) { 110 | return $next($request); 111 | } 112 | } 113 | 114 | $this->redirectManager->redirectWithRule($rule, $requestUri); 115 | 116 | return $next($request); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /DOCUMENTATION.md: -------------------------------------------------------------------------------- 1 | # Winter.Redirect documentation 2 | 3 | This plugin should be easy to understand if you are familiar with the basics of the web. If you have issues setting up some redirects, please do not hesitate to contact me. 4 | 5 | ## Redirect types 6 | 7 | This plugins ships with two types of redirects: 8 | 9 | * **Exact**; performs an exact match on the Source path. 10 | * **Placeholders**; matches placeholders like {id} or {category} (like the defined routes in Symfony or Laravel framework). 11 | * **Regular expression**; Use regular expressions to match multiple patterns at once (advanced). If the regex contains named groups, groups will get matched to cms page params. 12 | 13 | ## Redirect target types 14 | 15 | This plugin allows you to redirect to the following types: 16 | 17 | * An internal path 18 | * An internal CMS Page 19 | * An internal Static Page (`Winter.Pages` plugin) 20 | * An external URL 21 | 22 | ## Relative vs. Absolute URLs 23 | 24 | Both types are supported by the Redirect plugin. Absolute URLs are generated by default. 25 | This setting can be changed at the Redirect Settings page. 26 | 27 | In some cases it is necessary to use Relative URLs only. When using a reverse proxy for example. Or when you are 28 | hosting multiple domains on one single codebase. 29 | 30 | Example when Absolute URLs are enabled (default): 31 | 32 | ``` 33 | Source path: /path/from 34 | Target path: /path/to 35 | Result path when matched: https://example.com/path/to 36 | ``` 37 | 38 | Example when Relative URLs are enabled: 39 | 40 | ``` 41 | Source path: /path/from 42 | Target path: /path/to 43 | Result path when matched: /path/to 44 | ``` 45 | 46 | ## Scheme matching 47 | 48 | This plugin allows you to match requests from a `http://` scheme to a `https://` scheme and vice versa. 49 | 50 | ## Placeholders 51 | 52 | Every placeholder can be attached to a requirement. A requirement consists of a `placeholder`, `requirement` and an optional `replacement` value. 53 | 54 | Example: 55 | 56 | ``` 57 | Input path: 58 | /blog.php?category=cat&id=145 59 | 60 | Source path: 61 | /blog.php?category={category}&id={id} 62 | 63 | Target path: 64 | /blog/{category}/{id} 65 | 66 | Result path: 67 | /blog/cat/145 68 | ``` 69 | 70 | * The requirement for `{category}` would be: `[a-zA-Z]` or could be more specific like `(dog|cat|mouse)`. 71 | * The requirement for `{id}` would be: `[0-9]+`. 72 | 73 | **Replacement value** 74 | 75 | A requirement can also contain a replacement value. Provide this replacement value if you need to rewrite a certain placeholder to a static value. 76 | 77 | Example: 78 | 79 | The requirement for `{category}` is `(dog|cat|mouse)`, with replacement value `animals`. 80 | 81 | ``` 82 | Input path: 83 | /blog.php?category=mouse&id=1337 84 | 85 | Source path: 86 | /blog.php?category={category}&id={id} 87 | 88 | Target path: 89 | /blog/{category}/{id} 90 | 91 | Result: 92 | /blog/animals/1337 93 | ``` 94 | 95 | ![](https://i.imgur.com/928z7pI.png) 96 | 97 | Result in TestLab: 98 | 99 | ![](https://i.imgur.com/BswnUAo.png) 100 | 101 | ## Regular expression (advanced) 102 | 103 | For advanced users there's the Regular Expression matching logic. Please refer to the PHP.net `preg_match` manual. 104 | 105 | The actual `$matches` result from the `preg_match($sourcePath, $url, $matches)` function can be used in the target path and will be replaced with the matched value. 106 | 107 | Example (with matches replacement): 108 | 109 | ``` 110 | Input path: /foo/my-match 111 | Source Path: @/foo/(.*)?@ 112 | Target Path: /bar/{1} 113 | Result: /bar/my-match 114 | ``` 115 | 116 | ## Redirect Target 117 | 118 | You can select a CMS Page as a Redirect target. Placeholders are supported. Let's assume there is a page 'Blog' with the following URL: `/blog/:category/:subcategory`. 119 | 120 | It is possible to create a Redirect with placeholders that has this CMS Page as a target: 121 | 122 | ```` 123 | Redirect with: 124 | Source: `/blog.php?cat={category}&subcat={subcategory}` 125 | Placeholders: {category}, {subcategory} 126 | Target: CMS Page `Blog` 127 | 128 | Request path: /blog.php?cat=news&subcat=general 129 | Result: /blog/news/general 130 | ```` 131 | 132 | ## Events 133 | 134 | ### Fires events 135 | 136 | | Event | Payload | Description | 137 | | --- | --- | --- | 138 | | `winter.redirect.match` | none | When a request matched, right before the redirect response. 139 | | `winter.redirect.changed` | int[] $redirectId | When one or more redirects are changed. 140 | 141 | ### Listens to events 142 | 143 | | Event | Payload | Description | 144 | | --- | --- | --- | 145 | | `winter.redirect.toUrlChanged` | `string $oldUrl, string $newUrl` | Can be fired from a third-party plugin. 146 | 147 | ## Commands 148 | 149 | | Command | Description | 150 | | --- | --- | 151 | | `winter:redirect:publish-redirects` | Publish all redirects. | 152 | -------------------------------------------------------------------------------- /controllers/redirects/_list_toolbar.php: -------------------------------------------------------------------------------- 1 |
2 | 4 | 5 | 6 | 7 | 8 | 14 | 15 | 16 | 17 | 18 | 20 | 21 | 22 | 23 | 103 | 110 |
111 | -------------------------------------------------------------------------------- /controllers/TestLab.php: -------------------------------------------------------------------------------- 1 | bodyClass = 'layout-relative'; 34 | 35 | parent::__construct(); 36 | 37 | BackendMenu::setContext('Winter.Redirect', 'redirect', 'test_lab'); 38 | 39 | $this->loadRedirects(); 40 | 41 | $this->request = $request; 42 | $this->translator = $translator; 43 | $this->flash = resolve('flash'); 44 | } 45 | 46 | public function index(): void 47 | { 48 | $this->pageTitle = 'winter.redirect::lang.title.test_lab'; 49 | 50 | $this->addCss('/plugins/winter/redirect/assets/css/redirect.css'); 51 | $this->addCss('/plugins/winter/redirect/assets/css/test-lab.css'); 52 | $this->addJs('/plugins/winter/redirect/assets/javascript/test-lab.js'); 53 | 54 | $this->vars['redirectCount'] = $this->getRedirectCount(); 55 | } 56 | 57 | private function loadRedirects(): void 58 | { 59 | /** @var Collection $redirects */ 60 | $this->redirects = array_values(Redirect::enabled() 61 | ->testLabEnabled() 62 | ->orderBy('sort_order') 63 | ->get() 64 | ->filter(static function (Redirect $redirect): bool { 65 | return $redirect->isActiveOnDate(Carbon::today()); 66 | }) 67 | ->all()); 68 | } 69 | 70 | private function offsetGetRedirect(int $offset): ?Redirect 71 | { 72 | return $this->redirects[$offset] ?? null; 73 | } 74 | 75 | public function onTest(): string 76 | { 77 | $offset = (int) $this->request->get('offset'); 78 | 79 | $redirect = $this->offsetGetRedirect($offset); 80 | 81 | if ($redirect === null) { 82 | return ''; 83 | } 84 | 85 | try { 86 | $partial = (string) $this->makePartial('tester_result', [ 87 | 'redirect' => $redirect, 88 | 'testPath' => $this->getTestPath($redirect), 89 | 'testResults' => $this->getTestResults($redirect), 90 | ]); 91 | } catch (Throwable $e) { 92 | $partial = (string) $this->makePartial('tester_failed', [ 93 | 'redirect' => $redirect, 94 | 'message' => $e->getMessage(), 95 | ]); 96 | } 97 | 98 | return $partial; 99 | } 100 | 101 | /** 102 | * @throws ModelNotFoundException 103 | */ 104 | public function onReRun(): array 105 | { 106 | /** @var Redirect $redirect */ 107 | $redirect = Redirect::query()->findOrFail($this->request->get('id')); 108 | 109 | $this->flash->success(trans('winter.redirect::lang.test_lab.flash_test_executed')); 110 | 111 | return [ 112 | '#testerResult' . $redirect->getKey() => $this->makePartial( 113 | 'tester_result_items', 114 | $this->getTestResults($redirect) 115 | ), 116 | ]; 117 | } 118 | 119 | /** 120 | * @throws ModelNotFoundException 121 | */ 122 | public function onExclude(): array 123 | { 124 | /** @var Redirect $redirect */ 125 | $redirect = Redirect::query()->findOrFail($this->request->get('id')); 126 | $redirect->update(['test_lab' => false]); 127 | 128 | $this->flash->success(trans('winter.redirect::lang.test_lab.flash_redirect_excluded')); 129 | 130 | return [ 131 | '#testButtonWrapper' => $this->makePartial('test_button', [ 132 | 'redirectCount' => $this->getRedirectCount(), 133 | ]), 134 | ]; 135 | } 136 | 137 | public function getTestPath(Redirect $redirect): string 138 | { 139 | $testPath = '/'; 140 | 141 | if ($redirect->isMatchTypeExact()) { 142 | $testPath = (string) $redirect->getAttribute('from_url'); 143 | } elseif ($redirect->getAttribute('test_lab_path')) { 144 | $testPath = (string) $redirect->getAttribute('test_lab_path'); 145 | } 146 | 147 | return $testPath; 148 | } 149 | 150 | public function getTestResults(Redirect $redirect): array 151 | { 152 | $testPath = $this->getTestPath($redirect); 153 | 154 | return [ 155 | 'maxRedirectsResult' => (new Testers\RedirectLoop($testPath))->execute(), 156 | 'matchedRedirectResult' => (new Testers\RedirectMatch($testPath))->execute(), 157 | 'responseCodeResult' => (new Testers\ResponseCode($testPath))->execute(), 158 | 'redirectCountResult' => (new Testers\RedirectCount($testPath))->execute(), 159 | 'finalDestinationResult' => (new Testers\RedirectFinalDestination($testPath))->execute(), 160 | ]; 161 | } 162 | 163 | private function getRedirectCount(): int 164 | { 165 | return count($this->redirects); 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Redirect Plugin 2 | 3 | ![Banner](https://github.com/wintercms/wn-redirect-plugin/raw/main/.github/banner.png?raw=true) 4 | 5 | [![Version](https://img.shields.io/github/v/release/wintercms/wn-redirect-plugin?sort=semver&style=flat-square)](https://github.com/wintercms/wn-redirect-plugin/releases) 6 | ![Packagist PHP Version Support](https://img.shields.io/packagist/php-v/winter/wn-redirect-plugin?style=flat-square) 7 | [![License](https://img.shields.io/github/license/wintercms/wn-redirect-plugin?label=open%20source&style=flat-square)](https://packagist.org/packages/winter/wn-redirect-plugin) 8 | [![Discord](https://img.shields.io/discord/816852513684193281?label=discord&style=flat-square)](https://discord.gg/D5MFSPH6Ux) 9 | 10 | Manage all your HTTP redirects with an easy to use GUI. This is an essential SEO plugin. With this plugin installed you can manage redirects directly from Winter's beautiful interface. Many webmasters and SEO specialists use redirects to optimise their website for search engines. 11 | 12 | ## Installation 13 | 14 | This plugin is available for installation via [Composer](http://getcomposer.org/). 15 | 16 | ```bash 17 | composer require winter/wn-redirect-plugin 18 | ``` 19 | 20 | After installing the plugin you will need to run the migrations and (if you are using a [public folder](https://wintercms.com/docs/develop/docs/setup/configuration#using-a-public-folder)) [republish your public directory](https://wintercms.com/docs/develop/docs/console/setup-maintenance#mirror-public-files). 21 | 22 | ```bash 23 | php artisan migrate 24 | ``` 25 | 26 | ## Features 27 | 28 | * **Quick** matching algorithm 29 | * A **test** utility for redirects 30 | * Matching using **placeholders** (dynamic paths) 31 | * Matching using **regular expressions** 32 | * **Exact** path matching 33 | * **Importing** and **exporting** redirect rules 34 | * **Schedule** redirects (e.g. active for 2 months) 35 | * Redirect to **external** URLs 36 | * Redirect to **internal** CMS pages 37 | * Redirect to relative or absolute URLs 38 | * Redirect **log** 39 | * **Categorize** redirects 40 | * **Statistics** 41 | * Hits per redirect 42 | * Popular redirects per month (top 10) 43 | * Popular crawlers per month (top 10) 44 | * Number of redirects per month 45 | * And more... 46 | * Multilingual ***(Need help translating!)*** 47 | * Supports MySQL, SQLite and Postgres 48 | * HTTP status codes 301, 302, 303, 404, 410 49 | * Caching 50 | 51 | ## History 52 | 53 | - 2016: Originally built by Alwin Drenth, a Software Engineer at Van der Let & Partners. 54 | - 2018: The plugin is re-distributed under the vendor name VDLP.Redirect (formerly known as Adrenth.Redirect). 55 | - 2022: The plugin is forked by the Winter CMS maintainers and made available for Winter CMS as Winter.Redirect 56 | 57 | The Winter.Redirect plugin is currently maintained by the Winter CMS maintainers and you (the open-source community). 58 | 59 | ## What does this plugin offer? 60 | 61 | This plugin adds a 'Redirects' section to the main menu of Winter CMS. This plugin has a unique and fast matching algorithm to match your redirects before your website is being rendered. 62 | 63 | ## Requirements 64 | 65 | * Winter CMS 1.1 or higher. 66 | * PHP version 7.4 or higher. 67 | * PHP extensions: `ext-curl` and `ext-json`. 68 | 69 | ## Supported database platforms 70 | 71 | * MySQL 72 | * Postgres 73 | * SQLite 74 | 75 | ## Supported HTTP status codes 76 | 77 | * `HTTP/1.1 301 Moved Permanently` 78 | * `HTTP/1.1 302 Found` 79 | * `HTTP/1.1 303 See Other` 80 | * `HTTP/1.1 404 Not Found` 81 | * `HTTP/1.1 410 Gone` 82 | 83 | ## Supported HTTP request methods 84 | 85 | * `GET` 86 | * `POST` 87 | * `HEAD` 88 | 89 | ## Performance 90 | 91 | All redirects are stored in the database and will be automatically "published" to a file which the internal redirect mechanism uses to determine if a certain request needs to be redirected. This is way faster than querying a database. 92 | 93 | This plugin is designed to be fast and should have no negative effect on the performance of your website. 94 | 95 | To gain maximum performance with this plugin: 96 | 97 | * Enable redirect caching using a "in-memory" caching method (see Caching). 98 | * Maintain your redirects frequently to keep the number of redirects as low as possible. 99 | * Try to use placeholders to keep your number of redirect low (less redirects is better performance). 100 | 101 | ## Caching 102 | 103 | If your website has a lot of redirects it is recommended to enable redirect caching. You can enable redirect caching in the settings panel of this plugin. 104 | 105 | Only cache drivers which support tagged cache are supported. So driver `file` and `database` are not supported. For this plugin database and file caching do not increase performance, but can actually have a negative influence on performance. So it is recommended to use an in-memory caching solution like `memcached` or `redis`. 106 | 107 | ### How caching works 108 | 109 | If caching is enabled (and supported) every request which is handled by this plugin will be cached. It will be stored with tag `Winter.Redirect`. 110 | 111 | When you modify a redirect all redirect cache will be invalidated automatically. It is also possible to manually clear the cache using the 'Clear cache' button in the Backend. 112 | 113 | ## Placeholders 114 | 115 | This plugin makes advantage of the `symfony/routing` package. So if you need more info on how to make placeholder requirements for your redirection URLs, please go to: https://symfony.com/doc/current/components/routing/introduction.html#usage 116 | 117 | ## Contribution 118 | 119 | Please feel free to [contribute](https://github.com/wintercms/wn-redirect-plugin) to this awesome plugin. 120 | 121 | ## Questions? Need help? 122 | 123 | If you have any question about how to use this plugin, please don't hesitate to contact us via the Winter CMS [Discord](https://discord.gg/D5MFSPH6Ux). We're happy to help you. 124 | -------------------------------------------------------------------------------- /controllers/Statistics.php: -------------------------------------------------------------------------------- 1 | pageTitle = 'winter.redirect::lang.title.statistics'; 28 | 29 | $this->addCss('/plugins/winter/redirect/assets/css/redirect.css'); 30 | $this->addCss('/plugins/winter/redirect/assets/css/statistics.css'); 31 | 32 | $this->helper = new StatisticsHelper(); 33 | } 34 | 35 | public function index(): void 36 | { 37 | } 38 | 39 | /** 40 | * @throws SystemException|JsonException|InvalidFormatException 41 | */ 42 | public function onLoadHitsPerDay(): array 43 | { 44 | $today = Carbon::today(); 45 | 46 | $postValue = post('period-month-year', $today->month . '_' . $today->year); 47 | 48 | [$month, $year] = explode('_', $postValue); 49 | 50 | return [ 51 | '#hitsPerDay' => $this->makePartial('hits-per-day', [ 52 | 'dataSets' => json_encode([ 53 | $this->getHitsPerDayAsDataSet((int) $month, (int) $year, true), 54 | $this->getHitsPerDayAsDataSet((int) $month, (int) $year, false), 55 | ], JSON_THROW_ON_ERROR), 56 | 'labels' => json_encode($this->getLabels(), JSON_THROW_ON_ERROR), 57 | 'monthYearOptions' => $this->helper->getMonthYearOptions(), 58 | 'monthYearSelected' => $month . '_' . $year, 59 | ]), 60 | ]; 61 | } 62 | 63 | /** 64 | * @throws SystemException|JsonException 65 | */ 66 | public function onSelectPeriodMonthYear(): array 67 | { 68 | return $this->onLoadHitsPerDay(); 69 | } 70 | 71 | /** 72 | * @throws SystemException 73 | */ 74 | public function onLoadTopRedirectsThisMonth(): array 75 | { 76 | return [ 77 | '#topRedirectsThisMonth' => $this->makePartial('top-redirects-this-month', [ 78 | 'topTenRedirectsThisMonth' => $this->helper->getTopRedirectsThisMonth(), 79 | ]), 80 | ]; 81 | } 82 | 83 | /** 84 | * @throws SystemException 85 | */ 86 | public function onLoadTopCrawlersThisMonth(): array 87 | { 88 | return [ 89 | '#topCrawlersThisMonth' => $this->makePartial('top-crawlers-this-month', [ 90 | 'topTenCrawlersThisMonth' => $this->helper->getTopTenCrawlersThisMonth(), 91 | ]), 92 | ]; 93 | } 94 | 95 | /** 96 | * @throws SystemException 97 | */ 98 | public function onLoadRedirectHitsPerMonth(): array 99 | { 100 | return [ 101 | '#redirectHitsPerMonth' => $this->makePartial('redirect-hits-per-month', [ 102 | 'redirectHitsPerMonth' => $this->helper->getRedirectHitsPerMonth(), 103 | ]), 104 | ]; 105 | } 106 | 107 | /** 108 | * @throws SystemException 109 | */ 110 | public function onLoadScoreBoard(): array 111 | { 112 | return [ 113 | '#scoreBoard' => $this->makePartial('score-board', [ 114 | 'redirectHitsPerMonth' => $this->helper->getRedirectHitsPerMonth(), 115 | 'totalActiveRedirects' => $this->helper->getTotalActiveRedirects(), 116 | 'activeRedirects' => $this->helper->getActiveRedirects(), 117 | 'totalRedirectsServed' => $this->helper->getTotalRedirectsServed(), 118 | 'totalThisMonth' => $this->helper->getTotalThisMonth(), 119 | 'totalLastMonth' => $this->helper->getTotalLastMonth(), 120 | 'latestClient' => $this->helper->getLatestClient(), 121 | ]), 122 | ]; 123 | } 124 | 125 | private function getLabels(): array 126 | { 127 | $labels = []; 128 | 129 | foreach (Carbon::today()->firstOfMonth()->daysUntil(Carbon::today()->endOfMonth()) as $date) { 130 | $labels[] = $date->isoFormat('LL'); 131 | } 132 | 133 | return $labels; 134 | } 135 | 136 | /** 137 | * @throws InvalidFormatException 138 | */ 139 | private function getHitsPerDayAsDataSet(int $month, int $year, bool $crawler): array 140 | { 141 | $today = Carbon::createFromDate($year, $month, 1); 142 | 143 | $data = $this->helper->getRedirectHitsPerDay($month, $year, $crawler); 144 | 145 | for ($i = $today->firstOfMonth()->day; $i <= $today->lastOfMonth()->day; $i++) { 146 | if (!array_key_exists($i, $data)) { 147 | $data[$i] = ['hits' => 0]; 148 | } 149 | } 150 | 151 | ksort($data); 152 | 153 | $brandSettings = new BrandSetting(); 154 | 155 | $color = $crawler 156 | ? $brandSettings->get('primary_color') 157 | : $brandSettings->get('secondary_color'); 158 | 159 | [$r, $g, $b] = sscanf($color, "#%02x%02x%02x"); 160 | 161 | return [ 162 | 'label' => $crawler 163 | ? e(trans('winter.redirect::lang.statistics.crawler_hits')) 164 | : e(trans('winter.redirect::lang.statistics.visitor_hits')), 165 | 'backgroundColor' => sprintf('rgb(%d, %d, %d, 0.5)', $r, $g, $b), 166 | 'borderColor' => sprintf('rgb(%d, %d, %d, 1)', $r, $g, $b), 167 | 'borderWidth' => 1, 168 | 'data' => data_get($data, '*.hits'), 169 | ]; 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /updates/version.yaml: -------------------------------------------------------------------------------- 1 | "1.0.0": 2 | - "Transfer vendor ownership. Adrenth.Redirect -> Vdlp.Redirect" 3 | - 20180718_0001_create_tables.php 4 | - 20180831_0002_upgrade_from_adrenth_redirect.php 5 | "1.1.0": "Redirect rules are now being cached (if caching enabled) -- See: https://github.com/vdlp/oc-redirect-plugin/issues/1" 6 | "1.2.0": "Add extra filters to the Redirects overview -- See: https://github.com/vdlp/oc-redirect-plugin/pull/5" 7 | "1.3.0": 8 | - "Add support for ignoring query parameters -- See: https://github.com/vdlp/oc-redirect-plugin/issues/6" 9 | - 20181019_0003_add_ignore_query_parameters_to_redirects_table.php 10 | "1.4.0": "Code dusting and improvements to the redirect list view" 11 | "1.4.1": 12 | - "Add extra statistics database index to improve performance" 13 | - 20181117_0004_add_redirect_timestamp_crawler_index_on_clients_table.php 14 | "1.4.2": 15 | - "Add extra statistics database index to improve performance (Statistics dashboard)" 16 | - 20181117_0005_add_month_year_crawler_index_on_clients_table.php 17 | "1.4.3": "Fix thrown BindingResolutionException on redirecting" 18 | "1.4.4": "Fixes a redirect loop bug which might occur after renaming content pages" 19 | "1.4.5": "Fixes critical issue with ignoring query parameters" 20 | "1.5.0": "Bugfixes and added more extensibility support -- See: https://github.com/vdlp/oc-redirect-plugin/releases/tag/1.5.0" 21 | "1.6.0": 22 | - "Minor UI additions and added new Match Type: Regular Expressions! -- See: https://github.com/vdlp/oc-redirect-plugin/releases/tag/1.6.0" 23 | - 20190404_0006_add_description_to_redirects_table.php 24 | "1.7.0": "Bugfixes and code improvements -- See: https://github.com/vdlp/oc-redirect-plugin/releases/tag/1.7.0" 25 | "1.8.0": "Add CLI command for publishing redirects -- See: https://github.com/vdlp/oc-redirect-plugin/releases/tag/1.8.0" 26 | "1.8.1": "Fix critical issue regarding regular expression redirects" 27 | "1.9.0": "Add setting for enabling/disabling automatic creation of redirects -- See: https://github.com/vdlp/oc-redirect-plugin/releases/tag/1.9.0" 28 | "1.10.0": 29 | - "Improve statistics performance -- See: https://github.com/vdlp/oc-redirect-plugin/releases/tag/1.10.0" 30 | - 20190704_0007_add_timestamp_crawler_index_on_clients_table.php 31 | "1.10.1": "This fixes an issue where redirects will fail to work when the redirects.csv file does not have the write permission -- See: https://github.com/vdlp/oc-redirect-plugin/releases/tag/1.10.1" 32 | "1.10.2": "Fixes a fatal error when running TestLab -- See: https://github.com/vdlp/oc-redirect-plugin/releases/tag/1.10.2" 33 | "1.10.3": "Fix support for Postgres -- See: https://github.com/vdlp/oc-redirect-plugin/releases/tag/1.10.3" 34 | "1.10.4": "Fixes reported issues #41 and #43 -- See: https://github.com/vdlp/oc-redirect-plugin/releases/tag/1.10.4" 35 | "1.10.5": "Fixes reported issues #46 and #49 -- See: https://github.com/vdlp/oc-redirect-plugin/releases/tag/1.10.5" 36 | "2.0.0": "Supports PHP 7.1.3 and higher. Read CHANGELOG.md -- See: https://github.com/vdlp/oc-redirect-plugin/releases/tag/2.0.0" 37 | "2.0.1": "Fix Middleware not being invoked in newer PHP versions -- See: https://github.com/vdlp/oc-redirect-plugin/releases/tag/2.0.1" 38 | "2.0.2": 39 | - "Minor database and configuration fixes -- See: https://github.com/vdlp/oc-redirect-plugin/releases/tag/2.0.2" 40 | - 20200408_0008_change_column_types_from_char_to_varchar.php 41 | "2.1.0": "Added support for October CMS L6 build, improved caching and more -- See: https://github.com/vdlp/oc-redirect-plugin/releases/tag/2.1.0" 42 | "2.1.1": "Update CHANGELOG" 43 | "2.2.0": "Add cache control header and UI improvements -- See: https://github.com/vdlp/oc-redirect-plugin/releases/tag/2.2.0" 44 | "2.3.0": 45 | - "Add new redirect options (ignore case and ignore trailing slash) -- See: https://github.com/vdlp/oc-redirect-plugin/releases/tag/2.3.0" 46 | - 20200414_0009_add_ignore_case_to_redirects_table.php 47 | - 20200414_0010_add_ignore_trailing_slash_to_redirects_table.php 48 | "2.3.1": "Fix SQLSTATE[42S22] error when installing plugin -- See: https://github.com/vdlp/oc-redirect-plugin/releases/tag/2.3.1" 49 | "2.3.2": "Improve error handling in plugin migration process -- See: https://github.com/vdlp/oc-redirect-plugin/releases/tag/2.3.2" 50 | "2.4.0": "Skip requests with header 'X-Requested-With: XMLHttpRequest' -- See: https://github.com/vdlp/oc-redirect-plugin/releases/tag/2.4.0" 51 | "2.4.1": "Add Redirect Extensions promo page -- See: https://github.com/vdlp/oc-redirect-plugin/releases/tag/2.4.1" 52 | "2.5.0": "Add support for using relative paths -- See: https://github.com/vdlp/oc-redirect-plugin/releases/tag/2.5.0" 53 | "2.5.1": "Fixes issues with redirect rules file not being present -- See: https://github.com/vdlp/oc-redirect-plugin/releases/tag/2.5.1" 54 | "2.5.2": "Fix bug that causes re-writing the redirect rules file when hits are updated -- See: https://github.com/vdlp/oc-redirect-plugin/releases/tag/2.5.2" 55 | "2.5.3": "Improve / fixes redirect rule caching -- See: https://github.com/vdlp/oc-redirect-plugin/releases/tag/2.5.3" 56 | "2.5.4": "Add support for symfony/stopwatch:^5.0 (version 4.0 is still supported) -- See: https://github.com/vdlp/oc-redirect-plugin/releases/tag/2.5.4" 57 | "2.5.5": "Suppress logging when redirect rules file is empty -- See: https://github.com/vdlp/oc-redirect-plugin/releases/tag/2.5.5" 58 | "2.5.6": "Prevent connection exception when accessing settings in CLI mode -- See: https://github.com/vdlp/oc-redirect-plugin/releases/tag/2.5.6" 59 | "2.5.7": "Improve redirect caching management -- See: https://github.com/vdlp/oc-redirect-plugin/releases/tag/2.5.7" 60 | "2.5.8": "Improve redirect caching management (revised) -- See: https://github.com/vdlp/oc-redirect-plugin/releases/tag/2.5.8" 61 | "2.5.9": "Fix import in Plugin file" 62 | "2.5.10": "Add PHP 8.0 version constraint and composer/installers package" 63 | "2.5.11": "Minor fixes -- See: https://github.com/vdlp/oc-redirect-plugin/releases/tag/2.5.11" 64 | "2.5.12": "Fix strpos() type error" 65 | "2.5.13": "Fix database error when cache is being cleared before installation of plugin." 66 | "2.6.0": "Update plugin dependencies." 67 | "3.0.0": 68 | - "Drop support for October CMS 1.1 and lower. See: https://github.com/vdlp/oc-redirect-plugin/releases/tag/3.0.0" 69 | - 20200918_0011_refactor_redirects_logs_table.php 70 | - 20200918_0012_add_redirect_id_to_system_request_logs_table.php 71 | "3.0.1": "Add support for regular expression matches in target path." 72 | "3.0.2": "Change version constraint for composer/installers." 73 | "3.0.3": "Update plugin dependencies" 74 | "4.0.1": 75 | - "Renamed to Winter.Redirect, forked for use as a Winter CMS plugin." 76 | - 20220415_0013_rename_to_winter_redirect.php 77 | "4.0.2": "Maintained compatibility with Winter 1.1, fixed issue with trailing data on imports" 78 | "4.0.3": 79 | - "Added support for forwarding query string params" 80 | - 20220728_0014_add_forward_query_parameters.php 81 | "4.0.4": 82 | - "Match named regex groups to CMS Page parameters" 83 | - "Add to_url to the list of available columns" 84 | - "Fixed issue where Save & Close did not actually close" 85 | - "Improved Russian translation" 86 | - "Improved French translation" 87 | - "Added Polish translation" 88 | "4.0.5": 89 | - "Improved Russian translation" 90 | - "Fix clients relation conflict" 91 | "4.1.0": "Switch to using new controller behavior default backend views added in Winter v1.2.8+" 92 | -------------------------------------------------------------------------------- /classes/RedirectRule.php: -------------------------------------------------------------------------------- 1 | id = (int) ($attributes['id'] ?? null); 37 | $this->matchType = (string) ($attributes['match_type'] ?? null); 38 | $this->targetType = (string) ($attributes['target_type'] ?? null); 39 | $this->fromUrl = (string) ($attributes['from_url'] ?? null); 40 | $this->fromScheme = (string) ($attributes['from_scheme'] ?? null); 41 | $this->toUrl = (string) ($attributes['to_url'] ?? null); 42 | $this->toScheme = (string) ($attributes['to_scheme'] ?? null); 43 | $this->cmsPage = (string) ($attributes['cms_page'] ?? null); 44 | $this->staticPage = (string) ($attributes['static_page'] ?? null); 45 | $this->statusCode = (int) ($attributes['status_code'] ?? null); 46 | 47 | try { 48 | $requirements = $attributes['requirements'] ?? null; 49 | 50 | if (!is_string($requirements)) { 51 | $requirements = '[]'; 52 | } 53 | 54 | $this->requirements = json_decode($requirements, true, 512, JSON_THROW_ON_ERROR); 55 | } catch (JsonException $exception) { 56 | $this->requirements = []; 57 | } 58 | 59 | if ( 60 | isset($attributes['from_date']) 61 | && is_string($attributes['from_date']) 62 | && $attributes['from_date'] !== '' 63 | ) { 64 | try { 65 | $date = Carbon::createFromFormat( 66 | 'Y-m-d H:i:s', 67 | substr($attributes['from_date'], 0, 10) . ' 00:00:00' 68 | ); 69 | 70 | $this->fromDate = $date === false ? null : $date; 71 | } catch (InvalidFormatException $exception) { 72 | // @ignoreException 73 | $this->fromDate = null; 74 | } 75 | } 76 | 77 | if ( 78 | isset($attributes['to_date']) 79 | && is_string($attributes['to_date']) 80 | && $attributes['to_date'] !== '' 81 | ) { 82 | try { 83 | $date = Carbon::createFromFormat( 84 | 'Y-m-d H:i:s', 85 | substr($attributes['to_date'], 0, 10) . ' 00:00:00' 86 | ); 87 | 88 | $this->toDate = $date === false ? null : $date; 89 | } catch (InvalidFormatException $exception) { 90 | // @ignoreException 91 | $this->toDate = null; 92 | } 93 | } 94 | 95 | $this->ignoreQueryParameters = (bool) ($attributes['ignore_query_parameters'] ?? false); 96 | $this->ignoreCase = (bool) ($attributes['ignore_case'] ?? false); 97 | $this->ignoreTrailingSlash = (bool) ($attributes['ignore_trailing_slash'] ?? false); 98 | $this->forwardQueryParameters = (bool) ($attributes['forward_query_parameters'] ?? false); 99 | } 100 | 101 | public static function createWithModel(Redirect $model): RedirectRule 102 | { 103 | $attributes = $model->getAttributes(); 104 | $requirements = $model->getAttribute('requirements'); 105 | 106 | if ($requirements === '' || $requirements === null) { 107 | $requirements = []; 108 | } 109 | 110 | try { 111 | $attributes['requirements'] = json_encode($requirements, JSON_THROW_ON_ERROR); 112 | } catch (JsonException $exception) { 113 | // @ignoreException 114 | $attributes['requirements'] = '[]'; 115 | } 116 | 117 | return new self($attributes); 118 | } 119 | 120 | public function getId(): int 121 | { 122 | return $this->id; 123 | } 124 | 125 | public function getMatchType(): string 126 | { 127 | return $this->matchType; 128 | } 129 | 130 | public function getTargetType(): string 131 | { 132 | return $this->targetType; 133 | } 134 | 135 | public function getFromUrl(): string 136 | { 137 | return $this->fromUrl; 138 | } 139 | 140 | public function getFromScheme(): string 141 | { 142 | return $this->fromScheme; 143 | } 144 | 145 | public function getToUrl(): string 146 | { 147 | return $this->toUrl; 148 | } 149 | 150 | public function getToScheme(): string 151 | { 152 | return $this->toScheme; 153 | } 154 | 155 | public function getCmsPage(): string 156 | { 157 | return $this->cmsPage; 158 | } 159 | 160 | public function getStaticPage(): string 161 | { 162 | return $this->staticPage; 163 | } 164 | 165 | public function getStatusCode(): int 166 | { 167 | return $this->statusCode; 168 | } 169 | 170 | public function getRequirements(): array 171 | { 172 | return $this->requirements; 173 | } 174 | 175 | public function getFromDate(): ?Carbon 176 | { 177 | return $this->fromDate; 178 | } 179 | 180 | public function getToDate(): ?Carbon 181 | { 182 | return $this->toDate; 183 | } 184 | 185 | public function isExactMatchType(): bool 186 | { 187 | return $this->matchType === Redirect::TYPE_EXACT; 188 | } 189 | 190 | public function isPlaceholdersMatchType(): bool 191 | { 192 | return $this->matchType === Redirect::TYPE_PLACEHOLDERS; 193 | } 194 | 195 | public function isRegexMatchType(): bool 196 | { 197 | return $this->matchType === Redirect::TYPE_REGEX; 198 | } 199 | 200 | public function getPlaceholderMatches(): array 201 | { 202 | return $this->placeholderMatches; 203 | } 204 | 205 | public function setPlaceholderMatches(array $placeholderMatches = []): self 206 | { 207 | $this->placeholderMatches = $placeholderMatches; 208 | 209 | return $this; 210 | } 211 | 212 | public function getPregMatchMatches(): array 213 | { 214 | return $this->pregMatchMatches; 215 | } 216 | 217 | public function setPregMatchMatches(array $pregMatchMatches): self 218 | { 219 | $this->pregMatchMatches = $pregMatchMatches; 220 | 221 | return $this; 222 | } 223 | 224 | public function isIgnoreQueryParameters(): bool 225 | { 226 | return $this->ignoreQueryParameters; 227 | } 228 | 229 | public function isIgnoreCase(): bool 230 | { 231 | return $this->ignoreCase; 232 | } 233 | 234 | public function isIgnoreTrailingSlash(): bool 235 | { 236 | return $this->ignoreTrailingSlash; 237 | } 238 | 239 | public function isForwardQueryParameters(): bool 240 | { 241 | return $this->forwardQueryParameters; 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /classes/StatisticsHelper.php: -------------------------------------------------------------------------------- 1 | count(); 19 | } 20 | 21 | public function getLatestClient(?int $redirectId = null): ?Models\Client 22 | { 23 | $builder = Models\Client::query() 24 | ->orderBy('timestamp', 'desc') 25 | ->limit(1); 26 | 27 | if ($redirectId !== null) { 28 | $builder->where('redirect_id', '=', $redirectId); 29 | } 30 | 31 | /** @noinspection PhpIncompatibleReturnTypeInspection */ 32 | return $builder->first(); 33 | } 34 | 35 | public function getTotalThisMonth(?int $redirectId = null): int 36 | { 37 | $builder = Models\Client::query() 38 | ->where('month', '=', date('m')) 39 | ->where('year', '=', date('Y')); 40 | 41 | if ($redirectId !== null) { 42 | $builder->where('redirect_id', '=', $redirectId); 43 | } 44 | 45 | return $builder->count(); 46 | } 47 | 48 | public function getTotalLastMonth(?int $redirectId = null): int 49 | { 50 | $lastMonth = Carbon::today(); 51 | $lastMonth->subMonthNoOverflow(); 52 | 53 | $builder = Models\Client::query() 54 | ->where('month', '=', $lastMonth->month) 55 | ->where('year', '=', $lastMonth->year); 56 | 57 | if ($redirectId !== null) { 58 | $builder->where('redirect_id', '=', $redirectId); 59 | } 60 | 61 | return $builder->count(); 62 | } 63 | 64 | public function getActiveRedirects(): array 65 | { 66 | $groupedRedirects = []; 67 | 68 | /** @var Collection $redirects */ 69 | $redirects = Models\Redirect::enabled() 70 | ->get() 71 | ->filter(static function (Models\Redirect $redirect): bool { 72 | return $redirect->isActiveOnDate(Carbon::today()); 73 | }); 74 | 75 | /** @var Models\Redirect $redirect */ 76 | foreach ($redirects as $redirect) { 77 | $groupedRedirects[$redirect->getAttribute('status_code')][] = $redirect; 78 | } 79 | 80 | return $groupedRedirects; 81 | } 82 | 83 | public function getTotalActiveRedirects(): int 84 | { 85 | return Models\Redirect::enabled() 86 | ->get() 87 | ->filter(static function (Models\Redirect $redirect): bool { 88 | return $redirect->isActiveOnDate(Carbon::today()); 89 | }) 90 | ->count(); 91 | } 92 | 93 | public function getMonthYearOptions(): array 94 | { 95 | $result = Models\Client::query() 96 | ->addSelect('month', 'year') 97 | ->groupBy('month', 'year') 98 | ->orderByRaw('year DESC, month DESC'); 99 | 100 | $data = $result->get() 101 | ->toArray(); 102 | 103 | $options = []; 104 | 105 | foreach ($data as $monthYear) { 106 | $options[$monthYear['month'] . '_' . $monthYear['year']] 107 | = Carbon::createFromDate($monthYear['year'], $monthYear['month'])->isoFormat('MMMM Y'); 108 | } 109 | 110 | return $options; 111 | } 112 | 113 | public function getRedirectHitsPerDay(int $month, int $year, bool $crawler = false): array 114 | { 115 | $result = Models\Client::query() 116 | ->selectRaw('COUNT(id) AS hits') 117 | ->where('month', $month) 118 | ->where('year', $year) 119 | ->addSelect('day', 'month', 'year') 120 | ->groupBy('day', 'month', 'year') 121 | ->orderByRaw('year DESC, month DESC, day DESC'); 122 | 123 | if ($crawler) { 124 | $result->whereNotNull('crawler'); 125 | } else { 126 | $result->whereNull('crawler'); 127 | } 128 | 129 | return $result->get() 130 | ->keyBy('day') 131 | ->toArray(); 132 | } 133 | 134 | public function getRedirectHitsSparkline(int $redirectId, bool $crawler, int $days = 30): array 135 | { 136 | $startDate = Carbon::now()->subDays($days); 137 | 138 | // DB index: redirect_timestamp_crawler 139 | $builder = Models\Client::query() 140 | ->selectRaw('COUNT(id) AS hits, DATE(timestamp) AS date') 141 | ->where('redirect_id', '=', $redirectId) 142 | ->groupBy('day', 'month', 'year', 'timestamp') 143 | ->orderByRaw('year ASC, month ASC, day ASC') 144 | ->where('timestamp', '>=', $startDate->toDateTimeString()); 145 | 146 | if ($crawler) { 147 | $builder->whereNotNull('crawler'); 148 | } else { 149 | $builder->whereNull('crawler'); 150 | } 151 | 152 | $result = $builder 153 | ->get() 154 | ->keyBy('date') 155 | ->toArray(); 156 | 157 | $hits = []; 158 | 159 | while ($startDate->lt(Carbon::now())) { 160 | if (isset($result[$startDate->toDateString()])) { 161 | $hits[] = (int) $result[$startDate->toDateString()]['hits']; 162 | } else { 163 | $hits[] = 0; 164 | } 165 | 166 | $startDate->addDay(); 167 | } 168 | 169 | return $hits; 170 | } 171 | 172 | public function getRedirectHitsPerMonth(): array 173 | { 174 | return Models\Client::query() 175 | ->selectRaw('COUNT(id) AS hits') 176 | ->addSelect('month', 'year') 177 | ->groupBy('month', 'year') 178 | ->orderByRaw('year DESC, month DESC') 179 | ->limit(12) 180 | ->get() 181 | ->toArray(); 182 | } 183 | 184 | public function getTopTenCrawlersThisMonth(): array 185 | { 186 | // DB index: month_year_crawler 187 | return Models\Client::query() 188 | ->selectRaw('COUNT(id) AS hits') 189 | ->addSelect('crawler') 190 | ->where('month', '=', (int) date('n')) 191 | ->where('year', '=', (int) date('Y')) 192 | ->whereNotNull('crawler') 193 | ->groupBy('crawler') 194 | ->orderByRaw('hits DESC') 195 | ->limit(10) 196 | ->get() 197 | ->toArray(); 198 | } 199 | 200 | public function getTopRedirectsThisMonth(int $limit = 10): array 201 | { 202 | return Models\Client::query() 203 | ->selectRaw('COUNT(redirect_id) AS hits') 204 | ->addSelect('redirect_id', 'r.from_url') 205 | ->join('winter_redirect_redirects AS r', 'r.id', '=', 'redirect_id') 206 | ->where('month', '=', (int) date('n')) 207 | ->where('year', '=', (int) date('Y')) 208 | ->groupBy('redirect_id', 'r.from_url') 209 | ->orderByRaw('hits DESC') 210 | ->limit($limit) 211 | ->get() 212 | ->toArray(); 213 | } 214 | 215 | public function increaseHitsForRedirect(int $redirectId): void 216 | { 217 | /** @var ?Models\Redirect $redirect */ 218 | $redirect = Models\Redirect::query()->find($redirectId); 219 | 220 | if ($redirect === null) { 221 | return; 222 | } 223 | 224 | $now = Carbon::now(); 225 | 226 | RedirectObserver::stopHandleChanges(); 227 | 228 | /** @noinspection PhpUndefinedClassInspection */ 229 | $redirect->forceFill(['hits' => DB::raw('hits + 1'), 'last_used_at' => $now]); 230 | $redirect->forceSave(); 231 | 232 | RedirectObserver::startHandleChanges(); 233 | 234 | $crawlerDetect = new CrawlerDetect(); 235 | 236 | Models\Client::create([ 237 | 'redirect_id' => $redirectId, 238 | 'timestamp' => $now, 239 | 'day' => $now->day, 240 | 'month' => $now->month, 241 | 'year' => $now->year, 242 | 'crawler' => $crawlerDetect->isCrawler() ? $crawlerDetect->getMatches() : null, 243 | ]); 244 | } 245 | } 246 | --------------------------------------------------------------------------------