diff --git a/.gitignore b/.gitignore
index e467e3e..8425751 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,6 +7,8 @@
config.json
App/config/config.local.php
+/App/logs/
+
**/files/**/*
!**/files/**/
!**/files/**/.gitkeep
diff --git a/App/config/config.php b/App/config/config.php
index fe95126..6e4ec07 100644
--- a/App/config/config.php
+++ b/App/config/config.php
@@ -27,4 +27,9 @@ return [
],
],
+ 'log' => [
+ 'path' => APPPATH . '/logs',
+ 'threshold' => 'debug',
+ ],
+
];
diff --git a/CLAUDE.md b/CLAUDE.md
index 42cd871..81a0f55 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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()` — готовый `` с токеном.
- `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)
diff --git a/System/Classes/Log.php b/System/Classes/Log.php
new file mode 100644
index 0000000..fd078d0
--- /dev/null
+++ b/System/Classes/Log.php
@@ -0,0 +1,209 @@
+ 42]);
+ * Log::error('Сбой оплаты');
+ */
+class Log
+{
+ public const DEBUG = 'debug';
+ public const INFO = 'info';
+ public const WARNING = 'warning';
+ public const ERROR = 'error';
+
+ /** @var array Веса уровней для фильтрации по порогу */
+ private const WEIGHTS = [
+ self::DEBUG => 100,
+ self::INFO => 200,
+ self::WARNING => 300,
+ self::ERROR => 400,
+ ];
+
+ /** @var array Канал (файл) для каждого уровня */
+ 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;
+ }
+}
diff --git a/System/Classes/MyException.php b/System/Classes/MyException.php
index 888cb81..7e907df 100644
--- a/System/Classes/MyException.php
+++ b/System/Classes/MyException.php
@@ -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();
diff --git a/System/Classes/Request.php b/System/Classes/Request.php
index d00c4cb..9cfaeaf 100644
--- a/System/Classes/Request.php
+++ b/System/Classes/Request.php
@@ -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';
diff --git a/tests/Unit/CSRFTest.php b/tests/Unit/CSRFTest.php
new file mode 100644
index 0000000..a3f1288
--- /dev/null
+++ b/tests/Unit/CSRFTest.php
@@ -0,0 +1,66 @@
+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);
+ }
+}
diff --git a/tests/Unit/ControllerTest.php b/tests/Unit/ControllerTest.php
new file mode 100644
index 0000000..15457a9
--- /dev/null
+++ b/tests/Unit/ControllerTest.php
@@ -0,0 +1,60 @@
+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'));
+ }
+}
diff --git a/tests/Unit/LogTest.php b/tests/Unit/LogTest.php
new file mode 100644
index 0000000..ce92059
--- /dev/null
+++ b/tests/Unit/LogTest.php
@@ -0,0 +1,137 @@
+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']);
+ }
+}