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']); + } +}