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