# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Project overview **Bicycle** — самописный PHP MVC micro-framework (PHP >= 8.2). Название отражает идиому «изобрести велосипед». ## Commands ```bash # Установка зависимостей composer install # Обновление автозагрузчика после добавления классов composer dump-autoload # Запуск всех тестов через Docker (контейнер уже должен быть запущен) docker exec bicycle vendor/bin/phpunit # Один файл / один тест docker exec bicycle vendor/bin/phpunit tests/Unit/RouteTest.php docker exec bicycle vendor/bin/phpunit --filter testControllerAndAction tests/Unit/RouteTest.php ``` Composer-скриптов нет — phpunit вызывается напрямую. Запускать тесты надо именно внутри контейнера: PHP-интерпретатор живёт в Docker, volume `/home/isaevea/http/Bicycle` → `/opt/project`. Проект запускается как веб-приложение через Apache + PHP 8.2 в контейнере `bicycle`. ## Architecture ### Directory layout ``` index.php — точка входа; константы EXT, DOCROOT, APPPATH, SYSPATH System/Classes/ — ядро фреймворка (namespace System\Classes\) System/Classes/HTTP/ — HTTP-инфраструктура: интерфейсы, заголовки, исключения System/Classes/HTTP/Client/ — исходящие HTTP-запросы (Curl, Request, Response) System/Classes/HTTP/Exception/ — HTTPException_302/403/404 App/Controller/ — контроллеры приложения (namespace App\Controller\) App/config/ — конфиги (config.php; config.local.php — в .gitignore) App/view/ — шаблоны приложения (.html файлы с PHP-кодом) App/media/ — статические ресурсы (js, css, img) Services/DataBase/, Mail/, PDF/ — пустые каталоги-заготовки под будущие сервисы (PDO / PHPMailer / dompdf); кода пока нет System/view/ — системные шаблоны (ошибки 404/500/403, exception) tools/sort_html_attrs.php — CLI-утилита сортировки HTML-атрибутов (gitignored: /tools/* в .gitignore) tests/ — PHPUnit тесты ``` > Каталоги `App/Classes/` и наполнение `Services/*` ещё не созданы — namespace-конвенции ниже описывают, *куда* класть код, когда он появится, а не существующие файлы. ### Autoload (composer.json) PSR-4 с относительными путями: - `System\` → `System/` - `App\` → `App/` - `Services\` → `Services/` - `Tests\` → `tests/` (dev) PhpStorm может показывать предупреждение «Namespace doesn't match PSR-4» — это ложное срабатывание. Отключается в `Settings → Editor → Inspections → PHP → General`. ### Namespaces - `System/Classes/*.php` → `namespace System\Classes;` - `System/Classes/HTTP/*.php` → `namespace System\Classes\HTTP;` - `System/Classes/HTTP/Client/*.php` → `namespace System\Classes\HTTP\Client;` - `System/Classes/HTTP/Exception/*.php` → `namespace System\Classes\HTTP\Exception;` - `App/Controller/*.php` → `namespace App\Controller;` - `App/Classes/*.php` → `namespace App\Classes;` - `Services/**/*.php` → `namespace Services\...;` ### Core classes | Класс | Роль | |---|---| | `System\Classes\Core` | Bootstrap, обработка ошибок, поиск файлов (`findFile`), константы среды | | `System\Classes\BaseController` | Голое ядро (abstract); жизненный цикл `executeAction()`: `before()` → экшен → `after()` | | `System\Classes\Controller` | Веб-контроллер (extends BaseController): `render()` + авто-CSRF в `before()` + `json()` | | `System\Classes\View` | Рендеринг `.html`-шаблонов через `ob_start` + `extract` + `include` | | `System\Classes\Route` | Автоматический роутинг: парсит URI, ищет контроллер в `App/Controller/` | | `System\Classes\Request` | Входящий HTTP-запрос; реализует `HTTP\Request`; `post()`, `query()`, `body()`, `isAjax()` | | `System\Classes\Response` | HTTP-ответ; `body()` — геттер/сеттер содержимого | | `System\Classes\MyException` | Кастомное исключение; зарегистрировано через `set_exception_handler` | | `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\LogReader` | Интерфейс чтения логов (`read()`, `dates()`); абстракция над хранилищем | | `System\Classes\FileLogReader` | Чтение/парсинг логов из файлов; реализация `LogReader` (позже возможен `DbLogReader`) | | `System\Classes\Validation` | Валидация входных данных по декларативным правилам; `factory()`, `rule()`, `check()`, `errors()` | | `System\Classes\CSRF` | Защита форм от CSRF; токен в сессии: `token()`, `validate()`, `field()` | | `System\Classes\HTTP` | `redirect()`, `requestHeaders()` | | `System\Classes\HTTP\Header` | Extends `ArrayObject`; `send()`, `__toString()` → RFC-формат | | `System\Classes\HTTP\Message` | Интерфейс: `protocol()`, `headers()`, `body()`, `render()` | | `System\Classes\HTTP\Request` | Интерфейс входящего запроса; константы методов GET/POST/PUT/... | | `System\Classes\HTTP\HTTPException` | HTTP-исключение; `factory(int $code)`, `getResponse(): Response` | | `System\Classes\HTTP\Exception\HTTPException_302` | Редирект через `HTTP::redirect()` | | `System\Classes\HTTP\Exception\HTTPException_403` | 403 Forbidden | | `System\Classes\HTTP\Exception\HTTPException_404` | 404 Not Found | | `System\Classes\HTTP\Client\Curl` | Исполнитель исходящих HTTP-запросов через cURL | | `System\Classes\HTTP\Client\Request` | Билдер исходящего запроса: `method()`, `header()`, `json()`, `timeout()` | | `System\Classes\HTTP\Client\Response` | Ответ внешнего запроса: `status()`, `body()`, `json()`, `isSuccess()` | ### Request lifecycle ``` index.php → Core::init() → Request::factory() # detectUri(), Route::resolve(), заполняет post/query/body/method → Request::execute() # require_once контроллера, вызывает executeAction({action}Action) → Response->body() # строка с HTML → echo ``` ### Internal Request (System\Classes\Request) Входящий запрос от браузера. Реализует `System\Classes\HTTP\Request`. - `factory()` — создаёт экземпляр, заполняет `$_GET`, `$_POST`, метод, тело, `X-Requested-With` - `$initial` — первый запрос; `$current` — текущий - `post($key?, $value?)` — getter/setter POST-данных - `query($key?, $value?)` — getter/setter GET-параметров - `body($content?)` — getter/setter тела запроса - `method($method?)` — getter/setter HTTP-метода - `isAjax()` — проверяет `X-Requested-With: XMLHttpRequest` - `detectUri()` — определяет URI через `PATH_INFO` → `REQUEST_URI` → `PHP_SELF` ### External HTTP Client (System\Classes\HTTP\Client\*) Исходящие запросы к внешним API. ```php use System\Classes\HTTP\Client\{Request as HttpRequest, Curl}; $response = (new Curl())->execute( HttpRequest::factory('https://api.example.com/data') ->method('POST') ->json(['key' => 'value']) ->timeout(10) ); if ($response->isSuccess()) { $data = $response->json(); } ``` ### Session (System\Classes\Session) Синглтон, поддерживает именованные сессии. ```php use System\Classes\Session; $session = Session::instance(); // сессия 'main' $session->set('user_id', 42); $id = $session->get('user_id'); $session->delete('user_id'); $session->regenerate(); // новый session_id, старые данные сохраняются $session->destroy(); // уничтожить сессию ``` ### Cookie (System\Classes\Cookie) Статический хелпер. ```php use System\Classes\Cookie; Cookie::set('token', 'abc123', time() + 3600); $val = Cookie::get('token'); Cookie::delete('token'); ``` Свойства по умолчанию: `$expiration=0`, `$path='/'`, `$domain=null`, `$secure=false`, `$httponly=true`, `$samesite='Lax'`. ### Config (System\Classes\Config) Lazy-load конфига из `App/config/config.php`. Если существует `App/config/config.local.php` — глубоко мержится поверх базового. ```php use System\Classes\Config; $db = Config::get('db'); // весь раздел $params = Config::get('session', 'cookie_params'); // вложенный ключ Config::set('foo', ['bar' => 'baz']); // переопределить в runtime ``` `config.local.php` исключён из git (`.gitignore`) — используется для переопределений на конкретном хосте. ### Validation (System\Classes\Validation) Валидация входных данных (обычно `Request::post()`/`query()`) по декларативным правилам. ```php use System\Classes\Validation; $validation = Validation::factory($request->post()) ->label('email', 'E-mail') ->rule('email', 'required') ->rule('email', 'email') ->rule('password', 'min_length', [8]) ->rule('password_confirm', 'matches', ['password']); if ($validation->check()) { // данные валидны } else { $errors = $validation->errors(); // [поле => сообщение] $one = $validation->error('email'); // сообщение одного поля или null } ``` Правила: `required`, `email`, `url`, `numeric`, `digit`, `min_length`, `max_length`, `exact_length`, `matches`, `in`, `regex`. Правила одного поля проверяются по порядку до первой ошибки. Пустое необязательное поле (нет `required`) остальные правила пропускает. Сообщения — шаблоны с плейсхолдерами `:field`/`:param1`/`:param2` (подстановка через `strtr`, как в `MyException`). ### CSRF (System\Classes\CSRF) Защита форм от CSRF. Токен хранится в сессии (`Session`), встраивается в форму скрытым полем и проверяется при обработке POST. **Контроллеры — наследники `Controller` — проверяют токен автоматически** в `before()` на методах `POST/PUT/PATCH/DELETE` (отключается флагом `$_csrf_protection = false`, например для API/вебхуков). В шаблоне достаточно вставить поле: ```php use System\Classes\CSRF;
``` Ручная проверка (если авто-CSRF отключён или нужен свой цикл) — через `CSRF::validate()`: ```php use System\Classes\HTTP\HTTPException; if (!CSRF::validate(Request::$current->post(CSRF::$key))) { throw HTTPException::factory(403); } ``` > Контроллер создаётся в `Request::execute()` как `new $class()` без передачи запроса — доступ к данным через статический `Request::$current`, не через `$this->request`. Рабочий пример формы (валидация поверх авто-CSRF): `App/Controller/FeedbackController.php`. - `token()` — токен текущей сессии (создаётся при первом обращении, `random_bytes(32)`). - `validate($value)` — сравнение с сессией через `hash_equals()` (устойчиво к timing-атакам). - `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/`). **Просмотр логов:** страница `/admin/logs` (`App\Controller\Admin\LogsController`) с фильтрами (дата, канал, уровень, текст). Источник — через интерфейс `LogReader` (реализация `FileLogReader` парсит файлы; позже подменяется на `DbLogReader`). Каталог берётся из `Log::directory()`. Вывод сообщений в шаблоне `App/view/Admin/Logs/logs.html` экранируется (`htmlspecialchars`) — в логах есть пользовательский ввод (XSS-вектор). ### HTTPException (System\Classes\HTTP\HTTPException) ```php use System\Classes\HTTP\HTTPException; throw HTTPException::factory(404, 'Страница :url не найдена', [':url' => $uri]); // или через getResponse() без throw: $response = HTTPException::factory(403)->getResponse(); ``` `factory(int $code)` создаёт `HTTPException_302/403/404` или базовый класс для неизвестных кодов. ### Routing (Route::resolve) Автоматический роутинг без конфигурации, поиск контроллера по файловой системе в `App/Controller/`: | URL | Контроллер | Метод | |-----|-----------|-------| | `/` | `IndexController` | `indexAction()` | | `/users` | `UsersController` | `indexAction()` | | `/users/list` | `UsersController` | `listAction()` | | `/users/list/42` | `UsersController` | `listAction()`, `param(0)=42` | | `/admin/users/edit` | `Admin/UsersController` | `editAction()` (subdir) | Поиск файлов и имён контроллеров — без учёта регистра. Если контроллер не найден, Route возвращает flat-роутинг (не теряет action). ### View path convention - Шаблон контента: `App/view/{Controller}/{template}.html` - Layout: `System/view/views/{layout}.html` - `Core::findFile($dir, $file, 'html')` ищет в `APPPATH/{dir}/{file}.html`, затем в `SYSPATH/{dir}/{file}.html` - `$_paths` инициализируется лениво при первом вызове `findFile()` - `Controller::render()` автоматически определяет `$dir` из имени класса (`App\Controller\FooController` → `view/Foo`) ### Controller hierarchy Расстановка имён — как в проекте eoffice_v3: - `System\Classes\BaseController` (abstract) — голое ядро: жизненный цикл `executeAction()` (`before()` → экшен → `after()`), хуки по умолчанию пустые. Нейтрально к вебу/API. - `System\Classes\Controller extends BaseController` — веб: `render()` (layout + content), авто-CSRF в `before()`, `json($data, $status)` для JSON-ответов, хук `layoutData()` (доп. данные в layout помимо `content`). **Контроллеры приложения наследуют его.** API-контроллер делается не отдельным классом, а флагом: `extends Controller` + `$_csrf_protection = false` + ответы через `json()` (так же, как в eoffice_v3). **Админка** (`App/Controller/Admin/`): базовый `App\Controller\Admin\AdminController extends Controller` задаёт layout `admin` (боковое меню, `System/view/views/admin.html`, Bootstrap из `/vendor/`) и наполняет `layoutData()` пунктами меню (`menu()` — пока статичный список, задел под БД). `before()` — заготовка под проверку прав (логина пока нет, **админка открыта**). Конкретные страницы наследуют `AdminController`. `Request::execute()` вызывает `executeAction({action}Action)`, поэтому `before()/after()` работают для любого контроллера прозрачно. Авторизацию добавлять в `before()` конкретных контроллеров (логина пока нет). ### Environment Среда задаётся через `SetEnv APP_ENV` в `.htaccess` (PRODUCTION / STAGING / TESTING / DEVELOPMENT). По умолчанию — `DEVELOPMENT`. Значение читается в `index.php` через `getenv('APP_ENV')`. ### Apache `ServerName localhost` добавлен в `/home/isaevea/http/docker-dev/conf/Bicycle/apache2/apache2.conf`. **Никогда не трогать** `/home/isaevea/http/docker-dev/docker/php-apache/8.2.8/Dockerfile`. ### Tests PHPUnit 11 в Docker-контейнере `bicycle`. Bootstrap: `tests/bootstrap.php`. Константы определяются в bootstrap. `Core::init()` не вызывается — `findFile()` инициализирует `$_paths` лениво. **PHPStorm**: интерпретатор PHP — Docker (`bicycle`), volume маппинг `/home/isaevea/http/Bicycle` → `/opt/project`, путь к autoload: `/opt/project/vendor/autoload.php`, конфиг: `/opt/project/phpunit.xml`. | Файл | Покрытие | |------|---------| | `tests/Unit/RouteTest.php` | `Route::resolve()`, case-insensitive, params, fallback | | `tests/Unit/ResponseTest.php` | `body()`, `status()`, fluent chain | | `tests/Unit/MyExceptionTest.php` | `strtr` замена, code, previous | | `tests/Unit/CoreTest.php` | `findFile()`, `getConst()`, environment | | `tests/Unit/RequestTest.php` | `factory()`, `post()`, `query()`, `body()`, `isAjax()`, `method()` | | `tests/Unit/ViewTest.php` | `render()`, `setFilename()`, data extract | | `tests/Unit/HttpClientRequestTest.php` | `HTTP\Client\Request` builder, json, fluent | | `tests/Unit/CookieTest.php` | `get()`, `set()`, `delete()`, defaults | | `tests/Unit/SessionTest.php` | singleton, `get/set/delete/destroy/regenerate/bind` | | `tests/Unit/ConfigTest.php` | `get()`, `set()`, `merge()`, lazy-load | | `tests/Unit/HTTPExceptionTest.php` | `factory()`, subclasses, `getResponse()`, codes | | `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()` | | `tests/Unit/FileLogReaderTest.php` | парсинг, фильтры (level/q/channel), newest-first, missing file, `dates()` | **162 теста, 270 assertion — все проходят.** ### Frontend dependencies (через Composer) Bootstrap 5.3, Bootstrap Icons 1.13, jQuery 3.7.1, jQuery UI 1.12, Select2 4.1 — из `vendor/`.