├── resources └── img │ ├── heart.png │ └── plugin-logo.png ├── .php-cs-fixer.dist.php ├── src ├── config.php ├── console │ └── controllers │ │ └── MapsController.php ├── icon.svg ├── icon-mask.svg ├── services │ ├── EventHandler.php │ ├── SiteHelper.php │ └── RedirectsMaps.php └── HeRe.php ├── .gitignore ├── phpstan.neon ├── CHANGELOG.md ├── ecs.php ├── LICENSE.md ├── composer.json └── README.md /resources/img/heart.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fork/craft-here/main/resources/img/heart.png -------------------------------------------------------------------------------- /resources/img/plugin-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fork/craft-here/main/resources/img/plugin-logo.png -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | setRules([ 5 | '@PSR12' => true, 6 | 'array_syntax' => ['syntax' => 'short'], 7 | 'no_unused_imports' => true, 8 | ]) 9 | ; 10 | -------------------------------------------------------------------------------- /src/config.php: -------------------------------------------------------------------------------- 1 | [ 6 | 'serverType' => 'nginx', // or 'apache' 7 | ], 8 | 9 | // Dev environment settings 10 | 'dev' => [ 11 | //'redirectsReloadCommand' => 'my-command', 12 | ], 13 | 14 | // Staging environment settings 15 | 'staging' => [ 16 | ], 17 | 18 | // Production environment settings 19 | 'production' => [ 20 | //'redirectsReloadCommand' => 'sudo /etc/init.d/nginx reload', 21 | ], 22 | ]; 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # CRAFT ENVIRONMENT 2 | .env.php 3 | .env.sh 4 | .env 5 | 6 | # COMPOSER 7 | /vendor 8 | /composer.lock 9 | 10 | # BUILD FILES 11 | /bower_components/* 12 | /node_modules/* 13 | /build/* 14 | /yarn-error.log 15 | 16 | # MISC FILES 17 | .cache 18 | .DS_Store 19 | .idea 20 | .project 21 | .settings 22 | *.esproj 23 | *.sublime-workspace 24 | *.sublime-project 25 | *.tmproj 26 | *.tmproject 27 | .vscode/* 28 | !.vscode/settings.json 29 | !.vscode/tasks.json 30 | !.vscode/launch.json 31 | !.vscode/extensions.json 32 | config.codekit3 33 | prepros-6.config 34 | .php-cs-fixer.cache 35 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - vendor/craftcms/phpstan/phpstan.neon 3 | 4 | parameters: 5 | level: 5 6 | paths: 7 | - %currentWorkingDirectory% 8 | excludePaths: 9 | - vendor 10 | scanFiles: 11 | - vendor/yiisoft/yii2/Yii.php 12 | - vendor/craftcms/cms/src/Craft.php 13 | - vendor/twig/twig/src/Extension/CoreExtension.php 14 | earlyTerminatingMethodCalls: 15 | Craft: 16 | - dd 17 | yii\base\Application: 18 | - end 19 | yii\base\ErrorHandler: 20 | - convertExceptionToError 21 | reportUnmatchedIgnoredErrors: false 22 | universalObjectCratesClasses: 23 | - craft\elements\Entry 24 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Craft Here Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). 6 | 7 | # 3.0.0 - 2025-02-28 8 | 9 | ### Added 10 | 11 | - Add support for Craft 5, drop support for Craft 4 12 | 13 | # 2.0.1 - 2023-07-13 14 | 15 | ### Fixed 16 | 17 | - Do not remove trailing slashes from request URIs 18 | 19 | # 2.0.0 - 2023-04-28 20 | 21 | ### Added 22 | 23 | - Support Craft 4 24 | 25 | ## 1.0.1 - 2021-02-22 26 | 27 | ### Fixed 28 | 29 | - Apache format (remove double quotes) 30 | 31 | ## 1.0.0 - 2020-12-23 32 | 33 | ### Added 34 | 35 | - Initial release 36 | -------------------------------------------------------------------------------- /ecs.php: -------------------------------------------------------------------------------- 1 | paths($paths); 16 | $ecsConfig->parallel(); 17 | $ecsConfig->sets([SetList::CRAFT_CMS_4]); 18 | 19 | $ecsConfig->rule(ArrayIndentationFixer::class); 20 | $ecsConfig->rule(MethodChainingIndentationFixer::class); 21 | $ecsConfig->ruleWithConfiguration(FunctionDeclarationFixer::class, [ 22 | 'closure_function_spacing' => FunctionDeclarationFixer::SPACING_ONE, 23 | ]); 24 | }; 25 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Fork Unstable Media GmbH 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /src/console/controllers/MapsController.php: -------------------------------------------------------------------------------- 1 | redirectsMaps->recreateMaps(); 30 | $this->stdout('✅ Re-/created redirects maps.' . PHP_EOL, BaseConsole::FG_GREEN); 31 | 32 | return ExitCode::OK; 33 | } 34 | 35 | /** 36 | * Deletes all existing redirects maps. 37 | * 38 | * @return int 39 | * 40 | * @throws Throwable 41 | */ 42 | public function actionClear(): int 43 | { 44 | HeRe::getInstance()->redirectsMaps->clear(); 45 | $this->stdout('✅ Removed redirects maps.' . PHP_EOL, BaseConsole::FG_GREEN); 46 | 47 | return ExitCode::OK; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | image/svg+xml -------------------------------------------------------------------------------- /src/icon-mask.svg: -------------------------------------------------------------------------------- 1 | 2 | image/svg+xml -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fork/craft-here", 3 | "description": "Use the SEO plugin redirects to write nginx and apache redirect map config files (perfect for headless Craft CMS Setups)", 4 | "type": "craft-plugin", 5 | "version": "3.0.0", 6 | "keywords": [ 7 | "craft", 8 | "cms", 9 | "craftcms", 10 | "craft-plugin", 11 | "redirects", 12 | "redirect", 13 | "headless", 14 | "nginx", 15 | "apache" 16 | ], 17 | "support": { 18 | "docs": "https://github.com/fork/craft-here/blob/master/README.md", 19 | "issues": "https://github.com/fork/craft-here/issues" 20 | }, 21 | "license": "MIT", 22 | "authors": [ 23 | { 24 | "name": "Fork Unstable Media GmbH", 25 | "homepage": "https://www.fork.de/" 26 | } 27 | ], 28 | "require": { 29 | "php": "^8.2", 30 | "craftcms/cms": "^5.0", 31 | "ether/seo": "^5.0" 32 | }, 33 | "autoload": { 34 | "psr-4": { 35 | "fork\\here\\": "src/" 36 | } 37 | }, 38 | "extra": { 39 | "name": "HeRe", 40 | "handle": "here", 41 | "developer": "Fork Unstable Media GmbH", 42 | "developerUrl": "https://www.fork.de/", 43 | "documentationUrl": "https://github.com/fork/craft-here/blob/master/README.md", 44 | "changelogUrl": "https://raw.githubusercontent.com/fork/craft-here/master/CHANGELOG.md", 45 | "class": "fork\\here\\HeRe" 46 | }, 47 | "minimum-stability": "dev", 48 | "prefer-stable": true, 49 | "require-dev": { 50 | "craftcms/ecs": "dev-main", 51 | "craftcms/phpstan": "dev-main", 52 | "craftcms/rector": "dev-main", 53 | "friendsofphp/php-cs-fixer": "^3.64.0", 54 | "phpstan/phpstan": "^1.12.6" 55 | }, 56 | "config": { 57 | "allow-plugins": { 58 | "yiisoft/yii2-composer": true, 59 | "craftcms/plugin-installer": true 60 | } 61 | }, 62 | "scripts": { 63 | "check-cs": "ecs check --ansi", 64 | "fix-cs": "ecs check --ansi --fix", 65 | "phpstan": "phpstan analyze --memory-limit=1G -c phpstan.neon" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/services/EventHandler.php: -------------------------------------------------------------------------------- 1 | redirectsMaps = $redirectsMaps; 32 | 33 | parent::__construct($config); 34 | } 35 | 36 | /** 37 | * Handles the given event (whose origin is a redirect record after being saved or deleted) for triggering the re-/creation of the redirects maps. 38 | * 39 | * @param \yii\base\Event $event 40 | */ 41 | public function handleRedirectRecordEvent(Event $event) 42 | { 43 | if ($event->sender instanceof RedirectRecord and in_array($event->name, [ 44 | RedirectRecord::EVENT_AFTER_INSERT, 45 | RedirectRecord::EVENT_AFTER_UPDATE, 46 | RedirectRecord::EVENT_AFTER_DELETE, 47 | ])) { 48 | $this->redirectsMaps->triggerRecreation(); 49 | } 50 | } 51 | 52 | /** 53 | * Handles the given event (whose origin is a service for handling sites like saving and deleting site records) for triggering the re-/creation of the 54 | * redirects maps. 55 | * 56 | * @param \yii\base\Event $event 57 | */ 58 | public function handleSitesEvent(Event $event) 59 | { 60 | if ($event->sender instanceof Sites and in_array($event->name, [ 61 | Sites::EVENT_AFTER_SAVE_SITE, 62 | Sites::EVENT_AFTER_DELETE_SITE, 63 | ])) { 64 | $this->redirectsMaps->triggerRecreation(); 65 | } 66 | } 67 | 68 | /** 69 | * Handles the given event (whose origin is the application at the end of processing the request) for re-/creating the redirects maps. 70 | * 71 | * @param \yii\base\Event $event 72 | * 73 | * @throws \yii\base\Exception 74 | */ 75 | public function handleApplicationEvent(Event $event) 76 | { 77 | if ($event->sender instanceof Application and $event->name == Application::EVENT_AFTER_REQUEST) { 78 | $this->redirectsMaps->recreateMapsIfTriggered(); 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/services/SiteHelper.php: -------------------------------------------------------------------------------- 1 | siteHosts[$siteId])) { 47 | $this->siteHosts[$siteId] = $this->getUrlInfoForSite($siteId, PHP_URL_HOST); 48 | } 49 | 50 | return $this->siteHosts[$siteId]; 51 | } 52 | 53 | /** 54 | * Returns the URI path for given site ID. 55 | * 56 | * @param int $siteId 57 | * 58 | * @return string 59 | */ 60 | public function getSitePath(int $siteId): string 61 | { 62 | if (empty($this->sitePaths[$siteId])) { 63 | $path = trim($this->getUrlInfoForSite($siteId, PHP_URL_PATH), '/'); 64 | $this->sitePaths[$siteId] = !empty($path) ? '/' . $path . '/' : $path; 65 | } 66 | 67 | return $this->sitePaths[$siteId]; 68 | } 69 | 70 | /** 71 | * Returns a single part from the site's URL with given ID. 72 | * 73 | * @param int $siteId 74 | * @param int $urlPart one of the `PHP_URL_*` constants 75 | * 76 | * @return string 77 | * 78 | * @see PHP_URL_SCHEME 79 | * @see PHP_URL_HOST 80 | * @see PHP_URL_PORT 81 | * @see PHP_URL_USER 82 | * @see PHP_URL_PASS 83 | * @see PHP_URL_PATH 84 | * @see PHP_URL_QUERY 85 | * @see PHP_URL_FRAGMENT 86 | */ 87 | public function getUrlInfoForSite(int $siteId, int $urlPart): string 88 | { 89 | $site = $this->getSite($siteId); 90 | 91 | if (!empty($site)) { 92 | return strval(parse_url($site->getBaseUrl(), $urlPart)); 93 | } 94 | 95 | return ''; 96 | } 97 | 98 | /** 99 | * Returns the site for given id. 100 | * 101 | * @param int $id 102 | * 103 | * @return Site|null 104 | */ 105 | public function getSite(int $id): ?Site 106 | { 107 | if (empty($this->sites[$id])) { 108 | $this->sites[$id] = Craft::$app->getSites()->getSiteById($id); 109 | } 110 | 111 | return $this->sites[$id]; 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 5 | **Table of contents** 6 | 7 | - [Features](#features) 8 | - [Requirements](#requirements) 9 | - [Setup](#setup) 10 | - [Usage](#usage) 11 | - [Roadmap](#roadmap) 12 | 13 | 14 | 15 | --- 16 | 17 | ## Features 18 | 19 | - Manage custom redirects directly within the server configuration (for Nginx and Apache) 20 | - Perfectly suited for headless Craft CMS Setups 21 | - Use the redirects features and UI of the [Craft SEO Plugin](https://github.com/ethercreative/seo) 22 | 23 | ## Requirements 24 | 25 | - Craft CMS 5 26 | - [Craft SEO Plugin](https://github.com/ethercreative/seo) 27 | 28 | ## Setup 29 | 30 | **1. Install** 31 | 32 | Install the package 33 | 34 | ```sh 35 | cd /path/to/project 36 | composer require fork/craft-here 37 | ``` 38 | 39 | **2. Configuration file** 40 | 41 | - Copy the example `config.php` to your Craft config directory and rename it to `redirects.php` 42 | - Specify the server type (and a reload command if you use nginx). Here's an example: 43 | 44 | ```php 45 | [ 50 | 'serverType' => 'nginx' // or 'apache' 51 | ], 52 | 53 | // Dev environment settings 54 | 'dev' => [ 55 | //'redirectsReloadCommand' => 'my-command', 56 | ], 57 | 58 | // Staging environment settings 59 | 'staging' => [ 60 | ], 61 | 62 | // Production environment settings 63 | 'production' => [ 64 | //'redirectsReloadCommand' => 'sudo /etc/init.d/nginx reload', 65 | ], 66 | ]; 67 | ``` 68 | 69 | In your server configuration include the redirect map files (which will be created after plugin has been installed): 70 | 71 | ```nginx 72 | # NGINX EXAMPLE: 73 | 74 | # see https://serverfault.com/a/890715/487169 for why we use "[.]" instead of a regular period "." 75 | include /var/www/html/redirects/my.domain.com/redirects-301[.]map; 76 | include /var/www/html/redirects/my.domain.com/redirects-302[.]map; 77 | 78 | # 301 MOVED PERMANENTLY 79 | if ($redirect_moved = false) { 80 | set $redirect_moved ""; 81 | } 82 | if ($redirect_moved != "") { 83 | rewrite ^(.*)$ $redirect_moved permanent; 84 | } 85 | # 302 FOUND (aka MOVED TEMPORARILY) 86 | if ($redirect_found = false) { 87 | set $redirect_found ""; 88 | } 89 | if ($redirect_found != "") { 90 | rewrite ^(.*)$ $redirect_found redirect; 91 | } 92 | ``` 93 | 94 | ```apacheconf 95 | # APACHE EXAMPLE: 96 | 97 | RewriteEngine On 98 | RewriteMap redirects-301 txt:/var/www/html/redirects/my.domain.com/redirects-301.map 99 | RewriteMap redirects-302 txt:/var/www/html/redirects/my.domain.com/redirects-302.map 100 | 101 | RewriteCond ${redirects-301:%{REQUEST_URI}} ^.+$ 102 | RewriteRule .* https://${redirects-301:%{HTTP_HOST}%{REQUEST_URI}} [redirect=permanent,last] 103 | 104 | RewriteCond ${redirects-302:%{REQUEST_URI}} ^.+$ 105 | RewriteRule .* https://${redirects-302:%{HTTP_HOST}%{REQUEST_URI}} [redirect=temp,last] 106 | ``` 107 | 108 | ## Usage 109 | 110 | Once the plugin has been installed it will create all necessary redirect map files which need to be included into the server config. 111 | After that just use the SEO Plugin UI to manage your redirects. 112 | 113 | ## Roadmap 114 | 115 | - [ ] Settings maybe (instead of config file) 116 | 117 | --- 118 | 119 |
120 | Fork Logo 121 | 122 |

Brought to you by Fork Unstable Media GmbH

123 |
124 | -------------------------------------------------------------------------------- /src/HeRe.php: -------------------------------------------------------------------------------- 1 | getBasePath()); 78 | 79 | // this module depends on the ether/seo plugin, so check for it to be enabled 80 | if (Craft::$app->getPlugins()->isPluginEnabled('seo')) { 81 | // register services 82 | $this->setComponents([ 83 | 'eventHandler' => EventHandler::class, 84 | 'redirectsMaps' => RedirectsMaps::class, 85 | 'siteHelper' => SiteHelper::class, 86 | ]); 87 | 88 | if (Craft::$app->getRequest()->getIsCpRequest()) { 89 | // whenever a redirect (in the redirects config page of the SEO plugin) has been saved or deleted --> trigger re-/creation of the redirects maps 90 | Event::on(RedirectRecord::class, 'after*', [$this->eventHandler, 'handleRedirectRecordEvent']); 91 | // whenever a site (in the multi-site settings) has been saved or deleted --> trigger re-/creation of the redirects maps 92 | Event::on(Sites::class, 'after*', [$this->eventHandler, 'handleSitesEvent']); 93 | // at the end of processing any CP request --> re-/create the redirects maps 94 | Event::on(Application::class, YiiApplication::EVENT_AFTER_REQUEST, [$this->eventHandler, 'handleApplicationEvent']); 95 | } 96 | 97 | if (Craft::$app->getRequest()->getIsConsoleRequest()) { 98 | $this->controllerNamespace = 'fork\here\console\controllers'; 99 | } 100 | } 101 | 102 | Event::on( 103 | Plugins::class, 104 | Plugins::EVENT_AFTER_INSTALL_PLUGIN, 105 | function (PluginEvent $event) { 106 | if ($event->plugin === $this) { 107 | // create empty map files to avoid (apache) errors 108 | HeRe::getInstance()->redirectsMaps->recreateMaps(); 109 | } 110 | } 111 | ); 112 | Event::on( 113 | Plugins::class, 114 | Plugins::EVENT_AFTER_UNINSTALL_PLUGIN, 115 | function (PluginEvent $event) { 116 | if ($event->plugin === $this) { 117 | // delete map files 118 | HeRe::getInstance()->redirectsMaps->clear(); 119 | } 120 | } 121 | ); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/services/RedirectsMaps.php: -------------------------------------------------------------------------------- 1 | 'moved', 37 | self::STATUS_CODE_302_FOUND => 'found', 38 | ]; 39 | 40 | /** 41 | * the server type ("nginx" or "apache") 42 | * 43 | * @var string $serverType 44 | */ 45 | protected string $serverType; 46 | 47 | /** 48 | * the config from site/config/redirects.php 49 | * 50 | * @var array $config 51 | */ 52 | protected array $config; 53 | 54 | /** 55 | * flag to determine if the redirects map has to be re-/created 56 | * 57 | * @var bool 58 | */ 59 | protected bool $isRecreationTriggered = false; 60 | 61 | /** 62 | * @var SiteHelper $siteHelper 63 | */ 64 | protected SiteHelper $siteHelper; 65 | 66 | /** 67 | * RedirectsMaps constructor. 68 | * 69 | * @param SiteHelper $siteHelper 70 | * @param array $config 71 | */ 72 | public function __construct(SiteHelper $siteHelper, array $config = []) 73 | { 74 | $this->siteHelper = $siteHelper; 75 | $this->config = Craft::$app->getConfig()->getConfigFromFile('redirects'); 76 | $this->serverType = $this->config['serverType'] ?? 'nginx'; 77 | 78 | parent::__construct($config); 79 | } 80 | 81 | /** 82 | * Sets the trigger for re-/creating the redirects maps. 83 | * 84 | * Must be invoked before re-/creating the redirects maps using `recreateMapIfTriggered()` method. 85 | * 86 | * @param bool $recreateMap 87 | * 88 | * @see RedirectsMaps::recreateMapsIfTriggered 89 | */ 90 | public function triggerRecreation(bool $recreateMap = true): void 91 | { 92 | $this->isRecreationTriggered = $recreateMap; 93 | } 94 | 95 | /** 96 | * Re-/creates the redirects maps unless the re-/creation is not properly triggered using the `triggerRecreation()` method. 97 | * 98 | * @throws Exception 99 | * 100 | * @see RedirectsMaps::triggerRecreation 101 | */ 102 | public function recreateMapsIfTriggered(): void 103 | { 104 | if ($this->isRecreationTriggered) { 105 | $this->recreateMaps(); 106 | } 107 | } 108 | 109 | /** 110 | * Re-/creates the redirects maps regardless of re-/creation has been triggered using the `triggerRecreation()` method. 111 | * 112 | * @throws Exception 113 | */ 114 | public function recreateMaps(): void 115 | { 116 | $maps = $this->getRedirectsMapsByType(); 117 | 118 | $this->recreateMapFile($maps, static::STATUS_CODE_301_MOVED_PERMANENTLY); 119 | $this->recreateMapFile($maps, static::STATUS_CODE_302_FOUND); 120 | 121 | // apache doesn't need reload 122 | if ($this->serverType === 'nginx') { 123 | $this->reloadNginxConfigs(); 124 | } 125 | } 126 | 127 | /** 128 | * Returns an array of redirects maps grouped by the HTTP response code respectively by the redirect type which is either "301" or "302". 129 | * 130 | * @return array 131 | * @throws Exception 132 | */ 133 | protected function getRedirectsMapsByType(): array 134 | { 135 | $maps = []; 136 | $siteIds = Craft::$app->getSites()->getAllSiteIds(); 137 | 138 | $redirectRecords = Seo::getInstance()->redirects->findAllRedirects(); 139 | $hosts = []; 140 | 141 | // init empty redirects maps (to avoid server errors for missing map files) 142 | foreach ($siteIds as $siteId) { 143 | $host = $this->siteHelper->getSiteHost($siteId); 144 | $hosts[$siteId] = $host; 145 | $maps[$host]['301'] = []; 146 | $maps[$host]['302'] = []; 147 | } 148 | 149 | /** @var RedirectRecord[]|null[] $redirects */ 150 | foreach ($redirectRecords as $redirects) { 151 | foreach ($redirects as $redirect) { 152 | if (!empty($redirect)) { 153 | $siteId = $redirect->siteId; 154 | 155 | // in case of this redirect is set for all sites 156 | if (empty($siteId)) { 157 | foreach ($siteIds as $siteId) { 158 | $host = $hosts[$siteId]; 159 | $maps[$host][$redirect->type][] = $this->getEntryForRedirectMap($redirect, $siteId); 160 | } 161 | continue; 162 | } 163 | 164 | // this redirect is set for just one specific site 165 | $host = $hosts[$siteId]; 166 | $maps[$host][$redirect->type][] = $this->getEntryForRedirectMap($redirect, $siteId); 167 | } 168 | } 169 | } 170 | 171 | return $maps; 172 | } 173 | 174 | /** 175 | * Returns an entry to be appended to the redirects map for given redirect record. 176 | * 177 | * @param RedirectRecord $redirect 178 | * @param int $siteId 179 | * 180 | * @return string 181 | * @throws Exception 182 | */ 183 | protected function getEntryForRedirectMap(RedirectRecord $redirect, int $siteId): string 184 | { 185 | $basePath = $this->siteHelper->getSitePath($siteId); 186 | 187 | $trailingSlash = mb_substr($redirect->uri, -1) === '/'; 188 | $redirectFrom = FileHelper::normalizePath($basePath . $redirect->uri); 189 | $redirectTo = $this->isPath($redirect->to) ? FileHelper::normalizePath($basePath . $redirect->to) : $redirect->to; 190 | 191 | $redirectFrom = StringHelper::ensureLeft($redirectFrom, '/'); 192 | if ($trailingSlash) { 193 | $redirectFrom = StringHelper::ensureRight($redirectFrom, '/'); 194 | } 195 | $redirectTo = UrlHelper::isAbsoluteUrl($redirectTo) ? $redirectTo : StringHelper::ensureLeft($redirectTo, '/'); 196 | 197 | if ($this->serverType === 'nginx') { 198 | return ' "' . $redirectFrom . '" "' . $redirectTo . '";'; 199 | } elseif ($this->serverType === 'apache') { 200 | return $redirectFrom . ' ' . $redirectTo; 201 | } else { 202 | throw new Exception("$this->serverType not supported!"); 203 | } 204 | } 205 | 206 | /** 207 | * Checks if given string is a path or something different (e.g. a URL). 208 | * 209 | * @param string $string 210 | * 211 | * @return bool 212 | */ 213 | protected function isPath(string $string): bool 214 | { 215 | return !boolval(filter_var($string, FILTER_VALIDATE_URL)); 216 | } 217 | 218 | /** 219 | * Re/creates the redirects map for given list of redirects and status code. 220 | * 221 | * @param array $maps 222 | * @param string $statusCode 223 | * 224 | * @throws Exception 225 | */ 226 | protected function recreateMapFile(array $maps, string $statusCode): void 227 | { 228 | foreach ($maps as $siteHost => $mapsForSite) { 229 | $tempDir = FileHelper::normalizePath(Craft::$app->getPath()->getTempPath()); 230 | $destDir = FileHelper::normalizePath($this->getRedirectsDir() . $siteHost); 231 | $filename = '/redirects-' . $statusCode . '.map'; 232 | $tempFile = $tempDir . $filename; 233 | $destFile = $destDir . $filename; 234 | 235 | if ($this->serverType === 'nginx') { 236 | $content = $this->getRedirectsMapNginxContent($mapsForSite, $statusCode); 237 | } elseif ($this->serverType === 'apache') { 238 | $content = $this->getRedirectsMapApacheContent($mapsForSite, $statusCode); 239 | } else { 240 | throw new Exception("$this->serverType not supported!"); 241 | } 242 | 243 | if (!file_exists($tempDir)) { 244 | FileHelper::createDirectory($tempDir); 245 | } 246 | 247 | if ($content ? file_put_contents($tempFile, $content) : touch($tempFile)) { 248 | if (!file_exists($destDir)) { 249 | FileHelper::createDirectory($destDir); 250 | } 251 | rename($tempFile, $destFile); 252 | } 253 | } 254 | } 255 | 256 | /** 257 | * Returns the file content for a redirects map for given redirects maps and status code (which is called "redirect type" in used SEO plugin). 258 | * 259 | * @param array $maps 260 | * @param string $statusCode 261 | * 262 | * @return string 263 | */ 264 | protected function getRedirectsMapNginxContent(array $maps, string $statusCode): string 265 | { 266 | $content = ''; 267 | 268 | if (!empty($maps[$statusCode])) { 269 | $content .= 'map $request_uri $redirect_' . self::REDIRECT_TYPES[$statusCode] . ' {' . PHP_EOL; 270 | $content .= implode(PHP_EOL, $maps[$statusCode]) . PHP_EOL; 271 | $content .= '}' . PHP_EOL; 272 | } 273 | 274 | return $content; 275 | } 276 | 277 | /** 278 | * Returns the file content for a redirects map for given redirects maps and status code (which is called "redirect type" in used SEO plugin). 279 | * 280 | * @param array $maps 281 | * @param string $statusCode 282 | * 283 | * @return string 284 | */ 285 | protected function getRedirectsMapApacheContent(array $maps, string $statusCode): string 286 | { 287 | $content = ''; 288 | 289 | if (!empty($maps[$statusCode])) { 290 | $content .= implode(PHP_EOL, $maps[$statusCode]) . PHP_EOL; 291 | } 292 | 293 | return $content; 294 | } 295 | 296 | /** 297 | * Tells the nginx server to reload its configs for the current redirects maps to take effect. 298 | */ 299 | protected function reloadNginxConfigs(): void 300 | { 301 | $reloadCommand = $this->config && !empty($this->config['redirectsReloadCommand']) ? $this->config['redirectsReloadCommand'] : null; 302 | 303 | if ($reloadCommand) { 304 | exec($reloadCommand, $output, $returned); 305 | } 306 | 307 | // TODO: any error-handling (like checking the output and/or the returned value)? 308 | } 309 | 310 | /** 311 | * Clears the redirects directory. 312 | * 313 | * @throws ErrorException 314 | */ 315 | public function clear(): void 316 | { 317 | FileHelper::clearDirectory(FileHelper::normalizePath($this->getRedirectsDir()), [ 318 | 'except' => ['.gitignore'], 319 | ]); 320 | } 321 | 322 | /** 323 | * Returns the directory path where all redirects maps are stored. 324 | * 325 | * The returned path is not normalized, do so using the `\craft\helpers\FileHelper` service. 326 | * 327 | * @return string 328 | * 329 | * @see FileHelper 330 | */ 331 | protected function getRedirectsDir(): string 332 | { 333 | return Craft::getAlias('@webroot') . '/../redirects/'; 334 | } 335 | } 336 | --------------------------------------------------------------------------------