Bicycle/CLAUDE.md
Egor Isaev 4e41390544 dev
2026-06-26 12:52:41 +03:00

369 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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;
<form method="post">
<?= CSRF::field() ?>
...
</form>
```
Ручная проверка (если авто-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()` — готовый `<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/`).
**Просмотр логов:** страница `/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/`.