1<?php
2
3declare(strict_types=1);
4
5/*
6 * This file is part of the TYPO3 CMS project.
7 *
8 * It is free software; you can redistribute it and/or modify it under
9 * the terms of the GNU General Public License, either version 2
10 * of the License, or any later version.
11 *
12 * For the full copyright and license information, please read the
13 * LICENSE.txt file that was distributed with this source code.
14 *
15 * The TYPO3 project - inspiring people to share!
16 */
17
18namespace TYPO3\CMS\Install\Updates;
19
20use TYPO3\CMS\Core\Database\ConnectionPool;
21use TYPO3\CMS\Core\Utility\GeneralUtility;
22
23/**
24 * Installs EXT:redirect if sys_domain.redirectTo is filled, and migrates the values from redirectTo
25 * to a proper sys_redirect entry.
26 * @internal This class is only meant to be used within EXT:install and is not part of the TYPO3 Core API.
27 */
28class RedirectsExtensionUpdate extends AbstractDownloadExtensionUpdate
29{
30    /**
31     * @var \TYPO3\CMS\Install\Updates\Confirmation
32     */
33    protected $confirmation;
34
35    public function __construct()
36    {
37        $this->extension = new ExtensionModel(
38            'redirects',
39            'Redirects',
40            '9.2',
41            'typo3/cms-redirects',
42            'Manage redirects for your TYPO3-based website'
43        );
44
45        $this->confirmation = new Confirmation(
46            'Are you sure?',
47            'You should install the "redirects" extension only if needed. ' . $this->extension->getDescription(),
48            true
49        );
50    }
51
52    /**
53     * Return a confirmation message instance
54     *
55     * @return \TYPO3\CMS\Install\Updates\Confirmation
56     */
57    public function getConfirmation(): Confirmation
58    {
59        return $this->confirmation;
60    }
61
62    /**
63     * Return the identifier for this wizard
64     * This should be the same string as used in the ext_localconf class registration
65     *
66     * @return string
67     */
68    public function getIdentifier(): string
69    {
70        return 'redirects';
71    }
72
73    /**
74     * Return the speaking name of this wizard
75     *
76     * @return string
77     */
78    public function getTitle(): string
79    {
80        return 'Install system extension "redirects" if a sys_domain entry with redirectTo is necessary';
81    }
82
83    /**
84     * Return the description for this wizard
85     *
86     * @return string
87     */
88    public function getDescription(): string
89    {
90        return 'The extension "redirects" includes functionality to handle any kind of redirects. '
91               . 'The functionality supersedes sys_domain entries with the only purpose of redirecting to a different domain or entry. '
92               . 'This upgrade wizard installs the redirect extension if necessary and migrates the sys_domain entries to standard redirects.';
93    }
94
95    /**
96     * Is an update necessary?
97     * Is used to determine whether a wizard needs to be run.
98     *
99     * @return bool
100     */
101    public function updateNecessary(): bool
102    {
103        return $this->checkIfWizardIsRequired();
104    }
105
106    /**
107     * Performs the update:
108     * - Install EXT:redirect
109     * - Migrate DB records
110     *
111     * @return bool
112     */
113    public function executeUpdate(): bool
114    {
115        // Install the EXT:redirects extension if not happened yet
116        $installationSuccessful = $this->installExtension($this->extension);
117        if ($installationSuccessful) {
118            // Migrate the database entries
119            $this->migrateRedirectDomainsToSysRedirect();
120        }
121        return $installationSuccessful;
122    }
123
124    /**
125     * Check if the database field "sys_domain.redirectTo" exists and if so, if there are entries in the DB table with the field filled.
126     *
127     * @return bool
128     * @throws \InvalidArgumentException
129     */
130    protected function checkIfWizardIsRequired(): bool
131    {
132        $connectionPool = GeneralUtility::makeInstance(ConnectionPool::class);
133        $connection = $connectionPool->getConnectionByName('Default');
134        $tables = $connection->getSchemaManager()->listTables();
135        $tableExists = false;
136        foreach ($tables as $table) {
137            if (strtolower($table->getName()) === 'sys_domain') {
138                $tableExists = true;
139            }
140        }
141        if (!$tableExists) {
142            return false;
143        }
144        $columns = $connection->getSchemaManager()->listTableColumns('sys_domain');
145        if (isset($columns['redirectto'])) {
146            // table is available, now check if there are entries in it
147            $queryBuilder = GeneralUtility::makeInstance(ConnectionPool::class)
148                ->getQueryBuilderForTable('sys_domain');
149            $queryBuilder->getRestrictions()->removeAll();
150            $numberOfEntries = $queryBuilder->count('*')
151                ->from('sys_domain')
152                ->where(
153                    $queryBuilder->expr()->neq('redirectTo', $queryBuilder->createNamedParameter('', \PDO::PARAM_STR))
154                )
155                ->execute()
156                ->fetchColumn();
157            return (bool)$numberOfEntries;
158        }
159
160        return false;
161    }
162
163    /**
164     * Move all sys_domain records with a "redirectTo" value filled (also deleted) to "sys_redirect" record
165     */
166    protected function migrateRedirectDomainsToSysRedirect()
167    {
168        $connDomains = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('sys_domain');
169        $connRedirects = GeneralUtility::makeInstance(ConnectionPool::class)->getConnectionForTable('sys_redirect');
170
171        $queryBuilder = $connDomains->createQueryBuilder();
172        $queryBuilder->getRestrictions()->removeAll();
173        $domainEntries = $queryBuilder->select('*')
174            ->from('sys_domain')
175            ->where(
176                $queryBuilder->expr()->neq('redirectTo', $queryBuilder->createNamedParameter('', \PDO::PARAM_STR))
177            )
178            ->execute()
179            ->fetchAll();
180
181        foreach ($domainEntries as $domainEntry) {
182            $domainName = $domainEntry['domainName'];
183            $target = $domainEntry['redirectTo'];
184            $sourceDetails = $this->getDomainDetails($domainName);
185            $targetDetails = $this->getDomainDetails($target);
186            $redirectRecord = [
187                'deleted' => (int)$domainEntry['deleted'],
188                'disabled' => (int)$domainEntry['hidden'],
189                'createdon' => (int)$domainEntry['crdate'],
190                'createdby' => (int)$domainEntry['cruser_id'],
191                'updatedon' => (int)$domainEntry['tstamp'],
192                'source_host' => $sourceDetails['host'] . ($sourceDetails['port'] ? ':' . $sourceDetails['port'] : ''),
193                'keep_query_parameters' => (int)$domainEntry['prepend_params'],
194                'target_statuscode' => (int)$domainEntry['redirectHttpStatusCode'],
195                'target' => $target
196            ];
197
198            if (isset($targetDetails['scheme']) && $targetDetails['scheme'] === 'https') {
199                $redirectRecord['force_https'] = 1;
200            }
201
202            if (empty($sourceDetails['path']) || $sourceDetails['path'] === '/') {
203                $redirectRecord['source_path'] = '#.*#';
204                $redirectRecord['is_regexp'] = 1;
205            } else {
206                // Remove the / and add a "/" always before, and at the very end, if path is not empty
207                $sourceDetails['path'] = trim($sourceDetails['path'], '/');
208                $redirectRecord['source_path'] = '/' . ($sourceDetails['path'] ? $sourceDetails['path'] . '/' : '');
209            }
210
211            // Add the redirect record
212            $connRedirects->insert('sys_redirect', $redirectRecord);
213
214            // Remove the sys_domain record (hard)
215            $connDomains->delete('sys_domain', ['uid' => (int)$domainEntry['uid']]);
216        }
217    }
218
219    /**
220     * Returns an array of class names of Prerequisite classes
221     * This way a wizard can define dependencies like "database up-to-date" or
222     * "reference index updated"
223     *
224     * @return string[]
225     */
226    public function getPrerequisites(): array
227    {
228        return [
229            DatabaseUpdatedPrerequisite::class
230        ];
231    }
232
233    /**
234     * parse_url('example.com/bar') returns ['path' => 'example.com/bar'] - it does not
235     * split into 'host' and 'path' if there is no scheme. Adding a scheme in this case
236     * leads to more reliable sys_domain transitions.
237     *
238     * @param string $domainName
239     * @return string[]
240     */
241    protected function getDomainDetails(string $domainName): array
242    {
243        if (substr($domainName, 0, 4) === 'http') {
244            return parse_url($domainName);
245        }
246        return parse_url('https://' . $domainName);
247    }
248}
249