f1e1aefab160adc3bb9a7d241d9a5c7bdd4776d3
[Packages/TYPO3.CMS.git] / typo3 / sysext / install / Classes / Service / UpgradeWizardsService.php
1 <?php
2 declare(strict_types = 1);
3
4 namespace TYPO3\CMS\Install\Service;
5
6 /*
7 * This file is part of the TYPO3 CMS project.
8 *
9 * It is free software; you can redistribute it and/or modify it under
10 * the terms of the GNU General Public License, either version 2
11 * of the License, or any later version.
12 *
13 * For the full copyright and license information, please read the
14 * LICENSE.txt file that was distributed with this source code.
15 *
16 * The TYPO3 project - inspiring people to share!
17 */
18
19 use Doctrine\DBAL\Platforms\MySqlPlatform;
20 use Doctrine\DBAL\Schema\Column;
21 use Doctrine\DBAL\Schema\Table;
22 use Symfony\Component\Console\Output\Output;
23 use Symfony\Component\Console\Output\StreamOutput;
24 use TYPO3\CMS\Core\Database\ConnectionPool;
25 use TYPO3\CMS\Core\Database\Schema\SchemaMigrator;
26 use TYPO3\CMS\Core\Database\Schema\SqlReader;
27 use TYPO3\CMS\Core\Messaging\FlashMessage;
28 use TYPO3\CMS\Core\Messaging\FlashMessageQueue;
29 use TYPO3\CMS\Core\Registry;
30 use TYPO3\CMS\Core\Utility\GeneralUtility;
31 use TYPO3\CMS\Install\Updates\AbstractUpdate;
32 use TYPO3\CMS\Install\Updates\ChattyInterface;
33 use TYPO3\CMS\Install\Updates\ConfirmableInterface;
34 use TYPO3\CMS\Install\Updates\RepeatableInterface;
35 use TYPO3\CMS\Install\Updates\RowUpdater\RowUpdaterInterface;
36 use TYPO3\CMS\Install\Updates\UpgradeWizardInterface;
37
38 /**
39 * Service class helping managing upgrade wizards
40 */
41 class UpgradeWizardsService
42 {
43 private $output;
44
45 public function __construct()
46 {
47 $this->output = new StreamOutput(fopen('php://temp', 'wb'), Output::VERBOSITY_NORMAL, false);
48 }
49
50 /**
51 * @return array List of wizards marked as done in registry
52 */
53 public function listOfWizardsDone(): array
54 {
55 $wizardsDoneInRegistry = [];
56 $registry = GeneralUtility::makeInstance(Registry::class);
57 foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['ext/install']['update'] as $identifier => $className) {
58 if ($registry->get('installUpdate', $className, false)) {
59 $wizardInstance = GeneralUtility::makeInstance($className);
60 $wizardsDoneInRegistry[] = [
61 'class' => $className,
62 'identifier' => $identifier,
63 'title' => $wizardInstance->getTitle(),
64 ];
65 }
66 }
67 return $wizardsDoneInRegistry;
68 }
69
70 /**
71 * @return array List of row updaters marked as done in registry
72 * @throws \RuntimeException
73 */
74 public function listOfRowUpdatersDone(): array
75 {
76 $registry = GeneralUtility::makeInstance(Registry::class);
77 $rowUpdatersDoneClassNames = $registry->get('installUpdateRows', 'rowUpdatersDone', []);
78 $rowUpdatersDone = [];
79 foreach ($rowUpdatersDoneClassNames as $rowUpdaterClassName) {
80 // Silently skip non existing DatabaseRowsUpdateWizards
81 if (!class_exists($rowUpdaterClassName)) {
82 continue;
83 }
84 /** @var RowUpdaterInterface $rowUpdater */
85 $rowUpdater = GeneralUtility::makeInstance($rowUpdaterClassName);
86 if (!$rowUpdater instanceof RowUpdaterInterface) {
87 throw new \RuntimeException(
88 'Row updater must implement RowUpdaterInterface',
89 1484152906
90 );
91 }
92 $rowUpdatersDone[] = [
93 'class' => $rowUpdaterClassName,
94 'identifier' => $rowUpdaterClassName,
95 'title' => $rowUpdater->getTitle(),
96 ];
97 }
98 return $rowUpdatersDone;
99 }
100
101 /**
102 * Mark one wizard as undone. This can be a "casual" wizard
103 * or a single "row updater".
104 *
105 * @param string $identifier Wizard or RowUpdater identifier
106 * @return bool True if wizard has been marked as undone
107 * @throws \RuntimeException
108 */
109 public function markWizardUndone(string $identifier): bool
110 {
111 $this->assertIdentifierIsValid($identifier);
112
113 $registry = GeneralUtility::makeInstance(Registry::class);
114 $aWizardHasBeenMarkedUndone = false;
115 $wizardsDoneList = $this->listOfWizardsDone();
116 foreach ($wizardsDoneList as $wizard) {
117 if ($wizard['identifier'] === $identifier) {
118 $aWizardHasBeenMarkedUndone = true;
119 $registry->set('installUpdate', $wizard['class'], 0);
120 }
121 }
122 if (!$aWizardHasBeenMarkedUndone) {
123 $rowUpdatersDoneList = $this->listOfRowUpdatersDone();
124 $registryArray = $registry->get('installUpdateRows', 'rowUpdatersDone', []);
125 foreach ($rowUpdatersDoneList as $rowUpdater) {
126 if ($rowUpdater['identifier'] === $identifier) {
127 $aWizardHasBeenMarkedUndone = true;
128 foreach ($registryArray as $rowUpdaterMarkedAsDonePosition => $rowUpdaterMarkedAsDone) {
129 if ($rowUpdaterMarkedAsDone === $rowUpdater['class']) {
130 unset($registryArray[$rowUpdaterMarkedAsDonePosition]);
131 break;
132 }
133 }
134 $registry->set('installUpdateRows', 'rowUpdatersDone', $registryArray);
135 }
136 }
137 }
138 return $aWizardHasBeenMarkedUndone;
139 }
140
141 /**
142 * Get a list of tables, single columns and indexes to add.
143 *
144 * @return array Array with possible keys "tables", "columns", "indexes"
145 */
146 public function getBlockingDatabaseAdds(): array
147 {
148 $sqlReader = GeneralUtility::makeInstance(SqlReader::class);
149 $databaseDefinitions = $sqlReader->getCreateTableStatementArray($sqlReader->getTablesDefinitionString());
150
151 $schemaMigrator = GeneralUtility::makeInstance(SchemaMigrator::class);
152 $databaseDifferences = $schemaMigrator->getSchemaDiffs($databaseDefinitions);
153
154 $adds = [];
155 foreach ($databaseDifferences as $schemaDiff) {
156 foreach ($schemaDiff->newTables as $newTable) {
157 /** @var Table $newTable */
158 if (!is_array($adds['tables'])) {
159 $adds['tables'] = [];
160 }
161 $adds['tables'][] = [
162 'table' => $newTable->getName(),
163 ];
164 }
165 foreach ($schemaDiff->changedTables as $changedTable) {
166 foreach ($changedTable->addedColumns as $addedColumn) {
167 /** @var Column $addedColumn */
168 if (!is_array($adds['columns'])) {
169 $adds['columns'] = [];
170 }
171 $adds['columns'][] = [
172 'table' => $changedTable->name,
173 'field' => $addedColumn->getName(),
174 ];
175 }
176 foreach ($changedTable->addedIndexes as $addedIndex) {
177 /** $var Index $addedIndex */
178 if (!is_array($adds['indexes'])) {
179 $adds['indexes'] = [];
180 }
181 $adds['indexes'][] = [
182 'table' => $changedTable->name,
183 'index' => $addedIndex->getName(),
184 ];
185 }
186 }
187 }
188
189 return $adds;
190 }
191
192 /**
193 * Add missing tables, indexes and fields to DB.
194 */
195 public function addMissingTablesAndFields(): array
196 {
197 $sqlReader = GeneralUtility::makeInstance(SqlReader::class);
198 $databaseDefinitions = $sqlReader->getCreateTableStatementArray($sqlReader->getTablesDefinitionString());
199 $schemaMigrator = GeneralUtility::makeInstance(SchemaMigrator::class);
200 return $schemaMigrator->install($databaseDefinitions, true);
201 }
202
203 /**
204 * True if DB main charset on mysql is utf8
205 *
206 * @return bool True if charset is ok
207 */
208 public function isDatabaseCharsetUtf8(): bool
209 {
210 /** @var \TYPO3\CMS\Core\Database\Connection $connection */
211 $connection = GeneralUtility::makeInstance(ConnectionPool::class)
212 ->getConnectionByName(ConnectionPool::DEFAULT_CONNECTION_NAME);
213
214 $isDefaultConnectionMysql = ($connection->getDatabasePlatform() instanceof MySqlPlatform);
215
216 if (!$isDefaultConnectionMysql) {
217 // Not tested on non mysql
218 $charsetOk = true;
219 } else {
220 $queryBuilder = $connection->createQueryBuilder();
221 $charset = (string)$queryBuilder->select('DEFAULT_CHARACTER_SET_NAME')
222 ->from('information_schema.SCHEMATA')
223 ->where(
224 $queryBuilder->expr()->eq(
225 'SCHEMA_NAME',
226 $queryBuilder->createNamedParameter($connection->getDatabase(), \PDO::PARAM_STR)
227 )
228 )
229 ->setMaxResults(1)
230 ->execute()
231 ->fetchColumn();
232 // check if database charset is utf-8, also allows utf8mb4
233 $charsetOk = strpos($charset, 'utf8') === 0;
234 }
235 return $charsetOk;
236 }
237
238 /**
239 * Set default connection MySQL database charset to utf8.
240 * Should be called only *if* default database connection is actually MySQL
241 */
242 public function setDatabaseCharsetUtf8()
243 {
244 $connection = GeneralUtility::makeInstance(ConnectionPool::class)
245 ->getConnectionByName(ConnectionPool::DEFAULT_CONNECTION_NAME);
246 $sql = 'ALTER DATABASE ' . $connection->quoteIdentifier($connection->getDatabase()) . ' CHARACTER SET utf8';
247 $connection->exec($sql);
248 }
249
250 /**
251 * Get list of registered upgrade wizards not marked done.
252 *
253 * @return array List of upgrade wizards in correct order with detail information
254 */
255 public function getUpgradeWizardsList(): array
256 {
257 $wizards = [];
258 foreach ($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['ext/install']['update'] as $identifier => $class) {
259 if ($this->isWizardDone($identifier)) {
260 continue;
261 }
262 /** @var AbstractUpdate $wizardInstance */
263 $wizardInstance = GeneralUtility::makeInstance($class);
264
265 // $explanation is changed by reference in Update objects!
266 // @todo deprecate once all wizards are migrated
267 $explanation = '';
268 $shouldRenderWizard = false;
269 if (!($wizardInstance instanceof UpgradeWizardInterface) && $wizardInstance instanceof AbstractUpdate) {
270 $wizardInstance->checkForUpdate($explanation);
271 $shouldRenderWizard = $wizardInstance->shouldRenderWizard();
272 }
273 if ($wizardInstance instanceof UpgradeWizardInterface) {
274 if ($wizardInstance instanceof ChattyInterface) {
275 $wizardInstance->setOutput($this->output);
276 }
277 $shouldRenderWizard = $wizardInstance->updateNecessary();
278 $explanation = $wizardInstance->getDescription();
279 }
280
281 $wizards[] = [
282 'class' => $class,
283 'identifier' => $identifier,
284 'title' => $wizardInstance->getTitle(),
285 'shouldRenderWizard' => $shouldRenderWizard,
286 'explanation' => $explanation,
287 ];
288 }
289 return $wizards;
290 }
291
292 /**
293 * Execute the "get user input" step of a wizard
294 *
295 * @param string $identifier
296 * @return array
297 * @throws \RuntimeException
298 */
299 public function getWizardUserInput(string $identifier): array
300 {
301 $this->assertIdentifierIsValid($identifier);
302
303 $class = $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['ext/install']['update'][$identifier];
304 $updateObject = GeneralUtility::makeInstance($class);
305 $wizardHtml = '';
306 if (method_exists($updateObject, 'getUserInput')) {
307 $wizardHtml = $updateObject->getUserInput('install[values][' . htmlspecialchars($identifier) . ']');
308 } elseif ($updateObject instanceof UpgradeWizardInterface && $updateObject instanceof ConfirmableInterface) {
309 $wizardHtml = '
310 <div class="panel panel-danger">
311 <div class="panel-heading">
312 ' . htmlspecialchars($updateObject->getConfirmation()->getTitle()) . '
313 </div>
314 <div class="panel-body">
315 <p>' . nl2br(htmlspecialchars($updateObject->getConfirmation()->getMessage())) . '</p>
316 <div class="btn-group" data-toggle="buttons">
317 <label class="btn btn-default active">
318 <input type="radio" name="install[values][' .
319 htmlspecialchars($updateObject->getIdentifier()) .
320 '][install]" value="0" checked="checked" /> No, skip wizard
321 </label>
322 <label class="btn btn-default">
323 <input type="radio" name="install[values][' .
324 htmlspecialchars($updateObject->getIdentifier()) .
325 '][install]" value="1" /> Yes, execute wizard
326 </label>
327 </div>
328 </div>
329 </div>
330 ';
331 }
332
333 $result = [
334 'identifier' => $identifier,
335 'title' => $updateObject->getTitle(),
336 'wizardHtml' => $wizardHtml,
337 ];
338
339 return $result;
340 }
341
342 /**
343 * Execute a single update wizard
344 *
345 * @param string $identifier
346 * @param int $showDatabaseQueries
347 * @return FlashMessageQueue
348 * @throws \RuntimeException
349 */
350 public function executeWizard(string $identifier, int $showDatabaseQueries = null): FlashMessageQueue
351 {
352 $this->assertIdentifierIsValid($identifier);
353
354 $class = $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['ext/install']['update'][$identifier];
355 $updateObject = GeneralUtility::makeInstance($class);
356
357 $messages = new FlashMessageQueue('install');
358 // $wizardInputErrorMessage is given as reference to wizard object!
359 $wizardInputErrorMessage = '';
360 if (method_exists($updateObject, 'checkUserInput') &&
361 !$updateObject->checkUserInput($wizardInputErrorMessage)) {
362 // @todo deprecate, unused
363 $messages->enqueue(
364 new FlashMessage(
365 $wizardInputErrorMessage ?: 'Something went wrong!',
366 'Input parameter broken',
367 FlashMessage::ERROR
368 )
369 );
370 } else {
371 if (!($updateObject instanceof UpgradeWizardInterface) && !method_exists($updateObject, 'performUpdate')) {
372 throw new \RuntimeException(
373 'No performUpdate method in update wizard with identifier ' . $identifier,
374 1371035200
375 );
376 }
377
378 // Both variables are used by reference in performUpdate()
379 $message = '';
380 $databaseQueries = [];
381 if ($updateObject instanceof UpgradeWizardInterface) {
382 $requestParams = GeneralUtility::_GP('install');
383 if ($updateObject instanceof ConfirmableInterface
384 && (
385 isset($requestParams['values'][$updateObject->getIdentifier()]['install'])
386 && empty($requestParams['values'][$updateObject->getIdentifier()]['install'])
387 )
388 ) {
389 // confirmation was set to "no"
390 $performResult = true;
391 } else {
392 // confirmation yes or non-confirmable
393 if ($updateObject instanceof ChattyInterface) {
394 $updateObject->setOutput($this->output);
395 }
396 $performResult = $updateObject->executeUpdate();
397 }
398 } else {
399 // @todo deprecate
400 $performResult = $updateObject->performUpdate($databaseQueries, $message);
401 }
402
403 $stream = $this->output->getStream();
404 rewind($stream);
405 if ($performResult) {
406 if ($updateObject instanceof UpgradeWizardInterface && !($updateObject instanceof RepeatableInterface)) {
407 // mark wizard as done if it's not repeatable and was successful
408 $this->markWizardAsDone($updateObject->getIdentifier());
409 }
410 $messages->enqueue(
411 new FlashMessage(
412 stream_get_contents($stream),
413 'Update successful'
414 )
415 );
416 } else {
417 $messages->enqueue(
418 new FlashMessage(
419 stream_get_contents($stream),
420 'Update failed!',
421 FlashMessage::ERROR
422 )
423 );
424 }
425 if ($showDatabaseQueries) {
426 // @todo deprecate
427 foreach ($databaseQueries as $query) {
428 $messages->enqueue(
429 new FlashMessage(
430 $query,
431 '',
432 FlashMessage::INFO
433 )
434 );
435 }
436 }
437 }
438 return $messages;
439 }
440
441 /**
442 * Marks some wizard as being "seen" so that it not shown again.
443 * Writes the info in LocalConfiguration.php
444 *
445 * @param string $identifier
446 * @throws \RuntimeException
447 */
448 public function markWizardAsDone(string $identifier): void
449 {
450 $this->assertIdentifierIsValid($identifier);
451
452 $class = $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['ext/install']['update'][$identifier];
453 GeneralUtility::makeInstance(Registry::class)->set('installUpdate', $class, 1);
454 }
455
456 /**
457 * Checks if this wizard has been "done" before
458 *
459 * @param string $identifier
460 * @return bool TRUE if wizard has been done before, FALSE otherwise
461 * @throws \RuntimeException
462 */
463 public function isWizardDone(string $identifier): bool
464 {
465 $this->assertIdentifierIsValid($identifier);
466
467 $class = $GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['ext/install']['update'][$identifier];
468 return (bool)GeneralUtility::makeInstance(Registry::class)->get('installUpdate', $class, false);
469 }
470
471 /**
472 * Validate identifier exists in upgrade wizard list
473 *
474 * @param string $identifier
475 * @throws \RuntimeException
476 */
477 protected function assertIdentifierIsValid(string $identifier): void
478 {
479 if ($identifier === '' || !isset($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['ext/install']['update'][$identifier])) {
480 throw new \RuntimeException('No valid wizard identifier given', 1502721731);
481 }
482 }
483 }