434 lines
12 KiB
PHP
434 lines
12 KiB
PHP
|
<?php
|
||
|
|
||
|
declare(strict_types=1);
|
||
|
|
||
|
namespace PhpMyAdmin;
|
||
|
|
||
|
use PhpMyAdmin\Server\SysInfo\SysInfo;
|
||
|
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
|
||
|
use Throwable;
|
||
|
|
||
|
use function __;
|
||
|
use function array_merge;
|
||
|
use function htmlspecialchars;
|
||
|
use function implode;
|
||
|
use function preg_match;
|
||
|
use function preg_replace_callback;
|
||
|
use function round;
|
||
|
use function sprintf;
|
||
|
use function str_contains;
|
||
|
use function substr;
|
||
|
use function vsprintf;
|
||
|
|
||
|
/**
|
||
|
* A simple rules engine, that executes the rules in the advisory_rules files.
|
||
|
*/
|
||
|
class Advisor
|
||
|
{
|
||
|
private const GENERIC_RULES_FILE = 'libraries/advisory_rules_generic.php';
|
||
|
private const BEFORE_MYSQL80003_RULES_FILE = 'libraries/advisory_rules_mysql_before80003.php';
|
||
|
|
||
|
/** @var DatabaseInterface */
|
||
|
private $dbi;
|
||
|
|
||
|
/** @var array */
|
||
|
private $variables;
|
||
|
|
||
|
/** @var array */
|
||
|
private $globals;
|
||
|
|
||
|
/** @var array */
|
||
|
private $rules;
|
||
|
|
||
|
/** @var array */
|
||
|
private $runResult;
|
||
|
|
||
|
/** @var ExpressionLanguage */
|
||
|
private $expression;
|
||
|
|
||
|
/**
|
||
|
* @param DatabaseInterface $dbi DatabaseInterface object
|
||
|
* @param ExpressionLanguage $expression ExpressionLanguage object
|
||
|
*/
|
||
|
public function __construct(DatabaseInterface $dbi, ExpressionLanguage $expression)
|
||
|
{
|
||
|
$this->dbi = $dbi;
|
||
|
$this->expression = $expression;
|
||
|
/*
|
||
|
* Register functions for ExpressionLanguage, we intentionally
|
||
|
* do not implement support for compile as we do not use it.
|
||
|
*/
|
||
|
$this->expression->register(
|
||
|
'round',
|
||
|
static function (): void {
|
||
|
},
|
||
|
/**
|
||
|
* @param array $arguments
|
||
|
* @param float $num
|
||
|
*/
|
||
|
static function ($arguments, $num) {
|
||
|
return round($num);
|
||
|
}
|
||
|
);
|
||
|
$this->expression->register(
|
||
|
'substr',
|
||
|
static function (): void {
|
||
|
},
|
||
|
/**
|
||
|
* @param array $arguments
|
||
|
* @param string $string
|
||
|
* @param int $start
|
||
|
* @param int $length
|
||
|
*/
|
||
|
static function ($arguments, $string, $start, $length) {
|
||
|
return substr($string, $start, $length);
|
||
|
}
|
||
|
);
|
||
|
$this->expression->register(
|
||
|
'preg_match',
|
||
|
static function (): void {
|
||
|
},
|
||
|
/**
|
||
|
* @param array $arguments
|
||
|
* @param string $pattern
|
||
|
* @param string $subject
|
||
|
*/
|
||
|
static function ($arguments, $pattern, $subject) {
|
||
|
return preg_match($pattern, $subject);
|
||
|
}
|
||
|
);
|
||
|
$this->expression->register(
|
||
|
'ADVISOR_bytime',
|
||
|
static function (): void {
|
||
|
},
|
||
|
/**
|
||
|
* @param array $arguments
|
||
|
* @param float $num
|
||
|
* @param int $precision
|
||
|
*/
|
||
|
static function ($arguments, $num, $precision) {
|
||
|
return self::byTime($num, $precision);
|
||
|
}
|
||
|
);
|
||
|
$this->expression->register(
|
||
|
'ADVISOR_timespanFormat',
|
||
|
static function (): void {
|
||
|
},
|
||
|
/**
|
||
|
* @param array $arguments
|
||
|
* @param string $seconds
|
||
|
*/
|
||
|
static function ($arguments, $seconds) {
|
||
|
return Util::timespanFormat((int) $seconds);
|
||
|
}
|
||
|
);
|
||
|
$this->expression->register(
|
||
|
'ADVISOR_formatByteDown',
|
||
|
static function (): void {
|
||
|
},
|
||
|
/**
|
||
|
* @param array $arguments
|
||
|
* @param int $value
|
||
|
* @param int $limes
|
||
|
* @param int $comma
|
||
|
*/
|
||
|
static function ($arguments, $value, $limes = 6, $comma = 0) {
|
||
|
return implode(' ', (array) Util::formatByteDown($value, $limes, $comma));
|
||
|
}
|
||
|
);
|
||
|
$this->expression->register(
|
||
|
'fired',
|
||
|
static function (): void {
|
||
|
},
|
||
|
/**
|
||
|
* @param array $arguments
|
||
|
* @param int $value
|
||
|
*/
|
||
|
function ($arguments, $value) {
|
||
|
if (! isset($this->runResult['fired'])) {
|
||
|
return 0;
|
||
|
}
|
||
|
|
||
|
// Did matching rule fire?
|
||
|
foreach ($this->runResult['fired'] as $rule) {
|
||
|
if ($rule['id'] == $value) {
|
||
|
return '1';
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return '0';
|
||
|
}
|
||
|
);
|
||
|
/* Some global variables for advisor */
|
||
|
$this->globals = [
|
||
|
'PMA_MYSQL_INT_VERSION' => $this->dbi->getVersion(),
|
||
|
'IS_MARIADB' => $this->dbi->isMariaDB(),
|
||
|
];
|
||
|
}
|
||
|
|
||
|
private function setVariables(): void
|
||
|
{
|
||
|
$globalStatus = $this->dbi->fetchResult('SHOW GLOBAL STATUS', 0, 1);
|
||
|
$globalVariables = $this->dbi->fetchResult('SHOW GLOBAL VARIABLES', 0, 1);
|
||
|
|
||
|
$sysInfo = SysInfo::get();
|
||
|
$memory = $sysInfo->memory();
|
||
|
$systemMemory = ['system_memory' => $memory['MemTotal'] ?? 0];
|
||
|
|
||
|
$this->variables = array_merge($globalStatus, $globalVariables, $systemMemory);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @param string|int $variable Variable to set
|
||
|
* @param mixed $value Value to set
|
||
|
*/
|
||
|
public function setVariable($variable, $value): void
|
||
|
{
|
||
|
$this->variables[$variable] = $value;
|
||
|
}
|
||
|
|
||
|
private function setRules(): void
|
||
|
{
|
||
|
$isMariaDB = str_contains($this->variables['version'], 'MariaDB');
|
||
|
$genericRules = include ROOT_PATH . self::GENERIC_RULES_FILE;
|
||
|
|
||
|
if (! $isMariaDB && $this->globals['PMA_MYSQL_INT_VERSION'] >= 80003) {
|
||
|
$this->rules = $genericRules;
|
||
|
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
$extraRules = include ROOT_PATH . self::BEFORE_MYSQL80003_RULES_FILE;
|
||
|
$this->rules = array_merge($genericRules, $extraRules);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @return array
|
||
|
*/
|
||
|
public function getRunResult(): array
|
||
|
{
|
||
|
return $this->runResult;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* @return array
|
||
|
*/
|
||
|
public function run(): array
|
||
|
{
|
||
|
$this->setVariables();
|
||
|
$this->setRules();
|
||
|
$this->runRules();
|
||
|
|
||
|
return $this->runResult;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Stores current error in run results.
|
||
|
*
|
||
|
* @param string $description description of an error.
|
||
|
* @param Throwable $exception exception raised
|
||
|
*/
|
||
|
private function storeError(string $description, Throwable $exception): void
|
||
|
{
|
||
|
$this->runResult['errors'][] = $description . ' ' . sprintf(
|
||
|
__('Error when evaluating: %s'),
|
||
|
$exception->getMessage()
|
||
|
);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Executes advisor rules
|
||
|
*/
|
||
|
private function runRules(): void
|
||
|
{
|
||
|
$this->runResult = [
|
||
|
'fired' => [],
|
||
|
'notfired' => [],
|
||
|
'unchecked' => [],
|
||
|
'errors' => [],
|
||
|
];
|
||
|
|
||
|
foreach ($this->rules as $rule) {
|
||
|
$this->variables['value'] = 0;
|
||
|
$precondition = true;
|
||
|
|
||
|
if (isset($rule['precondition'])) {
|
||
|
try {
|
||
|
$precondition = $this->evaluateRuleExpression($rule['precondition']);
|
||
|
} catch (Throwable $e) {
|
||
|
$this->storeError(
|
||
|
sprintf(
|
||
|
__('Failed evaluating precondition for rule \'%s\'.'),
|
||
|
$rule['name']
|
||
|
),
|
||
|
$e
|
||
|
);
|
||
|
continue;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
if (! $precondition) {
|
||
|
$this->addRule('unchecked', $rule);
|
||
|
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
try {
|
||
|
$value = $this->evaluateRuleExpression($rule['formula']);
|
||
|
} catch (Throwable $e) {
|
||
|
$this->storeError(
|
||
|
sprintf(
|
||
|
__('Failed calculating value for rule \'%s\'.'),
|
||
|
$rule['name']
|
||
|
),
|
||
|
$e
|
||
|
);
|
||
|
continue;
|
||
|
}
|
||
|
|
||
|
$this->variables['value'] = $value;
|
||
|
|
||
|
try {
|
||
|
if ($this->evaluateRuleExpression($rule['test'])) {
|
||
|
$this->addRule('fired', $rule);
|
||
|
} else {
|
||
|
$this->addRule('notfired', $rule);
|
||
|
}
|
||
|
} catch (Throwable $e) {
|
||
|
$this->storeError(
|
||
|
sprintf(
|
||
|
__('Failed running test for rule \'%s\'.'),
|
||
|
$rule['name']
|
||
|
),
|
||
|
$e
|
||
|
);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Adds a rule to the result list
|
||
|
*
|
||
|
* @param string $type type of rule
|
||
|
* @param array $rule rule itself
|
||
|
*/
|
||
|
public function addRule(string $type, array $rule): void
|
||
|
{
|
||
|
if ($type !== 'notfired' && $type !== 'fired') {
|
||
|
$this->runResult[$type][] = $rule;
|
||
|
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
if (isset($rule['justification_formula'])) {
|
||
|
try {
|
||
|
$params = $this->evaluateRuleExpression('[' . $rule['justification_formula'] . ']');
|
||
|
} catch (Throwable $e) {
|
||
|
$this->storeError(
|
||
|
sprintf(__('Failed formatting string for rule \'%s\'.'), $rule['name']),
|
||
|
$e
|
||
|
);
|
||
|
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
$rule['justification'] = vsprintf($rule['justification'], $params);
|
||
|
}
|
||
|
|
||
|
// Replaces {server_variable} with 'server_variable'
|
||
|
// linking to /server/variables
|
||
|
$rule['recommendation'] = preg_replace_callback(
|
||
|
'/\{([a-z_0-9]+)\}/Ui',
|
||
|
function (array $matches) {
|
||
|
return $this->replaceVariable($matches);
|
||
|
},
|
||
|
$rule['recommendation']
|
||
|
);
|
||
|
$rule['issue'] = preg_replace_callback(
|
||
|
'/\{([a-z_0-9]+)\}/Ui',
|
||
|
function (array $matches) {
|
||
|
return $this->replaceVariable($matches);
|
||
|
},
|
||
|
$rule['issue']
|
||
|
);
|
||
|
|
||
|
// Replaces external Links with Core::linkURL() generated links
|
||
|
$rule['recommendation'] = preg_replace_callback(
|
||
|
'#href=("|\')(https?://[^"\']+)\1#i',
|
||
|
function (array $matches) {
|
||
|
return $this->replaceLinkURL($matches);
|
||
|
},
|
||
|
$rule['recommendation']
|
||
|
);
|
||
|
|
||
|
$this->runResult[$type][] = $rule;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Callback for wrapping links with Core::linkURL
|
||
|
*
|
||
|
* @param array $matches List of matched elements form preg_replace_callback
|
||
|
*
|
||
|
* @return string Replacement value
|
||
|
*/
|
||
|
private function replaceLinkURL(array $matches): string
|
||
|
{
|
||
|
return 'href="' . Core::linkURL($matches[2]) . '" target="_blank" rel="noopener noreferrer"';
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Callback for wrapping variable edit links
|
||
|
*
|
||
|
* @param array $matches List of matched elements form preg_replace_callback
|
||
|
*
|
||
|
* @return string Replacement value
|
||
|
*/
|
||
|
private function replaceVariable(array $matches): string
|
||
|
{
|
||
|
return '<a href="' . Url::getFromRoute('/server/variables', ['filter' => $matches[1]])
|
||
|
. '">' . htmlspecialchars($matches[1]) . '</a>';
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Runs a code expression, replacing variable names with their respective values
|
||
|
*
|
||
|
* @return mixed result of evaluated expression
|
||
|
*/
|
||
|
private function evaluateRuleExpression(string $expression)
|
||
|
{
|
||
|
return $this->expression->evaluate($expression, array_merge($this->variables, $this->globals));
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Formats interval like 10 per hour
|
||
|
*
|
||
|
* @param float $num number to format
|
||
|
* @param int $precision required precision
|
||
|
*
|
||
|
* @return string formatted string
|
||
|
*/
|
||
|
public static function byTime(float $num, int $precision): string
|
||
|
{
|
||
|
if ($num >= 1) { // per second
|
||
|
$per = __('per second');
|
||
|
} elseif ($num * 60 >= 1) { // per minute
|
||
|
$num *= 60;
|
||
|
$per = __('per minute');
|
||
|
} elseif ($num * 60 * 60 >= 1) { // per hour
|
||
|
$num *= 60 * 60;
|
||
|
$per = __('per hour');
|
||
|
} else {
|
||
|
$num *= 24 * 60 * 60;
|
||
|
$per = __('per day');
|
||
|
}
|
||
|
|
||
|
$num = round($num, $precision);
|
||
|
|
||
|
if ($num == 0) {
|
||
|
$num = '<' . 10 ** (-$precision);
|
||
|
}
|
||
|
|
||
|
return $num . ' ' . $per;
|
||
|
}
|
||
|
}
|