Files
ave-cms/setup/Installer.php
T
2026-07-27 12:58:44 +03:00

977 lines
48 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/*
|--------------------------------------------------------------------------------------
| AVE.cms
|--------------------------------------------------------------------------------------
| @package AVE.cms
| @file setup/Installer.php
| @author AVE.cms <support@ave-cms.ru>
| @copyright 2007-2026 (c) AVE.cms
| @link https://ave-cms.ru
| @version 3.3
*/
defined('AVE_SETUP') || die('Direct access to this location is not allowed.');
class AveInstaller
{
const ADMIN_PASSWORD_MIN_LENGTH = 8;
protected $root;
protected $locale;
public function __construct($root, $locale = 'ru')
{
$this->root = rtrim(str_replace('\\', '/', (string) $root), '/');
$this->locale = in_array((string) $locale, array('ru', 'en'), true) ? (string) $locale : 'ru';
defined('BASEPATH') || define('BASEPATH', $this->root);
}
public function officialRepositoryProfile()
{
try {
$this->loadDistributionProfile();
$profile = \App\Common\DistributionProfile::official();
return array('available' => true, 'error' => '', 'profile' => $profile);
} catch (Throwable $e) {
return array('available' => false, 'error' => $e->getMessage(), 'profile' => null);
}
}
public function validateRepositorySelection($mode, $customProfile = '')
{
$mode = strtolower(trim((string) $mode));
if ($mode === 'disabled') {
return array('mode' => 'disabled', 'profile' => null);
}
$this->loadDistributionProfile();
if ($mode === 'official') {
return array('mode' => 'official', 'profile' => \App\Common\DistributionProfile::official());
}
if ($mode === 'custom') {
if (trim((string) $customProfile) === '') {
throw new InvalidArgumentException('Вставьте профиль подключения, полученный от издателя.');
}
return array('mode' => 'custom', 'profile' => \App\Common\DistributionProfile::decode($customProfile));
}
throw new InvalidArgumentException('Выберите способ подключения обновлений и модулей.');
}
public function requirements()
{
$checks = array(
$this->check('PHP 7.3 или новее', PHP_VERSION_ID >= 70300, PHP_VERSION),
$this->check('Расширение mysqli', extension_loaded('mysqli')),
$this->check('Расширение mbstring', extension_loaded('mbstring')),
$this->check('Расширение JSON', extension_loaded('json')),
$this->check('Расширение OpenSSL', extension_loaded('openssl')),
$this->check('Каталог configs доступен для записи', $this->writablePath($this->root . '/configs')),
$this->check('Каталог storage доступен для записи', $this->writablePath($this->root . '/storage')),
$this->check('Каталог tmp доступен для записи', $this->writablePath($this->root . '/tmp')),
$this->check('Каталог uploads доступен для записи', $this->writablePath($this->root . '/uploads')),
$this->check('Core-схема найдена', is_file(__DIR__ . '/schema.sql')),
);
$checks[] = $this->check('GD и WebP', $this->gdWebpAvailable(), 'Необязательно для установки', false);
return $checks;
}
public function requirementsPassed(array $checks)
{
foreach ($checks as $check) {
if (!empty($check['required']) && empty($check['passed'])) {
return false;
}
}
return true;
}
public function existingDatabaseConfig()
{
$file = $this->root . '/configs/db.config.php';
if (!is_file($file) || filesize($file) === 0) {
return null;
}
$config = null;
include $file;
return is_array($config) ? $config : null;
}
public function installationState()
{
if (is_file($this->lockFile())) {
return array('installed' => true, 'reason' => 'Установка уже завершена.');
}
$config = $this->existingDatabaseConfig();
if (!$config) {
return array('installed' => false, 'reason' => '');
}
try {
$db = $this->connect($config);
$prefix = $this->validatePrefix(isset($config['dbpref']) ? $config['dbpref'] : '');
if ($this->tableExists($db, $prefix . '_users')
&& $this->tableCount($db, $prefix . '_users') > 0) {
return array('installed' => true, 'reason' => 'В настроенной базе уже есть системный пользователь.');
}
} catch (Exception $e) {
return array('installed' => false, 'reason' => '');
}
return array('installed' => false, 'reason' => '');
}
public function install(array $database, array $site, array $options = array())
{
$writeFiles = !isset($options['write_files']) || (bool) $options['write_files'];
$progress = isset($options['progress']) && is_callable($options['progress']) ? $options['progress'] : null;
$resetPrefix = !empty($options['reset_prefix']);
$resetConfirmation = isset($options['reset_prefix_confirmation'])
? trim((string) $options['reset_prefix_confirmation'])
: '';
$repositories = isset($options['repositories']) && is_array($options['repositories'])
? $options['repositories']
: array('mode' => 'disabled', 'profile' => null);
$this->reportProgress($progress, 8, 'Проверяем данные', 'Параметры базы данных и администратора');
$database = $this->validateDatabase($database);
$site = $this->validateSite($site);
if ($resetPrefix && !hash_equals($database['dbpref'], $resetConfirmation)) {
throw new InvalidArgumentException('Для очистки таблиц введите точный префикс: ' . $database['dbpref']);
}
$this->reportProgress($progress, 18, 'Подключаемся к базе данных', $database['dbhost'] . ' / ' . $database['dbname']);
$db = $this->connect($database);
$prefix = $database['dbpref'];
$initialTables = $this->prefixedTables($db, $prefix);
$hadDatabaseConfig = is_file($this->root . '/configs/db.config.php');
$adminChange = null;
if ($resetPrefix) {
$this->reportProgress($progress, 24, 'Очищаем таблицы префикса', 'Удаляем объекты ' . $prefix . '_* без возможности восстановления');
$removedObjects = $this->dropPrefixedObjects($db, $prefix);
$initialTables = array();
$this->reportProgress($progress, 30, 'Таблицы префикса удалены', 'Удалено объектов: ' . $removedObjects);
}
$this->assertPristine($db, $prefix);
if (!$resetPrefix) {
$this->reportProgress($progress, 30, 'Подключение проверено', 'Префикс ' . $prefix . ' свободен');
}
try {
$this->reportProgress($progress, 38, 'Создаём структуру базы данных', 'Таблицы ядра и необходимые индексы');
$this->applySchema($db, $prefix);
$this->reportProgress($progress, 64, 'Структура базы данных готова', 'Начинаем заполнение системных данных');
$db->begin_transaction();
$this->reportProgress($progress, 70, 'Создаём начальные данные', 'Администратор, настройки, рубрика и документы');
$summary = $this->seed($db, $prefix, $site, $repositories);
$db->commit();
$this->reportProgress($progress, 84, 'Начальные данные созданы', 'Стартовая страница и страница 404 готовы');
if ($writeFiles) {
$this->reportProgress($progress, 90, 'Записываем конфигурацию', 'Подключение к базе и адрес панели управления');
$this->writeDatabaseConfig($database);
$adminChange = $this->applyAdminDirectory($site['admin_directory']);
$this->reportProgress($progress, 96, 'Очищаем кеш', 'Подготавливаем первый запуск сайта');
$this->clearRuntimeCache();
$this->writeLock($prefix, $site['admin_email'], $site['admin_directory']);
}
$summary['tables'] = count($this->prefixedTables($db, $prefix));
$summary['modules'] = $this->tableCount($db, $prefix . '_modules');
$summary['admin_directory'] = $site['admin_directory'];
$this->reportProgress($progress, 100, 'Установка завершена', 'AVE.cms готова к работе');
return $summary;
} catch (Throwable $e) {
@$db->rollback();
if ($adminChange) {
$this->rollbackAdminDirectory($adminChange);
}
if (!$initialTables) {
$this->dropPrefixedTables($db, $prefix);
if ($writeFiles && !$hadDatabaseConfig) {
@unlink($this->root . '/configs/db.config.php');
@unlink($this->lockFile());
}
}
throw $e;
}
}
protected function reportProgress($callback, $percent, $title, $detail = '')
{
if (!$callback) { return; }
call_user_func($callback, array(
'percent' => max(0, min(100, (int) $percent)),
'title' => (string) $title,
'detail' => (string) $detail,
));
}
public function validateDatabase(array $database)
{
$out = array(
'dbhost' => trim(isset($database['dbhost']) ? (string) $database['dbhost'] : ''),
'dbport' => max(0, (int) (isset($database['dbport']) ? $database['dbport'] : 3306)),
'dbname' => trim(isset($database['dbname']) ? (string) $database['dbname'] : ''),
'dbuser' => trim(isset($database['dbuser']) ? (string) $database['dbuser'] : ''),
'dbpass' => isset($database['dbpass']) ? (string) $database['dbpass'] : '',
'dbpref' => $this->validatePrefix(isset($database['dbpref']) ? $database['dbpref'] : ''),
'dbchar' => 'utf8mb4',
'dbsock' => null,
);
if ($out['dbhost'] === '' || $out['dbname'] === '' || $out['dbuser'] === '') {
throw new InvalidArgumentException('Заполните адрес, имя базы и пользователя БД.');
}
if (!preg_match('/^[A-Za-z0-9_$-]{1,64}$/', $out['dbname'])) {
throw new InvalidArgumentException('Некорректное имя базы данных.');
}
return $out;
}
public function validateSite(array $site)
{
$out = array(
'site_name' => trim(isset($site['site_name']) ? (string) $site['site_name'] : ''),
'admin_name' => trim(isset($site['admin_name']) ? (string) $site['admin_name'] : ''),
'admin_login' => trim(isset($site['admin_login']) ? (string) $site['admin_login'] : ''),
'admin_email' => trim(isset($site['admin_email']) ? (string) $site['admin_email'] : ''),
'admin_password' => isset($site['admin_password']) ? (string) $site['admin_password'] : '',
'admin_directory' => $this->validateAdminDirectory(isset($site['admin_directory']) ? $site['admin_directory'] : 'adminx'),
'admin_language' => isset($site['admin_language']) && in_array($site['admin_language'], array('ru', 'en'), true)
? (string) $site['admin_language']
: $this->locale,
);
if ($out['site_name'] === '' || $out['admin_name'] === '' || $out['admin_login'] === '') {
throw new InvalidArgumentException('Укажите название сайта, имя и логин администратора.');
}
if (!filter_var($out['admin_email'], FILTER_VALIDATE_EMAIL)) {
throw new InvalidArgumentException('Укажите корректный email администратора.');
}
if (!preg_match('/^[A-Za-z0-9_.@-]{3,150}$/', $out['admin_login'])) {
throw new InvalidArgumentException('Логин содержит недопустимые символы.');
}
if (mb_strlen($out['admin_password'], 'UTF-8') < self::ADMIN_PASSWORD_MIN_LENGTH) {
throw new InvalidArgumentException(
'Пароль администратора должен содержать не менее ' . self::ADMIN_PASSWORD_MIN_LENGTH . ' символов.'
);
}
return $out;
}
public function configuredAdminDirectory()
{
$file = $this->root . '/configs/public.config.php';
$config = is_file($file) ? include $file : array();
$directory = is_array($config) && isset($config['admin_directory']) ? $config['admin_directory'] : 'adminx';
try {
return $this->validateAdminDirectory($directory, false);
} catch (Throwable $e) {
return 'adminx';
}
}
protected function validateAdminDirectory($directory, $checkTarget = true)
{
$directory = trim((string) $directory);
if (!preg_match('/^[A-Za-z][A-Za-z0-9_-]{0,47}$/', $directory)) {
throw new InvalidArgumentException('Имя папки панели: латинская буква в начале, далее буквы, цифры, - или _.');
}
$reserved = array('api', 'configs', 'fields', 'install', 'modules', 'setup', 'storage', 'system', 'templates', 'tmp', 'uploads');
if (in_array(strtolower($directory), $reserved, true)) {
throw new InvalidArgumentException('Это имя зарезервировано системным каталогом: ' . $directory);
}
$source = $this->root . '/adminx';
$target = $this->root . '/' . $directory;
if ($checkTarget && !is_dir($source)) {
throw new RuntimeException('В сборке отсутствует исходная папка панели adminx.');
}
if ($checkTarget && $directory !== 'adminx' && file_exists($target)) {
throw new InvalidArgumentException('Каталог /' . $directory . ' уже существует. Выберите другое имя панели.');
}
if ($checkTarget && $directory !== 'adminx' && !is_writable($this->root)) {
throw new RuntimeException('Сервер не может переименовать папку adminx. Проверьте права на корень сайта.');
}
return $directory;
}
protected function seed(mysqli $db, $prefix, array $site, array $repositories)
{
$now = time();
$date = date('Y-m-d H:i:s', $now);
$password = password_hash($site['admin_password'], PASSWORD_BCRYPT, array('cost' => 12));
$roles = array(
1 => array('admin', 'Администратор'),
2 => array('guest', 'Гостевая'),
3 => array('moderator', 'Модератор'),
4 => array('user', 'Пользователи'),
);
foreach ($roles as $id => $role) {
$this->execute($db, 'INSERT INTO `' . $prefix . '_roles` (id,code,name,is_system,created_at,updated_at) VALUES (?,?,?,?,?,?)',
'ississ', array($id, $role[0], $role[1], 1, $date, $date));
}
$this->execute($db, 'INSERT INTO `' . $prefix . '_users` (id,name,email,password_hash,role,is_active,created_at,updated_at,login,first_name,last_name,legacy_id) VALUES (1,?,?,?,?,?,?,?,?,?,?,1)',
'ssssisssss', array($site['admin_name'], $site['admin_email'], $password, 'admin', 1, $date, $date, $site['admin_login'], $site['admin_name'], ''));
$permissions = array(
array('admin_panel', 'core', 'Доступ в панель управления', 'Разрешает вход в Adminx.', 1),
array('all_permissions', 'core', 'Полный доступ', 'Даёт администратору доступ ко всем возможностям.', 2),
array('view_public_debug', 'core', 'Публичная панель отладки', 'Разрешает просмотр служебной панели публичного сайта.', 3),
array('view_development_site', 'core', 'Просмотр сайта в режиме разработки', 'Разрешает видеть публичный сайт, закрытый для посетителей и поисковых систем.', 4),
);
foreach ($permissions as $permission) {
$this->execute($db, 'INSERT INTO `' . $prefix . '_permissions` (code,module,group_code,name,description,sort_order) VALUES (?,\'admin\',?,?,?,?)',
'ssssi', array($permission[0], $permission[1], $permission[2], $permission[3], $permission[4]));
}
$this->execute($db, 'INSERT INTO `' . $prefix . '_role_permissions` (role_id,permission_code) VALUES (1,\'all_permissions\')');
$this->seedSettings($db, $prefix, $site['site_name'], $site['admin_language'], $repositories);
$this->seedConstants($db, $prefix, $date);
$this->seedCoreMigrationLedger($db, $prefix, $date);
$this->seedPublicIdentity($db, $prefix, $site['site_name'], $site['admin_name'], $site['admin_login'], $site['admin_email'], $password, $date);
$this->seedContent($db, $prefix, $site['site_name'], $now);
return array(
'admin_id' => 1,
'template_id' => 1,
'rubric_id' => 1,
'field_id' => 1,
'document_id' => 1,
'not_found_document_id' => 2,
'repository_mode' => isset($repositories['mode']) ? (string) $repositories['mode'] : 'disabled',
'repository_name' => isset($repositories['profile']['name']) ? (string) $repositories['profile']['name'] : '',
);
}
protected function seedSettings(mysqli $db, $prefix, $siteName, $adminLanguage, array $repositories)
{
$profile = isset($repositories['profile']) && is_array($repositories['profile']) ? $repositories['profile'] : null;
$enabled = $profile !== null;
$settings = array(
'site_name' => array($siteName, 'string'),
'admin_default_language' => array($adminLanguage, 'string'),
'site_access_mode' => array('public', 'string'),
'site_development_title' => array('Сайт готовится к запуску', 'string'),
'site_development_message' => array('Мы завершаем настройку сайта. Пожалуйста, зайдите немного позже.', 'string'),
'page_not_found_id' => array('2', 'int'),
'date_format' => array('%d.%m.%Y', 'string'),
'time_format' => array('%d.%m.%Y, %H:%M', 'string'),
'default_country' => array('RU', 'string'),
'mail_type' => array('mail', 'string'),
'mail_content_type' => array('text/plain', 'string'),
'mail_from' => array('', 'string'),
'mail_from_name' => array($siteName, 'string'),
'mail_new_user' => array("Здравствуйте, %NAME%!\n\nВаш аккаунт на сайте %HOST% создан. Теперь вы можете войти, используя данные регистрации.", 'string'),
'mail_signature' => array('С уважением,' . "\n" . $siteName, 'string'),
'navi_box' => array('<ul class="pagination">%s</ul>', 'string'),
'start_label' => array('Первая', 'string'),
'end_label' => array('Последняя', 'string'),
'separator_label' => array('…', 'string'),
'next_label' => array('»', 'string'),
'prev_label' => array('«', 'string'),
'total_label' => array('Страница %d из %d', 'string'),
'link_box' => array('<li>%s</li>', 'string'),
'total_box' => array('<span>%s</span>', 'string'),
'active_box' => array('<li class="active">%s</li>', 'string'),
'separator_box' => array('<li>%s</li>', 'string'),
'bread_box' => array('<ol class="breadcrumb">%s</ol>', 'string'),
'bread_separator' => array('<li aria-hidden="true">/</li>', 'string'),
'bread_link_box' => array('<li>%s</li>', 'string'),
'bread_link_template' => array('<a href="[link]">[name]</a>', 'string'),
'bread_self_box' => array('<li aria-current="page">%s</li>', 'string'),
'distribution.repository_mode' => array(isset($repositories['mode']) ? (string) $repositories['mode'] : 'disabled', 'string'),
'distribution.repository_name' => array($enabled && isset($profile['name']) ? (string) $profile['name'] : '', 'string'),
'core_updates.repository_enabled' => array($enabled ? '1' : '0', 'bool'),
'core_updates.repository_url' => array($enabled ? (string) $profile['core_updates']['url'] : '', 'string'),
'core_updates.repository_public_key' => array($enabled ? (string) $profile['core_updates']['public_key'] : '', 'code'),
'modules.repository_enabled' => array($enabled ? '1' : '0', 'bool'),
'modules.repository_url' => array($enabled ? (string) $profile['modules']['url'] : '', 'string'),
'modules.repository_public_key' => array($enabled ? (string) $profile['modules']['public_key'] : '', 'code'),
);
foreach ($settings as $key => $definition) {
$this->execute($db, 'INSERT INTO `' . $prefix . '_settings` (param,value,type) VALUES (?,?,?)',
'sss', array($key, $definition[0], $definition[1]));
}
$this->execute($db,
'INSERT INTO `' . $prefix . '_paginations` (id,pagination_name,pagination_box,pagination_start_label,pagination_end_label,pagination_separator_box,pagination_separator_label,pagination_next_label,pagination_prev_label,pagination_link_box,pagination_active_link_box,pagination_link_template,pagination_link_active_template) VALUES (1,?,?,?,?,?,?,?,?,?,?,?,?)',
'ssssssssssss', array('По умолчанию', '<nav><ul>%s</ul></nav>', 'Первая', 'Последняя', '<li>%s</li>', '…', '»', '«', '<li>%s</li>', '<li class="active">%s</li>', '<a href="[link]">[name]</a>', '<span>[name]</span>'));
}
protected function loadDistributionProfile()
{
if (!class_exists('App\\Common\\DistributionProfile', false)) {
require_once $this->root . '/system/App/Common/DistributionProfile.php';
}
}
protected function seedConstants(mysqli $db, $prefix, $date)
{
if (!class_exists('App\\Common\\RuntimeConstantSchema', false)) {
require_once $this->root . '/system/App/Common/RuntimeConstantSchema.php';
}
$position = 10;
foreach (\App\Common\RuntimeConstantSchema::all(false) as $name => $definition) {
$value = $definition['default'];
if (is_bool($value)) {
$value = $value ? '1' : '0';
$type = 'bool';
} elseif (is_int($value)) {
$value = (string) $value;
$type = 'int';
} else {
$value = (string) $value;
$type = isset($definition['type']) ? (string) $definition['type'] : 'string';
}
$options = !empty($definition['options'])
? json_encode(array_values($definition['options']), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
: '';
$this->execute($db,
'INSERT INTO `' . $prefix . '_constants` (name,value,type,group_code,label,description,options,is_system,sort_order,updated_at) VALUES (?,?,?,?,?,?,?,1,?,?)',
'sssssssis', array($name, $value, $type, $definition['group_code'], $name, $definition['description'], $options, $position, $date));
$position += 10;
}
}
protected function seedCoreMigrationLedger(mysqli $db, $prefix, $date)
{
$manifest = require $this->root . '/setup/core-migrations.php';
foreach ($manifest as $module => $definition) {
$directory = $this->root . '/adminx/modules/' . $definition['directory'];
if (!is_dir($directory) && !empty($definition['optional'])) {
continue;
}
foreach ($definition['ids'] as $migration) {
$base = $directory . '/migrations/' . $migration;
$file = is_file($base . '.sql') ? $base . '.sql' : $base . '.php';
if (!is_file($file)) {
throw new RuntimeException('Core-миграция не найдена: ' . $module . '/' . $migration);
}
$checksum = sha1_file($file);
$this->execute($db,
'INSERT INTO `' . $prefix . '_module_migrations` (module,migration,checksum,applied_at) VALUES (?,?,?,?)',
'ssss', array($module, $migration, $checksum, $date));
}
}
}
protected function seedPublicIdentity(mysqli $db, $prefix, $siteName, $adminName, $adminLogin, $adminEmail, $adminPassword, $date)
{
$publicName = mb_substr((string) $adminName, 0, 50, 'UTF-8');
$publicLogin = mb_substr((string) $adminLogin, 0, 50, 'UTF-8');
$groups = array(1 => 'Администраторы', 2 => 'Гости', 3 => 'Модераторы', 4 => 'Пользователи');
foreach ($groups as $id => $name) {
$this->execute($db, 'INSERT INTO `' . $prefix . '_public_user_groups` (user_group,user_group_name,status,set_default_avatar,default_avatar,user_group_permission) VALUES (?,?,\'1\',\'0\',\'\',\'\')',
'is', array($id, $name));
}
$this->execute($db,
'INSERT INTO `' . $prefix . '_public_users` (Id,password,email,email_verified_at,firstname,user_name,user_group,reg_time,status,country,deleted) VALUES (1,?,?,?,?,?,?,?,\'1\',\'RU\',\'0\')',
'ssissii', array($adminPassword, $adminEmail, time(), $publicName, $publicLogin, 1, time()));
$checkoutPolicy = 'Я согласен с политикой конфиденциальности и обработкой персональных данных';
$checkoutSubject = 'Доступ к личному кабинету';
$checkoutTemplate = '<h2>Личный кабинет создан</h2>'
. '<p>{% if user.firstname %}{{ user.firstname }}, {% endif %}ваш заказ №{{ order.id }} оформлен, а личный кабинет уже доступен.</p>'
. '<p>Логин: <strong>{{ user.email }}</strong></p>'
. '<p><a href="{{ access_url }}">Задать пароль</a></p>'
. '<p>Ссылка действует {{ expires_hours }} ч. Если она истечёт, запросите новую на странице восстановления пароля.</p>';
$this->execute($db,
'INSERT INTO `' . $prefix . '_public_auth_settings` (id,registration_enabled,registration_mode,registration_gate,default_group,password_min_length,verification_ttl,reset_ttl,password_reset_enabled,require_firstname,show_lastname,require_lastname,show_phone,require_phone,show_company,require_company,deny_domains,deny_emails,checkout_registration_enabled,checkout_policy_url,checkout_policy_label,checkout_access_subject,checkout_access_template,pages_json,updated_at) VALUES (1,0,\'email\',\'email\',4,8,86400,3600,0,1,1,0,0,0,0,0,\'\',\'\',1,\'/privacy-policy\',?,?,?,NULL,?)',
'ssss', array($checkoutPolicy, $checkoutSubject, $checkoutTemplate, $date));
$message = "Здравствуйте %NAME%,\n\nВаш аккаунт на сайте %HOST% создан.\n";
$signature = 'С уважением, ' . $siteName;
$forbidden = '<h1>Доступ запрещён</h1><p>У вас нет прав для просмотра этой страницы.</p>';
$hidden = '<div class="hidden-box">Содержимое скрыто.</div>';
$this->execute($db,
'INSERT INTO `' . $prefix . '_public_settings` (Id,site_name,mail_type,mail_content_type,mail_port,mail_host,mail_smtp_login,mail_smtp_pass,mail_smtp_encrypt,mail_sendmail_path,mail_word_wrap,mail_from,mail_from_name,mail_new_user,mail_signature,page_not_found_id,message_forbidden,navi_box,start_label,end_label,separator_label,next_label,prev_label,total_label,link_box,total_box,active_box,separator_box,bread_box,bread_show_main,bread_show_host,bread_separator,bread_separator_use,bread_link_box,bread_link_template,bread_self_box,bread_link_box_last,date_format,time_format,default_country,use_doctime,hidden_text) VALUES (1,?,\'mail\',\'text/plain\',25,\'\',\'\',\'\',NULL,\'/usr/sbin/sendmail\',80,?,?,?, ?,2,?,\'<ul class="pagination">%s</ul>\',\'Первая\',\'Последняя\',\'…\',\'»\',\'«\',\'Страница %d из %d\',\'<li>%s</li>\',\'<span>%s</span>\',\'<span class="active">%s</span>\',\'<li>%s</li>\',\'<ol class="breadcrumb">%s</ol>\',\'1\',\'0\',\'<li>→</li>\',\'0\',\'<li>%s</li>\',\'<a href="[link]">[name]</a>\',\'<li>%s</li>\',\'1\',\'%d.%m.%Y\',\'%d.%m.%Y, %H:%M\',\'RU\',\'0\',?)',
'sssssss', array($siteName, $adminEmail, $siteName, $message, $signature, $forbidden, $hidden));
}
protected function seedContent(mysqli $db, $prefix, $siteName, $now)
{
$template = '<!doctype html>' . "\n"
. '<html lang="ru">' . "\n<head>\n"
. ' <meta charset="utf-8">' . "\n"
. ' <meta name="viewport" content="width=device-width, initial-scale=1">' . "\n"
. ' <title>[tag:title]</title>' . "\n"
. ' <meta name="description" content="[tag:description]">' . "\n"
. ' <meta name="keywords" content="[tag:keywords]">' . "\n"
. ' <meta name="robots" content="[tag:robots]">' . "\n"
. ' <link rel="canonical" href="[tag:canonical]">' . "\n"
. ' <meta property="og:type" content="website">' . "\n"
. ' <meta property="og:title" content="[tag:og:title]">' . "\n"
. ' <meta property="og:description" content="[tag:og:description]">' . "\n"
. ' <meta property="og:url" content="[tag:og:url]">' . "\n"
. ' <meta property="og:site_name" content="[tag:og:sitename]">' . "\n"
. '</head>' . "\n<body>\n"
. '[tag:navigation:main]' . "\n"
. '[tag:maincontent]' . "\n"
. '</body>' . "\n</html>";
$this->execute($db, 'INSERT INTO `' . $prefix . '_templates` (Id,template_title,template_text,template_author_id,template_created) VALUES (1,?,?,1,?)',
'ssi', array('Основной шаблон', $template, $now));
$this->execute($db,
'INSERT INTO `' . $prefix . '_rubrics` (Id,rubric_title,rubric_alias,rubric_alias_history,rubric_template,rubric_template_id,rubric_author_id,rubric_created,rubric_docs_active,rubric_start_code,rubric_code_start,rubric_code_end,rubric_teaser_template,rubric_header_template,rubric_og_template,rubric_footer_template,rubric_linked_rubric,rubric_description,rubric_meta_gen,rubric_position,rubric_changed,rubric_changed_fields) VALUES (1,\'Главная страница\',\'\',\'0\',\'<main><h1>[tag:fld:title]</h1><div class="page-content">[tag:fld:content]</div></main>\',1,1,?,1,\'\',\'\',\'\',\'\',\'\',\'\',\'\',\'0\',\'Стартовая рубрика чистой установки\',\'0\',10,?,?)',
'iii', array($now, $now, $now));
$titleFieldSettings = json_encode(array('width' => 'full'), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$contentFieldSettings = json_encode(array('width' => 'full', 'height' => 300), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
$this->execute($db,
'INSERT INTO `' . $prefix . '_rubric_fields` (Id,rubric_id,rubric_field_group,rubric_field_alias,rubric_field_title,rubric_field_type,rubric_field_numeric,rubric_field_position,rubric_field_default,rubric_field_settings,rubric_field_search,rubric_field_template,rubric_field_template_request,rubric_field_description) VALUES (1,1,NULL,\'title\',\'Заголовок страницы\',\'single_line\',\'0\',1,\'\',?,\'1\',\'\',\'\',\'Заголовок внутри содержимого страницы\')',
's', array($titleFieldSettings));
$this->execute($db,
'INSERT INTO `' . $prefix . '_rubric_fields` (Id,rubric_id,rubric_field_group,rubric_field_alias,rubric_field_title,rubric_field_type,rubric_field_numeric,rubric_field_position,rubric_field_default,rubric_field_settings,rubric_field_search,rubric_field_template,rubric_field_template_request,rubric_field_description) VALUES (2,1,NULL,\'content\',\'Содержимое страницы\',\'richtext\',\'0\',2,\'\',?,\'1\',\'\',\'\',\'Основной текст страницы с визуальным редактором\')',
's', array($contentFieldSettings));
foreach (array(1 => 'docread|alles|new|newnow|editown|delrev', 2 => 'docread', 3 => 'docread|alles|new|newnow|editown|delrev', 4 => 'docread') as $group => $permission) {
$this->execute($db, 'INSERT INTO `' . $prefix . '_rubric_permissions` (rubric_id,user_group_id,rubric_permission) VALUES (1,?,?)',
'is', array($group, $permission));
}
$title = 'AVE.cms установлена';
$description = 'Чистая установка ' . $siteName . ' завершена успешно.';
$this->execute($db,
'INSERT INTO `' . $prefix . '_documents` (Id,rubric_id,rubric_tmpl_id,document_parent,document_alias,document_alias_header,document_alias_history,document_short_alias,document_title,document_breadcrumb_title,document_published,document_expire,document_changed,document_author_id,document_in_search,document_meta_keywords,document_meta_description,document_meta_robots,document_sitemap_freq,document_sitemap_pr,document_status,document_deleted,document_count_print,document_count_view,document_linked_navi_id,document_excerpt,document_tags,document_property,document_position,module_catalog,guid) VALUES (1,1,0,0,\'/\',301,\'0\',\'\',?,?,?,0,?,1,\'1\',\'AVE.cms, установка\',?,\'index,follow\',3,0.5,\'1\',\'0\',0,0,0,\'\',\'\',\'\',0,\'\',\'\')',
'ssiis', array($title, 'Главная', $now, $now, $description));
$content = '<p>Чистая установка завершена успешно. Войдите в панель управления, создайте рубрики и документы, настройте шаблоны и подключите нужные модули.</p><p>Справка по разработке и возможностям системы находится в каталоге <code>help</code>.</p>';
$this->execute($db, 'INSERT INTO `' . $prefix . '_document_fields` (Id,rubric_field_id,document_id,field_number_value,field_value,document_in_search) VALUES (1,1,1,0,\'Поздравляем с установкой AVE.cms!\',\'1\')');
$this->execute($db, 'INSERT INTO `' . $prefix . '_document_fields` (Id,rubric_field_id,document_id,field_number_value,field_value,document_in_search) VALUES (2,2,1,0,\'\',\'1\')');
$this->execute($db, 'INSERT INTO `' . $prefix . '_document_fields_text` (Id,rubric_field_id,document_id,field_value) VALUES (2,2,1,?)',
's', array($content));
$notFoundTitle = 'Страница не найдена';
$notFoundDescription = 'Запрошенная страница не найдена.';
$this->execute($db,
'INSERT INTO `' . $prefix . '_documents` (Id,rubric_id,rubric_tmpl_id,document_parent,document_alias,document_alias_header,document_alias_history,document_short_alias,document_title,document_breadcrumb_title,document_published,document_expire,document_changed,document_author_id,document_in_search,document_meta_keywords,document_meta_description,document_meta_robots,document_sitemap_freq,document_sitemap_pr,document_status,document_deleted,document_count_print,document_count_view,document_linked_navi_id,document_excerpt,document_tags,document_property,document_position,module_catalog,guid) VALUES (2,1,0,0,\'404\',301,\'0\',\'\',?,?,?,0,?,1,\'0\',\'\',?,\'noindex,nofollow\',3,0,\'1\',\'0\',0,0,0,\'\',\'\',\'\',2,\'\',\'\')',
'ssiis', array($notFoundTitle, 'Ошибка 404', $now, $now, $notFoundDescription));
$notFoundContent = '<p>Запрошенная страница не существует или была перемещена.</p><p><a href="/">Перейти на главную</a></p>';
$this->execute($db, 'INSERT INTO `' . $prefix . '_document_fields` (Id,rubric_field_id,document_id,field_number_value,field_value,document_in_search) VALUES (3,1,2,0,\'Страница не найдена\',\'0\')');
$this->execute($db, 'INSERT INTO `' . $prefix . '_document_fields` (Id,rubric_field_id,document_id,field_number_value,field_value,document_in_search) VALUES (4,2,2,0,\'\',\'0\')');
$this->execute($db, 'INSERT INTO `' . $prefix . '_document_fields_text` (Id,rubric_field_id,document_id,field_value) VALUES (4,2,2,?)',
's', array($notFoundContent));
$this->execute($db,
'INSERT INTO `' . $prefix . '_navigation` (navigation_id,alias,title,level1,level2,level3,level1_active,level2_active,level3_active,level1_begin,level1_end,level2_begin,level2_end,level3_begin,level3_end,begin,end,user_group,expand_ext) VALUES (1,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',
'ssssssssssssssssss', array(
'main', 'Основная навигация',
'<li class="site-navigation-item"><a target="[tag:target]" href="[tag:link]">[tag:linkname]</a></li>', '', '',
'<li class="site-navigation-item is-active"><a target="[tag:target]" href="[tag:link]" aria-current="page">[tag:linkname]</a></li>', '', '',
'<ul class="site-navigation-list">[tag:content]', '</ul>', '', '', '', '',
'<nav class="site-navigation" aria-label="Основная навигация">', '</nav>', '1,2,3,4', '1',
));
$this->execute($db,
'INSERT INTO `' . $prefix . '_navigation_items` (navigation_item_id,navigation_id,document_id,alias,title,description,target,image,css_style,css_id,css_class,parent_id,level,position,status) VALUES (1,1,1,\'/\',\'Главная\',\'Главная страница сайта\',\'_self\',\'\',NULL,NULL,\'home\',0,\'1\',1,\'1\')');
}
protected function applySchema(mysqli $db, $prefix)
{
$sql = file_get_contents(__DIR__ . '/schema.sql');
if ($sql === false || trim($sql) === '') {
throw new RuntimeException('Core-схема setup/schema.sql пуста или недоступна.');
}
$sql = str_replace('{{prefix}}', $prefix, $sql);
if (strpos($sql, '{{') !== false) {
throw new RuntimeException('В core-схеме остались неизвестные placeholder.');
}
if (!$db->multi_query($sql)) {
throw new RuntimeException('Ошибка применения схемы: ' . $db->error);
}
do {
if ($result = $db->store_result()) {
$result->free();
}
if (!$db->more_results()) {
break;
}
} while ($db->next_result());
if ($db->errno) {
throw new RuntimeException('Ошибка применения схемы: ' . $db->error);
}
}
protected function assertPristine(mysqli $db, $prefix)
{
foreach (array('users', 'documents', 'rubrics', 'templates', 'modules') as $suffix) {
$table = $prefix . '_' . $suffix;
if ($this->tableExists($db, $table) && $this->tableCount($db, $table) > 0) {
throw new RuntimeException('Префикс «' . $prefix . '» уже содержит данные AVE.cms. Выберите пустой префикс.');
}
}
}
protected function connect(array $config)
{
mysqli_report(MYSQLI_REPORT_OFF);
$port = !empty($config['dbport']) ? (int) $config['dbport'] : 3306;
$db = @new mysqli($config['dbhost'], $config['dbuser'], $config['dbpass'], $config['dbname'], $port);
if ($db->connect_errno) {
throw new RuntimeException('Не удалось подключиться к БД: ' . $db->connect_error);
}
if (!$db->set_charset('utf8mb4')) {
throw new RuntimeException('База данных не поддерживает utf8mb4.');
}
return $db;
}
protected function execute(mysqli $db, $sql, $types = '', array $values = array())
{
$statement = $db->prepare($sql);
if (!$statement) {
throw new RuntimeException('Ошибка подготовки seed-запроса: ' . $db->error);
}
if ($types !== '') {
$params = array($types);
foreach ($values as $key => $value) {
$params[] = &$values[$key];
}
call_user_func_array(array($statement, 'bind_param'), $params);
}
if (!$statement->execute()) {
$message = $statement->error;
$statement->close();
throw new RuntimeException('Ошибка заполнения core-данных: ' . $message);
}
$statement->close();
}
protected function writeDatabaseConfig(array $config)
{
$file = $this->root . '/configs/db.config.php';
$payload = array(
'dbhost' => $config['dbhost'],
'dbuser' => $config['dbuser'],
'dbpass' => $config['dbpass'],
'dbname' => $config['dbname'],
'dbpref' => $config['dbpref'],
'dbchar' => 'utf8mb4',
'dbport' => $config['dbport'] ?: null,
'dbsock' => null,
);
$php = "<?php\n\n\$config = " . var_export($payload, true) . ";\n";
$tmp = $file . '.tmp-' . bin2hex(random_bytes(6));
if (file_put_contents($tmp, $php, LOCK_EX) === false || !@rename($tmp, $file)) {
@unlink($tmp);
throw new RuntimeException('Не удалось записать configs/db.config.php.');
}
@chmod($file, 0640);
}
protected function applyAdminDirectory($directory)
{
$directory = $this->validateAdminDirectory($directory);
$source = $this->root . '/adminx';
$target = $this->root . '/' . $directory;
$configFile = $this->root . '/configs/public.config.php';
$backup = is_file($configFile) ? (string) file_get_contents($configFile) : '';
$renamed = false;
try {
if ($directory !== 'adminx') {
if (!@rename($source, $target)) {
throw new RuntimeException('Не удалось переименовать папку панели управления.');
}
$renamed = true;
}
$this->writeAdminDirectoryConfig($directory, $backup);
} catch (Throwable $e) {
if ($renamed && is_dir($target) && !file_exists($source)) {
@rename($target, $source);
}
$this->restoreFile($configFile, $backup);
throw $e;
}
return array(
'directory' => $directory,
'source' => $source,
'target' => $target,
'renamed' => $renamed,
'config_file' => $configFile,
'config_backup' => $backup,
);
}
protected function rollbackAdminDirectory(array $change)
{
if (!empty($change['renamed']) && is_dir($change['target']) && !file_exists($change['source'])) {
@rename($change['target'], $change['source']);
}
$this->restoreFile($change['config_file'], $change['config_backup']);
}
protected function writeAdminDirectoryConfig($directory, $content)
{
$file = $this->root . '/configs/public.config.php';
$replacement = "\t\t'admin_directory' => " . var_export((string) $directory, true) . ',';
$count = 0;
$content = preg_replace(
"/^\\s*'admin_directory'\\s*=>\\s*[^,]+,/m",
$replacement,
(string) $content,
1,
$count
);
if ($count !== 1 || !is_string($content)) {
throw new RuntimeException('В configs/public.config.php отсутствует настройка admin_directory.');
}
$this->writeFile($file, $content, 0644, 'Не удалось записать имя папки панели управления.');
}
protected function restoreFile($file, $content)
{
if ((string) $content === '') {
return;
}
$this->writeFile($file, (string) $content, 0644, '');
}
protected function writeFile($file, $content, $mode, $error)
{
$tmp = $file . '.tmp-' . bin2hex(random_bytes(6));
if (file_put_contents($tmp, $content, LOCK_EX) === false || !@rename($tmp, $file)) {
@unlink($tmp);
if ($error !== '') {
throw new RuntimeException($error);
}
return false;
}
@chmod($file, $mode);
return true;
}
protected function writeLock($prefix, $email, $adminDirectory)
{
$release = is_file($this->root . '/system/release.php')
? require $this->root . '/system/release.php'
: array();
$packageManifestFile = $this->root . '/PACKAGE-MANIFEST.json';
$packageManifest = is_file($packageManifestFile)
? json_decode((string) file_get_contents($packageManifestFile), true)
: array();
$payload = json_encode(array(
'installed_at' => date(DATE_ATOM),
'version' => isset($release['version']) ? (string) $release['version'] : '3.3',
'build' => isset($release['build']) ? (string) $release['build'] : '0.20',
'prefix' => $prefix,
'administrator' => $email,
'admin_directory' => $adminDirectory,
'core_fingerprint' => is_array($packageManifest) && isset($packageManifest['core_fingerprint'])
? (string) $packageManifest['core_fingerprint']
: '',
'package_manifest_sha256' => is_file($packageManifestFile) ? hash_file('sha256', $packageManifestFile) : '',
), JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (file_put_contents($this->lockFile(), $payload . "\n", LOCK_EX) === false) {
throw new RuntimeException('Не удалось создать storage/installed.lock.');
}
@chmod($this->lockFile(), 0640);
}
protected function clearRuntimeCache()
{
foreach (array($this->root . '/tmp/cache', $this->root . '/tmp/sessions') as $directory) {
$this->clearDirectory($directory);
}
}
protected function clearDirectory($directory)
{
if (!is_dir($directory)) {
@mkdir($directory, 0775, true);
return;
}
foreach (scandir($directory) ?: array() as $entry) {
if ($entry === '.' || $entry === '..') { continue; }
$path = $directory . '/' . $entry;
if (is_dir($path) && !is_link($path)) {
$this->clearDirectory($path);
@rmdir($path);
} else {
@unlink($path);
}
}
}
protected function dropPrefixedTables(mysqli $db, $prefix)
{
$this->dropPrefixedObjects($db, $prefix);
}
protected function dropPrefixedObjects(mysqli $db, $prefix)
{
$pattern = str_replace(array('\\', '_', '%'), array('\\\\', '\\_', '\\%'), $prefix) . '\\_%';
$statement = $db->prepare(
'SELECT table_name, table_type FROM information_schema.tables '
. 'WHERE table_schema=DATABASE() AND table_name LIKE ? ORDER BY table_type DESC, table_name'
);
if (!$statement) {
throw new RuntimeException('Не удалось получить объекты базы данных: ' . $db->error);
}
$statement->bind_param('s', $pattern);
if (!$statement->execute()) {
throw new RuntimeException('Не удалось получить объекты префикса: ' . $statement->error);
}
$statement->bind_result($tableName, $tableType);
$tables = array();
$views = array();
while ($statement->fetch()) {
$name = '`' . str_replace('`', '``', (string) $tableName) . '`';
if (strtoupper((string) $tableType) === 'VIEW') {
$views[] = $name;
} else {
$tables[] = $name;
}
}
$statement->close();
$db->query('SET FOREIGN_KEY_CHECKS=0');
try {
foreach (array_chunk($views, 50) as $chunk) {
if (!$db->query('DROP VIEW IF EXISTS ' . implode(',', $chunk))) {
throw new RuntimeException('Не удалось удалить представления: ' . $db->error);
}
}
foreach (array_chunk($tables, 50) as $chunk) {
if (!$db->query('DROP TABLE IF EXISTS ' . implode(',', $chunk))) {
throw new RuntimeException('Не удалось удалить таблицы: ' . $db->error);
}
}
} finally {
$db->query('SET FOREIGN_KEY_CHECKS=1');
}
return count($tables) + count($views);
}
protected function prefixedTables(mysqli $db, $prefix)
{
$pattern = str_replace(array('\\', '_', '%'), array('\\\\', '\\_', '\\%'), $prefix) . '\\_%';
$statement = $db->prepare('SELECT table_name FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name LIKE ?');
if (!$statement) {
throw new RuntimeException('Не удалось получить список таблиц: ' . $db->error);
}
$statement->bind_param('s', $pattern);
$statement->execute();
$statement->bind_result($tableName);
$tables = array();
while ($statement->fetch()) {
$tables[] = (string) $tableName;
}
$statement->close();
return $tables;
}
protected function tableExists(mysqli $db, $table)
{
$statement = $db->prepare('SELECT 1 FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name=? LIMIT 1');
if (!$statement) { return false; }
$statement->bind_param('s', $table);
$statement->execute();
$statement->store_result();
$exists = $statement->num_rows > 0;
$statement->close();
return $exists;
}
protected function tableCount(mysqli $db, $table)
{
if (!preg_match('/^[A-Za-z0-9_]+$/', $table)) {
throw new InvalidArgumentException('Некорректное имя таблицы.');
}
$result = $db->query('SELECT COUNT(*) AS amount FROM `' . $table . '`');
$row = $result ? $result->fetch_assoc() : null;
return $row ? (int) $row['amount'] : 0;
}
protected function validatePrefix($prefix)
{
$prefix = trim((string) $prefix);
if (!preg_match('/^[A-Za-z][A-Za-z0-9_]{0,39}$/', $prefix)) {
throw new InvalidArgumentException('Префикс должен начинаться с буквы и содержать только буквы, цифры и _.');
}
return $prefix;
}
protected function check($label, $passed, $detail = '', $required = true)
{
return array('label' => $label, 'passed' => (bool) $passed, 'detail' => (string) $detail, 'required' => (bool) $required);
}
protected function writablePath($path)
{
return is_dir($path) ? is_writable($path) : is_writable(dirname($path));
}
protected function gdWebpAvailable()
{
if (!extension_loaded('gd') || !function_exists('imagewebp')) {
return false;
}
$info = function_exists('gd_info') ? gd_info() : array();
return !array_key_exists('WebP Support', $info) || !empty($info['WebP Support']);
}
protected function lockFile()
{
return $this->root . '/storage/installed.lock';
}
}