This commit is contained in:
Egor Isaev 2026-06-24 10:52:46 +03:00
parent b3c8188ee3
commit b6a8133170
9 changed files with 522 additions and 1 deletions

2
.gitignore vendored
View File

@ -7,6 +7,8 @@
config.json
App/config/config.local.php
/App/logs/
**/files/**/*
!**/files/**/
!**/files/**/.gitkeep

View File

@ -27,4 +27,9 @@ return [
],
],
'log' => [
'path' => APPPATH . '/logs',
'threshold' => 'debug',
],
];

View File

@ -84,6 +84,7 @@ PhpStorm может показывать предупреждение «Namespac
| `System\Classes\Cookie` | Статический хелпер для работы с куками: `get()`, `set()`, `delete()` |
| `System\Classes\Session` | Синглтон сессии: `instance(string $name)`, `get/set/delete/destroy/regenerate` |
| `System\Classes\Config` | Статический конфиг: lazy-load `App/config/config.php` + merge `config.local.php` |
| `System\Classes\Log` | Файловый логгер: уровни, аудит запросов, лог ошибок; `info()/error()`, маскировка |
| `System\Classes\Validation` | Валидация входных данных по декларативным правилам; `factory()`, `rule()`, `check()`, `errors()` |
| `System\Classes\CSRF` | Защита форм от CSRF; токен в сессии: `token()`, `validate()`, `field()` |
| `System\Classes\HTTP` | `redirect()`, `requestHeaders()` |
@ -240,6 +241,35 @@ if (!CSRF::validate(Request::$current->post(CSRF::$key))) {
- `field()` — готовый `<input type="hidden">` с токеном.
- `CSRF::$key` — имя поля/ключа (по умолчанию `csrf_token`).
### Log (System\Classes\Log)
Файловый логгер в стиле `Config`/`Cookie`. Пишет в два файла, именованных датой:
`App/logs/action-{Y-m-d}.log` (действия пользователя) и `App/logs/error-{Y-m-d}.log`
(ошибки и предупреждения). Канал выбирается по уровню.
```php
use System\Classes\Log;
Log::info('Пользователь :id вошёл', [':id' => 42]); // подстановка :key через strtr
Log::error('Сбой оплаты');
Log::debug(...); Log::warning(...);
```
- Уровни: `debug(100) < info(200) < warning(300) < error(400)`. Пишутся только уровни не ниже
порога `Log::$threshold` (по умолчанию из `Config::get('log','threshold')``debug`).
- Канал по уровню: `info`/`debug` → файл `action-…`, `warning`/`error` → файл `error-…`.
- Каталог — `Log::$directory``Config::get('log','path')``APPPATH/logs`. Создаётся на лету.
- `Log::requestInfo()` — строка контекста текущего запроса (`METHOD /uri Controller::action
params=… query=… post=…`) из `Request::$current`; чувствительные ключи (`Log::$mask_keys`:
`password`, `pass`, `csrf_token`, `token`) маскируются `***`.
**Интеграция (автоматически):**
- `Request::execute()` пишет INFO на **каждый** входящий запрос (аудит — кто что делал).
- `MyException::handler()` пишет ERROR на каждое неперехваченное исключение (текст + контекст
запроса), и в DEVELOPMENT, и в PRODUCTION.
Каталог логов в `.gitignore` (`/App/logs/`).
### HTTPException (System\Classes\HTTP\HTTPException)
```php
@ -318,8 +348,9 @@ PHPUnit 11 в Docker-контейнере `bicycle`. Bootstrap: `tests/bootstrap
| `tests/Unit/ValidationTest.php` | правила, `matches`, пропуск пустых, first-error, label/плейсхолдеры, fluent |
| `tests/Unit/CSRFTest.php` | `token()` стабильность/формат, `validate()`, `field()`, нет токена в сессии |
| `tests/Unit/ControllerTest.php` | `executeAction()`, порядок `before/action/after`, no-op хуки |
| `tests/Unit/LogTest.php` | уровни/порог, каналы (action/error), формат, append, `strtr`, маскировка, `requestInfo()` |
**145 тестов, 229 assertion — все проходят.**
**154 теста, 253 assertion — все проходят.**
### Frontend dependencies (через Composer)

209
System/Classes/Log.php Normal file
View File

@ -0,0 +1,209 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description Log.php
* @copyright (c) 24/06/2026
*/
namespace System\Classes;
/**
* Файловый логгер. Пишет в {directory}/{Y-m-d}.log с фильтрацией по уровню.
* Используется для аудита запросов (info) и логирования ошибок (error).
*
* Log::info('Пользователь :id вошёл', [':id' => 42]);
* Log::error('Сбой оплаты');
*/
class Log
{
public const DEBUG = 'debug';
public const INFO = 'info';
public const WARNING = 'warning';
public const ERROR = 'error';
/** @var array<string,int> Веса уровней для фильтрации по порогу */
private const WEIGHTS = [
self::DEBUG => 100,
self::INFO => 200,
self::WARNING => 300,
self::ERROR => 400,
];
/** @var array<string,string> Канал (файл) для каждого уровня */
private const CHANNELS = [
self::DEBUG => 'action', // действия пользователя (аудит)
self::INFO => 'action',
self::WARNING => 'error', // ошибки и предупреждения
self::ERROR => 'error',
];
/** @var string|null Каталог логов; null → Config('log','path') → APPPATH/logs */
public static ?string $directory = null;
/** @var string|null Минимальный уровень записи; null → Config('log','threshold') → debug */
public static ?string $threshold = null;
/** @var array Ключи, значения которых маскируются (***) при логировании query/post */
public static array $mask_keys = ['password', 'pass', 'csrf_token', 'token'];
/**
* Пишет сообщение в лог, если его уровень не ниже порога.
*
* @param string $level Уровень (см. константы)
* @param string $message Сообщение, может содержать плейсхолдеры для $variables
* @param array $variables Карта замен [':key' => 'value'] для strtr
* @return void
*/
public static function write(string $level, string $message, array $variables = []): void
{
if (self::weight($level) < self::weight(self::threshold())) {
return;
}
if ($variables) {
$message = strtr($message, $variables);
}
$dir = self::directory();
if (!is_dir($dir)) {
mkdir($dir, 0775, true);
}
$line = sprintf("[%s] %s: %s\n", date('Y-m-d H:i:s'), strtoupper($level), $message);
$file = $dir . '/' . self::channel($level) . '-' . date('Y-m-d') . '.log';
file_put_contents($file, $line, FILE_APPEND | LOCK_EX);
}
/**
* @param string $message Сообщение
* @param array $variables Карта замен для strtr
* @return void
*/
public static function debug(string $message, array $variables = []): void
{
self::write(self::DEBUG, $message, $variables);
}
/**
* @param string $message Сообщение
* @param array $variables Карта замен для strtr
* @return void
*/
public static function info(string $message, array $variables = []): void
{
self::write(self::INFO, $message, $variables);
}
/**
* @param string $message Сообщение
* @param array $variables Карта замен для strtr
* @return void
*/
public static function warning(string $message, array $variables = []): void
{
self::write(self::WARNING, $message, $variables);
}
/**
* @param string $message Сообщение
* @param array $variables Карта замен для strtr
* @return void
*/
public static function error(string $message, array $variables = []): void
{
self::write(self::ERROR, $message, $variables);
}
/**
* Собирает строку контекста текущего запроса для аудита/лога ошибок.
* Чувствительные значения query/post маскируются.
*
* @return string '' если текущего запроса нет (CLI/тесты)
*/
public static function requestInfo(): string
{
$request = Request::$current;
if ($request === null) {
return '';
}
$flags = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES;
$params = json_encode($request->params(), $flags);
$query = json_encode(self::mask($request->query()), $flags);
$post = json_encode(self::mask($request->post()), $flags);
return sprintf(
'%s /%s %s::%s params=%s query=%s post=%s',
$request->method(), $request->uri(), $request->controller(), $request->action(),
$params, $query, $post
);
}
/**
* Минимальный уровень записи (из свойства, конфига или дефолт debug).
*
* @return string
*/
private static function threshold(): string
{
return self::$threshold ?? Config::get('log', 'threshold') ?? self::DEBUG;
}
/**
* Каталог логов (из свойства, конфига или дефолт APPPATH/logs).
*
* @return string
*/
private static function directory(): string
{
return self::$directory ?? Config::get('log', 'path') ?? APPPATH . '/logs';
}
/**
* Вес уровня для сравнения с порогом.
*
* @param string $level Уровень
* @return int
*/
private static function weight(string $level): int
{
return self::WEIGHTS[strtolower($level)] ?? self::WEIGHTS[self::DEBUG];
}
/**
* Канал (имя файла без даты) для уровня: error/warning 'error',
* info/debug 'action'.
*
* @param string $level Уровень
* @return string
*/
private static function channel(string $level): string
{
return self::CHANNELS[strtolower($level)] ?? 'action';
}
/**
* Рекурсивно маскирует значения чувствительных ключей (без учёта регистра).
*
* @param array $data Данные
* @return array
*/
private static function mask(array $data): array
{
$mask = array_map('strtolower', self::$mask_keys);
$result = [];
foreach ($data as $key => $value) {
if (in_array(strtolower((string)$key), $mask, true)) {
$result[$key] = '***';
} elseif (is_array($value)) {
$result[$key] = self::mask($value);
} else {
$result[$key] = $value;
}
}
return $result;
}
}

View File

@ -44,6 +44,14 @@ class MyException extends Exception
$http_code = ($e->getCode() >= 100 && $e->getCode() <= 599) ? $e->getCode() : 500;
http_response_code($http_code);
Log::error(
sprintf(
'%s [%d]: %s в %s:%d | %s',
get_class($e), $e->getCode(), $e->getMessage(), $e->getFile(), $e->getLine(),
Log::requestInfo()
)
);
if (Core::$environment >= Core::DEVELOPMENT) {
$class = get_class($e);
$code = $e->getCode();

View File

@ -113,6 +113,9 @@ class Request implements HTTPRequest
*/
public function execute(): Response
{
// Аудит: каждый входящий запрос пишется в лог (включая 404).
Log::info(Log::requestInfo());
$method = lcfirst($this->_action) . 'Action';
$parts = explode('/', $this->_controller);
$file = DOCROOT . '/App/Controller/' . implode('/', array_map('ucfirst', $parts)) . 'Controller.php';

66
tests/Unit/CSRFTest.php Normal file
View File

@ -0,0 +1,66 @@
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
use System\Classes\CSRF;
use System\Classes\Session;
class CSRFTest extends TestCase
{
protected function tearDown(): void
{
Session::instance()->destroy();
// Сбрасываем синглтон сессии через рефлексию
$ref = new \ReflectionClass(Session::class);
$prop = $ref->getProperty('instances');
$prop->setAccessible(true);
$prop->setValue(null, []);
}
public function testTokenIsNonEmptyHex(): void
{
$token = CSRF::token();
$this->assertNotEmpty($token);
$this->assertSame(64, strlen($token)); // 32 байта → 64 hex-символа
$this->assertMatchesRegularExpression('/^[0-9a-f]+$/', $token);
}
public function testTokenIsStable(): void
{
$this->assertSame(CSRF::token(), CSRF::token());
}
public function testValidateAcceptsCorrectToken(): void
{
$token = CSRF::token();
$this->assertTrue(CSRF::validate($token));
}
public function testValidateRejectsWrongToken(): void
{
CSRF::token();
$this->assertFalse(CSRF::validate('deadbeef'));
}
public function testValidateRejectsNullAndEmpty(): void
{
CSRF::token();
$this->assertFalse(CSRF::validate(null));
$this->assertFalse(CSRF::validate(''));
}
public function testValidateFailsWhenNoTokenInSession(): void
{
// Токен ещё не создавали — в сессии его нет.
$this->assertFalse(CSRF::validate('whatever'));
}
public function testFieldContainsToken(): void
{
$field = CSRF::field();
$this->assertStringContainsString('name="' . CSRF::$key . '"', $field);
$this->assertStringContainsString('value="' . CSRF::token() . '"', $field);
}
}

View File

@ -0,0 +1,60 @@
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
use System\Classes\BaseController;
/**
* Тестовый контроллер: фиксирует порядок вызовов before/action/after.
*/
class LifecycleController extends BaseController
{
public array $log = [];
protected function before(): void
{
$this->log[] = 'before';
}
protected function after(): void
{
$this->log[] = 'after';
}
public function indexAction(): string
{
$this->log[] = 'action';
return 'body';
}
}
class ControllerTest extends TestCase
{
public function testExecuteActionReturnsActionBody(): void
{
$controller = new LifecycleController();
$this->assertSame('body', $controller->executeAction('indexAction'));
}
public function testHooksRunAroundActionInOrder(): void
{
$controller = new LifecycleController();
$controller->executeAction('indexAction');
$this->assertSame(['before', 'action', 'after'], $controller->log);
}
public function testDefaultHooksAreNoop(): void
{
// Голое ядро без переопределения хуков просто возвращает тело.
$controller = new class extends BaseController {
public function pingAction(): string
{
return 'pong';
}
};
$this->assertSame('pong', $controller->executeAction('pingAction'));
}
}

137
tests/Unit/LogTest.php Normal file
View File

@ -0,0 +1,137 @@
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
use System\Classes\Log;
class LogTest extends TestCase
{
private string $dir;
protected function setUp(): void
{
$this->dir = sys_get_temp_dir() . '/bicycle_log_' . uniqid();
Log::$directory = $this->dir;
Log::$threshold = Log::DEBUG;
}
protected function tearDown(): void
{
if (is_dir($this->dir)) {
foreach (glob($this->dir . '/*') as $file) {
unlink($file);
}
rmdir($this->dir);
}
Log::$directory = null;
Log::$threshold = null;
}
private function logFile(string $channel): string
{
return $this->dir . '/' . $channel . '-' . date('Y-m-d') . '.log';
}
public function testWriteCreatesFileWithFormattedLine(): void
{
Log::info('Привет');
$this->assertFileExists($this->logFile('action'));
$content = file_get_contents($this->logFile('action'));
$this->assertMatchesRegularExpression('/^\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\] INFO: Привет\n/', $content);
}
public function testChannelsSplitErrorsFromActions(): void
{
Log::info('действие');
Log::error('сбой');
// Действия — в action-файле, ошибки — в error-файле, не смешаны.
$action = file_get_contents($this->logFile('action'));
$error = file_get_contents($this->logFile('error'));
$this->assertStringContainsString('INFO: действие', $action);
$this->assertStringNotContainsString('сбой', $action);
$this->assertStringContainsString('ERROR: сбой', $error);
$this->assertStringNotContainsString('действие', $error);
}
public function testWarningGoesToErrorChannel(): void
{
Log::warning('предупреждение');
$this->assertFileDoesNotExist($this->logFile('action'));
$this->assertStringContainsString('WARNING: предупреждение', file_get_contents($this->logFile('error')));
}
public function testThresholdFiltersLowerLevels(): void
{
Log::$threshold = Log::WARNING;
Log::debug('низкий');
Log::info('низкий');
$this->assertFileDoesNotExist($this->logFile('action'));
Log::warning('высокий');
Log::error('высокий');
$content = file_get_contents($this->logFile('error'));
$this->assertStringContainsString('WARNING: высокий', $content);
$this->assertStringContainsString('ERROR: высокий', $content);
}
public function testShortcutsUseCorrectLevelLabel(): void
{
Log::debug('d');
Log::info('i');
Log::warning('w');
Log::error('e');
$action = file_get_contents($this->logFile('action'));
$error = file_get_contents($this->logFile('error'));
$this->assertStringContainsString('DEBUG: d', $action);
$this->assertStringContainsString('INFO: i', $action);
$this->assertStringContainsString('WARNING: w', $error);
$this->assertStringContainsString('ERROR: e', $error);
}
public function testStrtrSubstitution(): void
{
Log::info('Пользователь :id вошёл', [':id' => 42]);
$this->assertStringContainsString('Пользователь 42 вошёл', file_get_contents($this->logFile('action')));
}
public function testAppendsWithoutOverwriting(): void
{
Log::info('первая');
Log::info('вторая');
$content = file_get_contents($this->logFile('action'));
$this->assertStringContainsString('первая', $content);
$this->assertStringContainsString('вторая', $content);
$this->assertSame(2, substr_count($content, "\n"));
}
public function testRequestInfoEmptyWithoutCurrentRequest(): void
{
// Request::$current не установлен в юнит-окружении.
$this->assertSame('', Log::requestInfo());
}
public function testMaskHidesSensitiveKeys(): void
{
$method = new \ReflectionMethod(Log::class, 'mask');
$method->setAccessible(true);
$result = $method->invoke(null, [
'email' => 'a@b.com',
'password' => 'secret',
'csrf_token' => 'abc123',
'CSRF_TOKEN' => 'xyz', // регистр не важен
]);
$this->assertSame('a@b.com', $result['email']);
$this->assertSame('***', $result['password']);
$this->assertSame('***', $result['csrf_token']);
$this->assertSame('***', $result['CSRF_TOKEN']);
}
}