├── src ├── web │ └── assets │ │ └── dist │ │ ├── assets │ │ ├── retour-DnaB684y.js │ │ ├── retour-DnaB684y.js.map │ │ ├── import-Cap4Ox0Y.js.gz │ │ ├── widget-BQWJDBDA.js.gz │ │ ├── dashboard-DVAYjtqJ.js.gz │ │ ├── purify.es-DP_WP6YH.js.gz │ │ ├── redirects-B6OCJnQt.js.gz │ │ ├── retour-exeLeENM.css.gz │ │ ├── import-Cap4Ox0Y.js.map.gz │ │ ├── shortlinks-Jev4OoWa.js.gz │ │ ├── widget-BQWJDBDA.js.map.gz │ │ ├── LegacyUrl-CeiszSHN.js.map.gz │ │ ├── dashboard-DVAYjtqJ.js.map.gz │ │ ├── purify.es-DP_WP6YH.js.map.gz │ │ ├── redirects-B6OCJnQt.js.map.gz │ │ ├── shortlinks-Jev4OoWa.js.map.gz │ │ ├── vue-apexcharts-BHFWl5EO.js.gz │ │ ├── _plugin-vue2_normalizer-BFq-tfv7.js.map │ │ ├── vue-apexcharts-BHFWl5EO.js.map.gz │ │ ├── LegacyUrl-CeiszSHN.js │ │ ├── purify-DvoaRXnC.css │ │ ├── _plugin-vue2_normalizer-BFq-tfv7.js │ │ ├── widget-BQWJDBDA.js │ │ ├── LegacyUrl-CeiszSHN.js.map │ │ ├── widget-BQWJDBDA.js.map │ │ ├── retour-exeLeENM.css │ │ └── shortlinks-Jev4OoWa.js │ │ ├── manifest.json.gz │ │ ├── img │ │ └── icon-mask.svg │ │ └── manifest.json ├── templates │ ├── _layouts │ │ ├── widget-cp.twig │ │ └── retour-cp.twig │ ├── _includes │ │ ├── macros.twig │ │ └── sites-menu.twig │ ├── _components │ │ ├── widgets │ │ │ ├── Retour_settings.twig │ │ │ └── Retour_body.twig │ │ └── fields │ │ │ ├── ShortLink_settings.twig │ │ │ ├── ShortLink_input.twig │ │ │ └── ShortLink_preview.twig │ ├── index.twig │ ├── import │ │ ├── errors.twig │ │ └── index.twig │ ├── settings │ │ ├── index.twig │ │ └── _includes │ │ │ ├── general.twig │ │ │ ├── statistics.twig │ │ │ └── advanced.twig │ ├── shortlinks │ │ └── index.twig │ ├── redirects │ │ └── index.twig │ └── dashboard │ │ └── index.twig ├── helpers │ ├── Gql.php │ ├── Permission.php │ ├── Version.php │ ├── FileLog.php │ ├── Text.php │ ├── MultiSite.php │ └── UrlHelper.php ├── migrations │ ├── m190416_212500_widget_type_update.php │ ├── m221130_224500_add_priorities_column.php │ ├── m181013_122446_add_remote_ip.php │ ├── m200109_144310_add_redirectSrcUrl_index.php │ ├── m230627_141429_add_redirectMatchType_index.php │ ├── m181018_135656_add_redirect_status.php │ ├── m181013_202455_add_redirect_src_match.php │ ├── m210603_221000_add_gql_schema_components.php │ ├── m181216_043222_rebuild_indexes.php │ ├── m181018_123901_add_stats_info.php │ ├── m181013_171315_truncate_match_type.php │ └── m181213_233502_add_site_id.php ├── events │ ├── RedirectResolvedEvent.php │ ├── RedirectEvent.php │ └── ResolveRedirectEvent.php ├── icon-mask.svg ├── assetbundles │ └── retour │ │ ├── RetourAsset.php │ │ ├── RetourImportAsset.php │ │ ├── RetourDashboardAsset.php │ │ ├── RetourRedirectsAsset.php │ │ └── RetourWidgetAsset.php ├── gql │ ├── arguments │ │ └── RetourArguments.php │ ├── types │ │ ├── RetourType.php │ │ └── generators │ │ │ └── RetourGenerator.php │ ├── queries │ │ └── RetourQuery.php │ ├── resolvers │ │ └── RetourResolver.php │ └── interfaces │ │ └── RetourInterface.php ├── validators │ ├── DbStringValidator.php │ ├── ParsedUriValidator.php │ └── UriValidator.php ├── models │ ├── DbModel.php │ ├── Stats.php │ ├── Redirects.php │ ├── StaticRedirects.php │ └── Settings.php ├── controllers │ ├── ApiController.php │ ├── SettingsController.php │ ├── ChartsController.php │ └── StatisticsController.php ├── variables │ └── RetourVariable.php ├── console │ └── controllers │ │ └── StatsController.php ├── config.php ├── services │ └── ServicesTrait.php ├── widgets │ └── RetourWidget.php └── fields │ └── ShortLink.php ├── phpstan.neon ├── ecs.php ├── Makefile ├── composer.json ├── LICENSE.md └── README.md /src/web/assets/dist/assets/retour-DnaB684y.js: -------------------------------------------------------------------------------- 1 | 2 | //# sourceMappingURL=retour-DnaB684y.js.map 3 | -------------------------------------------------------------------------------- /src/web/assets/dist/manifest.json.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-retour/develop-v5/src/web/assets/dist/manifest.json.gz -------------------------------------------------------------------------------- /src/web/assets/dist/assets/retour-DnaB684y.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"retour-DnaB684y.js","sources":[],"sourcesContent":[],"names":[],"mappings":""} -------------------------------------------------------------------------------- /src/web/assets/dist/assets/import-Cap4Ox0Y.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-retour/develop-v5/src/web/assets/dist/assets/import-Cap4Ox0Y.js.gz -------------------------------------------------------------------------------- /src/web/assets/dist/assets/widget-BQWJDBDA.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-retour/develop-v5/src/web/assets/dist/assets/widget-BQWJDBDA.js.gz -------------------------------------------------------------------------------- /src/web/assets/dist/assets/dashboard-DVAYjtqJ.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-retour/develop-v5/src/web/assets/dist/assets/dashboard-DVAYjtqJ.js.gz -------------------------------------------------------------------------------- /src/web/assets/dist/assets/purify.es-DP_WP6YH.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-retour/develop-v5/src/web/assets/dist/assets/purify.es-DP_WP6YH.js.gz -------------------------------------------------------------------------------- /src/web/assets/dist/assets/redirects-B6OCJnQt.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-retour/develop-v5/src/web/assets/dist/assets/redirects-B6OCJnQt.js.gz -------------------------------------------------------------------------------- /src/web/assets/dist/assets/retour-exeLeENM.css.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-retour/develop-v5/src/web/assets/dist/assets/retour-exeLeENM.css.gz -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - %currentWorkingDirectory%/vendor/craftcms/phpstan/phpstan.neon 3 | 4 | parameters: 5 | level: 5 6 | paths: 7 | - src 8 | -------------------------------------------------------------------------------- /src/web/assets/dist/assets/import-Cap4Ox0Y.js.map.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-retour/develop-v5/src/web/assets/dist/assets/import-Cap4Ox0Y.js.map.gz -------------------------------------------------------------------------------- /src/web/assets/dist/assets/shortlinks-Jev4OoWa.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-retour/develop-v5/src/web/assets/dist/assets/shortlinks-Jev4OoWa.js.gz -------------------------------------------------------------------------------- /src/web/assets/dist/assets/widget-BQWJDBDA.js.map.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-retour/develop-v5/src/web/assets/dist/assets/widget-BQWJDBDA.js.map.gz -------------------------------------------------------------------------------- /src/web/assets/dist/assets/LegacyUrl-CeiszSHN.js.map.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-retour/develop-v5/src/web/assets/dist/assets/LegacyUrl-CeiszSHN.js.map.gz -------------------------------------------------------------------------------- /src/web/assets/dist/assets/dashboard-DVAYjtqJ.js.map.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-retour/develop-v5/src/web/assets/dist/assets/dashboard-DVAYjtqJ.js.map.gz -------------------------------------------------------------------------------- /src/web/assets/dist/assets/purify.es-DP_WP6YH.js.map.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-retour/develop-v5/src/web/assets/dist/assets/purify.es-DP_WP6YH.js.map.gz -------------------------------------------------------------------------------- /src/web/assets/dist/assets/redirects-B6OCJnQt.js.map.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-retour/develop-v5/src/web/assets/dist/assets/redirects-B6OCJnQt.js.map.gz -------------------------------------------------------------------------------- /src/web/assets/dist/assets/shortlinks-Jev4OoWa.js.map.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-retour/develop-v5/src/web/assets/dist/assets/shortlinks-Jev4OoWa.js.map.gz -------------------------------------------------------------------------------- /src/web/assets/dist/assets/vue-apexcharts-BHFWl5EO.js.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-retour/develop-v5/src/web/assets/dist/assets/vue-apexcharts-BHFWl5EO.js.gz -------------------------------------------------------------------------------- /src/web/assets/dist/assets/_plugin-vue2_normalizer-BFq-tfv7.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"_plugin-vue2_normalizer-BFq-tfv7.js","sources":[],"sourcesContent":[],"names":[],"mappings":""} -------------------------------------------------------------------------------- /src/web/assets/dist/assets/vue-apexcharts-BHFWl5EO.js.map.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nystudio107/craft-retour/develop-v5/src/web/assets/dist/assets/vue-apexcharts-BHFWl5EO.js.map.gz -------------------------------------------------------------------------------- /ecs.php: -------------------------------------------------------------------------------- 1 | paths([ 8 | __DIR__ . '/src', 9 | __FILE__, 10 | ]); 11 | $ecsConfig->parallel(); 12 | $ecsConfig->sets([SetList::CRAFT_CMS_4]); 13 | }; 14 | -------------------------------------------------------------------------------- /src/templates/_layouts/widget-cp.twig: -------------------------------------------------------------------------------- 1 | 2 | {% block head %} 3 | {% set tagOptions = { 4 | 'depends': [ 5 | 'nystudio107\\retour\\assetbundles\\retour\\RetourAsset' 6 | ], 7 | } %} 8 | {{ craft.retour.register('src/js/Retour.js', false, tagOptions, tagOptions) }} 9 | {% endblock %} 10 | 11 | {% block content %} 12 | {% endblock %} 13 | -------------------------------------------------------------------------------- /src/templates/_layouts/retour-cp.twig: -------------------------------------------------------------------------------- 1 | {% extends "_layouts/cp" %} 2 | 3 | {% block head %} 4 | {{ parent() }} 5 | {% set tagOptions = { 6 | 'depends': [ 7 | 'nystudio107\\retour\\assetbundles\\retour\\RetourAsset' 8 | ], 9 | } %} 10 | {{ craft.retour.register('src/js/Retour.js', false, tagOptions, tagOptions) }} 11 | {% endblock %} 12 | -------------------------------------------------------------------------------- /src/templates/_includes/macros.twig: -------------------------------------------------------------------------------- 1 | {% macro configWarning(setting, file) -%} 2 | {%- set configArray = craft.app.config.getConfigFromFile(file) -%} 3 | {%- if configArray[setting] is defined -%} 4 | {{- "This is being overridden by the `#{setting}` setting in the `config/#{file}.php` file." |raw }} 5 | {%- else -%} 6 | {{ false }} 7 | {%- endif -%} 8 | {%- endmacro %} 9 | -------------------------------------------------------------------------------- /src/helpers/Gql.php: -------------------------------------------------------------------------------- 1 | update('{{%widgets}}', [ 20 | 'type' => RetourWidget::class, 21 | ], ['type' => 'Retour']); 22 | 23 | return true; 24 | } 25 | 26 | /** 27 | * @inheritdoc 28 | */ 29 | public function safeDown(): bool 30 | { 31 | echo "m190416_212500_widget_type_update cannot be reverted.\n"; 32 | 33 | return false; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/events/RedirectResolvedEvent.php: -------------------------------------------------------------------------------- 1 | db->columnExists('{{%retour_static_redirects}}', 'priority')) { 19 | $this->addColumn( 20 | '{{%retour_static_redirects}}', 21 | 'priority', 22 | $this->integer()->null()->after('redirectHttpCode')->defaultValue(5) 23 | ); 24 | } 25 | } 26 | 27 | /** 28 | * @inheritdoc 29 | */ 30 | public function safeDown() 31 | { 32 | echo "m221130_224500_add_priorities_column cannot be reverted.\n"; 33 | return false; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/migrations/m181013_122446_add_remote_ip.php: -------------------------------------------------------------------------------- 1 | db->columnExists('{{%retour_stats}}', 'remoteIp')) { 20 | $this->addColumn( 21 | '{{%retour_stats}}', 22 | 'remoteIp', 23 | $this->string(45)->after('referrerUrl')->defaultValue('') 24 | ); 25 | } 26 | 27 | return true; 28 | } 29 | 30 | /** 31 | * @inheritdoc 32 | */ 33 | public function safeDown(): bool 34 | { 35 | echo "m181013_122446_add_remote_ip cannot be reverted.\n"; 36 | return false; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/templates/index.twig: -------------------------------------------------------------------------------- 1 | {# @var craft \craft\web\twig\variables\CraftVariable #} 2 | {# 3 | /** 4 | * Retour plugin for Craft CMS 5 | * 6 | * Retour index.twig 7 | * 8 | * @author nystudio107 9 | * @copyright Copyright (c) 2018 nystudio107 10 | * @link https://nystudio107.com/ 11 | * @package Retour 12 | * @since 3.x.x 13 | */ 14 | #} 15 | 16 | {% requirePermission 'accessPlugin-retour' %} 17 | 18 | {# Redirect the user to the first sub page they can access #} 19 | {% if currentUser.can('retour:dashboard') %} 20 | {% redirect cpUrl('retour/dashboard') %} 21 | {% elseif currentUser.can('retour:redirects') %} 22 | {% redirect cpUrl('retour/redirects') %} 23 | {% elseif currentUser.can('retour:shortlinks') %} 24 | {% redirect cpUrl('retour/shortlinks') %} 25 | {% elseif currentUser.can('retour:settings') %} 26 | {% redirect cpUrl('retour/settings') %} 27 | {% else %} 28 | {# ...or throw a 403 if they can't access anything at all #} 29 | {% exit 403 %} 30 | {% endif %} 31 | -------------------------------------------------------------------------------- /src/web/assets/dist/assets/_plugin-vue2_normalizer-BFq-tfv7.js: -------------------------------------------------------------------------------- 1 | var l=typeof globalThis<"u"?globalThis:typeof window<"u"?window:typeof global<"u"?global:typeof self<"u"?self:{};function s(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}function c(e){if(Object.prototype.hasOwnProperty.call(e,"__esModule"))return e;var t=e.default;if(typeof t=="function"){var o=function n(){return this instanceof n?Reflect.construct(t,arguments,this.constructor):t.apply(this,arguments)};o.prototype=t.prototype}else o={};return Object.defineProperty(o,"__esModule",{value:!0}),Object.keys(e).forEach(function(n){var u=Object.getOwnPropertyDescriptor(e,n);Object.defineProperty(o,n,u.get?u:{enumerable:!0,get:function(){return e[n]}})}),o}function d(e,t,o,n,u,f,a,i){var r=typeof e=="function"?e.options:e;return t&&(r.render=t,r.staticRenderFns=o,r._compiled=!0),f&&(r._scopeId="data-v-"+f),{exports:e,options:r}}export{c as a,l as c,s as g,d as n}; 2 | //# sourceMappingURL=_plugin-vue2_normalizer-BFq-tfv7.js.map 3 | -------------------------------------------------------------------------------- /src/icon-mask.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 11 | 12 | -------------------------------------------------------------------------------- /src/web/assets/dist/img/icon-mask.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 11 | 12 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | MAJOR_VERSION?=5 2 | PLUGINDEV_PROJECT_DIR?=/Users/andrew/webdev/sites/plugindev/cms_v${MAJOR_VERSION}/ 3 | VENDOR?=nystudio107 4 | PROJECT_PATH?=${VENDOR}/$(shell basename $(CURDIR)) 5 | 6 | .PHONY: dev docs release 7 | 8 | # Start up the buildchain dev server 9 | dev: 10 | ${MAKE} -C buildchain/ dev 11 | # Start up the docs dev server 12 | docs: 13 | ${MAKE} -C docs/ dev 14 | # Run code quality tools, tests, and build the buildchain & docs in preparation for a release 15 | release: --code-quality --code-tests --buildchain-clean-build --docs-clean-build 16 | # The internal targets used by the dev & release targets 17 | --buildchain-clean-build: 18 | ${MAKE} -C buildchain/ clean 19 | ${MAKE} -C buildchain/ image-build 20 | ${MAKE} -C buildchain/ build 21 | --code-quality: 22 | ${MAKE} -C ${PLUGINDEV_PROJECT_DIR} -- ecs check vendor/${PROJECT_PATH}/src --fix 23 | ${MAKE} -C ${PLUGINDEV_PROJECT_DIR} -- phpstan analyze -c vendor/${PROJECT_PATH}/phpstan.neon 24 | --code-tests: 25 | --docs-clean-build: 26 | ${MAKE} -C docs/ clean 27 | ${MAKE} -C docs/ image-build 28 | ${MAKE} -C docs/ fix 29 | -------------------------------------------------------------------------------- /src/web/assets/dist/assets/widget-BQWJDBDA.js: -------------------------------------------------------------------------------- 1 | import{A as n,a as i}from"./vue-apexcharts-BHFWl5EO.js";import{n as o}from"./_plugin-vue2_normalizer-BFq-tfv7.js";const c=e=>({baseURL:e,headers:{"X-Requested-With":"XMLHttpRequest"}}),h=(e,t,s,a)=>{e.get(t,{params:s}).then(r=>{a&&a(r.data)}).catch(r=>{console.error(r)})},p={components:{apexcharts:n},props:{title:{type:String,default:""},subTitle:{type:String,default:""},days:{type:String,default:""},apiUrl:{type:String,default:""}},data:function(){return{chartOptions:{chart:{id:"vuechart-widget",toolbar:{show:!1}},colors:["#008FFB","#DCE6EC"],labels:["404 hits","404 hits handled"]},series:[50,50]}},created:function(){this.getSeriesData()},methods:{async getSeriesData(){const e=i.create(c(this.apiUrl));await h(e,"",{days:this.days},t=>{this.series=t})}}};var d=function(){var t=this,s=t._self._c;return s("apexcharts",{attrs:{options:t.chartOptions,series:t.series,height:"200px",type:"donut",width:"100%"}})},l=[],u=o(p,d,l,!1,null,null);const f=u.exports,_=window.Vue;new _({el:"#widget-content",components:{"widget-chart":f}}); 2 | //# sourceMappingURL=widget-BQWJDBDA.js.map 3 | -------------------------------------------------------------------------------- /src/assetbundles/retour/RetourAsset.php: -------------------------------------------------------------------------------- 1 | sourcePath = '@nystudio107/retour/web/assets/dist'; 34 | $this->depends = [ 35 | CpAsset::class, 36 | VueAsset::class, 37 | ]; 38 | 39 | parent::init(); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/assetbundles/retour/RetourImportAsset.php: -------------------------------------------------------------------------------- 1 | sourcePath = '@nystudio107/retour/web/assets/dist'; 34 | $this->depends = [ 35 | CpAsset::class, 36 | VueAsset::class, 37 | RetourAsset::class, 38 | ]; 39 | 40 | parent::init(); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/templates/_components/fields/ShortLink_settings.twig: -------------------------------------------------------------------------------- 1 | {% import "_includes/forms" as forms %} 2 | 3 | {{ forms.selectField({ 4 | label: "Legacy URL Match Type"|t("retour"), 5 | instructions: "Should the legacy URL be matched by path (e.g. `/new-recipes/`) or by full URL (e.g.: `http://example.com/de/new-recipes/`)?"|t("retour"), 6 | id: "redirectSrcMatch", 7 | name: "redirectSrcMatch", 8 | value: field.redirectSrcMatch, 9 | options: { 10 | "pathonly": "Path Only"|t("retour"), 11 | "fullurl": "Full URL"|t("retour"), 12 | }, 13 | }) }} 14 | 15 | {{ forms.selectField({ 16 | label: "Redirect Type"|t("retour"), 17 | instructions: "Select whether the redirect should be permanent or temporary."|t("retour"), 18 | id: "redirectHttpCode", 19 | name: "redirectHttpCode", 20 | value: field.redirectHttpCode, 21 | options: { 22 | "301": "301 - Moved Permanently"|t("retour"), 23 | "302": "302 - Found"|t("retour"), 24 | "307": "307 - Temporary Redirect"|t("retour"), 25 | "308": "308 - Permanent Redirect"|t("retour"), 26 | "410": "410 - Gone"|t("retour"), 27 | }, 28 | }) }} 29 | -------------------------------------------------------------------------------- /src/assetbundles/retour/RetourDashboardAsset.php: -------------------------------------------------------------------------------- 1 | sourcePath = '@nystudio107/retour/web/assets/dist'; 34 | $this->depends = [ 35 | CpAsset::class, 36 | VueAsset::class, 37 | RetourAsset::class, 38 | ]; 39 | 40 | parent::init(); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/assetbundles/retour/RetourRedirectsAsset.php: -------------------------------------------------------------------------------- 1 | sourcePath = '@nystudio107/retour/web/assets/dist'; 34 | $this->depends = [ 35 | CpAsset::class, 36 | VueAsset::class, 37 | RetourAsset::class, 38 | ]; 39 | 40 | parent::init(); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/assetbundles/retour/RetourWidgetAsset.php: -------------------------------------------------------------------------------- 1 | sourcePath = '@nystudio107/retour/web/assets/dist'; 34 | $this->depends = [ 35 | CpAsset::class, 36 | VueAsset::class, 37 | RetourAsset::class, 38 | ]; 39 | $this->js = [ 40 | ]; 41 | 42 | parent::init(); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/migrations/m200109_144310_add_redirectSrcUrl_index.php: -------------------------------------------------------------------------------- 1 | createIndexes(); 18 | 19 | return true; 20 | } 21 | 22 | /** 23 | * @return void 24 | */ 25 | protected function createIndexes(): void 26 | { 27 | $this->createIndex( 28 | $this->db->getIndexName(), 29 | '{{%retour_static_redirects}}', 30 | 'redirectSrcUrl', 31 | false 32 | ); 33 | 34 | $this->createIndex( 35 | $this->db->getIndexName(), 36 | '{{%retour_stats}}', 37 | 'redirectSrcUrl', 38 | false 39 | ); 40 | } 41 | 42 | /** 43 | * @inheritdoc 44 | */ 45 | public function safeDown(): bool 46 | { 47 | echo "m200109_144310_add_redirectSrcUrl_index cannot be reverted.\n"; 48 | 49 | return false; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/migrations/m230627_141429_add_redirectMatchType_index.php: -------------------------------------------------------------------------------- 1 | createIndexes(); 18 | 19 | return true; 20 | } 21 | 22 | /** 23 | * @return void 24 | */ 25 | protected function createIndexes(): void 26 | { 27 | $this->createIndex( 28 | $this->db->getIndexName(), 29 | '{{%retour_static_redirects}}', 30 | 'redirectMatchType', 31 | false 32 | ); 33 | 34 | $this->createIndex( 35 | $this->db->getIndexName(), 36 | '{{%retour_redirects}}', 37 | 'redirectMatchType', 38 | false 39 | ); 40 | } 41 | 42 | /** 43 | * @inheritdoc 44 | */ 45 | public function safeDown(): bool 46 | { 47 | echo "m230627_141429_add_redirectMatchType_index cannot be reverted.\n"; 48 | return false; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/migrations/m181018_135656_add_redirect_status.php: -------------------------------------------------------------------------------- 1 | db->columnExists('{{%retour_redirects}}', 'enabled')) { 18 | $this->addColumn( 19 | '{{%retour_redirects}}', 20 | 'enabled', 21 | $this->boolean()->after('associatedElementId')->defaultValue(true) 22 | ); 23 | } 24 | if (!$this->db->columnExists('{{%retour_static_redirects}}', 'enabled')) { 25 | $this->addColumn( 26 | '{{%retour_static_redirects}}', 27 | 'enabled', 28 | $this->boolean()->after('associatedElementId')->defaultValue(true) 29 | ); 30 | } 31 | 32 | return true; 33 | } 34 | 35 | /** 36 | * @inheritdoc 37 | */ 38 | public function safeDown(): bool 39 | { 40 | echo "m181018_135656_add_redirect_status cannot be reverted.\n"; 41 | 42 | return false; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/templates/_components/fields/ShortLink_input.twig: -------------------------------------------------------------------------------- 1 | {% import "_includes/forms" as forms %} 2 | 3 | {%- set class = ['nicetext', 'retour-shortlink-icon'] %} 4 | 5 | {% set config = { 6 | id: id ?? field.getInputId(), 7 | describedBy: field.describedBy, 8 | name: name, 9 | value: value, 10 | class: class, 11 | required: field.required, 12 | } %} 13 | 14 | 22 | 23 | {{ forms.text(config) }} 24 | -------------------------------------------------------------------------------- /src/templates/_components/fields/ShortLink_preview.twig: -------------------------------------------------------------------------------- 1 | {% import "_includes/forms" as forms %} 2 | 3 | {%- set class = ['retour-shortlink-preview'] %} 4 | {%- set class = (class ?? [])|explodeClass|merge([ 5 | ]|filter) %} 6 | 7 | {% set config = { 8 | name: name, 9 | text: value, 10 | class: class, 11 | readonly: true, 12 | } %} 13 | 14 | 23 | 24 | {{ tag('div', config) }} 25 | -------------------------------------------------------------------------------- /src/migrations/m181013_202455_add_redirect_src_match.php: -------------------------------------------------------------------------------- 1 | db->columnExists('{{%retour_redirects}}', 'redirectSrcMatch')) { 18 | $this->addColumn( 19 | '{{%retour_redirects}}', 20 | 'redirectSrcMatch', 21 | $this->string(32)->after('redirectSrcUrlParsed')->defaultValue('pathonly') 22 | ); 23 | } 24 | if (!$this->db->columnExists('{{%retour_static_redirects}}', 'redirectSrcMatch')) { 25 | $this->addColumn( 26 | '{{%retour_static_redirects}}', 27 | 'redirectSrcMatch', 28 | $this->string(32)->after('redirectSrcUrlParsed')->defaultValue('pathonly') 29 | ); 30 | } 31 | 32 | return true; 33 | } 34 | 35 | /** 36 | * @inheritdoc 37 | */ 38 | public function safeDown(): bool 39 | { 40 | echo "m181013_202455_add_redirect_src_match cannot be reverted.\n"; 41 | 42 | return false; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/templates/import/errors.twig: -------------------------------------------------------------------------------- 1 | {# @var craft \craft\web\twig\variables\CraftVariable #} 2 | {# 3 | /** 4 | * Retour plugin for Craft CMS 5 | * 6 | * @author nystudio107 7 | * @copyright Copyright (c) 2018 nystudio107 8 | * @link https://nystudio107.com/ 9 | * @package Retour 10 | * @since 3.2.0 11 | */ 12 | #} 13 | 14 | {% requirePermission "retour:redirects" %} 15 | 16 | {% extends "retour/_layouts/retour-cp.twig" %} 17 | 18 | {% import "_includes/forms" as forms %} 19 | 20 | {% block actionButton %} 21 | {% endblock %} 22 | 23 | {% block contextMenu %} 24 | {% endblock %} 25 | 26 | {% block content %} 27 |
28 |
29 |

30 | {{ "Some errors occured importing the CSV file."|t("retour") |md }} 31 |

32 |

{{ "All CSV rows without errors were still imported."|t("retour") }}

33 |
34 |
35 | 36 |
{{ errorLogContents | raw }}
37 |
38 |
39 |
40 |
41 |
42 | {% endblock %} 43 | 44 | {% block foot %} 45 | {# include our JavaScript modules #} 46 | {{ parent() }} 47 | {% endblock %} 48 | -------------------------------------------------------------------------------- /src/migrations/m210603_221000_add_gql_schema_components.php: -------------------------------------------------------------------------------- 1 | getProjectConfig(); 20 | $schemaVersion = $projectConfig->get('plugins.retour.schemaVersion', true); 21 | 22 | if (version_compare($schemaVersion, '3.0.10', '<')) { 23 | foreach ($projectConfig->get('graphql.schemas') ?? [] as $schemaUid => $schemaComponents) { 24 | if (isset($schemaComponents['scope'])) { 25 | $scope = $schemaComponents['scope']; 26 | $scope[] = 'retour.all:read'; 27 | 28 | $projectConfig->set("graphql.schemas.$schemaUid.scope", $scope); 29 | } 30 | } 31 | } 32 | 33 | return true; 34 | } 35 | 36 | /** 37 | * @inheritdoc 38 | */ 39 | public function safeDown(): bool 40 | { 41 | echo "m210603_221000_add_gql_schema_components cannot be reverted.\n"; 42 | 43 | return false; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/events/RedirectEvent.php: -------------------------------------------------------------------------------- 1 | [ 33 | 'name' => 'uri', 34 | 'type' => Type::string(), 35 | 'description' => 'The URI to resolve a redirect for.', 36 | ], 37 | 'site' => [ 38 | 'name' => 'site', 39 | 'type' => Type::string(), 40 | 'description' => 'The site handle to resolve a redirect for.', 41 | ], 42 | 'siteId' => [ 43 | 'name' => 'siteId', 44 | 'type' => Type::int(), 45 | 'description' => 'The siteId to resolve a redirect for.', 46 | ], 47 | ]; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/events/ResolveRedirectEvent.php: -------------------------------------------------------------------------------- 1 | 33 | 34 | 35 | {{ redirectInput("retour/settings") }} 36 | 37 | {# -- General settings -- #} 38 | {% include "retour/settings/_includes/general.twig" %} 39 | 40 | {# -- Statistics settings -- #} 41 | {% include "retour/settings/_includes/statistics.twig" %} 42 | 43 | {# -- Advanced settings -- #} 44 | {% include "retour/settings/_includes/advanced.twig" %} 45 | 46 | {# include our JavaScript modules #} 47 | {{ parent() }} 48 | {% endblock %} 49 | -------------------------------------------------------------------------------- /src/web/assets/dist/assets/LegacyUrl-CeiszSHN.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"LegacyUrl-CeiszSHN.js","sources":["../../../../../buildchain/src/vue/LegacyUrl.vue"],"sourcesContent":["\n\n\n"],"names":["_sfc_main","title","enabled"],"mappings":"0DAcA,MAAAA,EAAA,CACA,MAAA,CACA,QAAA,CACA,KAAA,OACA,SAAA,EACA,EACA,SAAA,CACA,KAAA,OACA,QAAA,CACA,CACA,EACA,SAAA,CACA,UAAA,UAAA,CACA,IAAAC,EAAA,GAEA,OAAAA,GAAA,KAAA,QAAA,eAEAA,CACA,EACA,gBAAA,UAAA,CACA,IAAAC,EAAA,GAEA,MAAA,CAAA,KAAA,QAAA,QACA,OAGAA,CACA,CACA,CACA"} -------------------------------------------------------------------------------- /src/helpers/Permission.php: -------------------------------------------------------------------------------- 1 | getUser()->getIdentity()) === null) { 38 | throw new ForbiddenHttpException('Your account has no identity.'); 39 | } 40 | 41 | if (!$currentUser->can($permission)) { 42 | throw new ForbiddenHttpException("Your account doesn't have permission to assign access this resource."); 43 | } 44 | } 45 | 46 | // Protected Static Methods 47 | // ========================================================================= 48 | } 49 | -------------------------------------------------------------------------------- /src/templates/_components/widgets/Retour_body.twig: -------------------------------------------------------------------------------- 1 | {# @var craft \craft\web\twig\variables\CraftVariable #} 2 | {# 3 | /** 4 | * Retour plugin for Craft CMS 5 | * 6 | * Retour Widget Body 7 | * 8 | * @author nystudio107 9 | * @copyright Copyright (c) 2018 nystudio107 10 | * @link https://nystudio107.com/ 11 | * @package Retour 12 | * @since 3.0.0 13 | */ 14 | #} 15 | 16 | {% extends "retour/_layouts/widget-cp.twig" %} 17 | 18 | {% set baseAssetsUrl = view.getAssetManager().getPublishedUrl('@nystudio107/retour/web/assets/dist', true) %} 19 | {% set iconUrl = baseAssetsUrl ~ '/img/Retour-icon.svg' %} 20 | 21 | {% block content %} 22 |
23 |

404 Hits

24 |
Discrete 404s hits in the last {{ numberOfDays }} days
25 | 31 | 32 |
33 | Dashboard 34 |
35 |
36 | 37 | {% set tagOptions = { 38 | 'depends': [ 39 | 'nystudio107\\retour\\assetbundles\\retour\\RetourAsset' 40 | ], 41 | } %} 42 | {{ craft.retour.register('src/js/Widget.js', false, tagOptions, tagOptions) }} 43 | {% endblock %} 44 | -------------------------------------------------------------------------------- /src/validators/DbStringValidator.php: -------------------------------------------------------------------------------- 1 | max === null) { 40 | throw new InvalidConfigException('The "max" property must be set.'); 41 | } 42 | } 43 | 44 | /** 45 | * @inheritdoc 46 | */ 47 | public function validateAttribute($model, $attribute): void 48 | { 49 | $value = $model->$attribute; 50 | $value = TextHelper::cleanupText($value); 51 | $value = StringHelper::encodeMb4($value); 52 | $value = TextHelper::truncate($value, $this->max); 53 | $model->$attribute = $value; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/models/DbModel.php: -------------------------------------------------------------------------------- 1 | $propValue) { 48 | if (!property_exists($class, $propName)) { 49 | unset($config[$propName]); 50 | } 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/migrations/m181216_043222_rebuild_indexes.php: -------------------------------------------------------------------------------- 1 | dropIndexes(); 19 | $this->createIndexes(); 20 | 21 | return true; 22 | } 23 | 24 | /** 25 | * @return void 26 | */ 27 | protected function dropIndexes() 28 | { 29 | Db::dropIndexIfExists('{{%retour_static_redirects}}', 'redirectSrcUrlParsed', true, $this->db); 30 | Db::dropIndexIfExists('{{%retour_redirects}}', 'redirectSrcUrlParsed', true, $this->db); 31 | } 32 | 33 | /** 34 | * @return void 35 | */ 36 | protected function createIndexes(): void 37 | { 38 | $this->createIndex( 39 | $this->db->getIndexName(), 40 | '{{%retour_static_redirects}}', 41 | 'redirectSrcUrlParsed', 42 | false 43 | ); 44 | 45 | $this->createIndex( 46 | $this->db->getIndexName(), 47 | '{{%retour_redirects}}', 48 | 'redirectSrcUrlParsed', 49 | false 50 | ); 51 | } 52 | 53 | /** 54 | * @inheritdoc 55 | */ 56 | public function safeDown(): bool 57 | { 58 | echo "m181216_043222_rebuild_indexes cannot be reverted.\n"; 59 | 60 | return false; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/controllers/ApiController.php: -------------------------------------------------------------------------------- 1 | enableApiEndpoint) { 47 | $this->allowAnonymous = false; 48 | } 49 | 50 | return parent::beforeAction($action); 51 | } 52 | 53 | /** 54 | * @param null $siteId 55 | * @return Response 56 | */ 57 | public function actionGetRedirects($siteId = null): Response 58 | { 59 | $redirects = Retour::$plugin->redirects->getAllStaticRedirects(null, $siteId); 60 | 61 | return $this->asJson($redirects); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/variables/RetourVariable.php: -------------------------------------------------------------------------------- 1 | redirects->getMatchesList(); 38 | } 39 | 40 | /** 41 | * Return the http status code 42 | * 43 | * @return int 44 | */ 45 | public function getHttpStatus(): int 46 | { 47 | return http_response_code(); 48 | } 49 | 50 | /** 51 | * Return whether we are running Craft 3.1 or later 52 | * 53 | * @return bool 54 | */ 55 | public function craft31(): bool 56 | { 57 | return true; 58 | } 59 | 60 | /** 61 | * Return whether we are running Craft 3.2 or later 62 | * 63 | * @return bool 64 | */ 65 | public function craft32(): bool 66 | { 67 | return true; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/helpers/Version.php: -------------------------------------------------------------------------------- 1 | getPrettyVersion(); 46 | } catch (Throwable $e) { 47 | Craft::error($e, __METHOD__); 48 | } 49 | if ($installedVersion) { 50 | if (Semver::satisfies($installedVersion, '^8.0.0')) { 51 | $version = 8; 52 | } 53 | if (Semver::satisfies($installedVersion, '^9.0.0')) { 54 | $version = 9; 55 | } 56 | } 57 | 58 | return $version; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/validators/ParsedUriValidator.php: -------------------------------------------------------------------------------- 1 | source === null) { 52 | throw new InvalidConfigException('The "source" property must be set.'); 53 | } 54 | } 55 | 56 | /** 57 | * @inheritdoc 58 | */ 59 | public function validateAttribute($model, $attribute): void 60 | { 61 | // Set the attribute to be the same value as the source 62 | $srcValue = $this->source; 63 | $value = $model->$srcValue; 64 | $model->$attribute = $value; 65 | // If we're supposed to parse it, do so 66 | if ($this->parse) { 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/gql/types/RetourType.php: -------------------------------------------------------------------------------- 1 | fieldName; 48 | $result = $source[$fieldName] ?? ''; 49 | // Handle the `site` virtual field 50 | if ($fieldName === 'site') { 51 | $result = null; 52 | $siteId = $source['siteId'] ?? null; 53 | if ($siteId) { 54 | $site = Craft::$app->getSites()->getSiteById($siteId); 55 | if ($site !== null) { 56 | $result = $site->handle; 57 | } 58 | } 59 | } 60 | 61 | if (empty($result)) { 62 | $result = null; 63 | } 64 | 65 | return $result; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/migrations/m181018_123901_add_stats_info.php: -------------------------------------------------------------------------------- 1 | db->columnExists('{{%retour_stats}}', 'exceptionFileLine')) { 18 | $this->addColumn( 19 | '{{%retour_stats}}', 20 | 'exceptionFileLine', 21 | $this->integer(45)->after('remoteIp')->defaultValue(0) 22 | ); 23 | } 24 | if (!$this->db->columnExists('{{%retour_stats}}', 'exceptionFilePath')) { 25 | $this->addColumn( 26 | '{{%retour_stats}}', 27 | 'exceptionFilePath', 28 | $this->string(255)->after('remoteIp')->defaultValue('') 29 | ); 30 | } 31 | if (!$this->db->columnExists('{{%retour_stats}}', 'exceptionMessage')) { 32 | $this->addColumn( 33 | '{{%retour_stats}}', 34 | 'exceptionMessage', 35 | $this->string(255)->after('remoteIp')->defaultValue('') 36 | ); 37 | } 38 | if (!$this->db->columnExists('{{%retour_stats}}', 'userAgent')) { 39 | $this->addColumn( 40 | '{{%retour_stats}}', 41 | 'userAgent', 42 | $this->string(255)->after('remoteIp')->defaultValue('') 43 | ); 44 | } 45 | 46 | return true; 47 | } 48 | 49 | /** 50 | * @inheritdoc 51 | */ 52 | public function safeDown(): bool 53 | { 54 | echo "m181018_123901_add_stats_info cannot be reverted.\n"; 55 | 56 | return false; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/validators/UriValidator.php: -------------------------------------------------------------------------------- 1 | $attribute; 50 | $redirectMatchType = 'redirectMatchType'; 51 | // Always remove whitespace 52 | $value = preg_replace("/\r|\n/", '', $value); 53 | $value = trim($value); 54 | $model->$attribute = $value; 55 | // Only do any kind of validation for exact match redirects 56 | if ($model->$redirectMatchType !== 'exactmatch') { 57 | return; 58 | } 59 | // Don't mess with URLs that are already full URLs 60 | if (UrlHelper::isFullUrl($value)) { 61 | return; 62 | } 63 | // Make sure there is a leading / 64 | $value = '/' . ltrim($value, '/'); 65 | $model->$attribute = $value; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/migrations/m181013_171315_truncate_match_type.php: -------------------------------------------------------------------------------- 1 | db->getIsPgsql()) { 19 | $this->alterColumn( 20 | '{{%retour_redirects}}', 21 | 'redirectMatchType', 22 | $this->string(32) 23 | ); 24 | $this->alterColumn( 25 | '{{%retour_redirects}}', 26 | 'redirectMatchType', 27 | "SET DEFAULT 'exactmatch'" 28 | ); 29 | $this->alterColumn( 30 | '{{%retour_static_redirects}}', 31 | 'redirectMatchType', 32 | $this->string(32) 33 | ); 34 | $this->alterColumn( 35 | '{{%retour_static_redirects}}', 36 | 'redirectMatchType', 37 | "SET DEFAULT 'exactmatch'" 38 | ); 39 | } 40 | if ($this->db->getIsMysql()) { 41 | $this->alterColumn( 42 | '{{%retour_redirects}}', 43 | 'redirectMatchType', 44 | $this->string(32)->defaultValue('exactmatch') 45 | ); 46 | $this->alterColumn( 47 | '{{%retour_static_redirects}}', 48 | 'redirectMatchType', 49 | $this->string(32)->defaultValue('exactmatch') 50 | ); 51 | } 52 | 53 | return true; 54 | } 55 | 56 | /** 57 | * @inheritdoc 58 | */ 59 | public function safeDown(): bool 60 | { 61 | echo "m181013_171315_truncate_match_type cannot be reverted.\n"; 62 | 63 | return false; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/templates/shortlinks/index.twig: -------------------------------------------------------------------------------- 1 | {# @var craft \craft\web\twig\variables\CraftVariable #} 2 | {# 3 | /** 4 | * Retour plugin for Craft CMS 5 | * 6 | * Retour Redirects index.twig 7 | * 8 | * @author nystudio107 9 | * @copyright Copyright (c) 2018 nystudio107 10 | * @link https://nystudio107.com/ 11 | * @package Retour 12 | * @since 3.0.0 13 | */ 14 | #} 15 | 16 | {% requirePermission "retour:shortlinks" %} 17 | 18 | {% extends "retour/_layouts/retour-cp.twig" %} 19 | 20 | {% import "_includes/forms" as forms %} 21 | 22 | {% do view.registerTranslations('retour', [ 23 | 'Short Link', 24 | 'Redirect To', 25 | 'Match Type', 26 | 'Sites', 27 | 'Status', 28 | 'Hits', 29 | 'Last Hit', 30 | 'Search for:', 31 | 'Reset', 32 | 'Displaying', 33 | 'to', 34 | 'of', 35 | 'items', 36 | 'Per-page:', 37 | 'Delete', 38 | 'redirect', 39 | 'redirects', 40 | ]) %} 41 | 42 | {% block contextMenu %} 43 | {% include "retour/_includes/sites-menu.twig" %} 44 | {% endblock %} 45 | 46 | {% block actionButton %} 47 |
48 |
49 | {% endblock %} 50 | 51 | {% block content %} 52 |
53 |
54 |

{{ "Short Links are a way to create a short link or alias to an Entry, so the link is easier to type or remember."|t('retour') }}

55 |

{{ "Short Links are created by adding a Short Link field to the field layout of a Section."|t('retour') }}

56 |
57 | 61 | 62 |
63 | {% endblock %} 64 | 65 | {% block foot %} 66 | {# include our JavaScript modules #} 67 | {{ parent() }} 68 | {% set tagOptions = { 69 | 'depends': [ 70 | 'nystudio107\\retour\\assetbundles\\retour\\RetourAsset' 71 | ], 72 | } %} 73 | {{ craft.retour.register('src/js/Shortlinks.js', false, tagOptions, tagOptions) }} 74 | {% endblock %} 75 | -------------------------------------------------------------------------------- /src/console/controllers/StatsController.php: -------------------------------------------------------------------------------- 1 | statistics->trimStatistics($this->limit); 68 | echo Craft::t( 69 | 'retour', 70 | 'Trimmed {rows} from retour_stats table', 71 | ['rows' => $affectedRows] 72 | ) . PHP_EOL; 73 | 74 | return 0; 75 | } 76 | 77 | // Protected Methods 78 | // ========================================================================= 79 | } 80 | -------------------------------------------------------------------------------- /src/gql/types/generators/RetourGenerator.php: -------------------------------------------------------------------------------- 1 | $typeName, 43 | 'args' => function() use ($retourArgs) { 44 | return $retourArgs; 45 | }, 46 | 'fields' => function() use ($retourFields) { 47 | return $retourFields; 48 | }, 49 | 'description' => 'This entity has all the Retour fields', 50 | ])); 51 | 52 | $gqlTypes[$typeName] = $retourType; 53 | TypeLoader::registerType($typeName, function() use ($retourType) { 54 | return $retourType; 55 | }); 56 | 57 | return $gqlTypes; 58 | } 59 | 60 | /** 61 | * @inheritdoc 62 | */ 63 | public static function getName($context = null): string 64 | { 65 | return 'RetourType'; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nystudio107/craft-retour", 3 | "description": "Retour allows you to intelligently redirect legacy URLs, so that you don't lose SEO value when rebuilding & restructuring a website", 4 | "type": "craft-plugin", 5 | "version": "5.0.12", 6 | "keywords": [ 7 | "craftcms", 8 | "craft-plugin", 9 | "retour", 10 | "redirect", 11 | "404", 12 | "statistics" 13 | ], 14 | "support": { 15 | "docs": "https://nystudio107.com/docs/retour/", 16 | "issues": "https://nystudio107.com/plugins/retour/support", 17 | "source": "https://github.com/nystudio107/craft-retour" 18 | }, 19 | "license": "proprietary", 20 | "authors": [ 21 | { 22 | "name": "nystudio107", 23 | "homepage": "https://nystudio107.com/" 24 | } 25 | ], 26 | "require-dev": { 27 | }, 28 | "require": { 29 | "php": "^8.2", 30 | "craftcms/cms": "^5.0.0", 31 | "nystudio107/craft-plugin-vite": "^5.0.0", 32 | "league/csv": "^8.2 || ^9.0", 33 | "jean85/pretty-package-versions": "^1.5 || ^2.0" 34 | }, 35 | "require-dev": { 36 | "codeception/codeception": "^5.0.11", 37 | "codeception/module-asserts": "^3.0.0", 38 | "codeception/module-datafactory": "^3.0.0", 39 | "codeception/module-phpbrowser": "^3.0.0", 40 | "codeception/module-rest": "^3.3.2", 41 | "codeception/module-yii2": "^1.1.9", 42 | "vlucas/phpdotenv": "^5.4.0", 43 | "craftcms/ecs": "dev-main", 44 | "craftcms/phpstan": "dev-main", 45 | "craftcms/rector": "dev-main" 46 | }, 47 | "scripts": { 48 | "phpstan": "phpstan --ansi --memory-limit=1G", 49 | "check-cs": "ecs check --ansi", 50 | "fix-cs": "ecs check --fix --ansi", 51 | "test": "codecept run unit --coverage-xml" 52 | }, 53 | "config": { 54 | "allow-plugins": { 55 | "craftcms/plugin-installer": true, 56 | "yiisoft/yii2-composer": true 57 | }, 58 | "optimize-autoloader": true, 59 | "platform": { 60 | "php": "8.2" 61 | }, 62 | "platform-check": false, 63 | "sort-packages": true 64 | }, 65 | "autoload": { 66 | "psr-4": { 67 | "nystudio107\\retour\\": "src/" 68 | } 69 | }, 70 | "extra": { 71 | "class": "nystudio107\\retour\\Retour", 72 | "handle": "retour", 73 | "name": "Retour" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright © nystudio107 2 | 3 | Permission is hereby granted to any person obtaining a copy of this software 4 | (the “Software”) to use, copy, modify, merge, publish and/or distribute copies 5 | of the Software, and to permit persons to whom the Software is furnished to do 6 | so, subject to the following conditions: 7 | 8 | 1. **Don’t plagiarize.** The above copyright notice and this license shall be 9 | included in all copies or substantial portions of the Software. 10 | 11 | 2. **Don’t use the same license on more than one project.** Each licensed copy 12 | of the Software shall be actively installed in no more than one production 13 | environment at a time. 14 | 15 | 3. **Don’t mess with the licensing features.** Software features related to 16 | licensing shall not be altered or circumvented in any way, including (but 17 | not limited to) license validation, payment prompts, feature restrictions, 18 | and update eligibility. 19 | 20 | 4. **Pay up.** Payment shall be made immediately upon receipt of any notice, 21 | prompt, reminder, or other message indicating that a payment is owed. 22 | 23 | 5. **Follow the law.** All use of the Software shall not violate any applicable 24 | law or regulation, nor infringe the rights of any other person or entity. 25 | 26 | Failure to comply with the foregoing conditions will automatically and 27 | immediately result in termination of the permission granted hereby. This 28 | license does not include any right to receive updates to the Software or 29 | technical support. Licensees bear all risk related to the quality and 30 | performance of the Software and any modifications made or obtained to it, 31 | including liability for actual and consequential harm, such as loss or 32 | corruption of data, and any necessary service, repair, or correction. 33 | 34 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 35 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 36 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 37 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER 38 | LIABILITY, INCLUDING SPECIAL, INCIDENTAL AND CONSEQUENTIAL DAMAGES, WHETHER IN 39 | AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 40 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 41 | -------------------------------------------------------------------------------- /src/templates/_includes/sites-menu.twig: -------------------------------------------------------------------------------- 1 | {% import "_includes/forms" as forms %} 2 | 3 | {% set baseUrl = "retour/#{controllerHandle}/" %} 4 | {% set params = [] %} 5 | 6 | {% if showSites %} 7 | {{ sitesMenuLabel }} 8 | 10 | 11 | 52 | {% endif %} 53 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/nystudio107/craft-retour/badges/quality-score.png?b=v5)](https://scrutinizer-ci.com/g/nystudio107/craft-retour/?branch=v5) [![Build Status](https://scrutinizer-ci.com/g/nystudio107/craft-retour/badges/build.png?b=v5)](https://scrutinizer-ci.com/g/nystudio107/craft-retour/build-status/v5) [![Code Intelligence Status](https://scrutinizer-ci.com/g/nystudio107/craft-retour/badges/code-intelligence.svg?b=v5)](https://scrutinizer-ci.com/code-intelligence) [![Code Coverage](https://scrutinizer-ci.com/g/nystudio107/craft-retour/badges/coverage.png?b=v5)](https://scrutinizer-ci.com/g/nystudio107/craft-retour/?branch=v5) 2 | 3 | # Retour plugin for Craft CMS 5.x 4 | 5 | Retour allows you to intelligently redirect legacy URLs, so that you don't lose SEO value when rebuilding & restructuring a website 6 | 7 | ![Screenshot](./docs/docs/resources/img/plugin-banner.jpg) 8 | 9 | Related: [Retour for Craft 2.x](https://github.com/nystudio107/retour) 10 | 11 | **Note**: _The license fee for this plugin is $59.00 via the Craft Plugin Store._ 12 | 13 | ### Upgrading from Retour 1.x for Craft CMS 2.x 14 | 15 | Even though this version of Retour was entirely rewritten for Craft CMS 3, it was designed to use all of the same data used by the Craft CMS 2.x version of Retour. 16 | 17 | So any existing redirects and statistics will continue to be in place. 18 | 19 | ## Used By 20 | 21 | ![Screenshot](./docs/docs/resources/img/moz-logo-blue.png)![Screenshot](./docs/docs/resources/img/craft-cms-logo.png) 22 | 23 | Retour is the redirect tool that the SEO experts at [Moz.com](https://moz.com/) and the creators of Craft CMS, Pixel & Tonic, rely on to handle their website redirects! 24 | 25 | ## Requirements 26 | 27 | This plugin requires Craft CMS 5.0.0 or later. 28 | 29 | ## Installation 30 | 31 | To install the plugin, follow these instructions. 32 | 33 | 1. Open your terminal and go to your Craft project: 34 | 35 | cd /path/to/project 36 | 37 | 2. Then tell Composer to load the plugin: 38 | 39 | composer require nystudio107/craft-retour 40 | 41 | 3. Install the plugin via `./craft install/plugin retour` via the CLI, or in the Control Panel, go to Settings → Plugins and click the “Install” button for Retour. 42 | 43 | You can also install Retour via the **Plugin Store** in the Craft Control Panel. 44 | 45 | ## Documentation 46 | 47 | Click here -> [Retour Documentation](https://nystudio107.com/plugins/retour/documentation) 48 | 49 | ## Retour Roadmap 50 | 51 | Some things to do, and ideas for potential features: 52 | 53 | * Release it 54 | 55 | Brought to you by [nystudio107](https://nystudio107.com/) 56 | -------------------------------------------------------------------------------- /src/templates/import/index.twig: -------------------------------------------------------------------------------- 1 | {# @var craft \craft\web\twig\variables\CraftVariable #} 2 | {# 3 | /** 4 | * Retour plugin for Craft CMS 5 | * 6 | * Retour Dashboard index.twig 7 | * 8 | * @author nystudio107 9 | * @copyright Copyright (c) 2018 nystudio107 10 | * @link https://nystudio107.com/ 11 | * @package Retour 12 | * @since 3.0.0 13 | */ 14 | #} 15 | 16 | {% requirePermission "retour:redirects" %} 17 | 18 | {% extends "retour/_layouts/retour-cp.twig" %} 19 | 20 | {% import "_includes/forms" as forms %} 21 | 22 | {% do view.registerTranslations('retour', [ 23 | 'Legacy URL Pattern', 24 | 'Redirect To', 25 | 'Match Type', 26 | 'HTTP Status', 27 | 'Site ID', 28 | 'Legacy URL Match Type', 29 | 'Hits', 30 | 'Priority', 31 | ]) %} 32 | 33 | {% block actionButton %} 34 | 35 | {% endblock %} 36 | 37 | {% block contextMenu %} 38 | {% endblock %} 39 | 40 | {% block content %} 41 | 42 | {{ csrfInput() }} 43 | {{ redirectInput("retour/redirects") }} 44 | 45 | {{ forms.hidden({ 46 | id: "filename", 47 | name: "filename", 48 | value: filename, 49 | }) }} 50 | 51 |
52 |
53 |
54 |
55 | {{ "Choose the fields to import into Retour from the CSV file by dragging them in the appropriate order. Click on the `x` to delete an unused field." |t("retour") |md }} 56 |
57 |
58 |
59 | 61 |
62 |
63 |
64 | {{ "The **Match Type** field must be either `exactmatch` or `regexmatch` (case sensitive). Anything left blank will be filled in with default values." |t("retour") |md }} 65 |
66 |
67 |
68 |
69 | {% endblock %} 70 | 71 | {% block foot %} 72 | {# include our JavaScript modules #} 73 | {{ parent() }} 74 | {% set tagOptions = { 75 | 'depends': [ 76 | 'nystudio107\\retour\\assetbundles\\retour\\RetourAsset' 77 | ], 78 | } %} 79 | {{ craft.retour.register('src/js/Import.js', false, tagOptions, tagOptions) }} 80 | {% endblock %} 81 | 82 | -------------------------------------------------------------------------------- /src/web/assets/dist/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "_LegacyUrl-CeiszSHN.js": { 3 | "file": "assets/LegacyUrl-CeiszSHN.js", 4 | "name": "LegacyUrl", 5 | "imports": [ 6 | "__plugin-vue2_normalizer-BFq-tfv7.js" 7 | ] 8 | }, 9 | "__plugin-vue2_normalizer-BFq-tfv7.js": { 10 | "file": "assets/_plugin-vue2_normalizer-BFq-tfv7.js", 11 | "name": "_plugin-vue2_normalizer" 12 | }, 13 | "_purify-DvoaRXnC.css": { 14 | "file": "assets/purify-DvoaRXnC.css", 15 | "src": "_purify-DvoaRXnC.css" 16 | }, 17 | "_purify.es-DP_WP6YH.js": { 18 | "file": "assets/purify.es-DP_WP6YH.js", 19 | "name": "purify.es", 20 | "imports": [ 21 | "__plugin-vue2_normalizer-BFq-tfv7.js" 22 | ], 23 | "css": [ 24 | "assets/purify-DvoaRXnC.css" 25 | ] 26 | }, 27 | "_vue-apexcharts-BHFWl5EO.js": { 28 | "file": "assets/vue-apexcharts-BHFWl5EO.js", 29 | "name": "vue-apexcharts", 30 | "imports": [ 31 | "__plugin-vue2_normalizer-BFq-tfv7.js" 32 | ] 33 | }, 34 | "src/js/Dashboard.js": { 35 | "file": "assets/dashboard-DVAYjtqJ.js", 36 | "name": "dashboard", 37 | "src": "src/js/Dashboard.js", 38 | "isEntry": true, 39 | "imports": [ 40 | "_purify.es-DP_WP6YH.js", 41 | "__plugin-vue2_normalizer-BFq-tfv7.js", 42 | "_vue-apexcharts-BHFWl5EO.js" 43 | ] 44 | }, 45 | "src/js/Import.js": { 46 | "file": "assets/import-Cap4Ox0Y.js", 47 | "name": "import", 48 | "src": "src/js/Import.js", 49 | "isEntry": true, 50 | "imports": [ 51 | "__plugin-vue2_normalizer-BFq-tfv7.js" 52 | ] 53 | }, 54 | "src/js/Redirects.js": { 55 | "file": "assets/redirects-B6OCJnQt.js", 56 | "name": "redirects", 57 | "src": "src/js/Redirects.js", 58 | "isEntry": true, 59 | "imports": [ 60 | "_purify.es-DP_WP6YH.js", 61 | "_LegacyUrl-CeiszSHN.js", 62 | "__plugin-vue2_normalizer-BFq-tfv7.js" 63 | ] 64 | }, 65 | "src/js/Retour.js": { 66 | "file": "assets/retour-DnaB684y.js", 67 | "name": "retour", 68 | "src": "src/js/Retour.js", 69 | "isEntry": true, 70 | "css": [ 71 | "assets/retour-exeLeENM.css" 72 | ] 73 | }, 74 | "src/js/Shortlinks.js": { 75 | "file": "assets/shortlinks-Jev4OoWa.js", 76 | "name": "shortlinks", 77 | "src": "src/js/Shortlinks.js", 78 | "isEntry": true, 79 | "imports": [ 80 | "_purify.es-DP_WP6YH.js", 81 | "_LegacyUrl-CeiszSHN.js", 82 | "__plugin-vue2_normalizer-BFq-tfv7.js" 83 | ] 84 | }, 85 | "src/js/Widget.js": { 86 | "file": "assets/widget-BQWJDBDA.js", 87 | "name": "widget", 88 | "src": "src/js/Widget.js", 89 | "isEntry": true, 90 | "imports": [ 91 | "_vue-apexcharts-BHFWl5EO.js", 92 | "__plugin-vue2_normalizer-BFq-tfv7.js" 93 | ] 94 | } 95 | } -------------------------------------------------------------------------------- /src/gql/queries/RetourQuery.php: -------------------------------------------------------------------------------- 1 | [ 42 | 'type' => RetourInterface::getType(), 43 | 'args' => RetourArguments::getArguments(), 44 | 'resolve' => RetourResolver::class . '::resolve', 45 | 'description' => 'This query is used to query for Retour redirects.', 46 | 'deprecationReason' => 'This query is deprecated and will be removed in the future. You should use `retourResolveRedirect` instead.', 47 | ], 48 | 'retourResolveRedirect' => [ 49 | 'type' => RetourInterface::getType(), 50 | 'args' => RetourArguments::getArguments(), 51 | 'resolve' => RetourResolver::class . '::resolve', 52 | 'description' => 'This query is used to query for Retour redirects.', 53 | ], 54 | 'retourRedirects' => [ 55 | 'type' => Type::listOf(RetourInterface::getType()), 56 | 'args' => [ 57 | 'site' => [ 58 | 'name' => 'site', 59 | 'type' => Type::string(), 60 | 'description' => 'The site handle to list all redirects for.', 61 | ], 62 | 'siteId' => [ 63 | 'name' => 'siteId', 64 | 'type' => Type::int(), 65 | 'description' => 'The siteId to list all redirects for.', 66 | ], 67 | ], 68 | 'resolve' => RetourResolver::class . '::resolveAll', 69 | 'description' => 'This query is used to query for all Retour redirects for a site.', 70 | ], 71 | ]; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/templates/redirects/index.twig: -------------------------------------------------------------------------------- 1 | {# @var craft \craft\web\twig\variables\CraftVariable #} 2 | {# 3 | /** 4 | * Retour plugin for Craft CMS 5 | * 6 | * Retour Redirects index.twig 7 | * 8 | * @author nystudio107 9 | * @copyright Copyright (c) 2018 nystudio107 10 | * @link https://nystudio107.com/ 11 | * @package Retour 12 | * @since 3.0.0 13 | */ 14 | #} 15 | 16 | {% requirePermission "retour:redirects" %} 17 | 18 | {% extends "retour/_layouts/retour-cp.twig" %} 19 | 20 | {% import "_includes/forms" as forms %} 21 | 22 | {% do view.registerTranslations('retour', [ 23 | 'Legacy URL Pattern', 24 | 'Redirect To', 25 | 'Match Type', 26 | 'Sites', 27 | 'Status', 28 | 'Hits', 29 | 'Last Hit', 30 | 'Search for:', 31 | 'Reset', 32 | 'Displaying', 33 | 'to', 34 | 'of', 35 | 'items', 36 | 'Per-page:', 37 | 'Delete', 38 | 'redirect', 39 | 'redirects', 40 | ]) %} 41 | 42 | {% block contextMenu %} 43 | {% include "retour/_includes/sites-menu.twig" %} 44 | {% endblock %} 45 | 46 | {% block actionButton %} 47 |
48 |
49 | {{ "Export CSV File"|t("retour") }} 51 |
52 |
53 |
54 | {{ csrfInput() }} 55 | 56 | 58 | 59 |
60 |
61 | {{ "New Static Redirect"|t("retour") }} 63 |
64 | {% endblock %} 65 | 66 | {% block content %} 67 |
68 |
69 |

{{ "Redirects only happen if Craft throws a `404` exception for the Legacy URL Pattern."|t('retour')|md|raw }}

70 |
71 | 75 | 76 |
77 | {% endblock %} 78 | 79 | {% block foot %} 80 | {# include our JavaScript modules #} 81 | {{ parent() }} 82 | {% set tagOptions = { 83 | 'depends': [ 84 | 'nystudio107\\retour\\assetbundles\\retour\\RetourAsset' 85 | ], 86 | } %} 87 | {{ craft.retour.register('src/js/Redirects.js', false, tagOptions, tagOptions) }} 88 | {% endblock %} 89 | -------------------------------------------------------------------------------- /src/config.php: -------------------------------------------------------------------------------- 1 | 'Retour', 29 | 30 | // Controls whether Retour automatically creates static redirects when an entry's URI changes. 31 | 'createUriChangeRedirects' => true, 32 | 33 | // Should the legacy URL be matched by path (e.g. `/new-recipes/`) or by full URL (e.g.: `http://example.com/de/new-recipes/`)? 34 | 'uriChangeRedirectSrcMatch' => 'pathonly', 35 | 36 | // Should the query string be stripped from all 404 URLs before their evaluation? 37 | 'alwaysStripQueryString' => false, 38 | 39 | // Should the query string be preserved and passed along to the redirected URL? 40 | 'preserveQueryString' => false, 41 | 42 | // Should `no-cache` headers be set on the redirect response to prevent client-side caching? 43 | 'setNoCacheHeaders' => true, 44 | 45 | // Should the query string be stripped from the saved statistics source URLs? 46 | 'stripQueryStringFromStats' => true, 47 | 48 | // Should the anonymous ip address of the client causing a 404 be recorded? 49 | 'recordRemoteIp' => true, 50 | 51 | // How many stats should be stored 52 | 'statsStoredLimit' => 1000, 53 | 54 | // Dashboard data live refresh interval 55 | 'refreshIntervalSecs' => 5, 56 | 57 | // Whether statistics should be kept to track how many times a redirect has been followed 58 | 'enableStatistics' => true, 59 | 60 | // Whether the Statistics should be trimmed after each new statistic is recorded 61 | 'automaticallyTrimStatistics' => true, 62 | 63 | // The number of milliseconds required between trimming of statistics 64 | 'statisticsRateLimitMs' => 3600000, 65 | 66 | // Determines whether the Retour API endpoint should be enabled for anonymous frontend access 67 | 'enableApiEndpoint' => false, 68 | 69 | // Should Craft sites factor into determining redirect destination URLs 70 | 'resolveCraftSites' => true, 71 | 72 | // [Regular expressions](https://regexr.com/) to match URLs to exclude from tracking 73 | 'excludePatterns' => [ 74 | ], 75 | 76 | // Additional headers to add to redirected requests 77 | 'additionalHeaders' => [ 78 | ], 79 | 80 | // The delimiter between data column values for importing CSV files (normally `,`) 81 | 'csvColumnDelimiter' => ',', 82 | ]; 83 | -------------------------------------------------------------------------------- /src/helpers/FileLog.php: -------------------------------------------------------------------------------- 1 | $fileName, 48 | 'categories' => [$category], 49 | 'level' => LogLevel::INFO, 50 | 'logContext' => false, 51 | 'allowLineBreaks' => true, 52 | 'formatter' => new LineFormatter( 53 | format: "%datetime% [%channel%.%level_name%] [%extra.yii_category%] %message% %context% %extra%\n", 54 | dateFormat: 'Y-m-d H:i:s', 55 | allowInlineLineBreaks: true, 56 | ignoreEmptyContextAndExtra: true, 57 | ), 58 | ]); 59 | // Add the new target file target to the dispatcher 60 | Craft::getLogger()->dispatcher->targets[] = $fileTarget; 61 | } 62 | 63 | /** 64 | * Delete the passed in log file $fileName 65 | * 66 | * @param string $fileName 67 | * @return void 68 | */ 69 | public static function delete(string $fileName): void 70 | { 71 | FileHelper::unlink(self::getLogFilePath($fileName)); 72 | } 73 | 74 | /** 75 | * Get the contents of the log file 76 | * 77 | * @param string $fileName 78 | * @return string 79 | */ 80 | public static function getContents(string $fileName): string 81 | { 82 | $contents = @file_get_contents(self::getLogFilePath($fileName)); 83 | 84 | return $contents === false ? '' : $contents; 85 | } 86 | 87 | /** 88 | * Return a full path to the log file 89 | * 90 | * @param string $fileName 91 | * @return string 92 | */ 93 | public static function getLogFilePath(string $fileName): string 94 | { 95 | $logfile = $fileName . "-" . date(self::FILE_PER_DAY); 96 | 97 | return Craft::getAlias("@storage/logs/$logfile.log"); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/web/assets/dist/assets/widget-BQWJDBDA.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"widget-BQWJDBDA.js","sources":["../../../../../buildchain/src/vue/WidgetChart.vue","../../../../../buildchain/src/js/Widget.js"],"sourcesContent":["\n\n\n","import Vue from 'vue';\nimport WidgetChart from '@/vue/WidgetChart.vue';\n\n// Create our vue instance\nnew Vue({\n el: \"#widget-content\",\n components: {\n 'widget-chart': WidgetChart,\n },\n});\n\n// Accept HMR as per: https://vitejs.dev/guide/api-hmr.html\nif (import.meta.hot) {\n import.meta.hot.accept(() => {\n console.log(\"HMR\")\n });\n}\n"],"names":["configureApi","url","queryApi","api","uri","params","callback","result","error","_sfc_main","ApexCharts","chartsAPI","Axios","data","Vue","WidgetChart"],"mappings":"kHAgBA,MAAAA,EAAAC,IACA,CACA,QAAAA,EACA,QAAA,CACA,mBAAA,gBACA,CACA,GAGAC,EAAA,CAAAC,EAAAC,EAAAC,EAAAC,IAAA,CACAH,EAAA,IAAAC,EAAA,CAAA,OAAAC,CAAA,CAAA,EACA,KAAAE,GAAA,CACAD,GACAA,EAAAC,EAAA,IAAA,CAEA,CAAA,EACA,MAAAC,GAAA,CACA,QAAA,MAAAA,CAAA,CACA,CAAA,CACA,EAGAC,EAAA,CACA,WAAA,CACA,WAAAC,CACA,EACA,MAAA,CACA,MAAA,CACA,KAAA,OACA,QAAA,EACA,EACA,SAAA,CACA,KAAA,OACA,QAAA,EACA,EACA,KAAA,CACA,KAAA,OACA,QAAA,EACA,EACA,OAAA,CACA,KAAA,OACA,QAAA,EACA,CACA,EACA,KAAA,UAAA,CACA,MAAA,CACA,aAAA,CACA,MAAA,CACA,GAAA,kBACA,QAAA,CACA,KAAA,EACA,CACA,EACA,OAAA,CAAA,UAAA,SAAA,EACA,OAAA,CACA,WACA,kBACA,CACA,EACA,OAAA,CAAA,GAAA,EAAA,CACA,CACA,EACA,QAAA,UAAA,CACA,KAAA,cAAA,CACA,EACA,QAAA,CAEA,MAAA,eAAA,CACA,MAAAC,EAAAC,EAAA,OAAAZ,EAAA,KAAA,MAAA,CAAA,EACA,MAAAE,EAAAS,EAAA,GAAA,CAAA,KAAA,KAAA,IAAA,EAAAE,GAAA,CACA,KAAA,OAAAA,CACA,CAAA,CACA,CACA,CACA,4MC1FAC,EAAA,OAAA,IAIA,IAAIA,EAAI,CACN,GAAI,kBACJ,WAAY,CACV,eAAgBC,CAAA,CAEpB,CAAC"} -------------------------------------------------------------------------------- /src/services/ServicesTrait.php: -------------------------------------------------------------------------------- 1 | = 8.2, and config() is called before __construct(), 39 | // so we can't extract it from the passed in $config 40 | $majorVersion = '5'; 41 | // Dev server container name & port are based on the major version of this plugin 42 | $devPort = 3000 + (int)$majorVersion; 43 | $versionName = 'v' . $majorVersion; 44 | return [ 45 | 'components' => [ 46 | 'events' => Events::class, 47 | 'redirects' => Redirects::class, 48 | 'statistics' => Statistics::class, 49 | // Register the vite service 50 | 'vite' => [ 51 | 'assetClass' => RetourAsset::class, 52 | 'checkDevServer' => true, 53 | 'class' => VitePluginService::class, 54 | 'devServerInternal' => 'http://craft-retour-' . $versionName . '-buildchain-dev:' . $devPort, 55 | 'devServerPublic' => 'http://localhost:' . $devPort, 56 | 'errorEntry' => 'src/js/Retour.js', 57 | 'useDevServer' => true, 58 | ], 59 | ], 60 | ]; 61 | } 62 | 63 | // Public Methods 64 | // ========================================================================= 65 | 66 | /** 67 | * Returns the events service 68 | * 69 | * @return Events The events service 70 | * @throws InvalidConfigException 71 | */ 72 | public function getEvents(): Events 73 | { 74 | return $this->get('events'); 75 | } 76 | 77 | /** 78 | * Returns the redirects service 79 | * 80 | * @return Redirects The redirects service 81 | * @throws InvalidConfigException 82 | */ 83 | public function getRedirects(): Redirects 84 | { 85 | return $this->get('redirects'); 86 | } 87 | 88 | /** 89 | * Returns the statistics service 90 | * 91 | * @return Statistics The statistics service 92 | * @throws InvalidConfigException 93 | */ 94 | public function getStatistics(): Statistics 95 | { 96 | return $this->get('statistics'); 97 | } 98 | 99 | /** 100 | * Returns the vite service 101 | * 102 | * @return VitePluginService The vite service 103 | * @throws InvalidConfigException 104 | */ 105 | public function getVite(): VitePluginService 106 | { 107 | return $this->get('vite'); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/helpers/Text.php: -------------------------------------------------------------------------------- 1 | truncate($length, $substring); 48 | } 49 | 50 | return $result; 51 | } 52 | 53 | /** 54 | * Truncates the string to a given length, while ensuring that it does not 55 | * split words. If $substring is provided, and truncating occurs, the 56 | * string is further truncated so that the substring may be appended without 57 | * exceeding the desired length. 58 | * 59 | * @param string $string The string to truncate 60 | * @param int $length Desired length of the truncated string 61 | * @param string $substring The substring to append if it can fit 62 | * 63 | * @return string with the resulting $str after truncating 64 | */ 65 | public static function truncateOnWord(string $string, int $length, string $substring = '…'): string 66 | { 67 | $result = $string; 68 | 69 | if (!empty($string)) { 70 | $string = strip_tags($string); 71 | $result = (string)Stringy::create($string)->safeTruncate($length, $substring); 72 | } 73 | 74 | return $result; 75 | } 76 | 77 | /** 78 | * Clean up the passed in text by converting it to UTF-8, stripping tags, 79 | * removing whitespace, and decoding HTML entities 80 | * 81 | * @param string $text 82 | * 83 | * @return string 84 | */ 85 | public static function cleanupText(string $text): string 86 | { 87 | if (empty($text)) { 88 | return ''; 89 | } 90 | // Convert to UTF-8 91 | $text = StringHelper::convertToUtf8($text); 92 | // Strip HTML tags 93 | $text = strip_tags($text); 94 | // Remove excess whitespace 95 | $text = preg_replace('/\s{2,}/u', ' ', $text); 96 | // Decode any HTML entities 97 | $text = html_entity_decode($text); 98 | 99 | return $text; 100 | } 101 | 102 | // Protected Static Methods 103 | // ========================================================================= 104 | } 105 | -------------------------------------------------------------------------------- /src/migrations/m181213_233502_add_site_id.php: -------------------------------------------------------------------------------- 1 | db->columnExists('{{%retour_redirects}}', 'locale')) { 21 | $this->dropColumn( 22 | '{{%retour_redirects}}', 23 | 'locale' 24 | ); 25 | } 26 | if ($this->db->columnExists('{{%retour_static_redirects}}', 'locale')) { 27 | $this->dropColumn( 28 | '{{%retour_static_redirects}}', 29 | 'locale' 30 | ); 31 | } 32 | // Add in the siteId columns 33 | if (!$this->db->columnExists('{{%retour_redirects}}', 'siteId')) { 34 | $this->addColumn( 35 | '{{%retour_redirects}}', 36 | 'siteId', 37 | $this->integer()->null()->after('uid')->defaultValue(null) 38 | ); 39 | } 40 | if (!$this->db->columnExists('{{%retour_static_redirects}}', 'siteId')) { 41 | $this->addColumn( 42 | '{{%retour_static_redirects}}', 43 | 'siteId', 44 | $this->integer()->null()->after('uid')->defaultValue(null) 45 | ); 46 | } 47 | if (!$this->db->columnExists('{{%retour_stats}}', 'siteId')) { 48 | $this->addColumn( 49 | '{{%retour_stats}}', 50 | 'siteId', 51 | $this->integer()->null()->after('uid')->defaultValue(null) 52 | ); 53 | } 54 | // Add foreign keys 55 | $this->addForeignKeys(); 56 | // Create indexes 57 | $this->createIndexes(); 58 | 59 | return true; 60 | } 61 | 62 | /** 63 | * @inheritdoc 64 | */ 65 | public function safeDown() 66 | { 67 | echo "m181213_233502_add_site_id cannot be reverted.\n"; 68 | return false; 69 | } 70 | 71 | /** 72 | * @return void 73 | */ 74 | protected function addForeignKeys(): void 75 | { 76 | $this->addForeignKey( 77 | $this->db->getForeignKeyName(), 78 | '{{%retour_static_redirects}}', 79 | 'siteId', 80 | '{{%sites}}', 81 | 'id', 82 | 'CASCADE', 83 | 'CASCADE' 84 | ); 85 | 86 | $this->addForeignKey( 87 | $this->db->getForeignKeyName(), 88 | '{{%retour_stats}}', 89 | 'siteId', 90 | '{{%sites}}', 91 | 'id', 92 | 'CASCADE', 93 | 'CASCADE' 94 | ); 95 | } 96 | 97 | /** 98 | * @return void 99 | */ 100 | protected function createIndexes(): void 101 | { 102 | $this->createIndex( 103 | $this->db->getIndexName(), 104 | '{{%retour_redirects}}', 105 | 'siteId', 106 | false 107 | ); 108 | 109 | $this->createIndex( 110 | $this->db->getIndexName(), 111 | '{{%retour_static_redirects}}', 112 | 'siteId', 113 | false 114 | ); 115 | 116 | $this->createIndex( 117 | $this->db->getIndexName(), 118 | '{{%retour_stats}}', 119 | 'siteId', 120 | false 121 | ); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/templates/settings/_includes/general.twig: -------------------------------------------------------------------------------- 1 | {# @var craft \craft\web\twig\variables\CraftVariable #} 2 | {# 3 | /** 4 | * Retour plugin for Craft CMS 5 | * 6 | * Retour Settings index.twig 7 | * 8 | * @author nystudio107 9 | * @copyright Copyright (c) 2018 nystudio107 10 | * @link https://nystudio107.com/ 11 | * @package Retour 12 | * @since 3.0.0 13 | */ 14 | #} 15 | 16 | {% import "_includes/forms" as forms %} 17 | {% from "retour/_includes/macros.twig" import configWarning %} 18 | 19 |
20 | {% namespace "settings" %} 21 | {{ forms.textField({ 22 | label: "Plugin name"|t("retour"), 23 | instructions: "The public-facing name of the plugin"|t("retour"), 24 | id: "pluginName", 25 | name: "pluginName", 26 | value: settings.pluginName, 27 | warning: configWarning("pluginName", "retour"), 28 | errors: settings.getErrors("pluginName"), 29 | }) }} 30 | 31 | {{ forms.lightswitchField({ 32 | label: "Create Entry Redirects"|t("retour"), 33 | instructions: "Controls whether Retour automatically creates static redirects when an entry's URI changes."|t("retour"), 34 | id: "createUriChangeRedirects", 35 | name: "createUriChangeRedirects", 36 | on: settings.createUriChangeRedirects, 37 | warning: configWarning("createUriChangeRedirects", "retour"), 38 | errors: settings.getErrors("createUriChangeRedirects"), 39 | }) }} 40 | 41 | {{ forms.selectField({ 42 | label: "Entry Redirects URL Match Type"|t("retour"), 43 | instructions: "Should the automatically created Entry Redirects be matched by path (e.g. `/new-recipes/`) or by full URL (e.g.: `http://example.com/de/new-recipes/`)?"|t("retour"), 44 | id: "uriChangeRedirectSrcMatch", 45 | name: "uriChangeRedirectSrcMatch", 46 | value: settings.uriChangeRedirectSrcMatch ?? "pathonly", 47 | options: { 48 | "pathonly": "Path Only"|t("retour"), 49 | "fullurl": "Full URL"|t("retour"), 50 | }, 51 | errors: settings.getErrors("uriChangeRedirectSrcMatch"), 52 | }) }} 53 | 54 | {{ forms.lightswitchField({ 55 | label: "Strip Query String from 404s"|t("retour"), 56 | instructions: "Should the query string be stripped from all 404 URLs before their evaluation?"|t("retour"), 57 | id: "alwaysStripQueryString", 58 | name: "alwaysStripQueryString", 59 | on: settings.alwaysStripQueryString, 60 | warning: configWarning("alwaysStripQueryString", "retour"), 61 | errors: settings.getErrors("alwaysStripQueryString"), 62 | }) }} 63 | 64 | {{ forms.lightswitchField({ 65 | label: "Preserve Query String"|t("retour"), 66 | instructions: "Should the query string be preserved and passed along to the redirected URL?"|t("retour"), 67 | id: "preserveQueryString", 68 | name: "preserveQueryString", 69 | on: settings.preserveQueryString, 70 | warning: configWarning("preserveQueryString", "retour"), 71 | errors: settings.getErrors("preserveQueryString"), 72 | }) }} 73 | 74 | {{ forms.lightswitchField({ 75 | label: "Set No-Cache Headers"|t("retour"), 76 | instructions: "Should `no-cache` headers be set on the redirect response to prevent client-side caching?"|t("retour"), 77 | id: "setNoCacheHeaders", 78 | name: "setNoCacheHeaders", 79 | on: settings.setNoCacheHeaders, 80 | warning: configWarning("setNoCacheHeaders", "retour"), 81 | errors: settings.getErrors("setNoCacheHeaders"), 82 | }) }} 83 | 84 | {% endnamespace %} 85 |
86 | -------------------------------------------------------------------------------- /src/widgets/RetourWidget.php: -------------------------------------------------------------------------------- 1 | pluginName; 50 | } 51 | 52 | /** 53 | * @inheritdoc 54 | */ 55 | public static function iconPath(): bool|string 56 | { 57 | return Craft::getAlias("@nystudio107/retour/web/assets/dist/img/icon-mask.svg"); 58 | } 59 | 60 | /** 61 | * @inheritdoc 62 | */ 63 | public static function maxColspan(): ?int 64 | { 65 | return 1; 66 | } 67 | 68 | // Public Methods 69 | // ========================================================================= 70 | 71 | /** 72 | * @inheritdoc 73 | */ 74 | public function rules(): array 75 | { 76 | $rules = parent::rules(); 77 | $rules = array_merge( 78 | $rules, 79 | [ 80 | ['numberOfDays', 'integer', 'min' => 1], 81 | ['numberOfDays', 'default', 'value' => 30], 82 | ] 83 | ); 84 | return $rules; 85 | } 86 | 87 | /** 88 | * @inheritdoc 89 | */ 90 | public function getSettingsHtml(): null|string 91 | { 92 | try { 93 | return Craft::$app->getView()->renderTemplate( 94 | 'retour/_components/widgets/Retour_settings', 95 | [ 96 | 'widget' => $this, 97 | ] 98 | ); 99 | } catch (LoaderError $e) { 100 | Craft::error($e->getMessage(), __METHOD__); 101 | } catch (Exception $e) { 102 | Craft::error($e->getMessage(), __METHOD__); 103 | } 104 | 105 | return null; 106 | } 107 | 108 | /** 109 | * @inheritdoc 110 | */ 111 | public function getBodyHtml(): null|string 112 | { 113 | try { 114 | Craft::$app->getView()->registerAssetBundle(RetourWidgetAsset::class); 115 | } catch (InvalidConfigException $e) { 116 | Craft::error($e->getMessage(), __METHOD__); 117 | } 118 | 119 | try { 120 | return Craft::$app->getView()->renderTemplate( 121 | 'retour/_components/widgets/Retour_body', 122 | [ 123 | 'numberOfDays' => $this->numberOfDays, 124 | ] 125 | ); 126 | } catch (LoaderError $e) { 127 | Craft::error($e->getMessage(), __METHOD__); 128 | } catch (Exception $e) { 129 | Craft::error($e->getMessage(), __METHOD__); 130 | } 131 | 132 | return null; 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/templates/settings/_includes/statistics.twig: -------------------------------------------------------------------------------- 1 | {# @var craft \craft\web\twig\variables\CraftVariable #} 2 | {# 3 | /** 4 | * Retour plugin for Craft CMS 5 | * 6 | * Retour Settings index.twig 7 | * 8 | * @author nystudio107 9 | * @copyright Copyright (c) 2018 nystudio107 10 | * @link https://nystudio107.com/ 11 | * @package Retour 12 | * @since 3.0.0 13 | */ 14 | #} 15 | 16 | {% import "_includes/forms" as forms %} 17 | {% from "retour/_includes/macros.twig" import configWarning %} 18 | 19 | 99 | -------------------------------------------------------------------------------- /src/models/Stats.php: -------------------------------------------------------------------------------- 1 | null], 99 | [ 100 | [ 101 | 'redirectSrcUrl', 102 | 'userAgent', 103 | 'exceptionMessage', 104 | 'exceptionFilePath', 105 | ], 106 | DbStringValidator::class, 107 | 'max' => 255, 108 | ], 109 | [ 110 | [ 111 | 'redirectSrcUrl', 112 | 'userAgent', 113 | 'exceptionMessage', 114 | 'exceptionFilePath', 115 | ], 116 | 'string', 117 | ], 118 | [ 119 | [ 120 | 'redirectSrcUrl', 121 | 'userAgent', 122 | 'exceptionMessage', 123 | 'exceptionFilePath', 124 | ], 125 | 'default', 126 | 'value' => '', 127 | ], 128 | ['exceptionFileLine', 'integer'], 129 | ['exceptionFileLine', 'default', 'value' => 0], 130 | ['referrerUrl', DbStringValidator::class, 'max' => 2000], 131 | ['referrerUrl', 'string'], 132 | ['referrerUrl', 'default', 'value' => ''], 133 | ['remoteIp', DbStringValidator::class, 'max' => 45], 134 | ['remoteIp', 'string'], 135 | ['remoteIp', 'default', 'value' => ''], 136 | ['hitCount', 'integer'], 137 | ['hitCount', 'default', 'value' => 0], 138 | ['hitLastTime', 'safe'], 139 | ['handledByRetour', 'integer', 'min' => 0, 'max' => 1], 140 | ['handledByRetour', 'default', 'value' => 0], 141 | ]; 142 | } 143 | 144 | /** 145 | * @return array 146 | */ 147 | public function behaviors(): array 148 | { 149 | return [ 150 | 'typecast' => [ 151 | 'class' => AttributeTypecastBehavior::class, 152 | // 'attributeTypes' will be composed automatically according to `rules()` 153 | ], 154 | ]; 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/templates/settings/_includes/advanced.twig: -------------------------------------------------------------------------------- 1 | {# @var craft \craft\web\twig\variables\CraftVariable #} 2 | {# 3 | /** 4 | * Retour plugin for Craft CMS 5 | * 6 | * Retour Settings index.twig 7 | * 8 | * @author nystudio107 9 | * @copyright Copyright (c) 2018 nystudio107 10 | * @link https://nystudio107.com/ 11 | * @package Retour 12 | * @since 3.0.0 13 | */ 14 | #} 15 | 16 | {% import "_includes/forms" as forms %} 17 | {% from "retour/_includes/macros.twig" import configWarning %} 18 | 19 | 110 | -------------------------------------------------------------------------------- /src/gql/resolvers/RetourResolver.php: -------------------------------------------------------------------------------- 1 | uri; 41 | $siteId = $source->siteId; 42 | } else { 43 | // Otherwise, use the passed in arguments, or defaults 44 | $uri = $arguments['uri'] ?? '/'; 45 | $siteId = $arguments['siteId'] ?? null; 46 | if (isset($arguments['site'])) { 47 | $site = Craft::$app->getSites()->getSiteByHandle($arguments['site']); 48 | if ($site !== null) { 49 | $siteId = $site->id; 50 | } 51 | } 52 | } 53 | $uri = trim($uri === '/' ? '__home__' : $uri); 54 | 55 | $redirect = null; 56 | 57 | // Strip the query string if `alwaysStripQueryString` is set 58 | if (Retour::$settings->alwaysStripQueryString) { 59 | $uri = UrlHelper::stripQueryString($uri); 60 | } 61 | 62 | if (!Retour::$plugin->redirects->excludeUri($uri)) { 63 | $redirect = Retour::$plugin->redirects->findRedirectMatch($uri, $uri, $siteId); 64 | 65 | if ($redirect === null && Craft::$app->getElements()->getElementByUri(trim($uri, '/'), $siteId) === null) { 66 | // Set the `site` virtual field 67 | $redirect['site'] = null; 68 | $redirect['siteId'] = $siteId; 69 | if (isset($redirect['siteId']) && (int)$redirect['siteId'] !== 0) { 70 | $site = Craft::$app->getSites()->getSiteById((int)$redirect['siteId']); 71 | if ($site !== null) { 72 | $redirect['site'] = $site->handle; 73 | } 74 | } 75 | // Increment the stats 76 | Retour::$plugin->statistics->incrementStatistics($uri, false, $siteId); 77 | } 78 | } 79 | if ($redirect !== null && isset($redirect['redirectDestUrl']) && $redirect['redirectSrcMatch'] === 'pathonly' && Retour::$settings->resolveCraftSites) { 80 | $dest = $redirect['redirectDestUrl']; 81 | $path = $redirect['redirectDestUrl']; 82 | // Combine the URL and path together, merging them as appropriate 83 | try { 84 | if (!UrlHelper::isAbsoluteUrl($dest) && !UrlHelper::pathHasSitePrefix($path)) { 85 | $dest = UrlHelper::siteUrl('/', null, null, $siteId); 86 | $dest = parse_url($dest, PHP_URL_PATH); 87 | $dest = UrlHelper::mergeUrlWithPath($dest, $path); 88 | } 89 | } catch (Throwable $e) { 90 | // That's ok 91 | } 92 | $redirect['redirectDestUrl'] = $dest; 93 | } 94 | 95 | return $redirect; 96 | } 97 | 98 | /** 99 | * Return all static redirects for a site. 100 | * 101 | * @param $source 102 | * @param array $arguments 103 | * @param $context 104 | * @param ResolveInfo $resolveInfo 105 | * @return array 106 | * @throws SiteNotFoundException 107 | */ 108 | public static function resolveAll($source, array $arguments, $context, ResolveInfo $resolveInfo) 109 | { 110 | $siteId = $arguments['siteId'] ?? Craft::$app->getSites()->getCurrentSite()->id; 111 | 112 | $redirects = Retour::$plugin->redirects->getAllStaticRedirects(null, $siteId); 113 | 114 | return $redirects; 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/gql/interfaces/RetourInterface.php: -------------------------------------------------------------------------------- 1 | static::getName(), 50 | 'fields' => self::class . '::getFieldDefinitions', 51 | 'description' => 'This is the interface implemented by Retour.', 52 | 'resolveType' => function(array $value) { 53 | return GqlEntityRegistry::getEntity(RetourGenerator::getName()); 54 | }, 55 | ])); 56 | RetourGenerator::generateTypes(); 57 | 58 | return $type; 59 | } 60 | 61 | /** 62 | * @inheritdoc 63 | */ 64 | public static function getName(): string 65 | { 66 | return 'RetourInterface'; 67 | } 68 | 69 | /** 70 | * @inheritdoc 71 | */ 72 | public static function getFieldDefinitions(): array 73 | { 74 | return array_merge(parent::getFieldDefinitions(), [ 75 | 'id' => [ 76 | 'name' => 'id', 77 | 'type' => Type::int(), 78 | 'description' => 'The id of the redirect.', 79 | ], 80 | 'site' => [ 81 | 'name' => 'site', 82 | 'type' => Type::string(), 83 | 'description' => 'The site handle of the redirect (or null for all sites).', 84 | ], 85 | 'siteId' => [ 86 | 'name' => 'siteId', 87 | 'type' => Type::int(), 88 | 'description' => 'The siteId of the redirect (0 or null for all sites).', 89 | ], 90 | 'associatedElementId' => [ 91 | 'name' => 'associatedElementId', 92 | 'type' => Type::int(), 93 | 'description' => 'The id of the Element associated with this redirect (unused/vestigial).', 94 | ], 95 | 'enabled' => [ 96 | 'name' => 'enabled', 97 | 'type' => Type::boolean(), 98 | 'description' => 'Whether the redirect is enabled or not.', 99 | ], 100 | 'redirectSrcUrl' => [ 101 | 'name' => 'redirectSrcUrl', 102 | 'type' => Type::string(), 103 | 'description' => 'The unparsed URL pattern that Retour should match.', 104 | ], 105 | 'redirectSrcUrlParsed' => [ 106 | 'name' => 'redirectSrcUrlParsed', 107 | 'type' => Type::string(), 108 | 'description' => 'The parsed URL pattern that Retour should match.', 109 | ], 110 | 'redirectSrcMatch' => [ 111 | 'name' => 'redirectSrcMatch', 112 | 'type' => Type::string(), 113 | 'description' => 'Should the legacy URL be matched by path or by full URL?', 114 | ], 115 | 'redirectMatchType' => [ 116 | 'name' => 'redirectMatchType', 117 | 'type' => Type::string(), 118 | 'description' => 'Whether an `exactmatch` or `regexmatch` should be used when matching the URL.', 119 | ], 120 | 'redirectDestUrl' => [ 121 | 'name' => 'redirectDestUrl', 122 | 'type' => Type::string(), 123 | 'description' => 'The URL that should be redirected to.', 124 | ], 125 | 'redirectHttpCode' => [ 126 | 'name' => 'redirectHttpCode', 127 | 'type' => Type::int(), 128 | 'description' => 'The http status code that should be used for the redirect.', 129 | ], 130 | 'hitCount' => [ 131 | 'name' => 'hitCount', 132 | 'type' => Type::int(), 133 | 'description' => 'The number of times this redirect has been hit.', 134 | ], 135 | 'hitLastTime' => [ 136 | 'name' => 'hitLastTime', 137 | 'type' => Type::string(), 138 | 'description' => 'A datetime string of when this redirect was last hit.', 139 | ], 140 | ]); 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/controllers/SettingsController.php: -------------------------------------------------------------------------------- 1 | pluginName; 66 | $templateTitle = Craft::t('retour', 'Settings'); 67 | $view = Craft::$app->getView(); 68 | // Asset bundle 69 | try { 70 | $view->registerAssetBundle(RetourAsset::class); 71 | } catch (InvalidConfigException $e) { 72 | Craft::error($e->getMessage(), __METHOD__); 73 | } 74 | $variables['baseAssetsUrl'] = Craft::$app->assetManager->getPublishedUrl( 75 | '@nystudio107/retour/web/assets/dist', 76 | true 77 | ); 78 | // Basic variables 79 | $variables['fullPageForm'] = true; 80 | $variables['docsUrl'] = self::DOCUMENTATION_URL; 81 | $variables['pluginName'] = $pluginName; 82 | $variables['title'] = $templateTitle; 83 | $variables['crumbs'] = [ 84 | [ 85 | 'label' => $pluginName, 86 | 'url' => UrlHelper::cpUrl('retour'), 87 | ], 88 | [ 89 | 'label' => $templateTitle, 90 | 'url' => UrlHelper::cpUrl('retour/settings'), 91 | ], 92 | ]; 93 | $variables['docTitle'] = "{$pluginName} - {$templateTitle}"; 94 | $variables['selectedSubnavItem'] = 'settings'; 95 | $variables['settings'] = $settings; 96 | 97 | // Render the template 98 | return $this->renderTemplate('retour/settings', $variables); 99 | } 100 | 101 | /** 102 | * Saves a plugin’s settings. 103 | * 104 | * @return Response|null 105 | * @throws NotFoundHttpException if the requested plugin cannot be found 106 | * @throws BadRequestHttpException 107 | * @throws MissingComponentException 108 | * @throws ForbiddenHttpException 109 | */ 110 | public function actionSavePluginSettings(): ?Response 111 | { 112 | PermissionHelper::controllerPermissionCheck('retour:settings'); 113 | $this->requirePostRequest(); 114 | $pluginHandle = Craft::$app->getRequest()->getRequiredBodyParam('pluginHandle'); 115 | $settings = Craft::$app->getRequest()->getBodyParam('settings', []); 116 | $plugin = Craft::$app->getPlugins()->getPlugin($pluginHandle); 117 | 118 | if ($plugin === null) { 119 | throw new NotFoundHttpException('Plugin not found'); 120 | } 121 | 122 | if (!is_array($settings['additionalHeaders'])) { 123 | $settings['additionalHeaders'] = []; 124 | } 125 | if (!is_array($settings['excludePatterns'])) { 126 | $settings['excludePatterns'] = []; 127 | } 128 | if (!Craft::$app->getPlugins()->savePluginSettings($plugin, $settings)) { 129 | Craft::$app->getSession()->setError(Craft::t('app', "Couldn't save plugin settings.")); 130 | 131 | // Send the plugin back to the template 132 | /** @var UrlManager $urlManager */ 133 | $urlManager = Craft::$app->getUrlManager(); 134 | $urlManager->setRouteParams([ 135 | 'plugin' => $plugin, 136 | ]); 137 | 138 | return null; 139 | } 140 | 141 | Retour::$plugin->clearAllCaches(); 142 | Craft::$app->getSession()->setNotice(Craft::t('app', 'Plugin settings saved.')); 143 | 144 | return $this->redirectToPostedUrl(); 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/templates/dashboard/index.twig: -------------------------------------------------------------------------------- 1 | {# @var craft \craft\web\twig\variables\CraftVariable #} 2 | {# 3 | /** 4 | * Retour plugin for Craft CMS 5 | * 6 | * Retour Dashboard index.twig 7 | * 8 | * @author nystudio107 9 | * @copyright Copyright (c) 2018 nystudio107 10 | * @link https://nystudio107.com/ 11 | * @package Retour 12 | * @since 3.0.0 13 | */ 14 | #} 15 | 16 | {% requirePermission "retour:dashboard" %} 17 | 18 | {% extends "retour/_layouts/retour-cp.twig" %} 19 | 20 | {% import "_includes/forms" as forms %} 21 | 22 | {% do view.registerTranslations('retour', [ 23 | '404 File Not Found URL', 24 | 'Last Referrer URL', 25 | 'Remote IP', 26 | 'Hits', 27 | 'Last Hit', 28 | 'Handled', 29 | 'Search for:', 30 | 'Reset', 31 | 'Displaying', 32 | 'to', 33 | 'of', 34 | 'items', 35 | 'Per-page:', 36 | 'Delete', 37 | 'statistic', 38 | 'statistics', 39 | ]) %} 40 | 41 | {% block contextMenu %} 42 | {% include "retour/_includes/sites-menu.twig" %} 43 | {% endblock %} 44 | 45 | {% block actionButton %} 46 |
47 |
48 | {{ "Export CSV File"|t("retour") }} 50 |
51 | {{ "Clear Stats"|t("retour") }} 53 |
54 | {% endblock %} 55 | 56 | {% block content %} 57 |
58 |
59 | {% if showWelcome %} 60 | 61 | 62 |
63 | 65 |

Thanks for using Retour!

66 |

67 | Retour allows you to intelligently redirect legacy URLs, so that you don't lose SEO value when 68 | rebuilding & restructuring a website. 69 |

70 |

71 | Retour was entirely rewritten for Craft CMS, and was designed to be performant. 72 |

73 |

We hope you love it! For more information, please see the documentation. 75 |

76 |
77 | {% endif %} 78 |
79 |
80 |
81 |
82 | 90 | 91 |
92 |
93 |
94 |
95 | 103 | 104 |
105 |
106 |
107 |
108 | 116 | 117 |
118 |
119 |
120 | 125 | 126 | 127 |
128 | {% endblock %} 129 | 130 | {% block foot %} 131 | {# include our JavaScript modules #} 132 | {{ parent() }} 133 | {% set tagOptions = { 134 | 'depends': [ 135 | 'nystudio107\\retour\\assetbundles\\retour\\RetourAsset' 136 | ], 137 | } %} 138 | {{ craft.retour.register('src/js/Dashboard.js', false, tagOptions, tagOptions) }} 139 | {% endblock %} 140 | -------------------------------------------------------------------------------- /src/helpers/MultiSite.php: -------------------------------------------------------------------------------- 1 | Craft::t( 41 | 'retour', 42 | 'All Sites' 43 | ), 44 | ]; 45 | // Enabled sites 46 | $sites = Craft::$app->getSites(); 47 | if (Craft::$app->getIsMultiSite()) { 48 | $editableSites = $sites->getEditableSiteIds(); 49 | foreach ($sites->getAllGroups() as $group) { 50 | $groupSites = $sites->getSitesByGroupId($group->id); 51 | $variables['sitesMenu'][$group->name] 52 | = ['optgroup' => $group->name]; 53 | foreach ($groupSites as $groupSite) { 54 | if (in_array($groupSite->id, $editableSites, false)) { 55 | $variables['sitesMenu'][$groupSite->id] = $groupSite->name; 56 | } 57 | } 58 | } 59 | } 60 | } 61 | 62 | /** 63 | * @param string $siteHandle 64 | * @param $siteId 65 | * @param $variables 66 | * 67 | * @throws ForbiddenHttpException 68 | */ 69 | public static function setMultiSiteVariables($siteHandle, &$siteId, array &$variables): void 70 | { 71 | // Enabled sites 72 | $sites = Craft::$app->getSites(); 73 | if (Craft::$app->getIsMultiSite()) { 74 | // Set defaults based on the section settings 75 | $variables['enabledSiteIds'] = []; 76 | $variables['siteIds'] = []; 77 | 78 | foreach ($sites->getEditableSiteIds() as $editableSiteId) { 79 | $variables['enabledSiteIds'][] = $editableSiteId; 80 | $variables['siteIds'][] = $editableSiteId; 81 | } 82 | 83 | // Make sure the $siteId they are trying to edit is in our array of editable sites 84 | if (!in_array($siteId, $variables['enabledSiteIds'], false)) { 85 | if (!empty($variables['enabledSiteIds'])) { 86 | if ($siteId !== 0) { 87 | $siteId = reset($variables['enabledSiteIds']); 88 | } 89 | } else { 90 | self::requirePermission('editSite:' . $siteId); 91 | } 92 | } 93 | } 94 | // Set the currentSiteId and currentSiteHandle 95 | $variables['currentSiteId'] = empty($siteId) ? 0 : $siteId; 96 | $variables['currentSiteHandle'] = empty($siteHandle) 97 | ? Craft::$app->getSites()->currentSite->handle 98 | : $siteHandle; 99 | 100 | // Page title 101 | $variables['showSites'] = ( 102 | Craft::$app->getIsMultiSite() && 103 | count($variables['enabledSiteIds']) 104 | ); 105 | 106 | if ($variables['showSites']) { 107 | if ($variables['currentSiteId'] === 0) { 108 | $variables['sitesMenuLabel'] = Craft::t( 109 | 'retour', 110 | 'All Sites' 111 | ); 112 | } else { 113 | $variables['sitesMenuLabel'] = Craft::t( 114 | 'site', 115 | $sites->getSiteById((int)$variables['currentSiteId'])->name 116 | ); 117 | } 118 | } else { 119 | $variables['currentSiteId'] = 0; 120 | $variables['sitesMenuLabel'] = ''; 121 | } 122 | } 123 | 124 | /** 125 | * @param string $permissionName 126 | * 127 | * @throws ForbiddenHttpException 128 | */ 129 | public static function requirePermission(string $permissionName): void 130 | { 131 | if (!Craft::$app->getUser()->checkPermission($permissionName)) { 132 | throw new ForbiddenHttpException('User is not permitted to perform this action'); 133 | } 134 | } 135 | 136 | /** 137 | * Return a siteId from a siteHandle 138 | * 139 | * @param ?string $siteHandle 140 | * 141 | * @return int|null 142 | * @throws NotFoundHttpException 143 | */ 144 | public static function getSiteIdFromHandle(?string $siteHandle): ?int 145 | { 146 | // Get the site to edit 147 | if ($siteHandle !== null) { 148 | $site = Craft::$app->getSites()->getSiteByHandle($siteHandle); 149 | if (!$site) { 150 | throw new NotFoundHttpException('Invalid site handle: ' . $siteHandle); 151 | } 152 | $siteId = $site->id; 153 | } else { 154 | $siteId = 0; 155 | } 156 | 157 | return $siteId; 158 | } 159 | 160 | // Protected Static Methods 161 | // ========================================================================= 162 | } 163 | -------------------------------------------------------------------------------- /src/helpers/UrlHelper.php: -------------------------------------------------------------------------------- 1 | getSites()->getAllSites(); 89 | foreach ($sites as $site) { 90 | $sitePath = parse_url($site->baseUrl, PHP_URL_PATH); 91 | if (!empty($sitePath)) { 92 | // Normalizes a URI path by trimming leading/ trailing slashes and removing double slashes 93 | $sitePath = '/' . preg_replace('/\/\/+/', '/', trim($sitePath, '/')); 94 | } 95 | // See if the path begins with a site path prefix 96 | if ($sitePath !== '/' && str_starts_with($path, $sitePath)) { 97 | return true; 98 | } 99 | } 100 | 101 | return false; 102 | } 103 | 104 | /** 105 | * Merge the $url and $path together, combining any overlapping path segments 106 | * 107 | * @param ?string $url 108 | * @param ?string $path 109 | * @return string 110 | */ 111 | public static function mergeUrlWithPath(?string $url, ?string $path): string 112 | { 113 | $overlap = 0; 114 | $url = $url ?? ''; 115 | $path = $path ?? ''; 116 | $url = rtrim($url, '/') . '/'; 117 | $path = '/' . ltrim($path, '/'); 118 | $urlOffset = strlen($url); 119 | $pathLength = strlen($path); 120 | $pathOffset = 0; 121 | while ($urlOffset > 0 && $pathOffset < $pathLength) { 122 | $urlOffset--; 123 | $pathOffset++; 124 | if (str_starts_with($path, substr($url, $urlOffset, $pathOffset))) { 125 | $overlap = $pathOffset; 126 | } 127 | } 128 | 129 | return rtrim($url, '/') . '/' . ltrim(substr($path, $overlap), '/'); 130 | } 131 | 132 | /** 133 | * Return a sanitized URL 134 | * 135 | * @param string $url 136 | * 137 | * @return string 138 | */ 139 | public static function sanitizeUrl(string $url): string 140 | { 141 | $originalUrl = $url; 142 | // HTML decode the entities, then strip out any tags 143 | $url = html_entity_decode($url, ENT_NOQUOTES, 'UTF-8'); 144 | $url = urldecode($url); 145 | $decodedUrl = $url; 146 | $url = strip_tags($url); 147 | // Remove any Twig tags that somehow are present in the incoming URL 148 | /** @noinspection CallableParameterUseCaseInTypeContextInspection */ 149 | $url = preg_replace('/{.*}/', '', $url); 150 | // Remove any linebreaks that may be errantly in the URL 151 | $url = (string)str_replace([ 152 | PHP_EOL, 153 | "\r", 154 | "\n", 155 | ] 156 | , '', $url 157 | ); 158 | // If the URL didn't have anything stripped from it, us the original encoded URL 159 | if ($url === $decodedUrl) { 160 | $url = $originalUrl; 161 | } 162 | return $url; 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/models/Redirects.php: -------------------------------------------------------------------------------- 1 | redirectSrcUrl, 'http') === 0) { 100 | $this->redirectSrcMatch = 'fullurl'; 101 | } 102 | } 103 | 104 | /** 105 | * @inheritdoc 106 | */ 107 | public function rules(): array 108 | { 109 | return [ 110 | ['id', 'integer'], 111 | ['siteId', 'integer'], 112 | ['siteId', 'default', 'value' => null], 113 | ['associatedElementId', 'default', 'value' => 0], 114 | ['associatedElementId', 'integer'], 115 | ['enabled', 'boolean'], 116 | ['redirectSrcMatch', 'string'], 117 | ['redirectSrcMatch', 'in', 'range' => ['pathonly', 'fullurl']], 118 | ['redirectSrcMatch', 'default', 'value' => 'pathonly'], 119 | ['redirectSrcMatch', DbStringValidator::class, 'max' => 32], 120 | ['redirectMatchType', 'string'], 121 | ['redirectMatchType', 'in', 'range' => ['exactmatch', 'regexmatch']], 122 | ['redirectMatchType', 'default', 'value' => 'exactmatch'], 123 | ['redirectMatchType', DbStringValidator::class, 'max' => 32], 124 | [ 125 | [ 126 | 'redirectSrcUrl', 127 | 'redirectSrcUrlParsed', 128 | 'redirectDestUrl', 129 | ], 130 | 'default', 131 | 'value' => '', 132 | ], 133 | ['redirectSrcUrlParsed', ParsedUriValidator::class, 'source' => 'redirectSrcUrl'], 134 | [ 135 | [ 136 | 'redirectSrcUrl', 137 | 'redirectSrcUrlParsed', 138 | 'redirectDestUrl', 139 | ], 140 | UriValidator::class, 141 | ], 142 | [ 143 | [ 144 | 'redirectSrcUrl', 145 | 'redirectSrcUrlParsed', 146 | 'redirectMatchType', 147 | 'redirectDestUrl', 148 | ], 149 | DbStringValidator::class, 150 | 'max' => 255, 151 | ], 152 | [ 153 | [ 154 | 'redirectSrcUrl', 155 | 'redirectSrcUrlParsed', 156 | 'redirectDestUrl', 157 | ], 158 | 'string', 159 | ], 160 | ['redirectHttpCode', 'integer'], 161 | ['redirectHttpCode', 'default', 'value' => 301], 162 | ['redirectHttpCode', 'in', 'range' => [301, 302, 307, 308, 410]], 163 | ['hitCount', 'default', 'value' => 0], 164 | ['hitCount', 'integer'], 165 | ['hitLastTime', 'safe'], 166 | ]; 167 | } 168 | 169 | /** 170 | * @return array 171 | */ 172 | public function behaviors(): array 173 | { 174 | return [ 175 | 'typecast' => [ 176 | 'class' => AttributeTypecastBehavior::class, 177 | // 'attributeTypes' will be composed automatically according to `rules()` 178 | ], 179 | ]; 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /src/controllers/ChartsController.php: -------------------------------------------------------------------------------- 1 | getDb(); 72 | if ($db->getIsMysql()) { 73 | // Query the db 74 | $query = (new Query()) 75 | ->from('{{%retour_stats}}') 76 | ->select([ 77 | "DATE_FORMAT(hitLastTime, '%Y-%m-%d') AS date_formatted", 78 | 'COUNT(redirectSrcUrl) AS cnt', 79 | 'COUNT(handledByRetour = 1 or null) as handled_cnt', 80 | ]) 81 | ->where("hitLastTime >= ( CURDATE() - INTERVAL '{$days}' DAY )"); 82 | if ((int)$siteId !== 0) { 83 | $query->andWhere(['siteId' => $siteId]); 84 | } 85 | $query 86 | ->orderBy('date_formatted ASC') 87 | ->groupBy('date_formatted'); 88 | $stats = $query->all(); 89 | } 90 | if ($db->getIsPgsql()) { 91 | // Query the db 92 | $query = (new Query()) 93 | ->from('{{%retour_stats}}') 94 | ->select([ 95 | "to_char(\"hitLastTime\", 'yyyy-mm-dd') AS date_formatted", 96 | "COUNT(\"redirectSrcUrl\") AS cnt", 97 | "COUNT(CASE WHEN \"handledByRetour\" = true THEN 1 END) as handled_cnt", 98 | ]) 99 | ->where("\"hitLastTime\" >= ( CURRENT_TIMESTAMP - INTERVAL '{$days} days' )"); 100 | if ((int)$siteId !== 0) { 101 | $query->andWhere(['siteId' => $siteId]); 102 | } 103 | $query 104 | ->orderBy('date_formatted ASC') 105 | ->groupBy('date_formatted'); 106 | $stats = $query->all(); 107 | } 108 | if ($stats) { 109 | $data[] = [ 110 | 'name' => '404 hits', 111 | 'data' => array_merge(['0'], ArrayHelper::getColumn($stats, 'cnt')), 112 | 'labels' => array_merge(['-'], ArrayHelper::getColumn($stats, 'date_formatted')), 113 | ]; 114 | $data[] = [ 115 | 'name' => 'Handled 404 hits', 116 | 'data' => array_merge(['0'], ArrayHelper::getColumn($stats, 'handled_cnt')), 117 | 'labels' => array_merge(['-'], ArrayHelper::getColumn($stats, 'date_formatted')), 118 | ]; 119 | } 120 | 121 | return $this->asJson($data); 122 | } 123 | 124 | /** 125 | * The Dashboard chart 126 | * 127 | * @param int $days 128 | * 129 | * @return Response 130 | */ 131 | public function actionWidget(int $days = 1): Response 132 | { 133 | $data = []; 134 | // Different dbs do it different ways 135 | $stats = null; 136 | $handledStats = null; 137 | $db = Craft::$app->getDb(); 138 | if ($db->getIsMysql()) { 139 | // Query the db 140 | $stats = (new Query()) 141 | ->from('{{%retour_stats}}') 142 | ->where("hitLastTime >= ( CURDATE() - INTERVAL '{$days}' DAY )") 143 | ->count(); 144 | $handledStats = (new Query()) 145 | ->from('{{%retour_stats}}') 146 | ->where("hitLastTime >= ( CURDATE() - INTERVAL '{$days}' DAY )") 147 | ->andWhere('handledByRetour is TRUE') 148 | ->count(); 149 | } 150 | if ($db->getIsPgsql()) { 151 | // Query the db 152 | $stats = (new Query()) 153 | ->from('{{%retour_stats}}') 154 | ->where("\"hitLastTime\" >= ( CURRENT_TIMESTAMP - INTERVAL '{$days} days' )") 155 | ->count(); 156 | $handledStats = (new Query()) 157 | ->from('{{%retour_stats}}') 158 | ->where("\"hitLastTime\" >= ( CURRENT_TIMESTAMP - INTERVAL '{$days} days' )") 159 | ->andWhere('"handledByRetour" = TRUE') 160 | ->count(); 161 | } 162 | if ($stats) { 163 | $data = [ 164 | (int)$stats, 165 | (int)$handledStats, 166 | ]; 167 | } 168 | 169 | return $this->asJson($data); 170 | } 171 | 172 | // Protected Methods 173 | // ========================================================================= 174 | } 175 | -------------------------------------------------------------------------------- /src/models/StaticRedirects.php: -------------------------------------------------------------------------------- 1 | redirectSrcUrl, 'http') === 0) { 105 | $this->redirectSrcMatch = 'fullurl'; 106 | } 107 | } 108 | 109 | /** 110 | * @inheritdoc 111 | */ 112 | public function rules(): array 113 | { 114 | return [ 115 | ['id', 'integer'], 116 | ['siteId', 'integer'], 117 | ['siteId', 'default', 'value' => null], 118 | ['associatedElementId', 'default', 'value' => 0], 119 | ['associatedElementId', 'integer'], 120 | ['enabled', 'boolean'], 121 | ['redirectSrcMatch', 'string'], 122 | ['redirectSrcMatch', 'in', 'range' => ['pathonly', 'fullurl']], 123 | ['redirectSrcMatch', 'default', 'value' => 'pathonly'], 124 | ['redirectSrcMatch', DbStringValidator::class, 'max' => 32], 125 | ['redirectMatchType', 'string'], 126 | ['redirectMatchType', 'in', 'range' => ['exactmatch', 'regexmatch']], 127 | ['redirectMatchType', 'default', 'value' => 'exactmatch'], 128 | ['redirectMatchType', DbStringValidator::class, 'max' => 32], 129 | [ 130 | [ 131 | 'redirectSrcUrl', 132 | 'redirectSrcUrlParsed', 133 | 'redirectDestUrl', 134 | ], 135 | 'default', 136 | 'value' => '', 137 | ], 138 | ['redirectSrcUrlParsed', ParsedUriValidator::class, 'source' => 'redirectSrcUrl'], 139 | [ 140 | [ 141 | 'redirectSrcUrl', 142 | 'redirectSrcUrlParsed', 143 | 'redirectDestUrl', 144 | ], 145 | UriValidator::class, 146 | ], 147 | [ 148 | [ 149 | 'redirectSrcUrl', 150 | 'redirectSrcUrlParsed', 151 | 'redirectDestUrl', 152 | ], 153 | DbStringValidator::class, 154 | 'max' => 255, 155 | ], 156 | [ 157 | [ 158 | 'redirectSrcUrl', 159 | 'redirectSrcUrlParsed', 160 | 'redirectDestUrl', 161 | ], 162 | 'string', 163 | ], 164 | ['redirectHttpCode', 'integer'], 165 | ['redirectHttpCode', 'in', 'range' => [301, 302, 307, 308, 410]], 166 | ['redirectHttpCode', 'default', 'value' => 301], 167 | ['priority', 'default', 'value' => 5], 168 | ['priority', 'integer'], 169 | ['priority', 'in', 'range' => [1, 2, 3, 4, 5, 6, 7, 8, 9]], 170 | ['hitCount', 'default', 'value' => 0], 171 | ['hitCount', 'integer'], 172 | ['hitLastTime', 'safe'], 173 | ]; 174 | } 175 | 176 | /** 177 | * @return array 178 | */ 179 | public function behaviors(): array 180 | { 181 | return [ 182 | 'typecast' => [ 183 | 'class' => AttributeTypecastBehavior::class, 184 | // 'attributeTypes' will be composed automatically according to `rules()` 185 | ], 186 | ]; 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /src/controllers/StatisticsController.php: -------------------------------------------------------------------------------- 1 | statistics->trimStatistics(); 63 | // Get the site to edit 64 | $siteId = MultiSiteHelper::getSiteIdFromHandle($siteHandle); 65 | $pluginName = Retour::$settings->pluginName; 66 | $templateTitle = Craft::t('retour', 'Dashboard'); 67 | $view = Craft::$app->getView(); 68 | // Asset bundle 69 | try { 70 | $view->registerAssetBundle(RetourDashboardAsset::class); 71 | } catch (InvalidConfigException $e) { 72 | Craft::error($e->getMessage(), __METHOD__); 73 | } 74 | $variables['baseAssetsUrl'] = Craft::$app->assetManager->getPublishedUrl( 75 | '@nystudio107/retour/web/assets/dist', 76 | true 77 | ); 78 | // Enabled sites 79 | MultiSiteHelper::setMultiSiteVariables($siteHandle, $siteId, $variables); 80 | $variables['controllerHandle'] = 'dashboard'; 81 | 82 | // Basic variables 83 | $variables['fullPageForm'] = false; 84 | $variables['docsUrl'] = self::DOCUMENTATION_URL; 85 | $variables['pluginName'] = $pluginName; 86 | $variables['title'] = $templateTitle; 87 | $siteHandleUri = Craft::$app->isMultiSite ? '/' . $siteHandle : ''; 88 | $variables['crumbs'] = [ 89 | [ 90 | 'label' => $pluginName, 91 | 'url' => UrlHelper::cpUrl('retour'), 92 | ], 93 | [ 94 | 'label' => $templateTitle, 95 | 'url' => UrlHelper::cpUrl('retour/dashboard' . $siteHandleUri), 96 | ], 97 | ]; 98 | $variables['docTitle'] = "{$pluginName} - {$templateTitle}"; 99 | $variables['selectedSubnavItem'] = 'dashboard'; 100 | $variables['showWelcome'] = $showWelcome; 101 | $variables['settings'] = Retour::$settings; 102 | 103 | // Render the template 104 | return $this->renderTemplate('retour/dashboard/index', $variables); 105 | } 106 | 107 | /** 108 | * @return Response 109 | * @throws ForbiddenHttpException 110 | */ 111 | public function actionClearStatistics(): Response 112 | { 113 | PermissionHelper::controllerPermissionCheck('retour:dashboard'); 114 | $error = Retour::$plugin->statistics->clearStatistics(); 115 | Craft::info( 116 | Craft::t( 117 | 'retour', 118 | 'Retour statistics cleared: {error}', 119 | ['error' => $error] 120 | ), 121 | __METHOD__ 122 | ); 123 | Retour::$plugin->clearAllCaches(); 124 | try { 125 | Craft::$app->getSession()->setNotice(Craft::t('retour', 'Retour statistics cleared.')); 126 | } catch (MissingComponentException $e) { 127 | Craft::error($e->getMessage(), __METHOD__); 128 | } 129 | 130 | return $this->redirect('retour/dashboard'); 131 | } 132 | 133 | /** 134 | * @return ?Response 135 | * @throws MissingComponentException 136 | * @throws BadRequestHttpException 137 | * @throws ForbiddenHttpException 138 | */ 139 | public function actionDeleteStatistics(): ?Response 140 | { 141 | PermissionHelper::controllerPermissionCheck('retour:dashboard'); 142 | $request = Craft::$app->getRequest(); 143 | $statisticIds = $request->getRequiredBodyParam('statisticIds'); 144 | $stickyError = false; 145 | foreach ($statisticIds as $statisticId) { 146 | if (Retour::$plugin->statistics->deleteStatisticById($statisticId) === 0) { 147 | $stickyError = true; 148 | } 149 | } 150 | Craft::info( 151 | Craft::t( 152 | 'retour', 153 | 'Retour statistics deleted: {error}', 154 | ['error' => $stickyError] 155 | ), 156 | __METHOD__ 157 | ); 158 | 159 | Retour::$plugin->clearAllCaches(); 160 | // Handle any cumulative errors 161 | if (!$stickyError) { 162 | // Clear the caches and continue on 163 | Craft::$app->getSession()->setNotice(Craft::t('retour', 'Retour statistics deleted.')); 164 | 165 | return $this->redirect('retour/dashboard'); 166 | } 167 | Craft::$app->getSession()->setError(Craft::t('retour', "Couldn't delete statistic.")); 168 | 169 | return null; 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/fields/ShortLink.php: -------------------------------------------------------------------------------- 1 | getView()->renderTemplate( 81 | 'retour/_components/fields/ShortLink_input', 82 | [ 83 | 'name' => $this->handle, 84 | 'value' => $value, 85 | 'field' => $this, 86 | ] 87 | ); 88 | } 89 | 90 | /** 91 | * @inheritdoc 92 | */ 93 | public function getSettingsHtml(): string 94 | { 95 | return Craft::$app->getView()->renderTemplate('retour/_components/fields/ShortLink_settings', 96 | [ 97 | 'field' => $this, 98 | ]); 99 | } 100 | 101 | /** 102 | * @inheritdoc 103 | */ 104 | public function getPreviewHtml($value, ElementInterface $element): string 105 | { 106 | $decoded = Json::decodeIfJson($value); 107 | if (is_array($decoded)) { 108 | $value = $decoded['legacyUrl'] ?? ''; 109 | } 110 | // Render the preview template 111 | return Craft::$app->getView()->renderTemplate( 112 | 'retour/_components/fields/ShortLink_preview', 113 | [ 114 | 'name' => $this->handle, 115 | 'value' => $value, 116 | 'field' => $this, 117 | ] 118 | ); 119 | } 120 | 121 | /** 122 | * @inheritdoc 123 | */ 124 | public function afterElementSave(ElementInterface $element, bool $isNew): void 125 | { 126 | if (!self::$allowShortLinkUpdates || $element->getIsDraft() || !$element->getSite()->hasUrls) { 127 | return; 128 | } 129 | 130 | $value = $element->getFieldValue($this->handle); 131 | // Return for propagating elements 132 | if ($this->redirectSrcMatch === 'pathonly') { 133 | $parentElement = ElementHelper::rootElement($element); 134 | if ($this->translationMethod === Field::TRANSLATION_METHOD_NONE && ($element->propagating || $parentElement->propagating)) { 135 | return; 136 | } 137 | } elseif (!empty($value) && !StringHelper::startsWith($value, 'http')) { 138 | $value = UrlHelper::siteUrl($value, null, null, $element->siteId); 139 | } 140 | 141 | $parentElement = ElementHelper::rootElement($element); 142 | RetourPlugin::$plugin->redirects->removeElementRedirect($parentElement, false); 143 | 144 | if (!empty($value)) { 145 | $redirectSrcMatch = $this->redirectSrcMatch; 146 | 147 | RetourPlugin::$plugin->redirects->enableElementRedirect($parentElement, $value, $redirectSrcMatch, $this->redirectHttpCode); 148 | } 149 | 150 | parent::afterElementSave($element, $isNew); 151 | } 152 | 153 | /** 154 | * @inheritdoc 155 | */ 156 | public function afterElementDelete(ElementInterface $element): void 157 | { 158 | if (!$element->getIsCanonical()) { 159 | return; 160 | } 161 | 162 | RetourPlugin::$plugin->redirects->removeElementRedirect($element, true, true); 163 | parent::afterElementDelete($element); 164 | } 165 | 166 | /** 167 | * @inheritdoc 168 | */ 169 | public function getElementValidationRules(): array 170 | { 171 | return [ 172 | [ 173 | function(ElementInterface $element) { 174 | $value = $element->getFieldValue($this->handle); 175 | $redirect = RetourPlugin::$plugin->getRedirects()->getRedirectByRedirectSrcUrl($value); 176 | // Handle drafts 177 | $element = $element->getCanonical(); 178 | if ($redirect && isset($redirect['associatedElementId'])) { 179 | if ($redirect['associatedElementId'] == 0) { 180 | $element->addError($this->handle, Craft::t('retour', 'A Retour redirect with this Legacy URL already exists.')); 181 | } elseif ($redirect['associatedElementId'] !== $element->id) { 182 | $element->addError($this->handle, Craft::t('retour', 'A Short Link with this URL already exists.')); 183 | } 184 | } 185 | }, 186 | ], 187 | ]; 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /src/web/assets/dist/assets/retour-exeLeENM.css: -------------------------------------------------------------------------------- 1 | /*! tailwindcss v4.1.12 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-leading:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial}}}:root,:host{--color-gray-600:oklch(44.6% .03 256.802);--spacing:.25rem;--leading-tight:1.25}.visible{visibility:visible}.fixed{position:fixed}.relative{position:relative}.float-right{float:right}.container{width:100%}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.mx-2{margin-inline:calc(var(--spacing)*2)}.mr-2{margin-right:calc(var(--spacing)*2)}.mb-4{margin-bottom:calc(var(--spacing)*4)}.ml-2{margin-left:calc(var(--spacing)*2)}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-block{display:inline-block}.table{display:table}.w-full{width:100%}.flex-shrink{flex-shrink:1}.flex-grow{flex-grow:1}.transform{transform:var(--tw-rotate-x,)var(--tw-rotate-y,)var(--tw-rotate-z,)var(--tw-skew-x,)var(--tw-skew-y,)}.items-start{align-items:flex-start}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.border-solid{--tw-border-style:solid;border-style:solid}.py-3{padding-block:calc(var(--spacing)*3)}.py-4{padding-block:calc(var(--spacing)*4)}.py-5{padding-block:calc(var(--spacing)*5)}.pt-3{padding-top:calc(var(--spacing)*3)}.pl-3{padding-left:calc(var(--spacing)*3)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-bottom{vertical-align:bottom}.leading-tight{--tw-leading:var(--leading-tight);line-height:var(--leading-tight)}.text-gray-600{color:var(--color-gray-600)}.shadow{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a),0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.filter{filter:var(--tw-blur,)var(--tw-brightness,)var(--tw-contrast,)var(--tw-grayscale,)var(--tw-hue-rotate,)var(--tw-invert,)var(--tw-saturate,)var(--tw-sepia,)var(--tw-drop-shadow,)}div.retour-button-container{margin-right:10px;display:inline-block}table.vuetable{table-layout:fixed;width:100%;overflow:hidden}.retour-menubtn-asc:before{content:"upangle";font-weight:700}.retour-menubtn-desc:before{content:"downangle";font-weight:700}table.retour-dashboard th.vuetable-th-checkbox-id{width:3%!important}table.retour-dashboard th.vuetable-th-redirectSrcUrl{width:34%!important}th.vuetable-th-referrerUrl{width:20%!important}th.vuetable-th-remoteIp{width:14%!important}th.vuetable-th-hitCount{text-align:right!important;width:8%!important}th.vuetable-th-hitLastTime{width:16%!important}th.vuetable-th-handledByRetour{width:12%!important}th.vuetable-th-addLink,table.retour-redirects th.vuetable-th-checkbox-id{width:3%!important}table.retour-redirects th.vuetable-th-redirectSrcUrl{width:28%!important}th.vuetable-th-redirectDestUrl{width:22%!important}th.vuetable-th-redirectMatchType{width:10%!important}th.vuetable-th-priority{text-align:right!important;width:8%!important}th.vuetable-th-siteName{width:10%!important}th.vuetable-th-redirectHttpCode{width:7%!important}td.text-center,th.text-center{text-align:center!important}td.text-right,th.text-right{text-align:right!important}.retour-import-list-group-item{cursor:move;background-color:#fff;border:1px solid #ddd;margin-bottom:-1px;padding:10px 15px;display:block;position:relative}.retour-import-field-group-item{cursor:default;background-color:#fff;border:1px solid #ddd;margin-bottom:-1px;padding:10px 15px;display:block;position:relative}.retour-import-arrow-item{padding:11px 15px;display:block;position:relative}.retour-import-drag-area{min-height:100px}.retour-empty-item{background:repeating-linear-gradient(-55deg,#ddd,#ddd 10px,#eee 10px,#eee 20px)}.retour-inputfile{opacity:0;z-index:-1;width:.1px;height:.1px;position:absolute;overflow:hidden}.retour-reset:before{padding-bottom:4px}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-leading{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false} 2 | -------------------------------------------------------------------------------- /src/models/Settings.php: -------------------------------------------------------------------------------- 1 | 'Retour'], 157 | [ 158 | [ 159 | 'createUriChangeRedirects', 160 | 'alwaysStripQueryString', 161 | 'preserveQueryString', 162 | 'stripQueryStringFromStats', 163 | 'recordRemoteIp', 164 | 'enableApiEndpoint', 165 | 'resolveCraftSites', 166 | ], 167 | 'boolean', 168 | ], 169 | ['uriChangeRedirectSrcMatch', 'default', 'value' => 'pathonly'], 170 | ['uriChangeRedirectSrcMatch', 'string'], 171 | ['uriChangeRedirectSrcMatch', 'in', 'range' => [ 172 | 'pathonly', 173 | 'fullurl', 174 | ]], 175 | ['staticRedirectDisplayLimit', 'integer', 'min' => 1], 176 | ['staticRedirectDisplayLimit', 'default', 'value' => 100], 177 | ['dynamicRedirectDisplayLimit', 'integer', 'min' => 1], 178 | ['dynamicRedirectDisplayLimit', 'default', 'value' => 100], 179 | ['statsStoredLimit', 'integer', 'min' => 1], 180 | ['statsStoredLimit', 'default', 'value' => 1000], 181 | ['refreshIntervalSecs', 'integer', 'min' => 0], 182 | ['refreshIntervalSecs', 'default', 'value' => 3], 183 | ['statsDisplayLimit', 'integer', 'min' => 1], 184 | ['statsDisplayLimit', 'default', 'value' => 1000], 185 | [ 186 | [ 187 | 'excludePatterns', 188 | 'additionalHeaders', 189 | ], 190 | ArrayValidator::class, 191 | ], 192 | ]; 193 | } 194 | 195 | /** 196 | * @return array 197 | */ 198 | public function behaviors(): array 199 | { 200 | return [ 201 | 'parser' => [ 202 | 'class' => EnvAttributeParserBehavior::class, 203 | 'attributes' => [ 204 | ], 205 | ], 206 | ]; 207 | } 208 | } 209 | -------------------------------------------------------------------------------- /src/web/assets/dist/assets/shortlinks-Jev4OoWa.js: -------------------------------------------------------------------------------- 1 | import{s as c,V as d,a as u,b as f,c as h,p,d as m}from"./purify.es-DP_WP6YH.js";import{L as g}from"./LegacyUrl-CeiszSHN.js";import{n as l}from"./_plugin-vue2_normalizer-BFq-tfv7.js";const v=[{name:"__checkbox",titleClass:"center aligned",dataClass:"center aligned"},{name:"__component:legacy-url",sortField:"redirectSrcUrl",title:Craft.t("retour","Short Link"),titleClass:"center",dataClass:"center"},{name:"__component:element-url",sortField:"elementTitle",title:Craft.t("retour","Redirect To"),titleClass:"center",dataClass:"center"},{name:"redirectMatchType",sortField:"redirectMatchType",title:Craft.t("retour","Match Type"),titleClass:"text-left",dataClass:"text-left",callback:"matchFormatter"},{name:"siteName",sortField:"siteId",title:Craft.t("retour","Sites"),titleClass:"text-left",dataClass:"text-left"},{name:"redirectHttpCode",sortField:"redirectHttpCode",title:Craft.t("retour","Status"),titleClass:"text-left",dataClass:"text-left"},{name:"hitCount",sortField:"hitCount",title:Craft.t("retour","Hits"),titleClass:"text-right",dataClass:"text-right"},{name:"hitLastTime",sortField:"hitLastTime",title:Craft.t("retour","Last Hit"),titleClass:"center",dataClass:"center"}],b={props:{rowData:{type:Object,required:!0},rowIndex:{type:Number,default:0}},computed:{linkTitle:function(){let a="";return a+=this.rowData.redirectDestUrl,a}}};var C=function(){var e=this,t=e._self._c;return t("div",[t("a",{staticClass:"go",attrs:{href:e.rowData.elementCpUrl,title:e.linkTitle}},[t("span",{staticStyle:{"white-space":"nowrap"}},[t("span"),t("span",{staticStyle:{"white-space":"normal"}},[e._v(e._s(e.rowData.elementTitle))])])])])},_=[],T=l(b,C,_,!1,null,null);const x=T.exports;Vue.component("LegacyUrl",g);Vue.component("ElementUrl",x);const $={components:{vuetable:h,"vuetable-pagination":f,"vuetable-pagination-info":u,"vuetable-filter-bar":d},mixins:[c],props:{siteId:{type:Number,default:0},apiUrl:{type:String,default:""}},data:function(){return{moreParams:{siteId:this.siteId,shortLinks:!0},css:{tableClass:"data fullwidth retour-redirects",ascendingIcon:"icon retour-menubtn-asc",descendingIcon:"icon retour-menubtn-desc"},sortOrder:[{field:"hitCount",sortField:"hitCount",direction:"desc"}],fields:v,numSelected:0,selectedIds:[],filterText:"",perPage:20,stringPerPage:Craft.t("retour","Per-page:"),stringDelete:Craft.t("retour","Delete"),stringRedirect:Craft.t("retour","redirect"),stringRedirects:Craft.t("retour","redirects")}},computed:{csrfTokenName:function(){return window.Craft.csrfTokenName},csrfTokenValue:function(){return window.Craft.csrfTokenValue}},watch:{perPage:function(){this.$events.fire("refresh-table",this.$refs.vuetable)}},mounted(){this.$events.$on("filter-set",a=>this.onFilterSet(a)),this.$events.$on("filter-reset",()=>this.onFilterReset()),this.$refs.vuetable.$on("vuetable:checkbox-toggled",(a,e)=>this.onCheckboxToggled(a,e)),this.$refs.vuetable.$on("vuetable:checkbox-toggled-all",a=>this.onCheckboxToggled(a,null))},methods:{getSaveStateConfig(){return{cacheKey:"retour-shortlinks-state-v2-"+Craft.username+Craft.siteId,ignoreProperties:["numSelected","selectedIds","moreParams"]}},onFilterSet(a){this.filterText=a,this.moreParams={filter:this.filterText,siteId:this.siteId,shortLinks:!0},this.$events.fire("refresh-table",this.$refs.vuetable)},onFilterReset(){this.filterText="",this.moreParams={filter:this.filterText,siteId:this.siteId,shortLinks:!0},this.$events.fire("refresh-table",this.$refs.vuetable)},onPaginationData(a){this.$refs.paginationTop.setPaginationData(a),this.$refs.paginationInfoTop.setPaginationData(a),this.$refs.pagination.setPaginationData(a),this.$refs.paginationInfo.setPaginationData(a)},onChangePage(a){this.$refs.vuetable.changePage(a)},onCheckboxToggled(){this.numSelected=0,this.selectedIds=[],this.$refs.vuetable!==void 0&&this.$refs.vuetable.selectedTo!==void 0&&(this.numSelected=this.$refs.vuetable.selectedTo.length,this.selectedIds=this.$refs.vuetable.selectedTo)},matchFormatter(a){let e="Pluing Match";switch(a){case"exactmatch":e="Exact Match";break;case"regexmatch":e="RegEx Match";break}return e},urlFormatter(a){if(a==="")return"";a=p.sanitize(a),a=encodeURI(a);let e=a;return!new RegExp("^(?:[a-z]+:)?//","i").test(e)&&!e.includes("$")&&(e=Craft.getSiteUrl(e)),` 2 | ${a} 3 | `},deleteRedirectFormatter(a){return a===""?"":` 4 | 5 | `}}};var P=function(){var e=this,t=e._self._c;return t("div",[t("div",{directives:[{name:"show",rawName:"v-show",value:e.numSelected!==0,expression:"numSelected !== 0"}]},[t("form",{attrs:{"accept-charset":"UTF-8",method:"post"}},[t("input",{attrs:{name:e.csrfTokenName,type:"hidden"},domProps:{value:e.csrfTokenValue}}),e._l(e.selectedIds,function(r){return t("input",{key:r,attrs:{name:"redirectIds[]",type:"hidden"},domProps:{value:r}})}),t("label",{staticClass:"text-gray-600"},[e._v(e._s(e.numSelected)+" "),e.numSelected===1?t("span",[e._v(e._s(e.stringRedirect))]):e._e(),e.numSelected!==1?t("span",[e._v(e._s(e.stringRedirects))]):e._e(),e._v(":")]),t("div",{staticClass:"btngroup inline"},[t("div",{staticClass:"ml-2 btn menubtn",attrs:{"data-icon":"settings"}}),t("div",{staticClass:"menu",attrs:{"data-align":"right"}},[t("ul",[t("li",[t("a",{staticClass:"formsubmit",attrs:{"data-action":"retour/redirects/delete-shortlinks"}},[e._v(e._s(e.stringDelete))])])])])])],2)]),t("vuetable-filter-bar",{directives:[{name:"show",rawName:"v-show",value:e.numSelected===0,expression:"numSelected === 0"}],attrs:{"initial-filter-text":e.filterText}}),t("div",{staticClass:"vuetable-pagination clearafter"},[t("vuetable-pagination-info",{ref:"paginationInfoTop"}),t("div",{staticClass:"floated left vuetable-pagination-info py-3"},[t("div",{staticClass:"inline pl-3 text-gray-600"},[e._v(" "+e._s(e.stringPerPage)+" ")]),t("div",{staticClass:"inline pl-3 text-gray-600"},[t("div",{staticClass:"select"},[t("select",{directives:[{name:"model",rawName:"v-model",value:e.perPage,expression:"perPage"}],staticClass:"fieldtoggle",attrs:{"data-target-prefix":"per-page-",name:"perPage"},on:{change:function(r){var n=Array.prototype.filter.call(r.target.options,function(s){return s.selected}).map(function(s){var o="_value"in s?s._value:s.value;return o});e.perPage=r.target.multiple?n:n[0]}}},[t("option",{attrs:{selected:"",value:"20"}},[e._v(" 20 ")]),t("option",{attrs:{value:"50"}},[e._v(" 50 ")]),t("option",{attrs:{value:"100"}},[e._v(" 100 ")]),t("option",{attrs:{value:"500"}},[e._v(" 500 ")])])])])]),t("vuetable-pagination",{ref:"paginationTop",on:{"vuetable-pagination:change-page":e.onChangePage}})],1),t("vuetable",{ref:"vuetable",attrs:{"api-url":e.apiUrl,"append-params":e.moreParams,css:e.css,fields:e.fields,"per-page":e.perPage,"sort-order":e.sortOrder},on:{"vuetable:pagination-data":e.onPaginationData}}),t("div",{staticClass:"vuetable-pagination clearafter border-solid"},[t("vuetable-pagination-info",{ref:"paginationInfo"}),t("vuetable-pagination",{ref:"pagination",on:{"vuetable-pagination:change-page":e.onChangePage}})],1)],1)},k=[],w=l($,P,k,!1,null,null);const y=w.exports,i=window.Vue;i.use(m);new i({el:"#cp-nav-content",components:{"shortlinks-table":y},mounted(){this.$events.$on("refresh-table",a=>this.onTableRefresh(a))},methods:{onTableRefresh(a){i.nextTick(()=>a.refresh())}}}); 6 | //# sourceMappingURL=shortlinks-Jev4OoWa.js.map 7 | --------------------------------------------------------------------------------