mirror of
https://github.com/avecms/AVE.cms.git
synced 2026-08-01 08:45:44 +00:00
448 lines
20 KiB
PHP
448 lines
20 KiB
PHP
<?php
|
|
|
|
/*
|
|
|--------------------------------------------------------------------------------------
|
|
| AVE.cms
|
|
|--------------------------------------------------------------------------------------
|
|
| @package AVE.cms
|
|
| @file adminx/modules/Modules/ModuleRepository.php
|
|
| @author AVE.cms <support@ave-cms.ru>
|
|
| @copyright 2007-2026 (c) AVE.cms
|
|
| @link https://ave-cms.ru
|
|
| @version 3.3
|
|
*/
|
|
|
|
namespace App\Adminx\Modules;
|
|
|
|
defined('BASEPATH') || die('Direct access to this location is not allowed.');
|
|
|
|
use App\Common\Cache;
|
|
use App\Common\DistributionProfile;
|
|
use App\Common\ModuleSettings;
|
|
|
|
/** Подписанный удалённый каталог официальных ZIP-пакетов AVE.cms. */
|
|
class ModuleRepository
|
|
{
|
|
const MODULE = 'modules';
|
|
const ENVELOPE_FORMAT = 'ave-module-repository-v1';
|
|
const PAYLOAD_FORMAT = 'ave-module-index-v1';
|
|
const CACHE_TTL = 900;
|
|
const MAX_CATALOG_BYTES = 2097152;
|
|
|
|
public static function settings()
|
|
{
|
|
return array(
|
|
'enabled' => (bool) ModuleSettings::value('repository_enabled', false, self::MODULE),
|
|
'url' => trim((string) ModuleSettings::value('repository_url', '', self::MODULE)),
|
|
'public_key' => trim((string) ModuleSettings::value('repository_public_key', '', self::MODULE)),
|
|
);
|
|
}
|
|
|
|
public static function saveSettings(array $input)
|
|
{
|
|
$enabled = !empty($input['enabled']);
|
|
$url = self::catalogUrl(isset($input['url']) ? $input['url'] : '', $enabled);
|
|
$key = self::publicKey(isset($input['public_key']) ? $input['public_key'] : '', $enabled);
|
|
ModuleSettings::set('repository_enabled', $enabled, self::MODULE, 'bool');
|
|
ModuleSettings::set('repository_url', $url, self::MODULE, 'string');
|
|
ModuleSettings::set('repository_public_key', $key, self::MODULE, 'string');
|
|
Cache::forgetTag('module-repository');
|
|
|
|
return self::settings();
|
|
}
|
|
|
|
public static function officialSettings()
|
|
{
|
|
try {
|
|
$profile = DistributionProfile::official();
|
|
$repository = DistributionProfile::repository($profile, 'modules');
|
|
return array('available' => true, 'name' => $profile['name'], 'error' => '') + $repository;
|
|
} catch (\Throwable $e) {
|
|
return array('available' => false, 'name' => '', 'url' => '', 'public_key' => '', 'fingerprint' => '', 'error' => $e->getMessage());
|
|
}
|
|
}
|
|
|
|
public static function restoreOfficial()
|
|
{
|
|
$official = self::officialSettings();
|
|
if (empty($official['available'])) { throw new \RuntimeException($official['error']); }
|
|
return self::saveSettings(array('enabled' => true, 'url' => $official['url'], 'public_key' => $official['public_key']));
|
|
}
|
|
|
|
public static function catalog($force = false)
|
|
{
|
|
$settings = self::settings();
|
|
if (!$settings['enabled']) {
|
|
return self::emptyCatalog('disabled', 'Удалённый каталог выключен');
|
|
}
|
|
|
|
if ($settings['url'] === '' || $settings['public_key'] === '') {
|
|
return self::emptyCatalog('configuration', 'Укажите URL каталога и публичный ключ');
|
|
}
|
|
|
|
$key = 'module-repository:' . hash('sha256', $settings['url'] . "\0" . $settings['public_key']);
|
|
if ($force) { Cache::forget($key); }
|
|
try {
|
|
$catalog = Cache::rememberTagged($key, self::CACHE_TTL, array('module-repository'), function () use ($settings) {
|
|
$raw = self::request($settings['url'], self::MAX_CATALOG_BYTES);
|
|
return self::verifyEnvelope($raw, $settings['public_key'], $settings['url']);
|
|
});
|
|
$catalog['enabled'] = true;
|
|
$catalog['configured'] = true;
|
|
$catalog['error'] = '';
|
|
return self::withLocalState($catalog);
|
|
} catch (\Throwable $e) {
|
|
$catalog = self::emptyCatalog('fetch', $e->getMessage());
|
|
$catalog['enabled'] = true;
|
|
$catalog['configured'] = true;
|
|
return $catalog;
|
|
}
|
|
}
|
|
|
|
public static function install($code)
|
|
{
|
|
$code = strtolower(trim((string) $code));
|
|
$catalog = self::catalog(false);
|
|
if (!empty($catalog['error'])) { throw new \RuntimeException($catalog['error']); }
|
|
$item = null;
|
|
foreach ($catalog['items'] as $candidate) {
|
|
if ($candidate['code'] === $code) { $item = $candidate; break; }
|
|
}
|
|
|
|
if (!$item) { throw new \InvalidArgumentException('Модуль отсутствует в проверенном каталоге'); }
|
|
if (empty($item['compatible'])) { throw new \RuntimeException((string) $item['compatibility_message']); }
|
|
if (!empty($item['local'])) {
|
|
if ($item['status'] === 'update') {
|
|
$archive = self::download($item);
|
|
$result = ModuleArchiveInstaller::updateDownloaded($archive, $item['code'], $item['version']);
|
|
$result['repository_operation'] = 'update';
|
|
return $result;
|
|
}
|
|
|
|
if (empty($item['installed']) && version_compare($item['local_version'], $item['version'], '=')) {
|
|
$result = NativeModuleAdapter::install($code);
|
|
$result['repository_operation'] = 'install';
|
|
return $result;
|
|
}
|
|
|
|
throw new \RuntimeException('Эта версия модуля уже находится на сервере');
|
|
}
|
|
|
|
$archive = self::download($item);
|
|
$result = ModuleArchiveInstaller::installDownloaded($archive, $item['code'], $item['version']);
|
|
$result['repository_operation'] = 'install';
|
|
return $result;
|
|
}
|
|
|
|
public static function verifyEnvelope($raw, $publicKey, $catalogUrl = 'https://repository.invalid/index.json')
|
|
{
|
|
if (!function_exists('openssl_pkey_get_public') || !function_exists('openssl_verify')) {
|
|
throw new \RuntimeException('Для проверки каталога требуется PHP-расширение OpenSSL');
|
|
}
|
|
|
|
$envelope = json_decode((string) $raw, true);
|
|
if (!is_array($envelope) || json_last_error() !== JSON_ERROR_NONE
|
|
|| !isset($envelope['format'], $envelope['payload'], $envelope['signature'])
|
|
|| $envelope['format'] !== self::ENVELOPE_FORMAT) {
|
|
throw new \RuntimeException('Удалённый каталог имеет неподдерживаемый формат');
|
|
}
|
|
|
|
$payloadRaw = base64_decode((string) $envelope['payload'], true);
|
|
$signature = base64_decode((string) $envelope['signature'], true);
|
|
if ($payloadRaw === false || $signature === false || strlen($payloadRaw) > self::MAX_CATALOG_BYTES) {
|
|
throw new \RuntimeException('Подпись удалённого каталога повреждена');
|
|
}
|
|
|
|
$key = openssl_pkey_get_public(self::publicKey($publicKey, true));
|
|
if (!$key) { throw new \RuntimeException('Публичный ключ каталога не читается OpenSSL'); }
|
|
$keyDetails = openssl_pkey_get_details($key);
|
|
if (!is_array($keyDetails) || !isset($keyDetails['type']) || $keyDetails['type'] !== OPENSSL_KEYTYPE_RSA) {
|
|
if (is_resource($key)) { openssl_free_key($key); }
|
|
throw new \RuntimeException('Каталог должен использовать публичный RSA-ключ');
|
|
}
|
|
|
|
$verified = openssl_verify($payloadRaw, $signature, $key, OPENSSL_ALGO_SHA256);
|
|
if (is_resource($key)) { openssl_free_key($key); }
|
|
if ($verified !== 1) { throw new \RuntimeException('Цифровая подпись каталога не прошла проверку'); }
|
|
|
|
$payload = json_decode($payloadRaw, true);
|
|
if (!is_array($payload) || json_last_error() !== JSON_ERROR_NONE
|
|
|| !isset($payload['format'], $payload['modules']) || $payload['format'] !== self::PAYLOAD_FORMAT
|
|
|| !is_array($payload['modules'])) {
|
|
throw new \RuntimeException('Подписанные данные каталога повреждены');
|
|
}
|
|
|
|
$items = array();
|
|
foreach ($payload['modules'] as $module) {
|
|
$item = self::normalizeItem($module, $catalogUrl);
|
|
if (isset($items[$item['code']])) { throw new \RuntimeException('Каталог содержит повторяющийся code: ' . $item['code']); }
|
|
$items[$item['code']] = $item;
|
|
}
|
|
|
|
uasort($items, array(self::class, 'compareByName'));
|
|
|
|
return array(
|
|
'enabled' => true, 'configured' => true, 'verified' => true, 'error' => '',
|
|
'name' => trim(isset($payload['name']) ? (string) $payload['name'] : 'AVE.cms'),
|
|
'channel' => trim(isset($payload['channel']) ? (string) $payload['channel'] : 'stable'),
|
|
'generated_at' => trim(isset($payload['generated_at']) ? (string) $payload['generated_at'] : ''),
|
|
'items' => array_values($items), 'available' => 0, 'updates' => 0,
|
|
);
|
|
}
|
|
|
|
protected static function withLocalState(array $catalog)
|
|
{
|
|
$locals = array();
|
|
foreach (NativeModuleAdapter::all() as $local) { $locals[$local['code']] = $local; }
|
|
$available = 0;
|
|
$updates = 0;
|
|
foreach ($catalog['items'] as &$item) {
|
|
$local = isset($locals[$item['code']]) ? $locals[$item['code']] : null;
|
|
$item['local'] = $local !== null;
|
|
$item['installed'] = $local && !empty($local['installed']);
|
|
$item['local_version'] = $local ? (string) $local['file_version'] : '';
|
|
$item['database_version'] = $local ? (string) $local['db_version'] : '';
|
|
$item['migration_pending'] = $local && !empty($local['migration_pending']);
|
|
$item['status'] = self::localStatus((string) $item['version'], $local);
|
|
if ($item['status'] === 'available') {
|
|
$available++;
|
|
} elseif ($item['status'] === 'update') {
|
|
$updates++;
|
|
} elseif ($item['status'] === 'local') {
|
|
$available++;
|
|
}
|
|
}
|
|
|
|
unset($item);
|
|
usort($catalog['items'], array(self::class, 'compareByName'));
|
|
$catalog['available'] = $available;
|
|
$catalog['updates'] = $updates;
|
|
return $catalog;
|
|
}
|
|
|
|
protected static function compareByName(array $left, array $right)
|
|
{
|
|
$name = strnatcasecmp((string) $left['name'], (string) $right['name']);
|
|
return $name !== 0 ? $name : strnatcasecmp((string) $left['code'], (string) $right['code']);
|
|
}
|
|
|
|
protected static function localStatus($remoteVersion, array $local = null)
|
|
{
|
|
if ($local === null) { return 'available'; }
|
|
$fileVersion = isset($local['file_version']) ? (string) $local['file_version'] : '';
|
|
if ($fileVersion !== '' && version_compare((string) $remoteVersion, $fileVersion, '>')) { return 'update'; }
|
|
if (empty($local['installed'])) { return 'local'; }
|
|
if (!empty($local['migration_pending'])) { return 'migration'; }
|
|
return 'current';
|
|
}
|
|
|
|
protected static function normalizeItem($module, $catalogUrl)
|
|
{
|
|
if (!is_array($module)) { throw new \RuntimeException('Каталог содержит некорректную запись модуля'); }
|
|
$code = strtolower(trim(isset($module['code']) ? (string) $module['code'] : ''));
|
|
$version = trim(isset($module['version']) ? (string) $module['version'] : '');
|
|
$checksum = strtolower(trim(isset($module['sha256']) ? (string) $module['sha256'] : ''));
|
|
$size = isset($module['size']) ? (int) $module['size'] : 0;
|
|
$kind = isset($module['kind']) && $module['kind'] === 'admin' ? 'admin' : 'package';
|
|
if (!preg_match('/^[a-z][a-z0-9_]{0,63}$/', $code)
|
|
|| !preg_match('/^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][A-Za-z0-9._-]+)?$/', $version)
|
|
|| !preg_match('/^[a-f0-9]{64}$/', $checksum)
|
|
|| $size < 1 || $size > ModuleArchiveInstaller::MAX_ARCHIVE_BYTES) {
|
|
throw new \RuntimeException('Каталог содержит некорректные метаданные модуля');
|
|
}
|
|
|
|
$phpMin = trim(isset($module['php_min']) ? (string) $module['php_min'] : '7.3.0');
|
|
$phpMax = trim(isset($module['php_max']) ? (string) $module['php_max'] : '');
|
|
$aveMin = trim(isset($module['ave_min']) ? (string) $module['ave_min'] : '3.3.0');
|
|
$aveMax = trim(isset($module['ave_max']) ? (string) $module['ave_max'] : '');
|
|
$engineVersion = self::normalizedVersion(defined('APP_VERSION') ? APP_VERSION : '3.3');
|
|
$compatible = version_compare(PHP_VERSION, $phpMin, '>=')
|
|
&& ($phpMax === '' || version_compare(PHP_VERSION, $phpMax, '<='))
|
|
&& version_compare($engineVersion, self::normalizedVersion($aveMin), '>=')
|
|
&& ($aveMax === '' || version_compare($engineVersion, self::normalizedVersion($aveMax), '<='));
|
|
return array(
|
|
'code' => $code, 'version' => $version, 'kind' => $kind,
|
|
'name' => trim(isset($module['name']) ? (string) $module['name'] : $code) ?: $code,
|
|
'description' => trim(isset($module['description']) ? (string) $module['description'] : ''),
|
|
'author' => trim(isset($module['author']) ? (string) $module['author'] : 'AVE.cms'),
|
|
'archive' => self::archiveUrl(isset($module['archive']) ? $module['archive'] : '', $catalogUrl),
|
|
'sha256' => $checksum, 'size' => $size,
|
|
'php_min' => $phpMin, 'php_max' => $phpMax, 'ave_min' => $aveMin, 'ave_max' => $aveMax,
|
|
'compatible' => $compatible,
|
|
'compatibility_message' => $compatible ? 'Совместим с этой установкой' : 'Модуль несовместим с текущими версиями PHP или AVE.cms',
|
|
);
|
|
}
|
|
|
|
protected static function download(array $item)
|
|
{
|
|
$directory = BASEPATH . DS . 'tmp';
|
|
if (!is_dir($directory) && !@mkdir($directory, 0755, true)) { throw new \RuntimeException('Не удалось создать временный каталог'); }
|
|
$target = $directory . DS . '.module-remote-' . bin2hex(random_bytes(8)) . '.zip';
|
|
try {
|
|
self::request($item['archive'], min(ModuleArchiveInstaller::MAX_ARCHIVE_BYTES, $item['size'] + 1), $target);
|
|
if (!is_file($target)) {
|
|
throw new \RuntimeException('Сервер каталога не вернул ZIP-пакет модуля «' . $item['name'] . '».');
|
|
}
|
|
|
|
$actualSize = (int) filesize($target);
|
|
if ($actualSize !== (int) $item['size']) {
|
|
throw new \RuntimeException(
|
|
'ZIP модуля «' . $item['name'] . '» загружен на сервер не полностью: ожидалось '
|
|
. (int) $item['size'] . ' байт, получено ' . $actualSize
|
|
. '. Повторно загрузите файл пакета и только после проверки обновляйте index.json.'
|
|
);
|
|
}
|
|
|
|
if (!hash_equals($item['sha256'], hash_file('sha256', $target))) {
|
|
throw new \RuntimeException(
|
|
'Контрольная сумма ZIP модуля «' . $item['name']
|
|
. '» не совпадает с подписанным каталогом. Повторно загрузите файл пакета и очистите кеш CDN.'
|
|
);
|
|
}
|
|
|
|
@chmod($target, 0600);
|
|
return $target;
|
|
} catch (\Throwable $e) {
|
|
if (is_file($target)) { @unlink($target); }
|
|
throw $e;
|
|
}
|
|
}
|
|
|
|
protected static function request($url, $maxBytes, $target = '')
|
|
{
|
|
if (!function_exists('curl_init')) { throw new \RuntimeException('Для удалённого каталога требуется PHP-расширение cURL'); }
|
|
$url = self::httpsUrl($url);
|
|
$parts = parse_url($url);
|
|
$ips = gethostbynamel((string) $parts['host']) ?: array();
|
|
$publicIps = array_values(array_filter($ips, function ($ip) {
|
|
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false;
|
|
}));
|
|
if (!$publicIps) { throw new \RuntimeException('Хост каталога не имеет доступного публичного IPv4-адреса'); }
|
|
|
|
$port = isset($parts['port']) ? (int) $parts['port'] : 443;
|
|
$handle = curl_init($url);
|
|
$options = array(
|
|
CURLOPT_FOLLOWLOCATION => false, CURLOPT_CONNECTTIMEOUT => 10, CURLOPT_TIMEOUT => $target === '' ? 20 : 120,
|
|
CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_FAILONERROR => false,
|
|
CURLOPT_USERAGENT => 'AVE.cms/' . (defined('APP_VERSION') ? APP_VERSION : '3.3') . ' ModuleRepository',
|
|
CURLOPT_HTTPHEADER => array('Accept: ' . ($target === '' ? 'application/json' : 'application/zip')),
|
|
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
|
|
CURLOPT_RESOLVE => array($parts['host'] . ':' . $port . ':' . $publicIps[0]),
|
|
);
|
|
$buffer = '';
|
|
$output = null;
|
|
$bytes = 0;
|
|
if ($target === '') {
|
|
$options[CURLOPT_WRITEFUNCTION] = function ($curl, $chunk) use (&$buffer, &$bytes, $maxBytes) {
|
|
$length = strlen($chunk);
|
|
$bytes += $length;
|
|
if ($bytes > $maxBytes) { return 0; }
|
|
$buffer .= $chunk;
|
|
return $length;
|
|
};
|
|
} else {
|
|
$output = @fopen($target, 'wb');
|
|
if (!$output) { throw new \RuntimeException('Не удалось создать временный ZIP'); }
|
|
$options[CURLOPT_WRITEFUNCTION] = function ($curl, $chunk) use (&$bytes, $maxBytes, $output) {
|
|
$length = strlen($chunk);
|
|
$bytes += $length;
|
|
if ($bytes > $maxBytes) { return 0; }
|
|
return fwrite($output, $chunk);
|
|
};
|
|
}
|
|
|
|
curl_setopt_array($handle, $options);
|
|
$result = curl_exec($handle);
|
|
$status = (int) curl_getinfo($handle, CURLINFO_HTTP_CODE);
|
|
$error = curl_error($handle);
|
|
curl_close($handle);
|
|
if (is_resource($output)) { fclose($output); }
|
|
if ($result === false || $status !== 200) {
|
|
if ($target !== '' && is_file($target)) { @unlink($target); }
|
|
throw new \RuntimeException($bytes > $maxBytes ? 'Удалённый файл превышает допустимый размер' : ('Сервер каталога недоступен' . ($error !== '' ? ': ' . $error : '')));
|
|
}
|
|
|
|
if ($target !== '') { return $target; }
|
|
if ($buffer === '' || strlen($buffer) > $maxBytes) { throw new \RuntimeException('Ответ каталога пуст или слишком велик'); }
|
|
return $buffer;
|
|
}
|
|
|
|
protected static function emptyCatalog($reason, $message)
|
|
{
|
|
return array(
|
|
'enabled' => false, 'configured' => false, 'verified' => false,
|
|
'error' => $reason === 'disabled' ? '' : (string) $message,
|
|
'message' => (string) $message, 'name' => '', 'channel' => '', 'generated_at' => '',
|
|
'items' => array(), 'available' => 0, 'updates' => 0,
|
|
);
|
|
}
|
|
|
|
protected static function catalogUrl($value, $required)
|
|
{
|
|
$value = trim((string) $value);
|
|
if ($value === '' && !$required) { return ''; }
|
|
return self::httpsUrl($value);
|
|
}
|
|
|
|
protected static function publicKey($value, $required)
|
|
{
|
|
$value = str_replace("\r\n", "\n", trim((string) $value));
|
|
if ($value === '' && !$required) { return ''; }
|
|
if (strlen($value) < 200 || strlen($value) > 10000 || strpos($value, 'BEGIN PUBLIC KEY') === false) {
|
|
throw new \InvalidArgumentException('Укажите публичный RSA-ключ каталога в формате PEM');
|
|
}
|
|
|
|
return $value . "\n";
|
|
}
|
|
|
|
protected static function archiveUrl($value, $catalogUrl)
|
|
{
|
|
$value = trim((string) $value);
|
|
$base = parse_url(self::httpsUrl($catalogUrl));
|
|
if (strpos($value, 'https://') === 0) {
|
|
$url = self::httpsUrl($value);
|
|
} else {
|
|
$path = $value !== '' && $value[0] === '/' ? $value : rtrim(dirname((string) $base['path']), '/') . '/' . ltrim($value, '/');
|
|
$url = 'https://' . $base['host'] . (isset($base['port']) ? ':' . (int) $base['port'] : '') . $path;
|
|
$url = self::httpsUrl($url);
|
|
}
|
|
|
|
$parts = parse_url($url);
|
|
$path = isset($parts['path']) ? rawurldecode((string) $parts['path']) : '';
|
|
if (strcasecmp((string) $parts['host'], (string) $base['host']) !== 0
|
|
|| (isset($parts['port']) ? (int) $parts['port'] : 443) !== (isset($base['port']) ? (int) $base['port'] : 443)
|
|
|| preg_match('#(?:^|/)\.\.(?:/|$)#', str_replace('\\', '/', $path))) {
|
|
throw new \RuntimeException('ZIP должен находиться на том же хосте, что и каталог');
|
|
}
|
|
|
|
return $url;
|
|
}
|
|
|
|
protected static function httpsUrl($value)
|
|
{
|
|
$value = trim((string) $value);
|
|
$parts = parse_url($value);
|
|
if (!is_array($parts) || !isset($parts['scheme'], $parts['host']) || strtolower($parts['scheme']) !== 'https'
|
|
|| isset($parts['user']) || isset($parts['pass']) || isset($parts['fragment'])
|
|
|| (isset($parts['port']) && ((int) $parts['port'] < 1 || (int) $parts['port'] > 65535))
|
|
|| filter_var($parts['host'], FILTER_VALIDATE_IP) !== false
|
|
|| in_array(strtolower((string) $parts['host']), array('localhost', 'localhost.localdomain'), true)) {
|
|
throw new \InvalidArgumentException('Каталог должен иметь публичный HTTPS URL без логина и фрагмента');
|
|
}
|
|
|
|
return $value;
|
|
}
|
|
|
|
/** Дополняет короткие версии ядра до semver без изменения prerelease-части. */
|
|
protected static function normalizedVersion($value)
|
|
{
|
|
$value = trim((string) $value);
|
|
if (!preg_match('/^(\d+)(?:\.(\d+))?(?:\.(\d+))?(.*)$/', $value, $matches)) { return $value; }
|
|
|
|
return $matches[1] . '.'
|
|
. (isset($matches[2]) && $matches[2] !== '' ? $matches[2] : '0') . '.'
|
|
. (isset($matches[3]) && $matches[3] !== '' ? $matches[3] : '0')
|
|
. (isset($matches[4]) ? $matches[4] : '');
|
|
}
|
|
}
|