Merge branch '5.3'
authorMatthias Schmidt <gravatronics@live.com>
Tue, 13 Apr 2021 14:41:02 +0000 (16:41 +0200)
committerMatthias Schmidt <gravatronics@live.com>
Tue, 13 Apr 2021 14:41:12 +0000 (16:41 +0200)
1  2 
wcfsetup/install/files/lib/system/database/table/DatabaseTableChangeProcessor.class.php
wcfsetup/install/files/lib/util/FileUtil.class.php
wcfsetup/install/lang/de.xml
wcfsetup/install/lang/en.xml

index 6df14452b969928ccb692fea3feb0e83c2241413,318d517c89e25cec96b91e1aca3c9b728ac4fa9a..36166a641f036b773954957cd29b931c9d7060ee
@@@ -15,1263 -13,1204 +15,1295 @@@ use wcf\system\WCF
  
  /**
   * Processes a given set of changes to database tables.
 - * 
 - * @author    Matthias Schmidt
 - * @copyright 2001-2020 WoltLab GmbH
 - * @license   GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
 - * @package   WoltLabSuite\Core\System\Database\Table
 - * @since     5.2
 + *
 + * @author  Matthias Schmidt
 + * @copyright   2001-2020 WoltLab GmbH
 + * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
 + * @package WoltLabSuite\Core\System\Database\Table
 + * @since   5.2
   */
 -class DatabaseTableChangeProcessor {
 -      /**
 -       * maps the registered database table column names to the ids of the packages they belong to
 -       * @var int[][]
 -       */
 -      protected $columnPackageIDs = [];
 -      
 -      /**
 -       * database table columns that will be added grouped by the name of the table to which they
 -       * will be added
 -       * @var IDatabaseTableColumn[][]
 -       */
 -      protected $columnsToAdd = [];
 -      
 -      /**
 -       * database table columns that will be altered grouped by the name of the table to which
 -       * they belong
 -       * @var IDatabaseTableColumn[][]
 -       */
 -      protected $columnsToAlter = [];
 -      
 -      /**
 -       * database table columns that will be dropped grouped by the name of the table from which
 -       * they will be dropped
 -       * @var IDatabaseTableColumn[][]
 -       */
 -      protected $columnsToDrop = [];
 -      
 -      /**
 -       * database editor to apply the relevant changes to the table layouts
 -       * @var DatabaseEditor
 -       */
 -      protected $dbEditor;
 -
 -      /**
 -       * list of all existing tables in the used database
 -       * @var string[]
 -       */
 -      protected $existingTableNames = [];
 -      
 -      /**
 -       * existing database tables
 -       * @var DatabaseTable[]
 -       */
 -      protected $existingTables = [];
 -      
 -      /**
 -       * maps the registered database table index names to the ids of the packages they belong to
 -       * @var int[][]
 -       */
 -      protected $indexPackageIDs = [];
 -      
 -      /**
 -       * indices that will be added grouped by the name of the table to which they will be added
 -       * @var DatabaseTableIndex[][] 
 -       */
 -      protected $indicesToAdd = [];
 -      
 -      /**
 -       * indices that will be dropped grouped by the name of the table from which they will be dropped
 -       * @var DatabaseTableIndex[][]
 -       */
 -      protected $indicesToDrop = [];
 -      
 -      /**
 -       * maps the registered database table foreign key names to the ids of the packages they belong to
 -       * @var int[][]
 -       */
 -      protected $foreignKeyPackageIDs = [];
 -      
 -      /**
 -       * foreign keys that will be added grouped by the name of the table to which they will be
 -       * added
 -       * @var DatabaseTableForeignKey[][]
 -       */
 -      protected $foreignKeysToAdd = [];
 -      
 -      /**
 -       * foreign keys that will be dropped grouped by the name of the table from which they will
 -       * be dropped
 -       * @var DatabaseTableForeignKey[][]
 -       */
 -      protected $foreignKeysToDrop = [];
 -      
 -      /**
 -       * package that wants to apply the changes
 -       * @var Package
 -       */
 -      protected $package;
 -      
 -      /**
 -       * message for the split node exception thrown after the changes have been applied
 -       * @var string
 -       */
 -      protected $splitNodeMessage = '';
 -      
 -      /**
 -       * layouts/layout changes of the relevant database table
 -       * @var DatabaseTable[]
 -       */
 -      protected $tables;
 -      
 -      /**
 -       * maps the registered database table names to the ids of the packages they belong to
 -       * @var int[]
 -       */
 -      protected $tablePackageIDs = [];
 -      
 -      /**
 -       * database table that will be created
 -       * @var DatabaseTable[]
 -       */
 -      protected $tablesToCreate = [];
 -      
 -      /**
 -       * database tables that will be dropped
 -       * @var DatabaseTable[]
 -       */
 -      protected $tablesToDrop = [];
 -      
 -      /**
 -       * Creates a new instance of `DatabaseTableChangeProcessor`.
 -       * 
 -       * @param       Package                 $package
 -       * @param       DatabaseTable[]         $tables
 -       * @param       DatabaseEditor          $dbEditor
 -       */
 -      public function __construct(Package $package, array $tables, DatabaseEditor $dbEditor) {
 -              $this->package = $package;
 -              
 -              $tableNames = [];
 -              foreach ($tables as $table) {
 -                      if (!($table instanceof DatabaseTable)) {
 -                              throw new \InvalidArgumentException("Tables must be instance of '" . DatabaseTable::class . "'");
 -                      }
 -                      
 -                      $tableNames[] = $table->getName();
 -              }
 -              
 -              $this->tables = $tables;
 -              $this->dbEditor = $dbEditor;
 -              
 -              $this->existingTableNames = $dbEditor->getTableNames();
 -              
 -              $conditionBuilder = new PreparedStatementConditionBuilder();
 -              $conditionBuilder->add('sqlTable IN (?)', [$tableNames]);
 -              $conditionBuilder->add('isDone = ?', [1]);
 -              
 -              $sql = "SELECT  *
 -                      FROM    wcf" . WCF_N . "_package_installation_sql_log
 -                      " . $conditionBuilder;
 -              $statement = WCF::getDB()->prepareStatement($sql);
 -              $statement->execute($conditionBuilder->getParameters());
 -              
 -              while ($row = $statement->fetchArray()) {
 -                      if ($row['sqlIndex'] === '' && $row['sqlColumn'] === '') {
 -                              $this->tablePackageIDs[$row['sqlTable']] = $row['packageID'];
 -                      }
 -                      else if ($row['sqlIndex'] === '') {
 -                              $this->columnPackageIDs[$row['sqlTable']][$row['sqlColumn']] = $row['packageID'];
 -                      }
 -                      else if (substr($row['sqlIndex'], -3) === '_fk') {
 -                              $this->foreignKeyPackageIDs[$row['sqlTable']][$row['sqlIndex']] = $row['packageID'];
 -                      }
 -                      else {
 -                              $this->indexPackageIDs[$row['sqlTable']][$row['sqlIndex']] = $row['packageID'];
 -                      }
 -              }
 -      }
 -      
 -      /**
 -       * Adds the given index to the table.
 -       * 
 -       * @param       string                          $tableName
 -       * @param       DatabaseTableForeignKey         $foreignKey
 -       */
 -      protected function addForeignKey($tableName, DatabaseTableForeignKey $foreignKey) {
 -              $this->dbEditor->addForeignKey($tableName, $foreignKey->getName(), $foreignKey->getData());
 -      }
 -      
 -      /**
 -       * Adds the given index to the table.
 -       *
 -       * @param       string                  $tableName
 -       * @param       DatabaseTableIndex      $index
 -       */
 -      protected function addIndex($tableName, DatabaseTableIndex $index) {
 -              $this->dbEditor->addIndex($tableName, $index->getName(), $index->getData());
 -      }
 -      
 -      /**
 -       * Applies all of the previously determined changes to achieve the desired database layout.
 -       * 
 -       * @throws      SplitNodeException      if any change has been applied
 -       */
 -      protected function applyChanges() {
 -              $appliedAnyChange = false;
 -              
 -              foreach ($this->tablesToCreate as $table) {
 -                      $appliedAnyChange = true;
 -                      
 -                      $this->prepareTableLog($table);
 -                      $this->createTable($table);
 -                      $this->finalizeTableLog($table);
 -              }
 -              
 -              foreach ($this->tablesToDrop as $table) {
 -                      $appliedAnyChange = true;
 -                      
 -                      $this->dropTable($table);
 -                      $this->deleteTableLog($table);
 -              }
 -              
 -              $columnTables = array_unique(array_merge(
 -                      array_keys($this->columnsToAdd),
 -                      array_keys($this->columnsToAlter),
 -                      array_keys($this->columnsToDrop)
 -              ));
 -              foreach ($columnTables as $tableName) {
 -                      $appliedAnyChange = true;
 -                      
 -                      $columnsToAdd = $this->columnsToAdd[$tableName] ?? [];
 -                      $columnsToAlter = $this->columnsToAlter[$tableName] ?? [];
 -                      $columnsToDrop = $this->columnsToDrop[$tableName] ?? [];
 -                      
 -                      foreach ($columnsToAdd as $column) {
 -                              $this->prepareColumnLog($tableName, $column);
 -                      }
 -                      
 -                      $this->applyColumnChanges(
 -                              $tableName,
 -                              $columnsToAdd,
 -                              $columnsToAlter,
 -                              $columnsToDrop
 -                      );
 -                      
 -                      foreach ($columnsToAdd as $column) {
 -                              $this->finalizeColumnLog($tableName, $column);
 -                      }
 -                      
 -                      foreach ($columnsToDrop as $column) {
 -                              $this->deleteColumnLog($tableName, $column);
 -                      }
 -              }
 -              
 -              foreach ($this->foreignKeysToDrop as $tableName => $foreignKeys) {
 -                      foreach ($foreignKeys as $foreignKey) {
 -                              $appliedAnyChange = true;
 -                              
 -                              $this->dropForeignKey($tableName, $foreignKey);
 -                              $this->deleteForeignKeyLog($tableName, $foreignKey);
 -                      }
 -              }
 -              
 -              foreach ($this->foreignKeysToAdd as $tableName => $foreignKeys) {
 -                      foreach ($foreignKeys as $foreignKey) {
 -                              $appliedAnyChange = true;
 -                              
 -                              $this->prepareForeignKeyLog($tableName, $foreignKey);
 -                              $this->addForeignKey($tableName, $foreignKey);
 -                              $this->finalizeForeignKeyLog($tableName, $foreignKey);
 -                      }
 -              }
 -              
 -              foreach ($this->indicesToDrop as $tableName => $indices) {
 -                      foreach ($indices as $index) {
 -                              $appliedAnyChange = true;
 -                              
 -                              $this->dropIndex($tableName, $index);
 -                              $this->deleteIndexLog($tableName, $index);
 -                      }
 -              }
 -              
 -              foreach ($this->indicesToAdd as $tableName => $indices) {
 -                      foreach ($indices as $index) {
 -                              $appliedAnyChange = true;
 -                              
 -                              $this->prepareIndexLog($tableName, $index);
 -                              $this->addIndex($tableName, $index);
 -                              $this->finalizeIndexLog($tableName, $index);
 -                      }
 -              }
 -              
 -              if ($appliedAnyChange) {
 -                      throw new SplitNodeException($this->splitNodeMessage);
 -              }
 -      }
 -      
 -      /**
 -       * Adds, alters, and drop columns of the same table.
 -       * 
 -       * Before a column is dropped, all of its foreign keys are dropped.
 -       * 
 -       * @param       string                  $tableName
 -       * @param       IDatabaseTableColumn[]  $addedColumns
 -       * @param       IDatabaseTableColumn[]  $alteredColumns
 -       * @param       IDatabaseTableColumn[]  $droppedColumns
 -       */
 -      protected function applyColumnChanges($tableName, array $addedColumns, array $alteredColumns, array $droppedColumns) {
 -              $dropForeignKeys = [];
 -              
 -              $columnData = [];
 -              foreach ($droppedColumns as $droppedColumn) {
 -                      $columnData[$droppedColumn->getName()] = [
 -                              'action' => 'drop'
 -                      ];
 -                      
 -                      foreach ($this->getExistingTable($tableName)->getForeignKeys() as $foreignKey) {
 -                              if (in_array($droppedColumn->getName(), $foreignKey->getColumns())) {
 -                                      $dropForeignKeys[] = $foreignKey;
 -                              }
 -                      }
 -              }
 -              foreach ($addedColumns as $addedColumn) {
 -                      $columnData[$addedColumn->getName()] = [
 -                              'action' => 'add',
 -                              'data' => $addedColumn->getData()
 -                      ];
 -              }
 -              foreach ($alteredColumns as $alteredColumn) {
 -                      $columnData[$alteredColumn->getName()] = [
 -                              'action' => 'alter',
 -                              'data' => $alteredColumn->getData(),
 -                              'oldColumnName' => $alteredColumn->getName()
 -                      ];
 -              }
 -              
 -              if (!empty($columnData)) {
 -                      foreach ($dropForeignKeys as $foreignKey) {
 -                              $this->dropForeignKey($tableName, $foreignKey);
 -                              $this->deleteForeignKeyLog($tableName, $foreignKey);
 -                      }
 -                      
 -                      $this->dbEditor->alterColumns($tableName, $columnData);
 -              }
 -      }
 -      
 -      /**
 -       * Calculates all of the necessary changes to be executed.
 -       */
 -      protected function calculateChanges() {
 -              foreach ($this->tables as $table) {
 -                      $tableName = $table->getName();
 -                      
 -                      if ($table->willBeDropped()) {
 -                              if (in_array($tableName, $this->existingTableNames)) {
 -                                      $this->tablesToDrop[] = $table;
 -                                      
 -                                      $this->splitNodeMessage .= "Dropped table '{$tableName}'.";
 -                                      break;
 -                              }
 -                              else if (isset($this->tablePackageIDs[$tableName])) {
 -                                      $this->deleteTableLog($table);
 -                              }
 -                      }
 -                      else if (!in_array($tableName, $this->existingTableNames)) {
 -                              if ($table instanceof PartialDatabaseTable) {
 -                                      throw new \LogicException("Partial table '{$tableName}' cannot be created.");
 -                              }
 -                              
 -                              $this->tablesToCreate[] = $table;
 -                              
 -                              $this->splitNodeMessage .= "Created table '{$tableName}'.";
 -                              break;
 -                      }
 -                      else {
 -                              // calculate difference between tables
 -                              $existingTable = $this->getExistingTable($tableName);
 -                              $existingColumns = $existingTable->getColumns();
 -                              
 -                              foreach ($table->getColumns() as $column) {
 -                                      if ($column->willBeDropped()) {
 -                                              if (isset($existingColumns[$column->getName()])) {
 -                                                      if (!isset($this->columnsToDrop[$tableName])) {
 -                                                              $this->columnsToDrop[$tableName] = [];
 -                                                      }
 -                                                      $this->columnsToDrop[$tableName][] = $column;
 -                                              }
 -                                              else if (isset($this->columnPackageIDs[$tableName][$column->getName()])) {
 -                                                      $this->deleteColumnLog($tableName, $column);
 -                                              }
 -                                      }
 -                                      else if (!isset($existingColumns[$column->getName()])) {
 -                                              if (!isset($this->columnsToAdd[$tableName])) {
 -                                                      $this->columnsToAdd[$tableName] = [];
 -                                              }
 -                                              $this->columnsToAdd[$tableName][] = $column;
 -                                      }
 -                                      else if ($this->diffColumns($existingColumns[$column->getName()], $column)) {
 -                                              if (!isset($this->columnsToAlter[$tableName])) {
 -                                                      $this->columnsToAlter[$tableName] = [];
 -                                              }
 -                                              $this->columnsToAlter[$tableName][] = $column;
 -                                      }
 -                              }
 -                              
 -                              // all column-related changes are executed in one query thus break
 -                              // here and not within the previous loop
 -                              if (!empty($this->columnsToAdd) || !empty($this->columnsToAlter) || !empty($this->columnsToDrop)) {
 -                                      $this->splitNodeMessage .= "Altered columns of table '{$tableName}'.";
 -                                      break;
 -                              }
 -                              
 -                              $existingForeignKeys = $existingTable->getForeignKeys();
 -                              foreach ($table->getForeignKeys() as $foreignKey) {
 -                                      $matchingExistingForeignKey = null;
 -                                      foreach ($existingForeignKeys as $existingForeignKey) {
 -                                              if (empty(array_diff($foreignKey->getDiffData(), $existingForeignKey->getDiffData()))) {
 -                                                      $matchingExistingForeignKey = $existingForeignKey;
 -                                                      break;
 -                                              }
 -                                      }
 -                                      
 -                                      if ($foreignKey->willBeDropped()) {
 -                                              if ($matchingExistingForeignKey !== null) {
 -                                                      if (!isset($this->foreignKeysToDrop[$tableName])) {
 -                                                              $this->foreignKeysToDrop[$tableName] = [];
 -                                                      }
 -                                                      $this->foreignKeysToDrop[$tableName][] = $foreignKey;
 -                                                      
 -                                                      $this->splitNodeMessage .= "Dropped foreign key '{$tableName}." . implode(',', $foreignKey->getColumns()) . "'.";
 -                                                      break 2;
 -                                              }
 -                                              else if (isset($this->foreignKeyPackageIDs[$tableName][$foreignKey->getName()])) {
 -                                                      $this->deleteForeignKeyLog($tableName, $foreignKey);
 -                                              }
 -                                      }
 -                                      else if ($matchingExistingForeignKey === null) {
 -                                              // If the referenced database table does not already exists, delay the
 -                                              // foreign key creation until after the referenced table has been created.
 -                                              if (!in_array($foreignKey->getReferencedTable(), $this->existingTableNames)) {
 -                                                      continue;
 -                                              }
 -                                              
 -                                              if (!isset($this->foreignKeysToAdd[$tableName])) {
 -                                                      $this->foreignKeysToAdd[$tableName] = [];
 -                                              }
 -                                              $this->foreignKeysToAdd[$tableName][] = $foreignKey;
 -                                              
 -                                              $this->splitNodeMessage .= "Added foreign key '{$tableName}." . implode(',', $foreignKey->getColumns()) . "'.";
 -                                              break 2;
 -                                      }
 -                                      else if (!empty(array_diff($foreignKey->getData(), $matchingExistingForeignKey->getData()))) {
 -                                              if (!isset($this->foreignKeysToDrop[$tableName])) {
 -                                                      $this->foreignKeysToDrop[$tableName] = [];
 -                                              }
 -                                              $this->foreignKeysToDrop[$tableName][] = $matchingExistingForeignKey;
 -                                              
 -                                              if (!isset($this->foreignKeysToAdd[$tableName])) {
 -                                                      $this->foreignKeysToAdd[$tableName] = [];
 -                                              }
 -                                              $this->foreignKeysToAdd[$tableName][] = $foreignKey;
 -                                              
 -                                              $this->splitNodeMessage .= "Replaced foreign key '{$tableName}." . implode(',', $foreignKey->getColumns()) . "'.";
 -                                              break 2;
 -                                      }
 -                              }
 -                              
 -                              $existingIndices = $existingTable->getIndices();
 -                              foreach ($table->getIndices() as $index) {
 -                                      $matchingExistingIndex = null;
 -                                      foreach ($existingIndices as $existingIndex) {
 -                                              if (!$this->diffIndices($existingIndex, $index)) {
 -                                                      $matchingExistingIndex = $existingIndex;
 -                                                      break;
 -                                              }
 -                                      }
 -                                      
 -                                      if ($index->willBeDropped()) {
 -                                              if ($matchingExistingIndex !== null) {
 -                                                      if (!isset($this->indicesToDrop[$tableName])) {
 -                                                              $this->indicesToDrop[$tableName] = [];
 -                                                      }
 -                                                      $this->indicesToDrop[$tableName][] = $matchingExistingIndex;
 -                                                      
 -                                                      $this->splitNodeMessage .= "Dropped index '{$tableName}." . implode(',', $index->getColumns()) . "'.";
 -                                                      break 2;
 -                                              }
 -                                              else if (isset($this->indexPackageIDs[$tableName][$index->getName()])) {
 -                                                      $this->deleteIndexLog($tableName, $index);
 -                                              }
 -                                      }
 -                                      else if ($matchingExistingIndex !== null) {
 -                                              // updating index type and index columns is supported with an
 -                                              // explicit index name is given (automatically generated index
 -                                              // names are not deterministic)
 -                                              if (!$index->hasGeneratedName() && !empty(array_diff($matchingExistingIndex->getData(), $index->getData()))) {
 -                                                      if (!isset($this->indicesToDrop[$tableName])) {
 -                                                              $this->indicesToDrop[$tableName] = [];
 -                                                      }
 -                                                      $this->indicesToDrop[$tableName][] = $matchingExistingIndex;
 -                                                      
 -                                                      if (!isset($this->indicesToAdd[$tableName])) {
 -                                                              $this->indicesToAdd[$tableName] = [];
 -                                                      }
 -                                                      $this->indicesToAdd[$tableName][] = $index;
 -                                              }
 -                                      }
 -                                      else {
 -                                              if (!isset($this->indicesToAdd[$tableName])) {
 -                                                      $this->indicesToAdd[$tableName] = [];
 -                                              }
 -                                              $this->indicesToAdd[$tableName][] = $index;
 -                                              
 -                                              $this->splitNodeMessage .= "Added index '{$tableName}." . implode(',', $index->getColumns()) . "'.";
 -                                              break 2;
 -                                      }
 -                              }
 -                      }
 -              }
 -      }
 -      
 -      /**
 -       * Checks for any pending log entries for the package and either marks them as done or
 -       * deletes them so that after this method finishes, there are no more undone log entries
 -       * for the package.
 -       */
 -      protected function checkPendingLogEntries() {
 -              $sql = "SELECT  *
 -                      FROM    wcf" . WCF_N . "_package_installation_sql_log
 -                      WHERE   packageID = ?
 -                              AND isDone = ?";
 -              $statement = WCF::getDB()->prepareStatement($sql);
 -              $statement->execute([$this->package->packageID, 0]);
 -              
 -              $doneEntries = $undoneEntries = [];
 -              while ($row = $statement->fetchArray()) {
 -                      // table
 -                      if ($row['sqlIndex'] === '' && $row['sqlColumn'] === '') {
 -                              if (in_array($row['sqlTable'], $this->existingTableNames)) {
 -                                      $doneEntries[] = $row;
 -                              }
 -                              else {
 -                                      $undoneEntries[] = $row;
 -                              }
 -                      }
 -                      // column
 -                      else if ($row['sqlIndex'] === '') {
 -                              if (isset($this->getExistingTable($row['sqlTable'])->getColumns()[$row['sqlColumn']])) {
 -                                      $doneEntries[] = $row;
 -                              }
 -                              else {
 -                                      $undoneEntries[] = $row;
 -                              }
 -                      }
 -                      // foreign key
 -                      else if (substr($row['sqlIndex'], -3) === '_fk') {
 -                              if (isset($this->getExistingTable($row['sqlTable'])->getForeignKeys()[$row['sqlIndex']])) {
 -                                      $doneEntries[] = $row;
 -                              }
 -                              else {
 -                                      $undoneEntries[] = $row;
 -                              }
 -                      }
 -                      // index
 -                      else {
 -                              if (isset($this->getExistingTable($row['sqlTable'])->getIndices()[$row['sqlIndex']])) {
 -                                      $doneEntries[] = $row;
 -                              }
 -                              else {
 -                                      $undoneEntries[] = $row;
 -                              }
 -                      }
 -              }
 -              
 -              WCF::getDB()->beginTransaction();
 -              foreach ($doneEntries as $entry) {
 -                      $this->finalizeLog($entry);
 -              }
 -              
 -              // to achieve a consistent state, undone log entries will be deleted here even though
 -              // they might be re-created later to ensure that after this method finishes, there are
 -              // no more undone entries in the log for the relevant package
 -              foreach ($undoneEntries as $entry) {
 -                      $this->deleteLog($entry);
 -              }
 -              WCF::getDB()->commitTransaction();
 -      }
 -      
 -      /**
 -       * Creates a done log entry for the given foreign key.
 -       * 
 -       * @param       string                          $tableName
 -       * @param       DatabaseTableForeignKey         $foreignKey
 -       */
 -      protected function createForeignKeyLog($tableName, DatabaseTableForeignKey $foreignKey) {
 -              $sql = "INSERT INTO     wcf" . WCF_N . "_package_installation_sql_log
 -                                      (packageID, sqlTable, sqlIndex, isDone)
 -                      VALUES          (?, ?, ?, ?)";
 -              $statement = WCF::getDB()->prepareStatement($sql);
 -              
 -              $statement->execute([
 -                      $this->package->packageID,
 -                      $tableName,
 -                      $foreignKey->getName(),
 -                      1
 -              ]);
 -      }
 -      
 -      /**
 -       * Creates the given table.
 -       * 
 -       * @param       DatabaseTable           $table
 -       */
 -      protected function createTable(DatabaseTable $table) {
 -              $hasPrimaryKey = false;
 -              $columnData = array_map(function(IDatabaseTableColumn $column) use (&$hasPrimaryKey) {
 -                      $data = $column->getData();
 -                      if (isset($data['key']) && $data['key'] === 'PRIMARY') {
 -                              $hasPrimaryKey = true;
 -                      }
 -                      
 -                      return [
 -                              'data' => $data,
 -                              'name' => $column->getName()
 -                      ];
 -              }, $table->getColumns());
 -              $indexData = array_map(function(DatabaseTableIndex $index) {
 -                      return [
 -                              'data' => $index->getData(),
 -                              'name' => $index->getName()
 -                      ];
 -              }, $table->getIndices());
 -              
 -              // Auto columns are implicitly defined as the primary key by MySQL.
 -              if ($hasPrimaryKey) {
 -                      $indexData = array_filter($indexData, function($key) {
 -                              return $key !== 'PRIMARY';
 -                      }, ARRAY_FILTER_USE_KEY);
 -              }
 -              
 -              $this->dbEditor->createTable($table->getName(), $columnData, $indexData);
 -              
 -              foreach ($table->getForeignKeys() as $foreignKey) {
 -                      // Only try to create the foreign key if the referenced database table already exists.
 -                      // If it will be created later on, delay the foreign key creation until after the
 -                      // referenced table has been created.
 -                      if (
 -                              in_array($foreignKey->getReferencedTable(), $this->existingTableNames)
 -                              || $foreignKey->getReferencedTable() === $table->getName()
 -                      ) {
 -                              $this->dbEditor->addForeignKey($table->getName(), $foreignKey->getName(), $foreignKey->getData());
 -                              
 -                              // foreign keys need to be explicitly logged for proper uninstallation
 -                              $this->createForeignKeyLog($table->getName(), $foreignKey);
 -                      }
 -              }
 -      }
 -      
 -      /**
 -       * Deletes the log entry for the given column.
 -       * 
 -       * @param       string                          $tableName
 -       * @param       IDatabaseTableColumn            $column
 -       */
 -      protected function deleteColumnLog($tableName, IDatabaseTableColumn $column) {
 -              $this->deleteLog(['sqlTable' => $tableName, 'sqlColumn' => $column->getName()]);
 -      }
 -      
 -      /**
 -       * Deletes the log entry for the given foreign key.
 -       *
 -       * @param       string                          $tableName
 -       * @param       DatabaseTableForeignKey         $foreignKey
 -       */
 -      protected function deleteForeignKeyLog($tableName, DatabaseTableForeignKey $foreignKey) {
 -              $this->deleteLog(['sqlTable' => $tableName, 'sqlIndex' => $foreignKey->getName()]);
 -      }
 -      
 -      /**
 -       * Deletes the log entry for the given index.
 -       * 
 -       * @param       string                  $tableName
 -       * @param       DatabaseTableIndex      $index
 -       */
 -      protected function deleteIndexLog($tableName, DatabaseTableIndex $index) {
 -              $this->deleteLog(['sqlTable' => $tableName, 'sqlIndex' => $index->getName()]);
 -      }
 -      
 -      /**
 -       * Deletes a log entry.
 -       * 
 -       * @param       array   $data
 -       */
 -      protected function deleteLog(array $data) {
 -              $sql = "DELETE FROM     wcf" . WCF_N . "_package_installation_sql_log
 -                      WHERE           packageID = ?
 -                                      AND sqlTable = ?
 -                                      AND sqlColumn = ?
 -                                      AND sqlIndex = ?";
 -              $statement = WCF::getDB()->prepareStatement($sql);
 -              
 -              $statement->execute([
 -                      $this->package->packageID,
 -                      $data['sqlTable'],
 -                      $data['sqlColumn'] ?? '',
 -                      $data['sqlIndex'] ?? ''
 -              ]);
 -      }
 -      
 -      /**
 -       * Deletes all log entry related to the given table.
 -       * 
 -       * @param       DatabaseTable   $table
 -       */
 -      protected function deleteTableLog(DatabaseTable $table) {
 -              $sql = "DELETE FROM     wcf" . WCF_N . "_package_installation_sql_log
 -                      WHERE           packageID = ?
 -                                      AND sqlTable = ?";
 -              $statement = WCF::getDB()->prepareStatement($sql);
 -              
 -              $statement->execute([
 -                      $this->package->packageID,
 -                      $table->getName()
 -              ]);
 -      }
 -      
 -      /**
 -       * Returns `true` if the two columns differ.
 -       * 
 -       * @param       IDatabaseTableColumn    $oldColumn
 -       * @param       IDatabaseTableColumn    $newColumn
 -       * @return      bool
 -       */
 -      protected function diffColumns(IDatabaseTableColumn $oldColumn, IDatabaseTableColumn $newColumn) {
 -              $diff = array_diff($oldColumn->getData(), $newColumn->getData());
 -              if (!empty($diff)) {
 -                      // see https://github.com/WoltLab/WCF/pull/3167
 -                      if (
 -                              array_key_exists('length', $diff)
 -                              && $oldColumn instanceof AbstractIntDatabaseTableColumn
 -                              && (
 -                                      !($oldColumn instanceof TinyintDatabaseTableColumn)
 -                                      || $oldColumn->getLength() != 1
 -                              )
 -                      ) {
 -                              unset($diff['length']);
 -                      }
 -                      
 -                      if (!empty($diff)) {
 -                              return true;
 -                      }
 -              }
 -              
 -              // default type has to be checked explicitly for `null` to properly detect changing
 -              // from no default value (`null`) and to an empty string as default value (and vice
 -              // versa)
 -              if ($oldColumn->getDefaultValue() === null || $newColumn->getDefaultValue() === null) {
 -                      return $oldColumn->getDefaultValue() !== $newColumn->getDefaultValue();
 -              }
 -              
 -              // for all other cases, use weak comparison so that `'1'` (from database) and `1`
 -              // (from script PIP) match, for example 
 -              return $oldColumn->getDefaultValue() != $newColumn->getDefaultValue();
 -      }
 -      
 -      /**
 -       * Returns `true` if the two indices differ.
 -       * 
 -       * @param       DatabaseTableIndex      $oldIndex
 -       * @param       DatabaseTableIndex      $newIndex
 -       * @return      bool
 -       */
 -      protected function diffIndices(DatabaseTableIndex $oldIndex, DatabaseTableIndex $newIndex) {
 -              if ($newIndex->hasGeneratedName()) {
 -                      return !empty(array_diff($oldIndex->getData(), $newIndex->getData()));
 -              }
 -              
 -              return $oldIndex->getName() !== $newIndex->getName();
 -      }
 -      
 -      /**
 -       * Drops the given foreign key.
 -       * 
 -       * @param       string                          $tableName
 -       * @param       DatabaseTableForeignKey         $foreignKey
 -       */
 -      protected function dropForeignKey($tableName, DatabaseTableForeignKey $foreignKey) {
 -              $this->dbEditor->dropForeignKey($tableName, $foreignKey->getName());
 -              $this->dbEditor->dropIndex($tableName, $foreignKey->getName());
 -      }
 -      
 -      /**
 -       * Drops the given index.
 -       * 
 -       * @param       string                  $tableName
 -       * @param       DatabaseTableIndex      $index
 -       */
 -      protected function dropIndex($tableName, DatabaseTableIndex $index) {
 -              $this->dbEditor->dropIndex($tableName, $index->getName());
 -      }
 -      
 -      /**
 -       * Drops the given table.
 -       * 
 -       * @param       DatabaseTable           $table
 -       */
 -      protected function dropTable(DatabaseTable $table) {
 -              $this->dbEditor->dropTable($table->getName());
 -      }
 -      
 -      /**
 -       * Finalizes the log entry for the creation of the given column.
 -       * 
 -       * @param       string                  $tableName
 -       * @param       IDatabaseTableColumn    $column
 -       */
 -      protected function finalizeColumnLog($tableName, IDatabaseTableColumn $column) {
 -              $this->finalizeLog(['sqlTable' => $tableName, 'sqlColumn' => $column->getName()]);
 -      }
 -      
 -      /**
 -       * Finalizes the log entry for adding the given index.
 -       * 
 -       * @param       string                          $tableName
 -       * @param       DatabaseTableForeignKey         $foreignKey
 -       */
 -      protected function finalizeForeignKeyLog($tableName, DatabaseTableForeignKey $foreignKey) {
 -              $this->finalizeLog(['sqlTable' => $tableName, 'sqlIndex' => $foreignKey->getName()]);
 -      }
 -      
 -      /**
 -       * Finalizes the log entry for adding the given index.
 -       *
 -       * @param       string                  $tableName
 -       * @param       DatabaseTableIndex      $index
 -       */
 -      protected function finalizeIndexLog($tableName, DatabaseTableIndex $index) {
 -              $this->finalizeLog(['sqlTable' => $tableName, 'sqlIndex' => $index->getName()]);
 -      }
 -      
 -      /**
 -       * Finalizes a log entry after the relevant change has been executed.
 -       * 
 -       * @param       array   $data
 -       */
 -      protected function finalizeLog(array $data) {
 -              $sql = "UPDATE  wcf" . WCF_N . "_package_installation_sql_log
 -                      SET     isDone = ?
 -                      WHERE   packageID = ?
 -                              AND sqlTable = ?
 -                              AND sqlColumn = ?
 -                              AND sqlIndex = ?";
 -              $statement = WCF::getDB()->prepareStatement($sql);
 -              
 -              $statement->execute([
 -                      1,
 -                      $this->package->packageID,
 -                      $data['sqlTable'],
 -                      $data['sqlColumn'] ?? '',
 -                      $data['sqlIndex'] ?? ''
 -              ]);
 -      }
 -      
 -      /**
 -       * Finalizes the log entry for the creation of the given table.
 -       * 
 -       * @param       DatabaseTable   $table
 -       */
 -      protected function finalizeTableLog(DatabaseTable $table) {
 -              $this->finalizeLog(['sqlTable' => $table->getName()]);
 -      }
 -      
 -      /**
 -       * Returns the id of the package to with the given column belongs to. If there is no specific
 -       * log entry for the given column, the table log is checked and the relevant package id of
 -       * the whole table is returned. If the package of the table is also unknown, `null` is returned.
 -       * 
 -       * @param       DatabaseTable           $table
 -       * @param       IDatabaseTableColumn    $column
 -       * @return      null|int
 -       */
 -      protected function getColumnPackageID(DatabaseTable $table, IDatabaseTableColumn $column) {
 -              if (isset($this->columnPackageIDs[$table->getName()][$column->getName()])) {
 -                      return $this->columnPackageIDs[$table->getName()][$column->getName()];
 -              }
 -              else if (isset($this->tablePackageIDs[$table->getName()])) {
 -                      return $this->tablePackageIDs[$table->getName()];
 -              }
 -              
 -              return null;
 -      }
 -      
 -      /**
 -       * Returns the `DatabaseTable` object for the table with the given name.
 -       * 
 -       * @param       string          $tableName
 -       * @return      DatabaseTable
 -       */
 -      protected function getExistingTable($tableName) {
 -              if (!isset($this->existingTables[$tableName])) {
 -                      $this->existingTables[$tableName] = DatabaseTable::createFromExistingTable($this->dbEditor, $tableName);
 -              }
 -              
 -              return $this->existingTables[$tableName];
 -      }
 -      
 -      /**
 -       * Returns the id of the package to with the given foreign key belongs to. If there is no specific
 -       * log entry for the given foreign key, the table log is checked and the relevant package id of
 -       * the whole table is returned. If the package of the table is also unknown, `null` is returned.
 -       * 
 -       * @param       DatabaseTable                   $table
 -       * @param       DatabaseTableForeignKey         $foreignKey
 -       * @return      null|int
 -       */
 -      protected function getForeignKeyPackageID(DatabaseTable $table, DatabaseTableForeignKey $foreignKey) {
 -              if (isset($this->foreignKeyPackageIDs[$table->getName()][$foreignKey->getName()])) {
 -                      return $this->foreignKeyPackageIDs[$table->getName()][$foreignKey->getName()];
 -              }
 -              else if (isset($this->tablePackageIDs[$table->getName()])) {
 -                      return $this->tablePackageIDs[$table->getName()];
 -              }
 -              
 -              return null;
 -      }
 -      
 -      /**
 -       * Returns the id of the package to with the given index belongs to. If there is no specific
 -       * log entry for the given index, the table log is checked and the relevant package id of
 -       * the whole table is returned. If the package of the table is also unknown, `null` is returned.
 -       * 
 -       * @param       DatabaseTable           $table
 -       * @param       DatabaseTableIndex      $index
 -       * @return      null|int
 -       */
 -      protected function getIndexPackageID(DatabaseTable $table, DatabaseTableIndex $index) {
 -              if (isset($this->indexPackageIDs[$table->getName()][$index->getName()])) {
 -                      return $this->indexPackageIDs[$table->getName()][$index->getName()];
 -              }
 -              else if (isset($this->tablePackageIDs[$table->getName()])) {
 -                      return $this->tablePackageIDs[$table->getName()];
 -              }
 -              
 -              return null;
 -      }
 -      
 -      /**
 -       * Prepares the log entry for the creation of the given column.
 -       * 
 -       * @param       string                  $tableName
 -       * @param       IDatabaseTableColumn    $column
 -       */
 -      protected function prepareColumnLog($tableName, IDatabaseTableColumn $column) {
 -              $this->prepareLog(['sqlTable' => $tableName, 'sqlColumn' => $column->getName()]);
 -      }
 -      
 -      /**
 -       * Prepares the log entry for adding the given foreign key.
 -       * 
 -       * @param       string                          $tableName
 -       * @param       DatabaseTableForeignKey         $foreignKey
 -       */
 -      protected function prepareForeignKeyLog($tableName, DatabaseTableForeignKey $foreignKey) {
 -              $this->prepareLog(['sqlTable' => $tableName, 'sqlIndex' => $foreignKey->getName()]);
 -      }
 -      
 -      /**
 -       * Prepares the log entry for adding the given index.
 -       *
 -       * @param       string                  $tableName
 -       * @param       DatabaseTableIndex      $index
 -       */
 -      protected function prepareIndexLog($tableName, DatabaseTableIndex $index) {
 -              $this->prepareLog(['sqlTable' => $tableName, 'sqlIndex' => $index->getName()]);
 -      }
 -      
 -      /**
 -       * Prepares a log entry before the relevant change has been executed.
 -       *
 -       * @param       array   $data
 -       */
 -      protected function prepareLog(array $data) {
 -              $sql = "INSERT INTO     wcf" . WCF_N . "_package_installation_sql_log
 -                                      (packageID, sqlTable, sqlColumn, sqlIndex, isDone)
 -                      VALUES          (?, ?, ?, ?, ?)";
 -              $statement = WCF::getDB()->prepareStatement($sql);
 -              
 -              $statement->execute([
 -                      $this->package->packageID,
 -                      $data['sqlTable'],
 -                      $data['sqlColumn'] ?? '',
 -                      $data['sqlIndex'] ?? '',
 -                      0
 -              ]);
 -      }
 -      
 -      /**
 -       * Prepares the log entry for the creation of the given table.
 -       *
 -       * @param       DatabaseTable   $table
 -       */
 -      protected function prepareTableLog(DatabaseTable $table) {
 -              $this->prepareLog(['sqlTable' => $table->getName()]);
 -      }
 -      
 -      /**
 -       * Processes all tables and updates the current table layouts to match the specified layouts. 
 -       * 
 -       * @throws      \RuntimeException       if validation of the required layout changes fails
 -       */
 -      public function process() {
 -              $this->checkPendingLogEntries();
 -              
 -              $errors = $this->validate();
 -              if (!empty($errors)) {
 -                      throw new \RuntimeException(WCF::getLanguage()->getDynamicVariable('wcf.acp.package.error.databaseChange', [
 -                              'errors' => $errors
 -                      ]));
 -              }
 -              
 -              $this->calculateChanges();
 -              
 -              $this->applyChanges();
 -      }
 -      
 -      /**
 -       * Checks if the relevant table layout changes can be executed and returns an array with information
 -       * on all validation errors.
 -       * 
 -       * @return      array
 -       */
 -      public function validate() {
 -              $errors = [];
 -              foreach ($this->tables as $table) {
 -                      if ($table->willBeDropped()) {
 -                              if (in_array($table->getName(), $this->existingTableNames)) {
 -                                      if (!isset($this->tablePackageIDs[$table->getName()])) {
 -                                              $errors[] = [
 -                                                      'tableName' => $table->getName(),
 -                                                      'type' => 'unregisteredTableDrop'
 -                                              ];
 -                                      }
 -                                      else if ($this->tablePackageIDs[$table->getName()] !== $this->package->packageID) {
 -                                              $errors[] = [
 -                                                      'tableName' => $table->getName(),
 -                                                      'type' => 'foreignTableDrop'
 -                                              ];
 -                                      }
 -                              }
 -                      }
 -                      else {
 -                              $existingTable = null;
 -                              if (in_array($table->getName(), $this->existingTableNames)) {
 -                                      if (!isset($this->tablePackageIDs[$table->getName()])) {
 -                                              $errors[] = [
 -                                                      'tableName' => $table->getName(),
 -                                                      'type' => 'unregisteredTableChange',
 -                                              ];
 -                                      }
 -                                      else {
 -                                              $existingTable = DatabaseTable::createFromExistingTable($this->dbEditor, $table->getName());
 -                                              $existingColumns = $existingTable->getColumns();
 -                                              $existingIndices = $existingTable->getIndices();
 -                                              $existingForeignKeys = $existingTable->getForeignKeys();
 -                                              
 -                                              foreach ($table->getColumns() as $column) {
 -                                                      if (isset($existingColumns[$column->getName()])) {
 -                                                              $columnPackageID = $this->getColumnPackageID($table, $column);
 -                                                              if ($column->willBeDropped()) {
 -                                                                      if ($columnPackageID !== $this->package->packageID) {
 -                                                                              $errors[] = [
 -                                                                                      'columnName' => $column->getName(),
 -                                                                                      'tableName' => $table->getName(),
 -                                                                                      'type' => 'foreignColumnDrop',
 -                                                                              ];
 -                                                                      }
 -                                                              }
 -                                                              else if ($columnPackageID !== $this->package->packageID) {
 -                                                                      $errors[] = [
 -                                                                              'columnName' => $column->getName(),
 -                                                                              'tableName' => $table->getName(),
 -                                                                              'type' => 'foreignColumnChange',
 -                                                                      ];
 -                                                              }
 -                                                      }
 -                                              }
 -                                              
 -                                              foreach ($table->getIndices() as $index) {
 -                                                      foreach ($existingIndices as $existingIndex) {
 -                                                              if (empty(array_diff($index->getData(), $existingIndex->getData()))) {
 -                                                                      if ($index->willBeDropped()) {
 -                                                                              if ($this->getIndexPackageID($table, $index) !== $this->package->packageID) {
 -                                                                                      $errors[] = [
 -                                                                                              'columnNames' => implode(',', $existingIndex->getColumns()),
 -                                                                                              'tableName' => $table->getName(),
 -                                                                                              'type' => 'foreignIndexDrop',
 -                                                                                      ];
 -                                                                              }
 -                                                                      }
 -                                                                      
 -                                                                      continue 2;
 -                                                              }
 -                                                      }
 -                                              }
 -                                              
 -                                              foreach ($table->getForeignKeys() as $foreignKey) {
 -                                                      foreach ($existingForeignKeys as $existingForeignKey) {
 -                                                              if (empty(array_diff($foreignKey->getData(), $existingForeignKey->getData()))) {
 -                                                                      if ($foreignKey->willBeDropped()) {
 -                                                                              if ($this->getForeignKeyPackageID($table, $foreignKey) !== $this->package->packageID) {
 -                                                                                      $errors[] = [
 -                                                                                              'columnNames' => implode(',', $existingForeignKey->getColumns()),
 -                                                                                              'tableName' => $table->getName(),
 -                                                                                              'type' => 'foreignForeignKeyDrop',
 -                                                                                      ];
 -                                                                              }
 -                                                                      }
 -                                                                      
 -                                                                      continue 2;
 -                                                              }
 -                                                      }
 -                                              }
 -                                      }
 -                              }
 -                              
 -                              foreach ($table->getIndices() as $index) {
 -                                      foreach ($index->getColumns() as $indexColumn) {
 -                                              $column = $this->getColumnByName($indexColumn, $table, $existingTable);
 -                                              if ($column === null) {
 -                                                      if (!$index->willBeDropped()) {
 -                                                              $errors[] = [
 -                                                                      'columnName' => $indexColumn,
 -                                                                      'columnNames' => implode(',', $index->getColumns()),
 -                                                                      'tableName' => $table->getName(),
 -                                                                      'type' => 'nonexistingColumnInIndex',
 -                                                              ];
 -                                                      }
 -                                              }
 -                                              else if (
 -                                                      $index->getType() === DatabaseTableIndex::PRIMARY_TYPE
 -                                                      && !$index->willBeDropped()
 -                                                      && !$column->isNotNull()
 -                                              ) {
 -                                                      $errors[] = [
 -                                                              'columnName' => $indexColumn,
 -                                                              'columnNames' => implode(',', $index->getColumns()),
 -                                                              'tableName' => $table->getName(),
 -                                                              'type' => 'nullColumnInPrimaryIndex',
 -                                                      ];
 -                                              }
 -                                      }
 -                              }
 -
 -                              foreach ($table->getForeignKeys() as $foreignKey) {
 -                                      $referencedTableExists = in_array($foreignKey->getReferencedTable(), $this->existingTableNames);
 -                                      foreach ($this->tables as $processedTable) {
 -                                              if ($processedTable->getName() === $foreignKey->getReferencedTable()) {
 -                                                      $referencedTableExists = !$processedTable->willBeDropped();
 -                                              }
 -                                      }
 -                                      
 -                                      if (!$referencedTableExists) {
 -                                              $errors[] = [
 -                                                      'columnNames' => implode(',', $foreignKey->getColumns()),
 -                                                      'referencedTableName' => $foreignKey->getReferencedTable(),
 -                                                      'tableName' => $table->getName(),
 -                                                      'type' => 'unknownTableInForeignKey',
 -                                              ];
 -                                      }
 -                              }
 -                      }
 -              }
 -              
 -              return $errors;
 -      }
 -      
 -      /**
 -       * Returns the column with the given name from the given table.
 -       * 
 -       * @param       string                  $columnName
 -       * @param       DatabaseTable           $updateTable
 -       * @param       DatabaseTable|null      $existingTable
 -       * @return      IDatabaseTableColumn|null
 -       * @since       5.2.10
 -       */
 -      protected function getColumnByName($columnName, DatabaseTable $updateTable, DatabaseTable $existingTable = null) {
 -              foreach ($updateTable->getColumns() as $column) {
 -                      if ($column->getName() === $columnName) {
 -                              return $column;
 -                      }
 -              }
 -              
 -              if ($existingTable) {
 -                      foreach ($existingTable->getColumns() as $column) {
 -                              if ($column->getName() === $columnName) {
 -                                      return $column;
 -                              }
 -                      }
 -              }
 -              
 -              return null;
 -      }
 +class DatabaseTableChangeProcessor
 +{
 +    /**
 +     * maps the registered database table column names to the ids of the packages they belong to
 +     * @var int[][]
 +     */
 +    protected $columnPackageIDs = [];
 +
 +    /**
 +     * database table columns that will be added grouped by the name of the table to which they
 +     * will be added
 +     * @var IDatabaseTableColumn[][]
 +     */
 +    protected $columnsToAdd = [];
 +
 +    /**
 +     * database table columns that will be altered grouped by the name of the table to which
 +     * they belong
 +     * @var IDatabaseTableColumn[][]
 +     */
 +    protected $columnsToAlter = [];
 +
 +    /**
 +     * database table columns that will be dropped grouped by the name of the table from which
 +     * they will be dropped
 +     * @var IDatabaseTableColumn[][]
 +     */
 +    protected $columnsToDrop = [];
 +
 +    /**
 +     * database editor to apply the relevant changes to the table layouts
 +     * @var DatabaseEditor
 +     */
 +    protected $dbEditor;
 +
 +    /**
 +     * list of all existing tables in the used database
 +     * @var string[]
 +     */
 +    protected $existingTableNames = [];
 +
 +    /**
 +     * existing database tables
 +     * @var DatabaseTable[]
 +     */
 +    protected $existingTables = [];
 +
 +    /**
 +     * maps the registered database table index names to the ids of the packages they belong to
 +     * @var int[][]
 +     */
 +    protected $indexPackageIDs = [];
 +
 +    /**
 +     * indices that will be added grouped by the name of the table to which they will be added
 +     * @var DatabaseTableIndex[][]
 +     */
 +    protected $indicesToAdd = [];
 +
 +    /**
 +     * indices that will be dropped grouped by the name of the table from which they will be dropped
 +     * @var DatabaseTableIndex[][]
 +     */
 +    protected $indicesToDrop = [];
 +
 +    /**
 +     * maps the registered database table foreign key names to the ids of the packages they belong to
 +     * @var int[][]
 +     */
 +    protected $foreignKeyPackageIDs = [];
 +
 +    /**
 +     * foreign keys that will be added grouped by the name of the table to which they will be
 +     * added
 +     * @var DatabaseTableForeignKey[][]
 +     */
 +    protected $foreignKeysToAdd = [];
 +
 +    /**
 +     * foreign keys that will be dropped grouped by the name of the table from which they will
 +     * be dropped
 +     * @var DatabaseTableForeignKey[][]
 +     */
 +    protected $foreignKeysToDrop = [];
 +
 +    /**
 +     * package that wants to apply the changes
 +     * @var Package
 +     */
 +    protected $package;
 +
 +    /**
 +     * message for the split node exception thrown after the changes have been applied
 +     * @var string
 +     */
 +    protected $splitNodeMessage = '';
 +
 +    /**
 +     * layouts/layout changes of the relevant database table
 +     * @var DatabaseTable[]
 +     */
 +    protected $tables;
 +
 +    /**
 +     * maps the registered database table names to the ids of the packages they belong to
 +     * @var int[]
 +     */
 +    protected $tablePackageIDs = [];
 +
 +    /**
 +     * database table that will be created
 +     * @var DatabaseTable[]
 +     */
 +    protected $tablesToCreate = [];
 +
 +    /**
 +     * database tables that will be dropped
 +     * @var DatabaseTable[]
 +     */
 +    protected $tablesToDrop = [];
 +
 +    /**
 +     * Creates a new instance of `DatabaseTableChangeProcessor`.
 +     *
 +     * @param Package $package
 +     * @param DatabaseTable[] $tables
 +     * @param DatabaseEditor $dbEditor
 +     */
 +    public function __construct(Package $package, array $tables, DatabaseEditor $dbEditor)
 +    {
 +        $this->package = $package;
 +
 +        $tableNames = [];
 +        foreach ($tables as $table) {
 +            if (!($table instanceof DatabaseTable)) {
 +                throw new \InvalidArgumentException("Tables must be instance of '" . DatabaseTable::class . "'");
 +            }
 +
 +            $tableNames[] = $table->getName();
 +        }
 +
 +        $this->tables = $tables;
 +        $this->dbEditor = $dbEditor;
 +
 +        $this->existingTableNames = $dbEditor->getTableNames();
 +
 +        $conditionBuilder = new PreparedStatementConditionBuilder();
 +        $conditionBuilder->add('sqlTable IN (?)', [$tableNames]);
 +        $conditionBuilder->add('isDone = ?', [1]);
 +
 +        $sql = "SELECT  *
 +                FROM    wcf" . WCF_N . "_package_installation_sql_log
 +                " . $conditionBuilder;
 +        $statement = WCF::getDB()->prepareStatement($sql);
 +        $statement->execute($conditionBuilder->getParameters());
 +
 +        while ($row = $statement->fetchArray()) {
 +            if ($row['sqlIndex'] === '' && $row['sqlColumn'] === '') {
 +                $this->tablePackageIDs[$row['sqlTable']] = $row['packageID'];
 +            } elseif ($row['sqlIndex'] === '') {
 +                $this->columnPackageIDs[$row['sqlTable']][$row['sqlColumn']] = $row['packageID'];
 +            } elseif (\substr($row['sqlIndex'], -3) === '_fk') {
 +                $this->foreignKeyPackageIDs[$row['sqlTable']][$row['sqlIndex']] = $row['packageID'];
 +            } else {
 +                $this->indexPackageIDs[$row['sqlTable']][$row['sqlIndex']] = $row['packageID'];
 +            }
 +        }
 +    }
 +
 +    /**
 +     * Adds the given index to the table.
 +     *
 +     * @param string $tableName
 +     * @param DatabaseTableForeignKey $foreignKey
 +     */
 +    protected function addForeignKey($tableName, DatabaseTableForeignKey $foreignKey)
 +    {
 +        $this->dbEditor->addForeignKey($tableName, $foreignKey->getName(), $foreignKey->getData());
 +    }
 +
 +    /**
 +     * Adds the given index to the table.
 +     *
 +     * @param string $tableName
 +     * @param DatabaseTableIndex $index
 +     */
 +    protected function addIndex($tableName, DatabaseTableIndex $index)
 +    {
 +        $this->dbEditor->addIndex($tableName, $index->getName(), $index->getData());
 +    }
 +
 +    /**
 +     * Applies all of the previously determined changes to achieve the desired database layout.
 +     *
 +     * @throws  SplitNodeException  if any change has been applied
 +     */
 +    protected function applyChanges()
 +    {
 +        $appliedAnyChange = false;
 +
 +        foreach ($this->tablesToCreate as $table) {
 +            $appliedAnyChange = true;
 +
 +            $this->prepareTableLog($table);
 +            $this->createTable($table);
 +            $this->finalizeTableLog($table);
 +        }
 +
 +        foreach ($this->tablesToDrop as $table) {
 +            $appliedAnyChange = true;
 +
 +            $this->dropTable($table);
 +            $this->deleteTableLog($table);
 +        }
 +
 +        $columnTables = \array_unique(\array_merge(
 +            \array_keys($this->columnsToAdd),
 +            \array_keys($this->columnsToAlter),
 +            \array_keys($this->columnsToDrop)
 +        ));
 +        foreach ($columnTables as $tableName) {
 +            $appliedAnyChange = true;
 +
 +            $columnsToAdd = $this->columnsToAdd[$tableName] ?? [];
 +            $columnsToAlter = $this->columnsToAlter[$tableName] ?? [];
 +            $columnsToDrop = $this->columnsToDrop[$tableName] ?? [];
 +
 +            foreach ($columnsToAdd as $column) {
 +                $this->prepareColumnLog($tableName, $column);
 +            }
 +
 +            $renamedColumnsWithLog = [];
 +            foreach ($columnsToAlter as $column) {
 +                if ($column->getNewName() && $this->getColumnLog($tableName, $column) !== null) {
 +                    $this->prepareColumnLog($tableName, $column, true);
 +                    $renamedColumnsWithLog[] = $column;
 +                }
 +            }
 +
 +            $this->applyColumnChanges(
 +                $tableName,
 +                $columnsToAdd,
 +                $columnsToAlter,
 +                $columnsToDrop
 +            );
 +
 +            foreach ($columnsToAdd as $column) {
 +                $this->finalizeColumnLog($tableName, $column);
 +            }
 +
 +            foreach ($renamedColumnsWithLog as $column) {
 +                $this->finalizeColumnLog($tableName, $column, true);
 +                $this->deleteColumnLog($tableName, $column);
 +            }
 +
 +            foreach ($columnsToDrop as $column) {
 +                $this->deleteColumnLog($tableName, $column);
 +            }
 +        }
 +
 +        foreach ($this->foreignKeysToDrop as $tableName => $foreignKeys) {
 +            foreach ($foreignKeys as $foreignKey) {
 +                $appliedAnyChange = true;
 +
 +                $this->dropForeignKey($tableName, $foreignKey);
 +                $this->deleteForeignKeyLog($tableName, $foreignKey);
 +            }
 +        }
 +
 +        foreach ($this->foreignKeysToAdd as $tableName => $foreignKeys) {
 +            foreach ($foreignKeys as $foreignKey) {
 +                $appliedAnyChange = true;
 +
 +                $this->prepareForeignKeyLog($tableName, $foreignKey);
 +                $this->addForeignKey($tableName, $foreignKey);
 +                $this->finalizeForeignKeyLog($tableName, $foreignKey);
 +            }
 +        }
 +
 +        foreach ($this->indicesToDrop as $tableName => $indices) {
 +            foreach ($indices as $index) {
 +                $appliedAnyChange = true;
 +
 +                $this->dropIndex($tableName, $index);
 +                $this->deleteIndexLog($tableName, $index);
 +            }
 +        }
 +
 +        foreach ($this->indicesToAdd as $tableName => $indices) {
 +            foreach ($indices as $index) {
 +                $appliedAnyChange = true;
 +
 +                $this->prepareIndexLog($tableName, $index);
 +                $this->addIndex($tableName, $index);
 +                $this->finalizeIndexLog($tableName, $index);
 +            }
 +        }
 +
 +        if ($appliedAnyChange) {
 +            throw new SplitNodeException($this->splitNodeMessage);
 +        }
 +    }
 +
 +    /**
 +     * Adds, alters, and drop columns of the same table.
 +     *
 +     * Before a column is dropped, all of its foreign keys are dropped.
 +     *
 +     * @param string $tableName
 +     * @param IDatabaseTableColumn[] $addedColumns
 +     * @param IDatabaseTableColumn[] $alteredColumns
 +     * @param IDatabaseTableColumn[] $droppedColumns
 +     */
 +    protected function applyColumnChanges($tableName, array $addedColumns, array $alteredColumns, array $droppedColumns)
 +    {
 +        $dropForeignKeys = [];
 +
 +        $columnData = [];
 +        foreach ($droppedColumns as $droppedColumn) {
 +            $columnData[$droppedColumn->getName()] = [
 +                'action' => 'drop',
 +            ];
 +
 +            foreach ($this->getExistingTable($tableName)->getForeignKeys() as $foreignKey) {
 +                if (\in_array($droppedColumn->getName(), $foreignKey->getColumns())) {
 +                    $dropForeignKeys[] = $foreignKey;
 +                }
 +            }
 +        }
 +        foreach ($addedColumns as $addedColumn) {
 +            $columnData[$addedColumn->getName()] = [
 +                'action' => 'add',
 +                'data' => $addedColumn->getData(),
 +            ];
 +        }
 +        foreach ($alteredColumns as $alteredColumn) {
 +            $columnData[$alteredColumn->getName()] = [
 +                'action' => 'alter',
 +                'data' => $alteredColumn->getData(),
 +                'newColumnName' => $alteredColumn->getNewName() ?? $alteredColumn->getName(),
 +            ];
 +        }
 +
 +        if (!empty($columnData)) {
 +            foreach ($dropForeignKeys as $foreignKey) {
 +                $this->dropForeignKey($tableName, $foreignKey);
 +                $this->deleteForeignKeyLog($tableName, $foreignKey);
 +            }
 +
 +            $this->dbEditor->alterColumns($tableName, $columnData);
 +        }
 +    }
 +
 +    /**
 +     * Calculates all of the necessary changes to be executed.
 +     */
 +    protected function calculateChanges()
 +    {
 +        foreach ($this->tables as $table) {
 +            $tableName = $table->getName();
 +
 +            if ($table->willBeDropped()) {
 +                if (\in_array($tableName, $this->existingTableNames)) {
 +                    $this->tablesToDrop[] = $table;
 +
 +                    $this->splitNodeMessage .= "Dropped table '{$tableName}'.";
 +                    break;
 +                } elseif (isset($this->tablePackageIDs[$tableName])) {
 +                    $this->deleteTableLog($table);
 +                }
 +            } elseif (!\in_array($tableName, $this->existingTableNames)) {
 +                if ($table instanceof PartialDatabaseTable) {
 +                    throw new \LogicException("Partial table '{$tableName}' cannot be created.");
 +                }
 +
 +                $this->tablesToCreate[] = $table;
 +
 +                $this->splitNodeMessage .= "Created table '{$tableName}'.";
 +                break;
 +            } else {
 +                // calculate difference between tables
 +                $existingTable = $this->getExistingTable($tableName);
 +                $existingColumns = $existingTable->getColumns();
 +
 +                foreach ($table->getColumns() as $column) {
 +                    if ($column->willBeDropped()) {
 +                        if (isset($existingColumns[$column->getName()])) {
 +                            if (!isset($this->columnsToDrop[$tableName])) {
 +                                $this->columnsToDrop[$tableName] = [];
 +                            }
 +                            $this->columnsToDrop[$tableName][] = $column;
 +                        } elseif (isset($this->columnPackageIDs[$tableName][$column->getName()])) {
 +                            $this->deleteColumnLog($tableName, $column);
 +                        }
 +                    } elseif (!isset($existingColumns[$column->getName()])) {
 +                        if (!isset($this->columnsToAdd[$tableName])) {
 +                            $this->columnsToAdd[$tableName] = [];
 +                        }
 +                        $this->columnsToAdd[$tableName][] = $column;
 +                    } elseif ($this->diffColumns($existingColumns[$column->getName()], $column)) {
 +                        if (!isset($this->columnsToAlter[$tableName])) {
 +                            $this->columnsToAlter[$tableName] = [];
 +                        }
 +                        $this->columnsToAlter[$tableName][] = $column;
 +                    }
 +                }
 +
 +                // all column-related changes are executed in one query thus break
 +                // here and not within the previous loop
 +                if (!empty($this->columnsToAdd) || !empty($this->columnsToAlter) || !empty($this->columnsToDrop)) {
 +                    $this->splitNodeMessage .= "Altered columns of table '{$tableName}'.";
 +                    break;
 +                }
 +
 +                $existingForeignKeys = $existingTable->getForeignKeys();
 +                foreach ($table->getForeignKeys() as $foreignKey) {
 +                    $matchingExistingForeignKey = null;
 +                    foreach ($existingForeignKeys as $existingForeignKey) {
 +                        if (empty(\array_diff($foreignKey->getDiffData(), $existingForeignKey->getDiffData()))) {
 +                            $matchingExistingForeignKey = $existingForeignKey;
 +                            break;
 +                        }
 +                    }
 +
 +                    if ($foreignKey->willBeDropped()) {
 +                        if ($matchingExistingForeignKey !== null) {
 +                            if (!isset($this->foreignKeysToDrop[$tableName])) {
 +                                $this->foreignKeysToDrop[$tableName] = [];
 +                            }
 +                            $this->foreignKeysToDrop[$tableName][] = $foreignKey;
 +
 +                            $this->splitNodeMessage .= "Dropped foreign key '{$tableName}." . \implode(
 +                                ',',
 +                                $foreignKey->getColumns()
 +                            ) . "'.";
 +                            break 2;
 +                        } elseif (isset($this->foreignKeyPackageIDs[$tableName][$foreignKey->getName()])) {
 +                            $this->deleteForeignKeyLog($tableName, $foreignKey);
 +                        }
 +                    } elseif ($matchingExistingForeignKey === null) {
++                        // If the referenced database table does not already exists, delay the
++                        // foreign key creation until after the referenced table has been created.
++                        if (!\in_array($foreignKey->getReferencedTable(), $this->existingTableNames)) {
++                            continue;
++                        }
++
 +                        if (!isset($this->foreignKeysToAdd[$tableName])) {
 +                            $this->foreignKeysToAdd[$tableName] = [];
 +                        }
 +                        $this->foreignKeysToAdd[$tableName][] = $foreignKey;
 +
 +                        $this->splitNodeMessage .= "Added foreign key '{$tableName}." . \implode(
 +                            ',',
 +                            $foreignKey->getColumns()
 +                        ) . "'.";
 +                        break 2;
 +                    } elseif (!empty(\array_diff($foreignKey->getData(), $matchingExistingForeignKey->getData()))) {
 +                        if (!isset($this->foreignKeysToDrop[$tableName])) {
 +                            $this->foreignKeysToDrop[$tableName] = [];
 +                        }
 +                        $this->foreignKeysToDrop[$tableName][] = $matchingExistingForeignKey;
 +
 +                        if (!isset($this->foreignKeysToAdd[$tableName])) {
 +                            $this->foreignKeysToAdd[$tableName] = [];
 +                        }
 +                        $this->foreignKeysToAdd[$tableName][] = $foreignKey;
 +
 +                        $this->splitNodeMessage .= "Replaced foreign key '{$tableName}." . \implode(
 +                            ',',
 +                            $foreignKey->getColumns()
 +                        ) . "'.";
 +                        break 2;
 +                    }
 +                }
 +
 +                $existingIndices = $existingTable->getIndices();
 +                foreach ($table->getIndices() as $index) {
 +                    $matchingExistingIndex = null;
 +                    foreach ($existingIndices as $existingIndex) {
 +                        if (!$this->diffIndices($existingIndex, $index)) {
 +                            $matchingExistingIndex = $existingIndex;
 +                            break;
 +                        }
 +                    }
 +
 +                    if ($index->willBeDropped()) {
 +                        if ($matchingExistingIndex !== null) {
 +                            if (!isset($this->indicesToDrop[$tableName])) {
 +                                $this->indicesToDrop[$tableName] = [];
 +                            }
 +                            $this->indicesToDrop[$tableName][] = $matchingExistingIndex;
 +
 +                            $this->splitNodeMessage .= "Dropped index '{$tableName}." . \implode(
 +                                ',',
 +                                $index->getColumns()
 +                            ) . "'.";
 +                            break 2;
 +                        } elseif (isset($this->indexPackageIDs[$tableName][$index->getName()])) {
 +                            $this->deleteIndexLog($tableName, $index);
 +                        }
 +                    } elseif ($matchingExistingIndex !== null) {
 +                        // updating index type and index columns is supported with an
 +                        // explicit index name is given (automatically generated index
 +                        // names are not deterministic)
 +                        if (
 +                            !$index->hasGeneratedName()
 +                            && !empty(\array_diff($matchingExistingIndex->getData(), $index->getData()))
 +                        ) {
 +                            if (!isset($this->indicesToDrop[$tableName])) {
 +                                $this->indicesToDrop[$tableName] = [];
 +                            }
 +                            $this->indicesToDrop[$tableName][] = $matchingExistingIndex;
 +
 +                            if (!isset($this->indicesToAdd[$tableName])) {
 +                                $this->indicesToAdd[$tableName] = [];
 +                            }
 +                            $this->indicesToAdd[$tableName][] = $index;
 +                        }
 +                    } else {
 +                        if (!isset($this->indicesToAdd[$tableName])) {
 +                            $this->indicesToAdd[$tableName] = [];
 +                        }
 +                        $this->indicesToAdd[$tableName][] = $index;
 +
 +                        $this->splitNodeMessage .= "Added index '{$tableName}." . \implode(
 +                            ',',
 +                            $index->getColumns()
 +                        ) . "'.";
 +                        break 2;
 +                    }
 +                }
 +            }
 +        }
 +    }
 +
 +    /**
 +     * Checks for any pending log entries for the package and either marks them as done or
 +     * deletes them so that after this method finishes, there are no more undone log entries
 +     * for the package.
 +     */
 +    protected function checkPendingLogEntries()
 +    {
 +        $sql = "SELECT  *
 +                FROM    wcf" . WCF_N . "_package_installation_sql_log
 +                WHERE   packageID = ?
 +                    AND isDone = ?";
 +        $statement = WCF::getDB()->prepareStatement($sql);
 +        $statement->execute([$this->package->packageID, 0]);
 +
 +        $doneEntries = $undoneEntries = [];
 +        while ($row = $statement->fetchArray()) {
 +            // table
 +            if ($row['sqlIndex'] === '' && $row['sqlColumn'] === '') {
 +                if (\in_array($row['sqlTable'], $this->existingTableNames)) {
 +                    $doneEntries[] = $row;
 +                } else {
 +                    $undoneEntries[] = $row;
 +                }
 +            } // column
 +            elseif ($row['sqlIndex'] === '') {
 +                if (isset($this->getExistingTable($row['sqlTable'])->getColumns()[$row['sqlColumn']])) {
 +                    $doneEntries[] = $row;
 +                } else {
 +                    $undoneEntries[] = $row;
 +                }
 +            } // foreign key
 +            elseif (\substr($row['sqlIndex'], -3) === '_fk') {
 +                if (isset($this->getExistingTable($row['sqlTable'])->getForeignKeys()[$row['sqlIndex']])) {
 +                    $doneEntries[] = $row;
 +                } else {
 +                    $undoneEntries[] = $row;
 +                }
 +            } // index
 +            else {
 +                if (isset($this->getExistingTable($row['sqlTable'])->getIndices()[$row['sqlIndex']])) {
 +                    $doneEntries[] = $row;
 +                } else {
 +                    $undoneEntries[] = $row;
 +                }
 +            }
 +        }
 +
 +        WCF::getDB()->beginTransaction();
 +        foreach ($doneEntries as $entry) {
 +            $this->finalizeLog($entry);
 +        }
 +
 +        // to achieve a consistent state, undone log entries will be deleted here even though
 +        // they might be re-created later to ensure that after this method finishes, there are
 +        // no more undone entries in the log for the relevant package
 +        foreach ($undoneEntries as $entry) {
 +            $this->deleteLog($entry);
 +        }
 +        WCF::getDB()->commitTransaction();
 +    }
 +
 +    /**
 +     * Creates a done log entry for the given foreign key.
 +     *
 +     * @param string $tableName
 +     * @param DatabaseTableForeignKey $foreignKey
 +     */
 +    protected function createForeignKeyLog($tableName, DatabaseTableForeignKey $foreignKey)
 +    {
 +        $sql = "INSERT INTO wcf" . WCF_N . "_package_installation_sql_log
 +                            (packageID, sqlTable, sqlIndex, isDone)
 +                VALUES      (?, ?, ?, ?)";
 +        $statement = WCF::getDB()->prepareStatement($sql);
 +
 +        $statement->execute([
 +            $this->package->packageID,
 +            $tableName,
 +            $foreignKey->getName(),
 +            1,
 +        ]);
 +    }
 +
 +    /**
 +     * Creates the given table.
 +     *
 +     * @param DatabaseTable $table
 +     */
 +    protected function createTable(DatabaseTable $table)
 +    {
 +        $hasPrimaryKey = false;
 +        $columnData = \array_map(static function (IDatabaseTableColumn $column) use (&$hasPrimaryKey) {
 +            $data = $column->getData();
 +            if (isset($data['key']) && $data['key'] === 'PRIMARY') {
 +                $hasPrimaryKey = true;
 +            }
 +
 +            return [
 +                'data' => $data,
 +                'name' => $column->getName(),
 +            ];
 +        }, $table->getColumns());
 +        $indexData = \array_map(static function (DatabaseTableIndex $index) {
 +            return [
 +                'data' => $index->getData(),
 +                'name' => $index->getName(),
 +            ];
 +        }, $table->getIndices());
 +
 +        // Auto columns are implicitly defined as the primary key by MySQL.
 +        if ($hasPrimaryKey) {
 +            $indexData = \array_filter($indexData, static function ($key) {
 +                return $key !== 'PRIMARY';
 +            }, \ARRAY_FILTER_USE_KEY);
 +        }
 +
 +        $this->dbEditor->createTable($table->getName(), $columnData, $indexData);
 +
 +        foreach ($table->getForeignKeys() as $foreignKey) {
-             $this->dbEditor->addForeignKey($table->getName(), $foreignKey->getName(), $foreignKey->getData());
++            // Only try to create the foreign key if the referenced database table already exists.
++            // If it will be created later on, delay the foreign key creation until after the
++            // referenced table has been created.
++            if (
++                \in_array($foreignKey->getReferencedTable(), $this->existingTableNames)
++                || $foreignKey->getReferencedTable() === $table->getName()
++            ) {
++                $this->dbEditor->addForeignKey($table->getName(), $foreignKey->getName(), $foreignKey->getData());
 +
-             // foreign keys need to be explicitly logged for proper uninstallation
-             $this->createForeignKeyLog($table->getName(), $foreignKey);
++                // foreign keys need to be explicitly logged for proper uninstallation
++                $this->createForeignKeyLog($table->getName(), $foreignKey);
++            }
 +        }
 +    }
 +
 +    /**
 +     * Deletes the log entry for the given column.
 +     *
 +     * @param string $tableName
 +     * @param IDatabaseTableColumn $column
 +     */
 +    protected function deleteColumnLog($tableName, IDatabaseTableColumn $column)
 +    {
 +        $this->deleteLog(['sqlTable' => $tableName, 'sqlColumn' => $column->getName()]);
 +    }
 +
 +    /**
 +     * Deletes the log entry for the given foreign key.
 +     *
 +     * @param string $tableName
 +     * @param DatabaseTableForeignKey $foreignKey
 +     */
 +    protected function deleteForeignKeyLog($tableName, DatabaseTableForeignKey $foreignKey)
 +    {
 +        $this->deleteLog(['sqlTable' => $tableName, 'sqlIndex' => $foreignKey->getName()]);
 +    }
 +
 +    /**
 +     * Deletes the log entry for the given index.
 +     *
 +     * @param string $tableName
 +     * @param DatabaseTableIndex $index
 +     */
 +    protected function deleteIndexLog($tableName, DatabaseTableIndex $index)
 +    {
 +        $this->deleteLog(['sqlTable' => $tableName, 'sqlIndex' => $index->getName()]);
 +    }
 +
 +    /**
 +     * Deletes a log entry.
 +     *
 +     * @param array $data
 +     */
 +    protected function deleteLog(array $data)
 +    {
 +        $sql = "DELETE FROM wcf" . WCF_N . "_package_installation_sql_log
 +                WHERE       packageID = ?
 +                        AND sqlTable = ?
 +                        AND sqlColumn = ?
 +                        AND sqlIndex = ?";
 +        $statement = WCF::getDB()->prepareStatement($sql);
 +
 +        $statement->execute([
 +            $this->package->packageID,
 +            $data['sqlTable'],
 +            $data['sqlColumn'] ?? '',
 +            $data['sqlIndex'] ?? '',
 +        ]);
 +    }
 +
 +    /**
 +     * Deletes all log entry related to the given table.
 +     *
 +     * @param DatabaseTable $table
 +     */
 +    protected function deleteTableLog(DatabaseTable $table)
 +    {
 +        $sql = "DELETE FROM wcf" . WCF_N . "_package_installation_sql_log
 +                WHERE       packageID = ?
 +                        AND sqlTable = ?";
 +        $statement = WCF::getDB()->prepareStatement($sql);
 +
 +        $statement->execute([
 +            $this->package->packageID,
 +            $table->getName(),
 +        ]);
 +    }
 +
 +    /**
 +     * Returns `true` if the two columns differ.
 +     *
 +     * @param IDatabaseTableColumn $oldColumn
 +     * @param IDatabaseTableColumn $newColumn
 +     * @return  bool
 +     */
 +    protected function diffColumns(IDatabaseTableColumn $oldColumn, IDatabaseTableColumn $newColumn)
 +    {
 +        $diff = \array_diff($oldColumn->getData(), $newColumn->getData());
 +        if (!empty($diff)) {
 +            // see https://github.com/WoltLab/WCF/pull/3167
 +            if (
 +                \array_key_exists('length', $diff)
 +                && $oldColumn instanceof AbstractIntDatabaseTableColumn
 +                && (
 +                    !($oldColumn instanceof TinyintDatabaseTableColumn)
 +                    || $oldColumn->getLength() != 1
 +                )
 +            ) {
 +                unset($diff['length']);
 +            }
 +
 +            if (!empty($diff)) {
 +                return true;
 +            }
 +        }
 +
 +        if ($newColumn->getNewName()) {
 +            return true;
 +        }
 +
 +        // default type has to be checked explicitly for `null` to properly detect changing
 +        // from no default value (`null`) and to an empty string as default value (and vice
 +        // versa)
 +        if ($oldColumn->getDefaultValue() === null || $newColumn->getDefaultValue() === null) {
 +            return $oldColumn->getDefaultValue() !== $newColumn->getDefaultValue();
 +        }
 +
 +        // for all other cases, use weak comparison so that `'1'` (from database) and `1`
 +        // (from script PIP) match, for example
 +        return $oldColumn->getDefaultValue() != $newColumn->getDefaultValue();
 +    }
 +
 +    /**
 +     * Returns `true` if the two indices differ.
 +     *
 +     * @param DatabaseTableIndex $oldIndex
 +     * @param DatabaseTableIndex $newIndex
 +     * @return  bool
 +     */
 +    protected function diffIndices(DatabaseTableIndex $oldIndex, DatabaseTableIndex $newIndex)
 +    {
 +        if ($newIndex->hasGeneratedName()) {
 +            return !empty(\array_diff($oldIndex->getData(), $newIndex->getData()));
 +        }
 +
 +        return $oldIndex->getName() !== $newIndex->getName();
 +    }
 +
 +    /**
 +     * Drops the given foreign key.
 +     *
 +     * @param string $tableName
 +     * @param DatabaseTableForeignKey $foreignKey
 +     */
 +    protected function dropForeignKey($tableName, DatabaseTableForeignKey $foreignKey)
 +    {
 +        $this->dbEditor->dropForeignKey($tableName, $foreignKey->getName());
 +        $this->dbEditor->dropIndex($tableName, $foreignKey->getName());
 +    }
 +
 +    /**
 +     * Drops the given index.
 +     *
 +     * @param string $tableName
 +     * @param DatabaseTableIndex $index
 +     */
 +    protected function dropIndex($tableName, DatabaseTableIndex $index)
 +    {
 +        $this->dbEditor->dropIndex($tableName, $index->getName());
 +    }
 +
 +    /**
 +     * Drops the given table.
 +     *
 +     * @param DatabaseTable $table
 +     */
 +    protected function dropTable(DatabaseTable $table)
 +    {
 +        $this->dbEditor->dropTable($table->getName());
 +    }
 +
 +    /**
 +     * Finalizes the log entry for the creation of the given column.
 +     *
 +     * @param string $tableName
 +     * @param IDatabaseTableColumn $column
 +     * @param bool $useNewName
 +     */
 +    protected function finalizeColumnLog($tableName, IDatabaseTableColumn $column, bool $useNewName = false)
 +    {
 +        $this->finalizeLog([
 +            'sqlTable' => $tableName,
 +            'sqlColumn' => $useNewName ? $column->getNewName() : $column->getName(),
 +        ]);
 +    }
 +
 +    /**
 +     * Finalizes the log entry for adding the given index.
 +     *
 +     * @param string $tableName
 +     * @param DatabaseTableForeignKey $foreignKey
 +     */
 +    protected function finalizeForeignKeyLog($tableName, DatabaseTableForeignKey $foreignKey)
 +    {
 +        $this->finalizeLog(['sqlTable' => $tableName, 'sqlIndex' => $foreignKey->getName()]);
 +    }
 +
 +    /**
 +     * Finalizes the log entry for adding the given index.
 +     *
 +     * @param string $tableName
 +     * @param DatabaseTableIndex $index
 +     */
 +    protected function finalizeIndexLog($tableName, DatabaseTableIndex $index)
 +    {
 +        $this->finalizeLog(['sqlTable' => $tableName, 'sqlIndex' => $index->getName()]);
 +    }
 +
 +    /**
 +     * Finalizes a log entry after the relevant change has been executed.
 +     *
 +     * @param array $data
 +     */
 +    protected function finalizeLog(array $data)
 +    {
 +        $sql = "UPDATE  wcf" . WCF_N . "_package_installation_sql_log
 +                SET     isDone = ?
 +                WHERE   packageID = ?
 +                    AND sqlTable = ?
 +                    AND sqlColumn = ?
 +                    AND sqlIndex = ?";
 +        $statement = WCF::getDB()->prepareStatement($sql);
 +
 +        $statement->execute([
 +            1,
 +            $this->package->packageID,
 +            $data['sqlTable'],
 +            $data['sqlColumn'] ?? '',
 +            $data['sqlIndex'] ?? '',
 +        ]);
 +    }
 +
 +    /**
 +     * Finalizes the log entry for the creation of the given table.
 +     *
 +     * @param DatabaseTable $table
 +     */
 +    protected function finalizeTableLog(DatabaseTable $table)
 +    {
 +        $this->finalizeLog(['sqlTable' => $table->getName()]);
 +    }
 +
 +    /**
 +     * Returns the log entry for the given column or `null` if there is no explicit entry for
 +     * this column.
 +     *
 +     * @param string $tableName
 +     * @param IDatabaseTableColumn $column
 +     * @return      array|null
 +     * @since       5.4
 +     */
 +    protected function getColumnLog(string $tableName, IDatabaseTableColumn $column): ?array
 +    {
 +        $sql = "SELECT  *
 +                FROM    wcf" . WCF_N . "_package_installation_sql_log
 +                WHERE   packageID = ?
 +                        AND sqlTable = ?
 +                        AND sqlColumn = ?";
 +        $statement = WCF::getDB()->prepareStatement($sql);
 +
 +        $statement->execute([
 +            $this->package->packageID,
 +            $tableName,
 +            $column->getName(),
 +        ]);
 +
 +        $row = $statement->fetchSingleRow();
 +        if ($row === false) {
 +            return null;
 +        }
 +
 +        return $row;
 +    }
 +
 +    /**
 +     * Returns the id of the package to with the given column belongs to. If there is no specific
 +     * log entry for the given column, the table log is checked and the relevant package id of
 +     * the whole table is returned. If the package of the table is also unknown, `null` is returned.
 +     *
 +     * @param DatabaseTable $table
 +     * @param IDatabaseTableColumn $column
 +     * @return  null|int
 +     */
 +    protected function getColumnPackageID(DatabaseTable $table, IDatabaseTableColumn $column)
 +    {
 +        if (isset($this->columnPackageIDs[$table->getName()][$column->getName()])) {
 +            return $this->columnPackageIDs[$table->getName()][$column->getName()];
 +        } elseif (isset($this->tablePackageIDs[$table->getName()])) {
 +            return $this->tablePackageIDs[$table->getName()];
 +        }
 +    }
 +
 +    /**
 +     * Returns the `DatabaseTable` object for the table with the given name.
 +     *
 +     * @param string $tableName
 +     * @return  DatabaseTable
 +     */
 +    protected function getExistingTable($tableName)
 +    {
 +        if (!isset($this->existingTables[$tableName])) {
 +            $this->existingTables[$tableName] = DatabaseTable::createFromExistingTable($this->dbEditor, $tableName);
 +        }
 +
 +        return $this->existingTables[$tableName];
 +    }
 +
 +    /**
 +     * Returns the id of the package to with the given foreign key belongs to. If there is no specific
 +     * log entry for the given foreign key, the table log is checked and the relevant package id of
 +     * the whole table is returned. If the package of the table is also unknown, `null` is returned.
 +     *
 +     * @param DatabaseTable $table
 +     * @param DatabaseTableForeignKey $foreignKey
 +     * @return  null|int
 +     */
 +    protected function getForeignKeyPackageID(DatabaseTable $table, DatabaseTableForeignKey $foreignKey)
 +    {
 +        if (isset($this->foreignKeyPackageIDs[$table->getName()][$foreignKey->getName()])) {
 +            return $this->foreignKeyPackageIDs[$table->getName()][$foreignKey->getName()];
 +        } elseif (isset($this->tablePackageIDs[$table->getName()])) {
 +            return $this->tablePackageIDs[$table->getName()];
 +        }
 +    }
 +
 +    /**
 +     * Returns the id of the package to with the given index belongs to. If there is no specific
 +     * log entry for the given index, the table log is checked and the relevant package id of
 +     * the whole table is returned. If the package of the table is also unknown, `null` is returned.
 +     *
 +     * @param DatabaseTable $table
 +     * @param DatabaseTableIndex $index
 +     * @return  null|int
 +     */
 +    protected function getIndexPackageID(DatabaseTable $table, DatabaseTableIndex $index)
 +    {
 +        if (isset($this->indexPackageIDs[$table->getName()][$index->getName()])) {
 +            return $this->indexPackageIDs[$table->getName()][$index->getName()];
 +        } elseif (isset($this->tablePackageIDs[$table->getName()])) {
 +            return $this->tablePackageIDs[$table->getName()];
 +        }
 +    }
 +
 +    /**
 +     * Prepares the log entry for the creation of the given column.
 +     *
 +     * @param string $tableName
 +     * @param IDatabaseTableColumn $column
 +     * @param bool $useNewName
 +     */
 +    protected function prepareColumnLog($tableName, IDatabaseTableColumn $column, bool $useNewName = false)
 +    {
 +        $this->prepareLog([
 +            'sqlTable' => $tableName,
 +            'sqlColumn' => $useNewName ? $column->getNewName() : $column->getName(),
 +        ]);
 +    }
 +
 +    /**
 +     * Prepares the log entry for adding the given foreign key.
 +     *
 +     * @param string $tableName
 +     * @param DatabaseTableForeignKey $foreignKey
 +     */
 +    protected function prepareForeignKeyLog($tableName, DatabaseTableForeignKey $foreignKey)
 +    {
 +        $this->prepareLog(['sqlTable' => $tableName, 'sqlIndex' => $foreignKey->getName()]);
 +    }
 +
 +    /**
 +     * Prepares the log entry for adding the given index.
 +     *
 +     * @param string $tableName
 +     * @param DatabaseTableIndex $index
 +     */
 +    protected function prepareIndexLog($tableName, DatabaseTableIndex $index)
 +    {
 +        $this->prepareLog(['sqlTable' => $tableName, 'sqlIndex' => $index->getName()]);
 +    }
 +
 +    /**
 +     * Prepares a log entry before the relevant change has been executed.
 +     *
 +     * @param array $data
 +     */
 +    protected function prepareLog(array $data)
 +    {
 +        $sql = "INSERT INTO wcf" . WCF_N . "_package_installation_sql_log
 +                            (packageID, sqlTable, sqlColumn, sqlIndex, isDone)
 +                VALUES      (?, ?, ?, ?, ?)";
 +        $statement = WCF::getDB()->prepareStatement($sql);
 +
 +        $statement->execute([
 +            $this->package->packageID,
 +            $data['sqlTable'],
 +            $data['sqlColumn'] ?? '',
 +            $data['sqlIndex'] ?? '',
 +            0,
 +        ]);
 +    }
 +
 +    /**
 +     * Prepares the log entry for the creation of the given table.
 +     *
 +     * @param DatabaseTable $table
 +     */
 +    protected function prepareTableLog(DatabaseTable $table)
 +    {
 +        $this->prepareLog(['sqlTable' => $table->getName()]);
 +    }
 +
 +    /**
 +     * Processes all tables and updates the current table layouts to match the specified layouts.
 +     *
 +     * @throws  \RuntimeException   if validation of the required layout changes fails
 +     */
 +    public function process()
 +    {
 +        $this->checkPendingLogEntries();
 +
 +        $errors = $this->validate();
 +        if (!empty($errors)) {
 +            throw new \RuntimeException(WCF::getLanguage()->getDynamicVariable(
 +                'wcf.acp.package.error.databaseChange',
 +                [
 +                    'errors' => $errors,
 +                ]
 +            ));
 +        }
 +
 +        $this->calculateChanges();
 +
 +        $this->applyChanges();
 +    }
 +
 +    /**
 +     * Checks if the relevant table layout changes can be executed and returns an array with information
 +     * on all validation errors.
 +     *
 +     * @return  array
 +     */
 +    public function validate()
 +    {
 +        $errors = [];
 +        foreach ($this->tables as $table) {
 +            if ($table->willBeDropped()) {
 +                if (\in_array($table->getName(), $this->existingTableNames)) {
 +                    if (!isset($this->tablePackageIDs[$table->getName()])) {
 +                        $errors[] = [
 +                            'tableName' => $table->getName(),
 +                            'type' => 'unregisteredTableDrop',
 +                        ];
 +                    } elseif ($this->tablePackageIDs[$table->getName()] !== $this->package->packageID) {
 +                        $errors[] = [
 +                            'tableName' => $table->getName(),
 +                            'type' => 'foreignTableDrop',
 +                        ];
 +                    }
 +                }
 +            } else {
 +                $existingTable = null;
 +                if (\in_array($table->getName(), $this->existingTableNames)) {
 +                    if (!isset($this->tablePackageIDs[$table->getName()])) {
 +                        $errors[] = [
 +                            'tableName' => $table->getName(),
 +                            'type' => 'unregisteredTableChange',
 +                        ];
 +                    } else {
 +                        $existingTable = DatabaseTable::createFromExistingTable($this->dbEditor, $table->getName());
 +                        $existingColumns = $existingTable->getColumns();
 +                        $existingIndices = $existingTable->getIndices();
 +                        $existingForeignKeys = $existingTable->getForeignKeys();
 +
 +                        foreach ($table->getColumns() as $column) {
 +                            if (isset($existingColumns[$column->getName()])) {
 +                                $columnPackageID = $this->getColumnPackageID($table, $column);
 +                                if ($column->willBeDropped()) {
 +                                    if ($columnPackageID !== $this->package->packageID) {
 +                                        $errors[] = [
 +                                            'columnName' => $column->getName(),
 +                                            'tableName' => $table->getName(),
 +                                            'type' => 'foreignColumnDrop',
 +                                        ];
 +                                    }
 +                                } elseif ($columnPackageID !== $this->package->packageID) {
 +                                    $errors[] = [
 +                                        'columnName' => $column->getName(),
 +                                        'tableName' => $table->getName(),
 +                                        'type' => 'foreignColumnChange',
 +                                    ];
 +                                }
 +                            } elseif ($column->getNewName()) {
 +                                $errors[] = [
 +                                    'columnName' => $column->getName(),
 +                                    'tableName' => $table->getName(),
 +                                    'type' => 'renameNonexistingColumn',
 +                                ];
 +                            }
 +                        }
 +
 +                        foreach ($table->getIndices() as $index) {
 +                            foreach ($existingIndices as $existingIndex) {
 +                                if (empty(\array_diff($index->getData(), $existingIndex->getData()))) {
 +                                    if ($index->willBeDropped()) {
 +                                        if ($this->getIndexPackageID($table, $index) !== $this->package->packageID) {
 +                                            $errors[] = [
 +                                                'columnNames' => \implode(',', $existingIndex->getColumns()),
 +                                                'tableName' => $table->getName(),
 +                                                'type' => 'foreignIndexDrop',
 +                                            ];
 +                                        }
 +                                    }
 +
 +                                    continue 2;
 +                                }
 +                            }
 +                        }
 +
 +                        foreach ($table->getForeignKeys() as $foreignKey) {
 +                            foreach ($existingForeignKeys as $existingForeignKey) {
 +                                if (empty(\array_diff($foreignKey->getData(), $existingForeignKey->getData()))) {
 +                                    if ($foreignKey->willBeDropped()) {
 +                                        if (
 +                                            $this->getForeignKeyPackageID(
 +                                                $table,
 +                                                $foreignKey
 +                                            ) !== $this->package->packageID
 +                                        ) {
 +                                            $errors[] = [
 +                                                'columnNames' => \implode(',', $existingForeignKey->getColumns()),
 +                                                'tableName' => $table->getName(),
 +                                                'type' => 'foreignForeignKeyDrop',
 +                                            ];
 +                                        }
 +                                    }
 +
 +                                    continue 2;
 +                                }
 +                            }
 +                        }
 +                    }
 +                }
 +
 +                foreach ($table->getIndices() as $index) {
 +                    foreach ($index->getColumns() as $indexColumn) {
 +                        $column = $this->getColumnByName($indexColumn, $table, $existingTable);
 +                        if ($column === null) {
 +                            if (!$index->willBeDropped()) {
 +                                $errors[] = [
 +                                    'columnName' => $indexColumn,
 +                                    'columnNames' => \implode(',', $index->getColumns()),
 +                                    'tableName' => $table->getName(),
 +                                    'type' => 'nonexistingColumnInIndex',
 +                                ];
 +                            }
 +                        } elseif (
 +                            $index->getType() === DatabaseTableIndex::PRIMARY_TYPE
 +                            && !$index->willBeDropped()
 +                            && !$column->isNotNull()
 +                        ) {
 +                            $errors[] = [
 +                                'columnName' => $indexColumn,
 +                                'columnNames' => \implode(',', $index->getColumns()),
 +                                'tableName' => $table->getName(),
 +                                'type' => 'nullColumnInPrimaryIndex',
 +                            ];
 +                        }
 +                    }
++                }
++
++                foreach ($table->getForeignKeys() as $foreignKey) {
++                    $referencedTableExists = \in_array($foreignKey->getReferencedTable(), $this->existingTableNames);
++                    foreach ($this->tables as $processedTable) {
++                        if ($processedTable->getName() === $foreignKey->getReferencedTable()) {
++                            $referencedTableExists = !$processedTable->willBeDropped();
++                        }
++                    }
++
++                    if (!$referencedTableExists) {
++                        $errors[] = [
++                            'columnNames' => \implode(',', $foreignKey->getColumns()),
++                            'referencedTableName' => $foreignKey->getReferencedTable(),
++                            'tableName' => $table->getName(),
++                            'type' => 'unknownTableInForeignKey',
++                        ];
++                    }
 +                }
 +            }
 +        }
 +
 +        return $errors;
 +    }
 +
 +    /**
 +     * Returns the column with the given name from the given table.
 +     *
 +     * @param string $columnName
 +     * @param DatabaseTable $updateTable
 +     * @param DatabaseTable|null $existingTable
 +     * @return      IDatabaseTableColumn|null
 +     * @since       5.2.10
 +     */
 +    protected function getColumnByName($columnName, DatabaseTable $updateTable, ?DatabaseTable $existingTable = null)
 +    {
 +        foreach ($updateTable->getColumns() as $column) {
 +            if (
 +                ($column->getNewName() === $columnName)
 +                || ($column->getName() === $columnName && !$column->getNewName())
 +            ) {
 +                return $column;
 +            }
 +        }
 +
 +        if ($existingTable) {
 +            foreach ($existingTable->getColumns() as $column) {
 +                if ($column->getName() === $columnName) {
 +                    return $column;
 +                }
 +            }
 +        }
 +    }
  }
index 4c34710856613d9af2c3513cf6a491bd999faa49,405f1c6d2bac3f878536bd8dcac921671fa0fd4f..626cc1885696d56ab130b4f91c115c5de98803f7
@@@ -9,743 -6,688 +9,745 @@@ use wcf\system\io\GZipFile
  
  /**
   * Contains file-related functions.
 - * 
 - * @author    Marcel Werk
 - * @copyright 2001-2019 WoltLab GmbH
 - * @license   GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
 - * @package   WoltLabSuite\Core\Util
 + *
 + * @author  Marcel Werk
 + * @copyright   2001-2019 WoltLab GmbH
 + * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
 + * @package WoltLabSuite\Core\Util
   */
 -final class FileUtil {
 -      /**
 -       * finfo instance
 -       * @var \finfo
 -       */
 -      protected static $finfo = null;
 -      
 -      /**
 -       * memory limit in bytes
 -       * @var integer
 -       */
 -      protected static $memoryLimit = null;
 -      
 -      /**
 -       * chmod mode
 -       * @var string
 -       */
 -      protected static $mode = null;
 -      
 -      /**
 -       * Tries to find the temp folder.
 -       * 
 -       * @return      string
 -       * @throws      SystemException
 -       */
 -      public static function getTempFolder() {
 -              try {
 -                      // This method does not contain any shut up operator by intent.
 -                      // Any operation that fails here is fatal.
 -                      $path = WCF_DIR.'tmp/';
 -                      
 -                      if (is_file($path)) {
 -                              // wat
 -                              unlink($path);
 -                      }
 -                      
 -                      if (!file_exists($path)) {
 -                              mkdir($path, 0777);
 -                      }
 -                      
 -                      if (!is_dir($path)) {
 -                              throw new SystemException("Temporary folder '".$path."' does not exist and could not be created. Please check the permissions of the '".WCF_DIR."' folder using your favorite ftp program.");
 -                      }
 -                      
 -                      if (!is_writable($path)) {
 -                              self::makeWritable($path);
 -                      }
 -                      
 -                      if (!is_writable($path)) {
 -                              throw new SystemException("Temporary folder '".$path."' is not writable. Please check the permissions using your favorite ftp program.");
 -                      }
 -
 -                      if (md5_file($path . '/.htaccess') !== '5cc8a02be988615b049f5abecba2f3a0') {
 -                              file_put_contents($path.'/.htaccess', 'deny from all');
 -                      }
 -                      
 -                      return $path;
 -              }
 -              catch (SystemException $e) {
 -                      // use tmp folder in document root by default
 -                      if (!empty($_SERVER['DOCUMENT_ROOT'])) {
 -                              if (strpos($_SERVER['DOCUMENT_ROOT'], 'strato') !== false) {
 -                                      // strato bugfix
 -                                      // create tmp folder in document root automatically
 -                                      if (!@file_exists($_SERVER['DOCUMENT_ROOT'].'/tmp')) {
 -                                              @mkdir($_SERVER['DOCUMENT_ROOT'].'/tmp/', 0777);
 -                                              self::makeWritable($_SERVER['DOCUMENT_ROOT'].'/tmp/');
 -                                      }
 -                              }
 -                              if (@file_exists($_SERVER['DOCUMENT_ROOT'].'/tmp') && @is_writable($_SERVER['DOCUMENT_ROOT'].'/tmp')) {
 -                                      return $_SERVER['DOCUMENT_ROOT'].'/tmp/';
 -                              }
 -                      }
 -                      
 -                      if (isset($_ENV['TMP']) && @is_writable($_ENV['TMP'])) {
 -                              return $_ENV['TMP'] . '/';
 -                      }
 -                      if (isset($_ENV['TEMP']) && @is_writable($_ENV['TEMP'])) {
 -                              return $_ENV['TEMP'] . '/';
 -                      }
 -                      if (isset($_ENV['TMPDIR']) && @is_writable($_ENV['TMPDIR'])) {
 -                              return $_ENV['TMPDIR'] . '/';
 -                      }
 -                      
 -                      if (($path = ini_get('upload_tmp_dir')) && @is_writable($path)) {
 -                              return $path . '/';
 -                      }
 -                      if (@file_exists('/tmp/') && @is_writable('/tmp/')) {
 -                              return '/tmp/';
 -                      }
 -                      if (function_exists('session_save_path') && ($path = session_save_path()) && @is_writable($path)) {
 -                              return $path . '/';
 -                      }
 -                      
 -                      throw new SystemException('There is no access to the system temporary folder due to an unknown reason and no user specific temporary folder exists in '.WCF_DIR.'! This is a misconfiguration of your webserver software! Please create a folder called '.$path.' using your favorite ftp program, make it writable and then retry this installation.');
 -              }
 -      }
 -      
 -      /** 
 -       * Generates a new temporary filename in TMP_DIR.
 -       * 
 -       * @param       string          $prefix
 -       * @param       string          $extension
 -       * @param       string          $dir
 -       * @return      string
 -       */
 -      public static function getTemporaryFilename($prefix = 'tmpFile_', $extension = '', $dir = TMP_DIR) {
 -              $dir = self::addTrailingSlash($dir);
 -              do {
 -                      $tmpFile = $dir.$prefix.bin2hex(\random_bytes(20)).$extension;
 -              }
 -              while (file_exists($tmpFile));
 -              
 -              return $tmpFile;
 -      }
 -      
 -      /**
 -       * Removes a leading slash from the given path.
 -       * 
 -       * @param       string          $path
 -       * @return      string
 -       */
 -      public static function removeLeadingSlash($path) {
 -              return ltrim($path, '/');
 -      }
 -      
 -      /**
 -       * Removes a trailing slash from the given path.
 -       * 
 -       * @param       string          $path
 -       * @return      string
 -       */
 -      public static function removeTrailingSlash($path) {
 -              return rtrim($path, '/');
 -      }
 -      
 -      /**
 -       * Adds a trailing slash to the given path.
 -       * 
 -       * @param       string          $path
 -       * @return      string
 -       */
 -      public static function addTrailingSlash($path) {
 -              return rtrim($path, '/').'/';
 -      }
 -      
 -      /**
 -       * Adds a leading slash to the given path.
 -       * 
 -       * @param       string          $path
 -       * @return      string
 -       */
 -      public static function addLeadingSlash($path) {
 -              return '/'.ltrim($path, '/');
 -      }
 -      
 -      /**
 -       * Returns the relative path from the given absolute paths.
 -       * 
 -       * @param       string          $currentDir
 -       * @param       string          $targetDir
 -       * @return      string
 -       */
 -      public static function getRelativePath($currentDir, $targetDir) {
 -              // remove trailing slashes
 -              $currentDir = self::removeTrailingSlash(self::unifyDirSeparator($currentDir));
 -              $targetDir = self::removeTrailingSlash(self::unifyDirSeparator($targetDir));
 -              
 -              if ($currentDir == $targetDir) {
 -                      return './';
 -              }
 -              
 -              $current = explode('/', $currentDir);
 -              $target = explode('/', $targetDir);
 -              
 -              $relPath = '';
 -              //for ($i = max(count($current), count($target)) - 1; $i >= 0; $i--) {
 -              for ($i = 0, $max = max(count($current), count($target)); $i < $max; $i++) {
 -                      if (isset($current[$i]) && isset($target[$i])) {
 -                              if ($current[$i] != $target[$i]) {
 -                                      for ($j = 0; $j < $i; $j++) {
 -                                              unset($target[$j]);
 -                                      }
 -                                      $relPath .= str_repeat('../', count($current) - $i).implode('/', $target).'/';
 -                                      
 -                                      break;
 -                              }
 -                      }
 -                      // go up one level
 -                      else if (isset($current[$i]) && !isset($target[$i])) {
 -                              $relPath .= '../';
 -                      }
 -                      else if (!isset($current[$i]) && isset($target[$i])) {
 -                              $relPath .= $target[$i].'/';
 -                      }
 -              }
 -              
 -              return $relPath;
 -      }
 -      
 -      /**
 -       * Creates a path on the local filesystem and returns true on success.
 -       * Parent directories do not need to exists as they will be created if
 -       * necessary.
 -       * 
 -       * @param       string          $path
 -       * @return      boolean
 -       */
 -      public static function makePath($path) {
 -              // directory already exists, abort
 -              if (file_exists($path)) {
 -                      return false;
 -              }
 -              
 -              // check if parent directory exists
 -              $parent = dirname($path);
 -              if ($parent != $path) {
 -                      // parent directory does not exist either
 -                      // we have to create the parent directory first
 -                      $parent = self::addTrailingSlash($parent);
 -                      if (!@file_exists($parent)) {
 -                              // could not create parent directory either => abort
 -                              if (!self::makePath($parent)) {
 -                                      return false;
 -                              }
 -                      }
 -                      
 -                      // well, the parent directory exists or has been created
 -                      // lets create this path
 -                      if (!@mkdir($path)) {
 -                              return false;
 -                      }
 -                      
 -                      self::makeWritable($path);
 -                      
 -                      return true;
 -              }
 -              
 -              return false;
 -      }
 -      
 -      /**
 -       * Unifies windows and unix directory separators.
 -       * 
 -       * @param       string          $path
 -       * @return      string
 -       */
 -      public static function unifyDirSeparator($path) {
 -              $path = str_replace('\\\\', '/', $path);
 -              $path = str_replace('\\', '/', $path);
 -              return $path;
 -      }
 -      
 -      /**
 -       * Scans a folder (and subfolder) for a specific file.
 -       * Returns the filename if found, otherwise false.
 -       * 
 -       * @param       string          $folder
 -       * @param       string          $searchfile
 -       * @param       boolean         $recursive
 -       * @return      mixed
 -       */
 -      public static function scanFolder($folder, $searchfile, $recursive = true) {
 -              if (!@is_dir($folder)) {
 -                      return false;
 -              }
 -              if (!$searchfile) {
 -                      return false;
 -              }
 -              
 -              $folder = self::addTrailingSlash($folder);
 -              $dirh = @opendir($folder);
 -              while ($filename = @readdir($dirh)) {
 -                      if ($filename == '.' || $filename == '..') {
 -                              continue;
 -                      }
 -                      if ($filename == $searchfile) {
 -                              @closedir($dirh);
 -                              return $folder.$filename;
 -                      }
 -                      
 -                      if ($recursive == true && @is_dir($folder.$filename)) {
 -                              if ($found = self::scanFolder($folder.$filename, $searchfile, $recursive)) {
 -                                      @closedir($dirh);
 -                                      return $found;
 -                              }
 -                      }
 -              }
 -              @closedir($dirh);
 -      }
 -      
 -      /**
 -       * Returns true if the given filename is an url (http or ftp).
 -       * 
 -       * @param       string          $filename
 -       * @return      boolean
 -       */
 -      public static function isURL($filename) {
 -              return preg_match('!^(https?|ftp)://!', $filename);
 -      }
 -      
 -      /**
 -       * Returns canonicalized absolute pathname.
 -       * 
 -       * @param       string          $path
 -       * @return      string          path
 -       */
 -      public static function getRealPath($path) {
 -              $path = self::unifyDirSeparator($path);
 -              
 -              $result = [];
 -              $pathA = explode('/', $path);
 -              if ($pathA[0] === '') {
 -                      $result[] = '';
 -              }
 -              
 -              foreach ($pathA as $key => $dir) {
 -                      if ($dir == '..') {
 -                              if (end($result) == '..') {
 -                                      $result[] = '..';
 -                              }
 -                              else {
 -                                      $lastValue = array_pop($result);
 -                                      if ($lastValue === '' || $lastValue === null) {
 -                                              $result[] = '..';
 -                                      }
 -                              }
 -                      }
 -                      else if ($dir !== '' && $dir != '.') {
 -                              $result[] = $dir;
 -                      }
 -              }
 -              
 -              $lastValue = end($pathA);
 -              if ($lastValue === '' || $lastValue === false) {
 -                      $result[] = '';
 -              }
 -              
 -              return implode('/', $result);
 -      }
 -      
 -      /**
 -       * Formats the given filesize.
 -       * 
 -       * @param       integer         $byte
 -       * @param       integer         $precision
 -       * @return      string
 -       */
 -      public static function formatFilesize($byte, $precision = 2) {
 -              $symbol = 'Byte';
 -              if ($byte >= 1000) {
 -                      $byte /= 1000;
 -                      $symbol = 'kB';
 -              }
 -              if ($byte >= 1000) {
 -                      $byte /= 1000;
 -                      $symbol = 'MB';
 -              }
 -              if ($byte >= 1000) {
 -                      $byte /= 1000;
 -                      $symbol = 'GB';
 -              }
 -              if ($byte >= 1000) {
 -                      $byte /= 1000;
 -                      $symbol = 'TB';
 -              }
 -              
 -              return StringUtil::formatNumeric(round($byte, $precision)).' '.$symbol;
 -      }
 -      
 -      /**
 -       * Formats a filesize with binary prefix.
 -       * 
 -       * For more information: <http://en.wikipedia.org/wiki/Binary_prefix>
 -       * 
 -       * @param       integer         $byte
 -       * @param       integer         $precision
 -       * @return      string
 -       */
 -      public static function formatFilesizeBinary($byte, $precision = 2) {
 -              $symbol = 'Byte';
 -              if ($byte >= 1024) {
 -                      $byte /= 1024;
 -                      $symbol = 'KiB';
 -              }
 -              if ($byte >= 1024) {
 -                      $byte /= 1024;
 -                      $symbol = 'MiB';
 -              }
 -              if ($byte >= 1024) {
 -                      $byte /= 1024;
 -                      $symbol = 'GiB';
 -              }
 -              if ($byte >= 1024) {
 -                      $byte /= 1024;
 -                      $symbol = 'TiB';
 -              }
 -              
 -              return StringUtil::formatNumeric(round($byte, $precision)).' '.$symbol;
 -      }
 -      
 -      /**
 -       * Downloads a package archive from an http URL and returns the path to
 -       * the downloaded file.
 -       * 
 -       * @param       string          $httpUrl
 -       * @param       string          $prefix
 -       * @param       array           $options
 -       * @param       array           $postParameters
 -       * @param       array           $headers                empty array or a not initialized variable
 -       * @return      string
 -       * @deprecated  This method currently only is a wrapper around \wcf\util\HTTPRequest. Please use
 -       *              HTTPRequest from now on, as this method may be removed in the future.
 -       */
 -      public static function downloadFileFromHttp($httpUrl, $prefix = 'package', array $options = [], array $postParameters = [], &$headers = []) {
 -              $request = new HTTPRequest($httpUrl, $options, $postParameters);
 -              $request->execute();
 -              $reply = $request->getReply();
 -              
 -              $newFileName = self::getTemporaryFilename($prefix.'_');
 -              file_put_contents($newFileName, $reply['body']); // the file to write.
 -              
 -              $tmp = $reply['headers']; // copy variable, to avoid problems with the reference
 -              $headers = $tmp;
 -              
 -              return $newFileName;
 -      }
 -      
 -      /**
 -       * Determines whether a file is text or binary by checking the first few bytes in the file.
 -       * The exact number of bytes is system dependent, but it is typically several thousand.
 -       * If every byte in that part of the file is non-null, considers the file to be text;
 -       * otherwise it considers the file to be binary.
 -       * 
 -       * @param       string          $file
 -       * @return      boolean
 -       */
 -      public static function isBinary($file) {
 -              // open file
 -              $file = new File($file, 'rb');
 -              
 -              // get block size
 -              $stat = $file->stat();
 -              $blockSize = $stat['blksize'];
 -              if ($blockSize < 0) $blockSize = 1024;
 -              if ($blockSize > $file->filesize()) $blockSize = $file->filesize();
 -              if ($blockSize <= 0) return false;
 -              
 -              // get bytes
 -              $block = $file->read($blockSize);
 -              return (strlen($block) == 0 || strpos($block, "\0") !== false);
 -      }
 -      
 -      /**
 -       * Uncompresses a gzipped file and returns true if successful.
 -       * 
 -       * @param       string          $gzipped
 -       * @param       string          $destination
 -       * @return      boolean
 -       */
 -      public static function uncompressFile($gzipped, $destination) {
 -              if (!@is_file($gzipped)) {
 -                      return false;
 -              }
 -              
 -              $sourceFile = new GZipFile($gzipped, 'rb');
 -              //$filesize = $sourceFile->getFileSize();
 -              $targetFile = new File($destination);
 -              while (!$sourceFile->eof()) {
 -                      $targetFile->write($sourceFile->read(512), 512);
 -              }
 -              $targetFile->close();
 -              $sourceFile->close();
 -              
 -              self::makeWritable($destination);
 -              
 -              return true;
 -      }
 -      
 -      /**
 -       * Returns true if php is running as apache module.
 -       * 
 -       * @return      boolean
 -       */
 -      public static function isApacheModule() {
 -              return function_exists('apache_get_version');
 -      }
 -      
 -      /**
 -       * Returns the mime type of a file.
 -       * 
 -       * @param       string          $filename
 -       * @return      string
 -       */
 -      public static function getMimeType($filename) {
 -              if (self::$finfo === null) {
 -                      if (!class_exists('\finfo', false)) return 'application/octet-stream';
 -                      self::$finfo = new \finfo(FILEINFO_MIME_TYPE);
 -              }
 -              
 -              // \finfo->file() can fail for files that contain only 1 byte, because libmagic expects at least
 -              // a few bytes in order to determine the type. See https://bugs.php.net/bug.php?id=64684
 -              $mimeType = @self::$finfo->file($filename);
 -              return $mimeType ?: 'application/octet-stream';
 -      }
 -      
 -      /**
 -       * Tries to make a file or directory writable. It starts of with the least
 -       * permissions and goes up until 0666 for files and 0777 for directories.
 -       * 
 -       * @param       string          $filename
 -       * @throws      SystemException
 -       */
 -      public static function makeWritable($filename) {
 -              if (!file_exists($filename)) {
 -                      return;
 -              }
 -              
 -              if (self::$mode === null) {
 -                      // WCFSetup
 -                      if (defined('INSTALL_SCRIPT') && file_exists(INSTALL_SCRIPT)) {
 -                              // do not use PHP_OS here, as this represents the system it was built on != running on
 -                              // php_uname() is forbidden on some strange hosts; PHP_EOL is reliable 
 -                              if (PHP_EOL == "\r\n") {
 -                                      // Windows
 -                                      self::$mode = '0777';
 -                              }
 -                              else {
 -                                      // anything but Windows
 -                                      clearstatcache();
 -                                      
 -                                      self::$mode = '0666';
 -                                      
 -                                      $tmpFilename = '__permissions_'.sha1((string) time()).'.txt';
 -                                      @touch($tmpFilename);
 -                                      
 -                                      // create a new file and check the file owner, if it is the same
 -                                      // as this file (uploaded through FTP), we can safely grant write
 -                                      // permissions exclusively to the owner rather than everyone
 -                                      if (file_exists($tmpFilename)) {
 -                                              $scriptOwner = fileowner(INSTALL_SCRIPT);
 -                                              $fileOwner = fileowner($tmpFilename);
 -                                              
 -                                              if ($scriptOwner === $fileOwner) {
 -                                                      self::$mode = '0644';
 -                                              }
 -                                              
 -                                              @unlink($tmpFilename);
 -                                      }
 -                              }
 -                      }
 -                      else {
 -                              // mirror permissions of WCF.class.php
 -                              if (!file_exists(WCF_DIR . 'lib/system/WCF.class.php')) {
 -                                      throw new SystemException("Unable to find 'wcf/lib/system/WCF.class.php'.");
 -                              }
 -                              
 -                              self::$mode = '0' . substr(sprintf('%o', fileperms(WCF_DIR . 'lib/system/WCF.class.php')), -3);
 -                      }
 -              }
 -              
 -              if (is_dir($filename)) {
 -                      if (self::$mode == '0644') {
 -                              @chmod($filename, 0755);
 -                      }
 -                      else {
 -                              @chmod($filename, 0777);
 -                      }
 -              }
 -              else {
 -                      @chmod($filename, octdec(self::$mode));
 -              }
 -              
 -              if (!is_writable($filename)) {
 -                      // does not work with 0777
 -                      throw new SystemException("Unable to make '".$filename."' writable. This is a misconfiguration of your server, please contact your system administrator or hosting provider.");
 -              }
 -      }
 -      
 -      /**
 -       * Returns memory limit in bytes.
 -       * 
 -       * @return      integer
 -       */
 -      public static function getMemoryLimit() {
 -              if (self::$memoryLimit === null) {
 -                      self::$memoryLimit = 0;
 -                      
 -                      $memoryLimit = ini_get('memory_limit');
 -                      
 -                      // no limit
 -                      if ($memoryLimit == -1) {
 -                              self::$memoryLimit = -1;
 -                      }
 -                      
 -                      // completely numeric, PHP assumes byte
 -                      if (is_numeric($memoryLimit)) {
 -                              self::$memoryLimit = $memoryLimit;
 -                      }
 -                      
 -                      // PHP supports 'K', 'M' and 'G' shorthand notation
 -                      if (preg_match('~^(\d+)\s*([KMG])$~i', $memoryLimit, $matches)) {
 -                              switch (strtoupper($matches[2])) {
 -                                      case 'K':
 -                                              self::$memoryLimit = $matches[1] * 1024;
 -                                      break;
 -                                      
 -                                      case 'M':
 -                                              self::$memoryLimit = $matches[1] * 1024 * 1024;
 -                                      break;
 -                                      
 -                                      case 'G':
 -                                              self::$memoryLimit = $matches[1] * 1024 * 1024 * 1024;
 -                                      break;
 -                              }
 -                      }
 -              }
 -              
 -              return self::$memoryLimit;
 -      }
 -      
 -      /**
 -       * Returns true if the given amount of memory is available.
 -       * 
 -       * @param       integer         $neededMemory
 -       * @return      boolean
 -       */
 -      public static function checkMemoryLimit($neededMemory) {
 -              return self::getMemoryLimit() == -1 || self::getMemoryLimit() > (memory_get_usage() + $neededMemory);
 -      }
 -      
 -      /**
 -       * Returns icon name for given filename.
 -       * 
 -       * @param       string          $filename
 -       * @return      string
 -       */
 -      public static function getIconNameByFilename($filename) {
 -              static $mapping = [
 -                      // archive
 -                      'zip' => 'archive', 'rar' => 'archive', 'tar' => 'archive', 'gz' => 'archive',
 -                      // audio
 -                      'mp3' => 'audio', 'ogg' => 'audio', 'wav' => 'audio',
 -                      // code
 -                      'php' => 'code', 'html' => 'code', 'htm' => 'code', 'tpl' => 'code', 'js' => 'code',
 -                      // excel
 -                      'xls' => 'excel', 'ods' => 'excel', 'xlsx' => 'excel',
 -                      // image
 -                      'gif' => 'image', 'jpg' => 'image', 'jpeg' => 'image', 'png' => 'image', 'bmp' => 'image',
 -                      // video
 -                      'avi' => 'video', 'wmv' => 'video', 'mov' => 'video', 'mp4' => 'video', 'mpg' => 'video', 'mpeg' => 'video', 'flv' => 'video',
 -                      // pdf
 -                      'pdf' => 'pdf',
 -                      // powerpoint
 -                      'ppt' => 'powerpoint', 'pptx' => 'powerpoint',
 -                      // text
 -                      'txt' => 'text',
 -                      // word
 -                      'doc' => 'word', 'docx' => 'word', 'odt' => 'word'
 -              ];
 -              
 -              $lastDotPosition = strrpos($filename, '.');
 -              if ($lastDotPosition !== false) {
 -                      $extension = substr($filename, $lastDotPosition + 1);
 -                      if (isset($mapping[$extension])) {
 -                              return $mapping[$extension];
 -                      }
 -              }
 -              
 -              return '';
 -      }
 -      
 -      /**
 -       * Forbid creation of FileUtil objects.
 -       */
 -      private function __construct() {
 -              // does nothing
 -      }
 +final class FileUtil
 +{
 +    /**
 +     * finfo instance
 +     * @var \finfo
 +     */
 +    protected static $finfo = null;
 +
 +    /**
 +     * memory limit in bytes
 +     * @var int
 +     */
 +    protected static $memoryLimit = null;
 +
 +    /**
 +     * chmod mode
 +     * @var string
 +     */
 +    protected static $mode = null;
 +
 +    /**
 +     * Tries to find the temp folder.
 +     *
 +     * @return  string
 +     * @throws  SystemException
 +     */
 +    public static function getTempFolder()
 +    {
 +        try {
 +            // This method does not contain any shut up operator by intent.
 +            // Any operation that fails here is fatal.
 +            $path = WCF_DIR . 'tmp/';
 +
 +            if (\is_file($path)) {
 +                // wat
 +                \unlink($path);
 +            }
 +
 +            if (!\file_exists($path)) {
 +                \mkdir($path, 0777);
 +            }
 +
 +            if (!\is_dir($path)) {
 +                throw new SystemException("Temporary folder '" . $path . "' does not exist and could not be created. Please check the permissions of the '" . WCF_DIR . "' folder using your favorite ftp program.");
 +            }
 +
 +            if (!\is_writable($path)) {
 +                self::makeWritable($path);
 +            }
 +
 +            if (!\is_writable($path)) {
 +                throw new SystemException("Temporary folder '" . $path . "' is not writable. Please check the permissions using your favorite ftp program.");
 +            }
 +
-             \file_put_contents($path . '/.htaccess', 'deny from all');
++            if (\md5_file($path . '/.htaccess') !== '5cc8a02be988615b049f5abecba2f3a0') {
++                \file_put_contents($path . '/.htaccess', 'deny from all');
++            }
 +
 +            return $path;
 +        } catch (SystemException $e) {
 +            // use tmp folder in document root by default
 +            if (!empty($_SERVER['DOCUMENT_ROOT'])) {
 +                if (\strpos($_SERVER['DOCUMENT_ROOT'], 'strato') !== false) {
 +                    // strato bugfix
 +                    // create tmp folder in document root automatically
 +                    if (!@\file_exists($_SERVER['DOCUMENT_ROOT'] . '/tmp')) {
 +                        @\mkdir($_SERVER['DOCUMENT_ROOT'] . '/tmp/', 0777);
 +                        self::makeWritable($_SERVER['DOCUMENT_ROOT'] . '/tmp/');
 +                    }
 +                }
 +                if (@\file_exists($_SERVER['DOCUMENT_ROOT'] . '/tmp') && @\is_writable($_SERVER['DOCUMENT_ROOT'] . '/tmp')) {
 +                    return $_SERVER['DOCUMENT_ROOT'] . '/tmp/';
 +                }
 +            }
 +
 +            if (isset($_ENV['TMP']) && @\is_writable($_ENV['TMP'])) {
 +                return $_ENV['TMP'] . '/';
 +            }
 +            if (isset($_ENV['TEMP']) && @\is_writable($_ENV['TEMP'])) {
 +                return $_ENV['TEMP'] . '/';
 +            }
 +            if (isset($_ENV['TMPDIR']) && @\is_writable($_ENV['TMPDIR'])) {
 +                return $_ENV['TMPDIR'] . '/';
 +            }
 +
 +            if (($path = \ini_get('upload_tmp_dir')) && @\is_writable($path)) {
 +                return $path . '/';
 +            }
 +            if (@\file_exists('/tmp/') && @\is_writable('/tmp/')) {
 +                return '/tmp/';
 +            }
 +            if (\function_exists('session_save_path') && ($path = \session_save_path()) && @\is_writable($path)) {
 +                return $path . '/';
 +            }
 +
 +            throw new SystemException('There is no access to the system temporary folder due to an unknown reason and no user specific temporary folder exists in ' . WCF_DIR . '! This is a misconfiguration of your webserver software! Please create a folder called ' . $path . ' using your favorite ftp program, make it writable and then retry this installation.');
 +        }
 +    }
 +
 +    /**
 +     * Generates a new temporary filename in TMP_DIR.
 +     *
 +     * @param string $prefix
 +     * @param string $extension
 +     * @param string $dir
 +     * @return  string
 +     */
 +    public static function getTemporaryFilename($prefix = 'tmpFile_', $extension = '', $dir = TMP_DIR)
 +    {
 +        $dir = self::addTrailingSlash($dir);
 +        do {
 +            $tmpFile = $dir . $prefix . Hex::encode(\random_bytes(20)) . $extension;
 +        } while (\file_exists($tmpFile));
 +
 +        return $tmpFile;
 +    }
 +
 +    /**
 +     * Removes a leading slash from the given path.
 +     *
 +     * @param string $path
 +     * @return  string
 +     */
 +    public static function removeLeadingSlash($path)
 +    {
 +        return \ltrim($path, '/');
 +    }
 +
 +    /**
 +     * Removes a trailing slash from the given path.
 +     *
 +     * @param string $path
 +     * @return  string
 +     */
 +    public static function removeTrailingSlash($path)
 +    {
 +        return \rtrim($path, '/');
 +    }
 +
 +    /**
 +     * Adds a trailing slash to the given path.
 +     *
 +     * @param string $path
 +     * @return  string
 +     */
 +    public static function addTrailingSlash($path)
 +    {
 +        return \rtrim($path, '/') . '/';
 +    }
 +
 +    /**
 +     * Adds a leading slash to the given path.
 +     *
 +     * @param string $path
 +     * @return  string
 +     */
 +    public static function addLeadingSlash($path)
 +    {
 +        return '/' . \ltrim($path, '/');
 +    }
 +
 +    /**
 +     * Returns the relative path from the given absolute paths.
 +     *
 +     * @param string $currentDir
 +     * @param string $targetDir
 +     * @return  string
 +     */
 +    public static function getRelativePath($currentDir, $targetDir)
 +    {
 +        // remove trailing slashes
 +        $currentDir = self::removeTrailingSlash(self::unifyDirSeparator($currentDir));
 +        $targetDir = self::removeTrailingSlash(self::unifyDirSeparator($targetDir));
 +
 +        if ($currentDir == $targetDir) {
 +            return './';
 +        }
 +
 +        $current = \explode('/', $currentDir);
 +        $target = \explode('/', $targetDir);
 +
 +        $relPath = '';
 +        //for ($i = max(count($current), count($target)) - 1; $i >= 0; $i--) {
 +        for ($i = 0, $max = \max(\count($current), \count($target)); $i < $max; $i++) {
 +            if (isset($current[$i]) && isset($target[$i])) {
 +                if ($current[$i] != $target[$i]) {
 +                    for ($j = 0; $j < $i; $j++) {
 +                        unset($target[$j]);
 +                    }
 +                    $relPath .= \str_repeat('../', \count($current) - $i) . \implode('/', $target) . '/';
 +
 +                    break;
 +                }
 +            } // go up one level
 +            elseif (isset($current[$i]) && !isset($target[$i])) {
 +                $relPath .= '../';
 +            } elseif (!isset($current[$i]) && isset($target[$i])) {
 +                $relPath .= $target[$i] . '/';
 +            }
 +        }
 +
 +        return $relPath;
 +    }
 +
 +    /**
 +     * Creates a path on the local filesystem and returns true on success.
 +     * Parent directories do not need to exists as they will be created if
 +     * necessary.
 +     *
 +     * @param string $path
 +     * @return  bool
 +     */
 +    public static function makePath($path)
 +    {
 +        // directory already exists, abort
 +        if (\file_exists($path)) {
 +            return false;
 +        }
 +
 +        // check if parent directory exists
 +        $parent = \dirname($path);
 +        if ($parent != $path) {
 +            // parent directory does not exist either
 +            // we have to create the parent directory first
 +            $parent = self::addTrailingSlash($parent);
 +            if (!@\file_exists($parent)) {
 +                // could not create parent directory either => abort
 +                if (!self::makePath($parent)) {
 +                    return false;
 +                }
 +            }
 +
 +            // well, the parent directory exists or has been created
 +            // lets create this path
 +            if (!@\mkdir($path)) {
 +                return false;
 +            }
 +
 +            self::makeWritable($path);
 +
 +            return true;
 +        }
 +
 +        return false;
 +    }
 +
 +    /**
 +     * Unifies windows and unix directory separators.
 +     *
 +     * @param string $path
 +     * @return  string
 +     */
 +    public static function unifyDirSeparator($path)
 +    {
 +        $path = \str_replace('\\\\', '/', $path);
 +
 +        return \str_replace('\\', '/', $path);
 +    }
 +
 +    /**
 +     * Scans a folder (and subfolder) for a specific file.
 +     * Returns the filename if found, otherwise false.
 +     *
 +     * @param string $folder
 +     * @param string $searchfile
 +     * @param bool $recursive
 +     * @return  mixed
 +     */
 +    public static function scanFolder($folder, $searchfile, $recursive = true)
 +    {
 +        if (!@\is_dir($folder)) {
 +            return false;
 +        }
 +        if (!$searchfile) {
 +            return false;
 +        }
 +
 +        $folder = self::addTrailingSlash($folder);
 +        $dirh = @\opendir($folder);
 +        while ($filename = @\readdir($dirh)) {
 +            if ($filename == '.' || $filename == '..') {
 +                continue;
 +            }
 +            if ($filename == $searchfile) {
 +                @\closedir($dirh);
 +
 +                return $folder . $filename;
 +            }
 +
 +            if ($recursive == true && @\is_dir($folder . $filename)) {
 +                if ($found = self::scanFolder($folder . $filename, $searchfile, $recursive)) {
 +                    @\closedir($dirh);
 +
 +                    return $found;
 +                }
 +            }
 +        }
 +        @\closedir($dirh);
 +    }
 +
 +    /**
 +     * Returns true if the given filename is an url (http or ftp).
 +     *
 +     * @param string $filename
 +     * @return  bool
 +     */
 +    public static function isURL($filename)
 +    {
 +        return \preg_match('!^(https?|ftp)://!', $filename);
 +    }
 +
 +    /**
 +     * Returns canonicalized absolute pathname.
 +     *
 +     * @param string $path
 +     * @return  string      path
 +     */
 +    public static function getRealPath($path)
 +    {
 +        $path = self::unifyDirSeparator($path);
 +
 +        $result = [];
 +        $pathA = \explode('/', $path);
 +        if ($pathA[0] === '') {
 +            $result[] = '';
 +        }
 +
 +        foreach ($pathA as $key => $dir) {
 +            if ($dir == '..') {
 +                if (\end($result) == '..') {
 +                    $result[] = '..';
 +                } else {
 +                    $lastValue = \array_pop($result);
 +                    if ($lastValue === '' || $lastValue === null) {
 +                        $result[] = '..';
 +                    }
 +                }
 +            } elseif ($dir !== '' && $dir != '.') {
 +                $result[] = $dir;
 +            }
 +        }
 +
 +        $lastValue = \end($pathA);
 +        if ($lastValue === '' || $lastValue === false) {
 +            $result[] = '';
 +        }
 +
 +        return \implode('/', $result);
 +    }
 +
 +    /**
 +     * Formats the given filesize.
 +     *
 +     * @param int $byte
 +     * @param int $precision
 +     * @return  string
 +     */
 +    public static function formatFilesize($byte, $precision = 2)
 +    {
 +        $symbol = 'Byte';
 +        if ($byte >= 1000) {
 +            $byte /= 1000;
 +            $symbol = 'kB';
 +        }
 +        if ($byte >= 1000) {
 +            $byte /= 1000;
 +            $symbol = 'MB';
 +        }
 +        if ($byte >= 1000) {
 +            $byte /= 1000;
 +            $symbol = 'GB';
 +        }
 +        if ($byte >= 1000) {
 +            $byte /= 1000;
 +            $symbol = 'TB';
 +        }
 +
 +        return StringUtil::formatNumeric(\round($byte, $precision)) . ' ' . $symbol;
 +    }
 +
 +    /**
 +     * Formats a filesize with binary prefix.
 +     *
 +     * For more information: <http://en.wikipedia.org/wiki/Binary_prefix>
 +     *
 +     * @param int $byte
 +     * @param int $precision
 +     * @return  string
 +     */
 +    public static function formatFilesizeBinary($byte, $precision = 2)
 +    {
 +        $symbol = 'Byte';
 +        if ($byte >= 1024) {
 +            $byte /= 1024;
 +            $symbol = 'KiB';
 +        }
 +        if ($byte >= 1024) {
 +            $byte /= 1024;
 +            $symbol = 'MiB';
 +        }
 +        if ($byte >= 1024) {
 +            $byte /= 1024;
 +            $symbol = 'GiB';
 +        }
 +        if ($byte >= 1024) {
 +            $byte /= 1024;
 +            $symbol = 'TiB';
 +        }
 +
 +        return StringUtil::formatNumeric(\round($byte, $precision)) . ' ' . $symbol;
 +    }
 +
 +    /**
 +     * Downloads a package archive from an http URL and returns the path to
 +     * the downloaded file.
 +     *
 +     * @param string $httpUrl
 +     * @param string $prefix
 +     * @param array $options
 +     * @param array $postParameters
 +     * @param array $headers empty array or a not initialized variable
 +     * @return  string
 +     * @deprecated  This method currently only is a wrapper around \wcf\util\HTTPRequest. Please use
 +     *      HTTPRequest from now on, as this method may be removed in the future.
 +     */
 +    public static function downloadFileFromHttp(
 +        $httpUrl,
 +        $prefix = 'package',
 +        array $options = [],
 +        array $postParameters = [],
 +        &$headers = []
 +    ) {
 +        $request = new HTTPRequest($httpUrl, $options, $postParameters);
 +        $request->execute();
 +        $reply = $request->getReply();
 +
 +        $newFileName = self::getTemporaryFilename($prefix . '_');
 +        \file_put_contents($newFileName, $reply['body']); // the file to write.
 +
 +        $tmp = $reply['headers']; // copy variable, to avoid problems with the reference
 +        $headers = $tmp;
 +
 +        return $newFileName;
 +    }
 +
 +    /**
 +     * Determines whether a file is text or binary by checking the first few bytes in the file.
 +     * The exact number of bytes is system dependent, but it is typically several thousand.
 +     * If every byte in that part of the file is non-null, considers the file to be text;
 +     * otherwise it considers the file to be binary.
 +     *
 +     * @param string $file
 +     * @return  bool
 +     */
 +    public static function isBinary($file)
 +    {
 +        // open file
 +        $file = new File($file, 'rb');
 +
 +        // get block size
 +        $stat = $file->stat();
 +        $blockSize = $stat['blksize'];
 +        if ($blockSize < 0) {
 +            $blockSize = 1024;
 +        }
 +        if ($blockSize > $file->filesize()) {
 +            $blockSize = $file->filesize();
 +        }
 +        if ($blockSize <= 0) {
 +            return false;
 +        }
 +
 +        // get bytes
 +        $block = $file->read($blockSize);
 +
 +        return \strlen($block) == 0 || \strpos($block, "\0") !== false;
 +    }
 +
 +    /**
 +     * Uncompresses a gzipped file and returns true if successful.
 +     *
 +     * @param string $gzipped
 +     * @param string $destination
 +     * @return  bool
 +     */
 +    public static function uncompressFile($gzipped, $destination)
 +    {
 +        if (!@\is_file($gzipped)) {
 +            return false;
 +        }
 +
 +        $sourceFile = new GZipFile($gzipped, 'rb');
 +        //$filesize = $sourceFile->getFileSize();
 +        $targetFile = new File($destination);
 +        while (!$sourceFile->eof()) {
 +            $targetFile->write($sourceFile->read(512), 512);
 +        }
 +        $targetFile->close();
 +        $sourceFile->close();
 +
 +        self::makeWritable($destination);
 +
 +        return true;
 +    }
 +
 +    /**
 +     * Returns true if php is running as apache module.
 +     *
 +     * @return  bool
 +     */
 +    public static function isApacheModule()
 +    {
 +        return \function_exists('apache_get_version');
 +    }
 +
 +    /**
 +     * Returns the mime type of a file.
 +     *
 +     * @param string $filename
 +     * @return  string
 +     */
 +    public static function getMimeType($filename)
 +    {
 +        if (self::$finfo === null) {
 +            if (!\class_exists('\finfo', false)) {
 +                return 'application/octet-stream';
 +            }
 +            self::$finfo = new \finfo(\FILEINFO_MIME_TYPE);
 +        }
 +
 +        // \finfo->file() can fail for files that contain only 1 byte, because libmagic expects at least
 +        // a few bytes in order to determine the type. See https://bugs.php.net/bug.php?id=64684
 +        $mimeType = @self::$finfo->file($filename);
 +
 +        return $mimeType ?: 'application/octet-stream';
 +    }
 +
 +    /**
 +     * Tries to make a file or directory writable. It starts of with the least
 +     * permissions and goes up until 0666 for files and 0777 for directories.
 +     *
 +     * @param string $filename
 +     * @throws  SystemException
 +     */
 +    public static function makeWritable($filename)
 +    {
 +        if (!\file_exists($filename)) {
 +            return;
 +        }
 +
 +        if (self::$mode === null) {
 +            // WCFSetup
 +            if (\defined('INSTALL_SCRIPT') && \file_exists(INSTALL_SCRIPT)) {
 +                // do not use PHP_OS here, as this represents the system it was built on != running on
 +                // php_uname() is forbidden on some strange hosts; PHP_EOL is reliable
 +                if (\PHP_EOL == "\r\n") {
 +                    // Windows
 +                    self::$mode = '0777';
 +                } else {
 +                    // anything but Windows
 +                    \clearstatcache();
 +
 +                    self::$mode = '0666';
 +
 +                    $tmpFilename = '__permissions_' . \sha1((string)\time()) . '.txt';
 +                    @\touch($tmpFilename);
 +
 +                    // create a new file and check the file owner, if it is the same
 +                    // as this file (uploaded through FTP), we can safely grant write
 +                    // permissions exclusively to the owner rather than everyone
 +                    if (\file_exists($tmpFilename)) {
 +                        $scriptOwner = \fileowner(INSTALL_SCRIPT);
 +                        $fileOwner = \fileowner($tmpFilename);
 +
 +                        if ($scriptOwner === $fileOwner) {
 +                            self::$mode = '0644';
 +                        }
 +
 +                        @\unlink($tmpFilename);
 +                    }
 +                }
 +            } else {
 +                // mirror permissions of WCF.class.php
 +                if (!\file_exists(WCF_DIR . 'lib/system/WCF.class.php')) {
 +                    throw new SystemException("Unable to find 'wcf/lib/system/WCF.class.php'.");
 +                }
 +
 +                self::$mode = '0' . \substr(\sprintf('%o', \fileperms(WCF_DIR . 'lib/system/WCF.class.php')), -3);
 +            }
 +        }
 +
 +        if (\is_dir($filename)) {
 +            if (self::$mode == '0644') {
 +                @\chmod($filename, 0755);
 +            } else {
 +                @\chmod($filename, 0777);
 +            }
 +        } else {
 +            @\chmod($filename, \octdec(self::$mode));
 +        }
 +
 +        if (!\is_writable($filename)) {
 +            // does not work with 0777
 +            throw new SystemException("Unable to make '" . $filename . "' writable. This is a misconfiguration of your server, please contact your system administrator or hosting provider.");
 +        }
 +    }
 +
 +    /**
 +     * Returns memory limit in bytes.
 +     *
 +     * @return  int
 +     */
 +    public static function getMemoryLimit()
 +    {
 +        if (self::$memoryLimit === null) {
 +            self::$memoryLimit = 0;
 +
 +            $memoryLimit = \ini_get('memory_limit');
 +
 +            // no limit
 +            if ($memoryLimit == -1) {
 +                self::$memoryLimit = -1;
 +            }
 +
 +            // completely numeric, PHP assumes byte
 +            if (\is_numeric($memoryLimit)) {
 +                self::$memoryLimit = $memoryLimit;
 +            }
 +
 +            // PHP supports 'K', 'M' and 'G' shorthand notation
 +            if (\preg_match('~^(\d+)\s*([KMG])$~i', $memoryLimit, $matches)) {
 +                switch (\strtoupper($matches[2])) {
 +                    case 'K':
 +                        self::$memoryLimit = $matches[1] * 1024;
 +                        break;
 +
 +                    case 'M':
 +                        self::$memoryLimit = $matches[1] * 1024 * 1024;
 +                        break;
 +
 +                    case 'G':
 +                        self::$memoryLimit = $matches[1] * 1024 * 1024 * 1024;
 +                        break;
 +                }
 +            }
 +        }
 +
 +        return self::$memoryLimit;
 +    }
 +
 +    /**
 +     * Returns true if the given amount of memory is available.
 +     *
 +     * @param int $neededMemory
 +     * @return  bool
 +     */
 +    public static function checkMemoryLimit($neededMemory)
 +    {
 +        return self::getMemoryLimit() == -1 || self::getMemoryLimit() > (\memory_get_usage() + $neededMemory);
 +    }
 +
 +    /**
 +     * Returns icon name for given filename.
 +     *
 +     * @param string $filename
 +     * @return      string
 +     */
 +    public static function getIconNameByFilename($filename)
 +    {
 +        static $mapping = [
 +            // archive
 +            'zip' => 'archive',
 +            'rar' => 'archive',
 +            'tar' => 'archive',
 +            'gz' => 'archive',
 +            // audio
 +            'mp3' => 'audio',
 +            'ogg' => 'audio',
 +            'wav' => 'audio',
 +            // code
 +            'php' => 'code',
 +            'html' => 'code',
 +            'htm' => 'code',
 +            'tpl' => 'code',
 +            'js' => 'code',
 +            // excel
 +            'xls' => 'excel',
 +            'ods' => 'excel',
 +            'xlsx' => 'excel',
 +            // image
 +            'gif' => 'image',
 +            'jpg' => 'image',
 +            'jpeg' => 'image',
 +            'png' => 'image',
 +            'bmp' => 'image',
 +            'webp' => 'image',
 +            // video
 +            'avi' => 'video',
 +            'wmv' => 'video',
 +            'mov' => 'video',
 +            'mp4' => 'video',
 +            'mpg' => 'video',
 +            'mpeg' => 'video',
 +            'flv' => 'video',
 +            // pdf
 +            'pdf' => 'pdf',
 +            // powerpoint
 +            'ppt' => 'powerpoint',
 +            'pptx' => 'powerpoint',
 +            // text
 +            'txt' => 'text',
 +            // word
 +            'doc' => 'word',
 +            'docx' => 'word',
 +            'odt' => 'word',
 +        ];
 +
 +        $lastDotPosition = \strrpos($filename, '.');
 +        if ($lastDotPosition !== false) {
 +            $extension = \substr($filename, $lastDotPosition + 1);
 +            if (isset($mapping[$extension])) {
 +                return $mapping[$extension];
 +            }
 +        }
 +
 +        return '';
 +    }
 +
 +    /**
 +     * Forbid creation of FileUtil objects.
 +     */
 +    private function __construct()
 +    {
 +        // does nothing
 +    }
  }
Simple merge
Simple merge