You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

599 lines
18 KiB

<?php
// (c) Copyright by authors of the Tiki Wiki CMS Groupware Project
//
// All Rights Reserved. See copyright.txt for details and a complete list of authors.
// Licensed under the GNU LESSER GENERAL PUBLIC LICENSE. See license.txt for details.
// $Id$
namespace Tiki\Installer;
use Tiki\TikiInit;
use TikiDb_Bridge;
use SplSubject;
use SplObjectStorage;
use SplObserver;
use TWVersion;
use Exception;
/**
* @see Patch
*/
class Installer extends TikiDb_Bridge implements SplSubject
{
public static $instance = null; // Singleton instance
private $observers;
public $scripts = [];
public $executed = [];
public $queries = [
'currentStmt' => '',
'currentFile' => '',
'executed' => 0,
'total' => 0,
'successful' => [],
'failed' => []
];
public $useInnoDB = true;
private function __construct()
{
$this->observers = new SplObjectStorage();
$this->buildPatchList();
$this->buildScriptList();
}
/**
* Get the instance (creating one if necessary)
* @return Installer
*/
public static function getInstance()
{
if (is_null(self::$instance)) {
self::$instance = new self();
}
return self::$instance;
}
public function cleanInstall()
{
if ($image = $this->getBaseImage()) {
$this->runFile($image);
$this->buildPatchList();
$this->buildScriptList();
} else {
// No image specified, standard install
$this->runFile(__DIR__ . '/../db/tiki.sql');
if ($this->isMySQLFulltextSearchSupported()) {
$this->runFile(__DIR__ . '/../db/tiki_fulltext_indexes.sql');
}
if ($this->useInnoDB) {
$this->runFile(__DIR__ . '/../db/tiki_innodb.sql');
} else {
$this->runFile(__DIR__ . '/../db/tiki_myisam.sql');
}
$this->buildPatchList();
$this->buildScriptList();
// Base SQL file contains the distribution tiki patches up to this point
foreach (Patch::getPatches([Patch::NOT_APPLIED]) as $patchName => $patch) {
if (preg_match('/_tiki$/', $patchName)) {
$patch->record();
}
}
}
$this->update();
}
public function update()
{
// Mark InnoDB usage for updates
if (strcasecmp($this->getCurrentEngine(), "InnoDB") == 0) {
$this->useInnoDB = true;
} else {
$this->useInnoDB = false;
}
if (! $this->tableExists('tiki_schema')) {
// DB too old to handle auto update
if (file_exists(__DIR__ . '/../db/custom_upgrade.sql')) {
$this->runFile(__DIR__ . '/../db/custom_upgrade.sql');
} else {
// If 1.9
if (! $this->tableExists('tiki_minichat')) {
$this->runFile(__DIR__ . '/../db/tiki_1.9to2.0.sql');
}
$this->runFile(__DIR__ . '/../db/tiki_2.0to3.0.sql');
}
}
$this->assureDefaultCharSetIsAlignedWithTikiSchema();
$TWV = new TWVersion();
$dbversion_tiki = $TWV->version;
// If a Mysql data file exists, use that. Very fast
// If data file is missing or the batch loader is not available, use the single insert method
$secdb = __DIR__ . '/../db/tiki-secdb_' . $dbversion_tiki . '_mysql.sql';
$secdbData = __DIR__ . '/../db/tiki-secdb_' . $dbversion_tiki . '_mysql.data';
if (file_exists($secdbData)) {
// A MySQL datafile exists
$truncateTable = true;
$rc = $this->runDataFile($secdbData, 'tiki_secdb', $truncateTable);
if ($rc == false) {
// The batch loader failed
if (file_exists($secdb)) {
// Run single inserts
$this->runFile($secdb, false);
}
}
} elseif (file_exists($secdb)) {
// Run single inserts
$this->runFile($secdb, false);
}
foreach (Patch::getPatches([Patch::NOT_APPLIED]) as $patchName => $patch) {
try {
$this->installPatch($patchName);
} catch (Exception $e) {
if ($e->getCode() != 2) {
throw $e;
}
}
}
foreach ($this->scripts as $script) {
$this->runScript($script);
}
}
/**
* @param $patch
* @param $force true if the patch should be applied even if already marked as applied
* @throws Exception Code 1 if unknown patch, 2 if application attempt fails, 3 if patch was already installed and $force is false
*/
public function installPatch($patch, $force = false)
{
if (! $force && isset(Patch::$list[$patch]) && Patch::$list[$patch]->isApplied()) {
throw new Exception('Patch already applied', 3);
}
$schema = __DIR__ . "/schema/$patch.sql";
$script = __DIR__ . "/schema/$patch.php";
$profile = __DIR__ . "/schema/$patch.yml";
$pre = "pre_$patch";
$post = "post_$patch";
$standalone = "upgrade_$patch";
if (file_exists($script)) {
require $script;
$status = true;
}
global $dbs_tiki;
$local_php = TikiInit::getCredentialsFile();
if (is_readable($local_php)) {
require($local_php);
unset($db_tiki, $host_tiki, $user_tiki, $pass_tiki);
}
if (function_exists($standalone)) {
$status = $standalone($this);
if (is_null($status)) {
$status = true;
}
} else {
if (function_exists($pre)) {
$pre($this);
}
if (file_exists($profile)) {
$status = $this->applyProfile($profile);
} else {
try {
$status = $this->runFile($schema);
} catch (Exception $e) {
}
}
if (function_exists($post)) {
$post($this);
}
}
if (! isset($status)) {
if (array_key_exists($patch, Patch::$list)) {
throw new LogicException('Patch not found');
} else {
throw new Exception('No such patch', 1);
}
} elseif (! $status) {
throw new Exception('Patch application failed', 2);
} else {
Patch::$list[$patch]->record();
}
}
/**
* @param $script
*/
public function runScript($script)
{
$file = __DIR__ . "/script/$script.php";
if (file_exists($file)) {
require $file;
}
if (function_exists($script)) {
$script($this);
}
$this->executed[] = $script;
}
private function applyProfile($profileFile)
{
// By the time a profile install is requested, the installation should be functional enough to work
require_once 'tiki-setup.php';
$directory = dirname($profileFile);
$profile = substr(basename($profileFile), 0, -4);
$profile = Tiki_Profile::fromFile($directory, $profile);
$tx = $this->begin();
$installer = new Tiki_Profile_Installer();
$ret = $installer->install($profile);
$tx->commit();
return $ret;
}
/**
* Batch insert from a mysql data file
*
* @param $file MySQL export file
* @param $targetTable Target table
* @param $clearTable=true Flag saying if the target table should be truncated or not
* @return bool
*/
public function runDataFile($file, $targetTable, $clearTable = true)
{
if (! is_file($file) || ! $command = file_get_contents($file)) {
print('Fatal: Cannot open ' . $file);
exit(1);
}
if ($clearTable == true) {
$statement = 'truncate table ' . $targetTable;
$this->query($statement);
}
// LOAD DATA INFILE doesn't like single \ directory separators. Replace with \\
$inFile = str_replace('\\', '\\\\', $file);
$status = true;
$statement = 'LOAD DATA INFILE "' . $inFile . '" INTO TABLE ' . $targetTable;
if ($this->query($statement) === false) {
$status = false;
}
return $status;
}
/**
* @param $file
* @return bool
*/
public function runFile($file, $convertFormat = true)
{
if (! is_file($file) || ! $command = file_get_contents($file)) {
throw new Exception('Fatal: Cannot open ' . $file);
}
// split the file into several queries?
$statements = preg_split("#(;\s*\n)|(;\s*\r\n)#", $command);
$statements = array_filter($statements, function ($st) {
return trim($st) && preg_match('/^\s*(?!-- )/m', $st);
});
$this->queries['currentFile'] = basename($file);
$this->queries['total'] += count($statements);
$status = true;
foreach ($statements as $statement) {
if ($this->useInnoDB && $convertFormat) {
// Convert all MyISAM statments to InnoDB
$statement = str_ireplace("MyISAM", "InnoDB", $statement);
}
if ($this->query($statement, [], -1, -1, true, $file) === false) {
$status = false;
}
$this->queries['executed'] += 1;
$this->queries['currentStmt'] = $statement;
$this->notify();
}
$this->queries['currentFile'] = '';
$this->queries['currentStmt'] = '';
return $status;
}
/**
* @param null $query
* @param array $values
* @param $numrows
* @param $offset
* @param bool $reporterrors
* @param string $patch
* @return bool
*/
public function query($query = null, $values = null, $numrows = -1, $offset = -1, $reporterrors = true, $patch = '')
{
$error = '';
$result = $this->queryError($query, $error, $values);
if ($result && empty($error)) {
$this->queries['successful'][] = $query;
return $result;
} else {
$this->queries['failed'][] = [$query, $error, substr(basename($patch), 0, -4)];
return false;
}
}
/**
* @throws Exception In case of filesystem access issue
*/
public function buildPatchList()
{
$patches = [];
foreach (['sql', 'yml', 'php' /* "php" for standalone PHP scripts */] as $extension) {
$files = glob(__DIR__ . '/schema/*_*.' . $extension); // glob() does not portably support brace expansion, hence the loop
if ($files === false) {
throw new Exception('Failed to scan patches');
}
foreach ($files as $file) {
$filename = basename($file);
$patches[] = substr($filename, 0, -4);
}
}
$patches = array_unique($patches);
$installed = [];
if ($this->tableExists('tiki_schema')) {
$installed = $this->table('tiki_schema')->fetchColumn('patch_name', []);
}
if (empty($installed)) {
// Erase initial error
$this->queries['failed'] = [];
}
Patch::$list = [];
sort($patches);
foreach ($patches as $patchName) {
if (in_array($patchName, $installed)) {
$status = Patch::ALREADY_APPLIED;
} else {
$status = Patch::NOT_APPLIED;
}
$patch = new Patch($patchName, $status);
$patch->optional = substr($patchName, 0, 8) == 'optional'; // Ignore patches starting with "optional". These patches have drawbacks and should be manually run by informed administrators.
Patch::$list[$patchName] = $patch;
}
}
public function buildScriptList()
{
$files = glob(__DIR__ . '/script/*.php');
if (empty($files)) {
return;
}
foreach ($files as $file) {
if (basename($file) === "index.php") {
continue;
}
$filename = basename($file);
$this->scripts[] = substr($filename, 0, -4);
}
}
/**
* @param $tableName
* @return bool
*/
public function tableExists($tableName)
{
$list = $this->listTables();
return in_array($tableName, $list);
}
public function isInstalled()
{
return $this->tableExists('tiki_preferences');
}
/**
* @return bool
*/
public function requiresUpdate()
{
return count(Patch::getPatches([Patch::NOT_APPLIED])) > 0;
}
public function checkInstallerLocked()
{
$iniFile = __DIR__ . '/../db/lock';
if (! is_readable($iniFile)) {
return 1;
}
}
private function getBaseImage()
{
$iniFile = __DIR__ . '/../db/install.ini';
$ini = [];
if (is_readable($iniFile)) {
$ini = parse_ini_file($iniFile);
}
$direct = __DIR__ . '/../db/custom_tiki.sql';
$fetch = null;
$check = null;
if (isset($ini['source.type'])) {
switch ($ini['source.type']) {
case 'local':
$direct = $ini['source.file'];
break;
case 'http':
$fetch = $ini['source.file'];
if (isset($ini['source.md5'])) {
$check = $ini['source.md5'];
}
break;
}
}
if (is_readable($direct)) {
return $direct;
}
if (! $fetch) {
return;
}
$cacheFile = __DIR__ . '/../temp/cache/sql' . md5($fetch);
if (is_readable($cacheFile)) {
return $cacheFile;
}
$read = fopen($fetch, 'r');
$write = fopen($cacheFile, 'w+');
if ($read && $write) {
while (! feof($read)) {
fwrite($write, fread($read, 1024 * 100));
}
fclose($read);
fclose($write);
if (! $check || $check == md5_file($cacheFile)) {
return $cacheFile;
} else {
unlink($cacheFile);
}
}
}
/**
* Use this if the default for a preference changes to preserve the old default behaviour on upgrades
*
* @param string $prefName
* @param string $oldDefault
*/
public function preservePreferenceDefault($prefName, $oldDefault)
{
if ($this->tableExists('tiki_preferences')) {
$tiki_preferences = $this->table('tiki_preferences');
$hasValue = $tiki_preferences->fetchCount(['name' => $prefName]);
if (empty($hasValue)) { // old value not in database so was on default value
$tiki_preferences->insert(['name' => $prefName, 'value' => $oldDefault]);
}
}
}
public function attach(SplObserver $observable)
{
if (method_exists($observable, 'update')) {
$this->observers->attach($observable);
} else {
throw new Exception('Observable should implement `update` method');
}
}
public function detach(SplObserver $observable)
{
$this->observers->detach($observable);
}
public function notify()
{
foreach ($this->observers as $observer) {
$observer->update($this);
}
}
/**
* Compares the charset encoding of the database with the one from tiki_schema, column patch_name (used as reference)
*
* If they are different, attempts to update teh default charset and collation from the database to
* match the one from tiki_schema, as it should be the reference for the encoding of that tiki database.
* The key case for both charset not to match is when the tiki db was restored to a new db but the encoding
* of that new db was not set to the right values. That will then cause that new tables won't be created with
* the right encoding (aligned with the rest of the tiki tables)
*/
protected function assureDefaultCharSetIsAlignedWithTikiSchema()
{
$databaseInfoResult = $this->query(
'SELECT SCHEMA_NAME, DEFAULT_CHARACTER_SET_NAME, DEFAULT_COLLATION_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = DATABASE()'
);
if (! $databaseInfoResult || ! $databaseInfo = $databaseInfoResult->fetchRow()) {
return;
}
$tableInfoResult = $this->query(
'SELECT TABLE_SCHEMA, CHARACTER_SET_NAME, COLLATION_NAME from INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = "tiki_schema" AND COLUMN_NAME="patch_name"'
);
if (! $tableInfoResult || ! $tableInfo = $tableInfoResult->fetchRow()) {
return;
}
if (! $databaseInfo || ! $tableInfo) { // if we cant retrieve the info we can not do anything
return;
}
if (
$databaseInfo['DEFAULT_CHARACTER_SET_NAME'] === $tableInfo['CHARACTER_SET_NAME']
&& $databaseInfo['DEFAULT_COLLATION_NAME'] === $tableInfo['COLLATION_NAME']
) {
// all OK, charset and collation are aligned
return;
}
// Info is not aligned, forcing to align the default values for the database with tiki_schema
// Someone may have restored the db without setting the right default values for teh database for instance.
switch ($tableInfo['CHARACTER_SET_NAME']) {
case 'utf8':
$this->query(
'ALTER DATABASE `' . $tableInfo['TABLE_SCHEMA'] . '` DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci'
);
break;
case 'utf8mb4':
$this->query(
'ALTER DATABASE `' . $tableInfo['TABLE_SCHEMA'] . '` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci'
);
break;
default:
// we will only attempt to align for some char sets, other configuration needs to be done manually
break;
}
}
}