/** * Соединяемся с базой или получаем дескриптор подключения. * * Запоминаем подключения по $confKey. Полагаем, что в настройках приложения для каждого ключа описано <i>уникальное * сочетание</i> базы/пользователя, поскольку идентичный конфиг не имеет смысла. * * Опционально ('mysql_timezone'): после установки соединения отправляется запрос на установку часового пояса сессии. * Подробнее см. в доке "DB.md" * * Единственный источник исключения PDOException тут - это сам конструктор PDO. В трейсе он будет занимать первую * позицию, потом будет модель, вызвавшая этот метод и только потом возможно одна/две позиции по клиентскому коду. * Поэтому такая глубина разбора трассировки. * * @param string $confKey ключ в настройках, по которому хранится массив с кофигурацией подключения к БД * @return PDO объект подключения к БД * @throws DbException */ public static function connect($confKey) { if (isset(self::$cons[$confKey])) { return self::$cons[$confKey]; } $conf = array_merge(['options' => null, 'mysql_timezone' => '+00:00'], App::conf($confKey)); try { $dbh = new PDO($conf['dsn'], $conf['user'], $conf['password'], $conf['options']); if ($tz = $conf['mysql_timezone']) { $sql = 'SET time_zone = ?'; if (false === $dbh->prepare($sql)->execute([$tz])) { throw new DbException('Ошибка установки часового пояса MySQL-сессии.' . PHP_EOL . 'Запрос: ' . str_replace('?', "'{$tz}'", $sql)); } } self::$cons[$confKey] = $dbh; } catch (\PDOException $e) { $msg = $e->getMessage(); $trace = $e->getTrace(); if (isset($trace[2])) { $msg .= PHP_EOL . 'Инициатор подключения ' . str_replace(ROOT_PATH, '', $trace[2]['file']) . '(' . $trace[2]['line'] . ')'; } if (isset($trace[3])) { $msg .= ' функция ' . $trace[3]['function'] . '(...)'; } throw new DbException($msg, DbException::CONNECT, $e); } return self::$cons[$confKey]; }
/** * Перехватчик исключений. * * Ловит исключения, которые не были пойманы ранее. Последний шанс обработать ошибку. Например, записать в лог или * намылить админу. Можно так же вежливо откланяться юзеру. * * После выполнения этого обработчика программа остановится, обеспечено PHP. * * Если указано предыдущее исключение, отсюда не пишем в лог. Сей факт указывает на то, что реальное исключение * уже было поймано и обработано. Считаем, что необходимость логирования была решена в предыдущих обработчиках. * * Прим: для поддержки PHP 7.0 тип ожидаемого параметра расширен, * см. {@see http://php.net/manual/ru/function.set-exception-handler.php PHP::set_exception_handler()} * * @param \Throwable $ex */ public static function exceptionHandler($ex) { $class = get_class($ex); $message = nl2br($ex->getMessage()); $file = str_replace(ROOT_PATH, '/', $ex->getFile()); $line = $ex->getLine(); $trace = $ex->getTraceAsString(); if (isConsoleInterface()) { echo 'Исключение: ' . $class . PHP_EOL . PHP_EOL . $message . PHP_EOL . PHP_EOL . 'Стек вызовов:' . PHP_EOL . $trace . PHP_EOL; return; } if (!headers_sent()) { header('500 Internal Server Error'); header('Content-Type: text/html; charset=UTF-8'); } if (DEBUG) { echo Render::fetch('exception.htm', compact('class', 'message', 'file', 'line', 'trace')); } else { echo Render::fetch('exception_prod.htm', ['domain' => Env::domainName()]); if ($ex->getPrevious() === null) { $logger = App::logger(); $logger->addTyped("Class: {$class}" . PHP_EOL . "Message: {$message}" . PHP_EOL . "Source: {$file}:{$line}" . PHP_EOL . PHP_EOL . "Trace: {$trace}", $logger::EXCEPTION); } } }
/** * Конструктор * * Логируем ошибку. В зависимости от кода пишем лог в БД или файлы: если исключение проброшено из попытки соединения, * сразу же пишем лог в файлы, не пытаясь еще раз ткнуться в базу. * * @param string $message * @param int $code * @param \Exception $previous */ public function __construct($message, $code = self::QUERY, $previous = null) { $logger = App::logger(); if ($code === self::CONNECT) { $logger->add(['message' => $message, 'type' => $logger::DB_CONNECT, 'file_force' => true]); } else { $logger->addTyped($message, $logger::DB_QUERY); } parent::__construct($message, $code, $previous); }
/** * Редирект с выходом из приложения. * * Прим.: указание абсолютного URL - требование спецификации HTTP/1.1, * {@link http://php.net/manual/ru/function.header.php} * * Быстрая справка по кодам с редиректом {@link http://php.net/manual/ru/function.header.php#78470} * * Хитрый редирект на основе комментария {@link http://php.net/manual/ru/function.headers-sent.php#60450} * * @param string $url новый относительный адрес, с ведущим слешем * @param int $code код ответа HTTP */ public static function redirect($url, $code = 302) { $url = Env::domainUrl() . $url; if (!headers_sent()) { header('location:' . $url, true, $code); } else { echo "\n <script type='text/javascript'>\n window.location.href='{$url}'\n </script>\n <noscript>\n <meta http-equiv='refresh' content='0; url={$url}'/>\n </noscript>\n "; } App::end(); }
/** * Письмо админу с текущим сообщение лога. * * @return void */ private function mailToAdmin() { $logIt = $this->logIt; if (!($mailTo = $this->conf['_mail'])) { $this->addTyped('Не задан email админа, не могу отправить сообщение от логера.', ILogger::ENGINE); return; } $domain = Env::domainName(); $date = $logIt['ts']->format('Y/m/d H:i:s P'); $rn = PHP_EOL; $letters['text'] = 'Сообщение от логера' . $rn . $rn . $logIt['message'] . $rn . $rn . "Тип: {$logIt['type']}{$rn}" . "Источник: {$logIt['source']}" . $rn . $rn . "URL запроса:{$logIt['request']}" . $rn . "IP юзера: {$logIt['userIP']}" . $rn . $rn . "{$date} (c) {$domain}"; $vars = ['message' => nl2br($logIt['message']), 'type' => $logIt['type'], 'source' => $logIt['source'], 'request' => $logIt['request'], 'userIP' => $logIt['userIP'], 'date' => $date, 'homeURL' => Env::indexPage(), 'domain' => $domain]; $letters['html'] = Render::fetch('log_letter.htm', $vars); $from = App::conf('noreply_mail') ?: "noreply@{$domain}"; if (!Mailer::complex($from, $mailTo, "Сообщение от логера сайта {$domain}", $letters)) { $this->addTyped('Не удалось отправить сообщение от логера.', ILogger::ENGINE); } }
/** * Получение URL по заданному маршруту * @dataProvider route2UrlProvider * @param string $expectUrl * @param array $route * @param array $params */ public function test_url($expectUrl, $route, $params = []) { $url = App::router()->url($route, $params); $this->assertEquals($expectUrl, $url); }
/** * Построение URL по описанию, используя карту роутов. * * Перебираем роуты в заданном пространстве имен. Сравниваем указанные контроллер/действие с правой частью роута. * Если нашли совпадение справа (будет массив), анализируем правило слева, выбирая необходимые подстановки - * "required params". Если сможем их обеспечить из массива совпадения и/или переданных параметров - роут найден. * Иначе продолжаем поиск. * * Все параметры, которые не пойдут в подстановки, допишутся в URL в качестве строки запроса. Если у элемента * не задано значение, а есть только ключ, типа ['p' => null], он попадет в URL без значения. * * Прим: поиск роута не зависит от регистра. Это немного упрощает требования к описанию параметров функции. * * Если ничего не найдем, проброс ошибки. Вообще ситуация некритичная, но иначе можно прозевать исчезновение роута * и получить битую ссылку. * * @param mixed $route <b>неассоциативный</b> массив 2-х элементов ["пространство имен", "контроллер/действие"] * @param array $params доп.параметры для передачи в адрес. Ассоциативный массив ['имя параметра' => 'значение'] * @return string готовый <b>относительный</b> URL * @throws RouteException */ public function url($route, array $params = []) { list($ns, $ctrlAct) = $route; $ctrlAct = trim($ctrlAct, '/'); $routes = App::conf("router.routes.{$ns}", false); if (!$routes) { throw new RouteException("Нет карты роутов для пространства имен '{$ns}'"); } $match = null; $paramsBackup = $params; foreach ($routes as $left => $right) { $left = ltrim($left, '/'); $right = ltrim($right, '/'); $pattern = preg_replace('~(<(.+)>)~U', '(?P<$2>[^/]+)', $right); $pattern = "~^{$pattern}\$~Ui"; if (preg_match($pattern, $ctrlAct, $matchCtrlAct)) { if (preg_match_all('~<([a-z0-9_]+):.+>~U', $left, $requiredParams)) { $requiredParams = array_flip($requiredParams[1]); foreach ($requiredParams as $k => &$v) { if (isset($matchCtrlAct[$k])) { $v = $matchCtrlAct[$k]; } else { if (isset($params[$k])) { $v = $params[$k]; // удаляем из параметров то, что будет использовано в подстановке unset($params[$k]); } else { $params = $paramsBackup; continue 2; } } } unset($v); } else { $requiredParams = []; } break; } } //dd($left, $match);//DBG if ($matchCtrlAct) { if ($requiredParams) { $placeholders = []; foreach ($requiredParams as $key => $v) { $placeholders[] = "~(<{$key}:.+>)~U"; } $left = preg_replace($placeholders, $requiredParams, $left); } if (preg_match('~[^/a-z0-9_-]~i', $left)) { $arr = explode('/', $left); $arr = array_map('urlencode', $arr); $left = implode('/', $arr); } $url = '/' . $left; if ($params) { foreach ($params as $k => &$v) { $v = urlencode($k) . ($v ? '=' . urlencode($v) : ''); } $url .= '?' . implode('&', $params); } return $url; } else { $strParams = []; foreach ($params as $k => $v) { $strParams[] = "{$k} => {$v}"; } $strParams = count($strParams) ? 'параметры [' . implode(', ', $strParams) . ']' : 'без параметров'; throw new RouteException("не могу построить URL по заданным значениям: ['{$ns}', '{$ctrlAct}'], {$strParams}"); } }
/** * Имя домена, типа "my.site.com". * * Пытаемся его получить из $_SERVER['HTTP_HOST']. Иначе ищем в настройках приложения ('domain'). * * @return string */ public static function domainName() { return isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : (App::conf('domain', false) ?: ''); }
/** * Типовой валидатор "bounds". * * Проверка рационального числа в заданных пределах. Полезно для проверки чисел с плавающей точкой, в дополнение * к filter_var(FILTER_VALIDATE_FLOAT). Описание: * <pre> * $desc = [ * 'min' => number, * 'max' => number, * 'message' => string, // свое сообщение об ошибке. Необязательно. * ]; * </pre> * * Любой из параметров можно пропустить. Значение 'NULL' - не проверять границу с этой стороны. * * Проверяемое значение может быть равно NULL, только если до этого были ошибки в других валидаторах. При этом * выходим без дополнительных сообщений. * * @param array $desc описание валидатора * @param number $data проверяемые данные * @param mixed $value куда писать валидное значение * @param mixed $error куда писать ошибку * @return bool * @throws \LogicException */ protected function validatorBounds(&$desc, &$data, &$value, &$error) { if (is_null($data)) { $this->isValid = false; return false; } // Предохранитель для разработчика if (!is_numeric($data)) { throw new \LogicException('Проверка границ числа не применима к такому типу данных: ' . gettype($value)); } $min = isset($desc['min']) ? (double) $desc['min'] : null; $max = isset($desc['max']) ? (double) $desc['max'] : null; $message = isset($desc['message']) ? App::t($desc['message']) : ''; $data *= 1; // приведение к типу if (!is_null($min) && $data < $min) { $errMsg[] = $message ?: App::t('Значение меньше допустимого, минимум M', ['M' => $min]); } elseif (!is_null($min) && $data > $max) { $errMsg[] = $message ?: App::t('Значение больше допустимого, максимум M', ['M' => $max]); } else { $errMsg = null; } if (!($passed = !$errMsg)) { $this->isValid = false; $error = is_array($error) ? array_merge($error, $errMsg) : $errMsg; } return $passed; }
<?php /** * Главный скрипт приложения 'app' */ use kira\core\App; mb_internal_encoding('UTF-8'); define('APP_NAMESPACE', 'app'); define('ROOT_PATH', str_replace('\\', '/', rtrim(__DIR__, '/')) . '/'); define('APP_PATH', ROOT_PATH . 'application/'); define('VIEWS_PATH', APP_PATH . 'views/'); define('TEMP_PATH', APP_PATH . 'temp/'); define('MAIN_CONFIG', APP_PATH . 'conf/main.php'); define('DEBUG', true); ini_set('display_errors', (int) DEBUG); ini_set('display_startup_errors', (int) DEBUG); error_reporting(DEBUG ? E_ALL : 0); $composer = (require ROOT_PATH . 'vendor/autoload.php'); App::setComposer($composer); unset($composer); date_default_timezone_set(App::conf('timezone')); App::router()->callAction();
/** * Проверка права запускать скрипты через Консоль * * В режиме отладки - разрешено без вопросов, иначе - при указании правильного ключа. Количество ошибок считается. * Ключ и допустимое количество прописываем в конфиге приложения: * <pre> * 'console => [ * 'ties' => int|0 * 'key' => string|'' * ] * </pre> * * <b>Внимание!</b> При отсутствии этого конфига, а так же со значениями по умолчанию, доступ будет разрешен. * * В дальнейшем можно добавить контроль на запрещенные/разрешенные IP и т.п. * * Неудавшиеся попытки логируются, после NN попыток консоль вообще блокируется через файл [TEMP_PATH/console.lock], * при блокировке админу пойдет письмо. * * @param string $key ключ доступа, спарсенный из запроса * @return null если нет права запуска, прямо тут все и закончится. Поэтому возвращаемое значение не важно. */ private function checkAccess(string $key) { if (DEBUG) { return; } $lockFile = TEMP_PATH . 'convisor.lock'; $count = file_exists($lockFile) ? file_get_contents($lockFile) : 0; $tries = (int) App::conf('convisor.tries', false); $critical = $tries && $count >= $tries; if ($critical) { exit('Консоль заблокирована' . PHP_EOL); } $allow = (string) App::conf('convisor.key', false) == $key; if (!$allow) { $count++; $msg = "{$count} неудачная попытка запуска скрипта в консоли: {$this->script} " . implode(' ', $this->params); if ($critical) { App::logger()->add(['message' => $msg, 'type' => 'deny console', 'notify' => true]); } else { App::logger()->addTyped($msg, 'deny console'); } file_put_contents($lockFile, $count); exit('Запуск запрещен' . PHP_EOL); } FS::deleteFile($lockFile); }
/** * Валидатор даты * * Проверяется соответствие формату и реальность даты. Решил не мудрить с настройками. Валидатор разрешает только * формат "dd.mm.yyyy" * * Возвращаемое значение приводится в формат MySQL "yyyy-mm-dd" для корректного сохранения в БД. * * @param string $date * @return array возвращаем в формате yyyy-mm-dd (mysql) */ public static function date($date) { if (!preg_match('~\\d{2}\\.\\d{2}\\.\\d{4}~', $date)) { $msg = App::t('Неверный формат даты. Ожидается "FORMAT".', ['FORMAT' => 'dd.mm.yyyy']); return ['error' => $msg]; } list($d, $m, $y) = explode('.', $date); if (!checkdate($m, $d, $y)) { return ['error' => App::t('Нереальная дата')]; } return ['value' => sprintf('%d-%d-%d', $y, $m, $d)]; }
/** * Добавление flash-сообщения в сессию * * Флаг перезаписи: true - любое значение переписываем, false - массивы объединяем, строки дописываем, <b>целые</b> * числа суммируем, с другими данными не работаем. При этом тип новых данных приводим к имеющемуся в сессии. * * @param string $key ключ в сессии * @param mixed $data данные для записи * @param bool $force флаг перезаписи. True - любое значение переписываем, false - массивы объединяем, * строки дописываем, числа суммируем. При этом тип новых данных приводим к имеющемуся в сессии. * @return void */ public static function addFlash($key, $data, $force = false) { self::init(); $data = App::t($data); if (isset($_SESSION['flash'][$key]) && !$force) { $curData = $_SESSION['flash'][$key]; if (is_array($curData)) { $data = array_merge($curData, (array) $data); } elseif (!is_numeric($curData)) { $data = $curData . $data; } elseif (preg_match('~^\\d+$~', $curData)) { $data = (int) $curData + intval($data); } } $_SESSION['flash'][$key] = $data; }