├── 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 |
--------------------------------------------------------------------------------
/src/icon-mask.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/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 |
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 |
--------------------------------------------------------------------------------