Bicycle/CLAUDE.md
Egor Isaev 9e20ae6c5f dev
2026-08-07 14:01:09 +03:00

652 lines
51 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/Repositories/ — конкретные репозитории (extends System\Classes\Repository), например BudgetRepository
App/config/ — конфиги (config.php; config.local.php — в .gitignore)
App/view/ — шаблоны приложения (.html файлы с PHP-кодом)
App/media/ — статические ресурсы (js, css, img)
Services/Auth/ — авторизация: интерфейс AuthDriver + FileAuthDriver (см. раздел Auth ниже)
Services/Database.php, DataBase/ — реляционная БД (MariaDB/PDO): фабрика + Model/Classes/{PdoDriver,Statement,Profiler} (см. раздел DataBase ниже)
Services/Elasticsearch.php, Elasticsearch/ — поиск: фабрика + Client (REST, см. раздел Elasticsearch ниже)
Services/Mongo.php, Mongo/ — документная БД: фабрика + MongoDriver (см. раздел Mongo ниже)
Services/Mail/, PDF/ — пустые каталоги-заготовки под будущие сервисы (PHPMailer / dompdf); кода пока нет
System/view/ — системные шаблоны (ошибки 404/500/403, exception)
tools/sort_html_attrs.php — CLI-утилита сортировки HTML-атрибутов (gitignored: /tools/* в .gitignore)
tests/ — PHPUnit тесты
```
> Каталог `App/Classes/` и часть `Services/*` (Mail, PDF) ещё не созданы — 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()`/`renderContent()` + авто-CSRF в `before()` + `json()` + заголовок `X-Profiler` в `after()` для admin |
| `System\Classes\View` | Рендеринг `.html`-шаблонов через `ob_start` + `extract` + `include`; `setStyle()`/`setScript()`/`setManifest()`+`getStyles()`/`getScripts()` — подключение CSS/JS из шаблона |
| `System\Classes\ProfilerToolbar` | Debug-панель внизу страницы (аналог ProfilerToolbar для Kohana): время/память/SQL из `Profiler`; `render()` — пусто, если пользователь не admin |
| `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()` |
| `Services\Auth` | Точка входа в авторизацию: `instance(?string $driver = null)`, кэш по драйверу |
| `Services\Auth\AuthDriver` | Интерфейс: `login()`, `logout()`, `loggedIn()`, `getUser()`, `checkPassword()` |
| `Services\Auth\FileAuthDriver` | Авторизация по файлу пользователей (по умолчанию); позже — БД/LDAP/Keycloak |
| `Services\Database` | Точка входа в реляционную БД (MariaDB): `instance(?string $name = null): PdoDriver`, кэш по имени подключения |
| `Services\DataBase\Classes\PdoDriver` | Обёртка над `PDO`: `query()`/`prepare()`/`exec()`, failover по `hosts`, транзакции с вложенными SAVEPOINT (`transaction()`) |
| `Services\DataBase\Classes\Statement` | `extends PDOStatement` (через `PDO::ATTR_STATEMENT_CLASS`); `showQuery()`/`sq()` — SQL с подставленными параметрами, для дебага; `execute()` также профилирует в `Profiler` |
| `Services\DataBase\Classes\Profiler` | Статический накопитель таймингов запросов: `log()`, `entries()`, `totalTime()`, `count()`, `reset()` |
| `Services\DataBase\Model` | `#[AllowDynamicProperties]`, лёгкий пассивный носитель данных строки с настоящими (не спрятанными) свойствами — под `PDO::FETCH_CLASS`; `toArray()` |
| `System\Classes\Repository` | `@template T`, abstract CRUD поверх таблицы: `get()/getItemWhere()/getList()/create()/update()/delete()/deleteWhere()`, `beginTransaction()/commit()/rollBack()/transaction()` (делегируют в `PdoDriver`); конкретные — в `App\Repositories\*` |
| `Services\Elasticsearch` | Точка входа в Elasticsearch: `instance(?string $name = null): Client`, кэш по имени подключения |
| `Services\Elasticsearch\Client` | REST-обёртка на `HTTP\Client\Curl` (без composer-зависимости `elasticsearch/elasticsearch`): `index()/get()/search()/delete()/exists()/createIndex()/deleteIndex()/ping()` |
| `Services\Mongo` | Точка входа в MongoDB: `instance(?string $name = null): MongoDriver`, кэш по имени подключения |
| `Services\Mongo\MongoDriver` | Обёртка над `MongoDB\Client` (пакет `mongodb/mongodb`, требует `ext-mongodb`): `client()`, `database()`, `collection()` |
### 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`).
### Auth (Services\Auth)
Авторизация через сменный драйвер (`Services\Auth\AuthDriver`), как в Kohana Auth: по умолчанию
`FileAuthDriver` (логины/пароли из PHP-файла), позже — БД/LDAP/Keycloak как новые классы
`implements AuthDriver` без изменения остального кода (тот же принцип, что и `LogReader`/`FileLogReader`).
- `Services\Auth::instance(?string $name = null)` — драйвер по имени (кэшируется); без аргумента —
драйвер из `Config::get('auth', 'driver')` (по умолчанию `'file'`).
- `AuthDriver::login($login, $password)` — проверяет и, если верно, авторизует (пишет в `Session`).
- `AuthDriver::logout()` / `loggedIn()` / `getUser()` (без пароля) / `checkPassword($password)`
(сверка пароля с текущим авторизованным пользователем, например перед сменой настроек).
**Файл пользователей** — `App/config/auth_users.php` (в `.gitignore`, как `config.local.php`):
```php
return [
'admin' => [
'password' => '<password_hash(...)>',
'full_name' => 'ФИО',
'email' => '...',
'role' => 'admin', // 'admin' | 'manager' | 'user' | ... — своё для каждого проекта
],
];
```
Путь берётся из `Config::get('auth', 'users_file')`. Поля, кроме `password`, произвольные —
`AuthDriver::getUser()` отдаёт их как есть (без `password`); `role` — единственное поле, которое
понимает framework-код (`Controller::$_auth_roles`), остальное (`full_name`, `email`, …) — просто
проброс для шаблонов/логов.
**Контроллеры** включают проверку флагом `$_auth_protection = true` (по умолчанию `false`,
как `$_csrf_protection`, но с обратной полярностью — авторизация не обязательна по умолчанию).
`$_auth_driver` — имя конкретного драйвера, если контроллеру нужен не дефолтный (`null` = дефолт
из конфига). `$_auth_roles` — список разрешённых ролей (`['admin']`, `['admin', 'manager']`);
пустой массив (по умолчанию) — любой авторизованный, без проверки роли.
Проверка — в `Controller::before()`: нет авторизации → редирект на `/login` (**не** `throw`:
неперехваченные исключения уходят в `MyException::handler()`, который не вызывает `getResponse()`,
поэтому для 302 вызывается `HTTPException::factory(302, url)->getResponse()` напрямую — она сама
делает `Location` + `exit`); авторизован, но роль не подходит → `throw HTTPException::factory(403)`.
`App\Controller\Admin\AdminController` включает `$_auth_protection = true` и `$_auth_roles = ['admin']`
для всей админки.
**Вход/выход** — `App/Controller/LoginController.php` (`/login` — форма и обработка,
`/login/logout`), шаблон `App/view/Login/login.html`. Меню сайта (`Controller::menu()`) показывает
«Войти» либо «Выйти (логин)» в зависимости от `Auth::instance()->getUser()`; пункт «Админка» виден
только при `role === 'admin'`.
### DataBase (Services\Database, System\Classes\Repository)
Реляционная БД (MariaDB) через PDO. Конфиг — `Config::get('db', $name)`, ключ `$name` — это имя
*подключения* (не тип драйвера — драйвер сейчас всегда `'pdo'`), по умолчанию `'default'`:
```php
use Services\Database;
$driver = Database::instance(); // Config::get('db', 'default')
$stmt = $driver->query('SELECT * FROM users WHERE id = ?', [42]);
$row = $stmt->fetch();
echo $stmt->sq(); // SQL с подставленными параметрами — для дебага (Statement::showQuery())
```
Подключение — ленивое (первое обращение к `pdo()`/`query()`), с failover: `connection.hosts`
(массив) перебирается по порядку до первого успешного, иначе — `MyException` со списком ошибок
по каждому хосту. Транзакции на уровне `PdoDriver` — вложенные через SAVEPOINT
(`beginTransaction()`/`commit()`/`rollback()` считают уровень вложенности сами), либо через обёртку:
```php
$driver->transaction(function ($driver) {
$driver->query('UPDATE accounts SET balance = balance - ? WHERE id = ?', [100, 1]);
$driver->query('UPDATE accounts SET balance = balance + ? WHERE id = ?', [100, 2]);
});
// исключение внутри колбэка → rollback (или ROLLBACK TO SAVEPOINT на вложенном уровне) + повторный throw
```
Тайминги запросов — `Services\DataBase\Classes\Profiler::entries()`/`totalTime()` (наполняется
автоматически из `Statement::execute()` — единой точки для любого выполнения запроса, в т.ч.
ручного `$driver->prepare()->execute()` в обход `query()`, как делает большинство методов `Repository`).
**Repository** — `System\Classes\Repository` (`@template T of object`, abstract CRUD), конкретные — в
`App/Repositories/*`. Таблица и класс строки задаются через конструктор, не через переопределение
свойства:
```php
namespace App\Repositories;
use System\Classes\Repository;
class BudgetRepository extends Repository
{
public function __construct(?string $connection = null)
{
parent::__construct('budget', connection: $connection); // + class-string<T> вторым аргументом, по умолчанию Model::class
}
}
```
```php
$repo = new BudgetRepository(); // подключение — Database::instance()
$repo->get(1); // T|false, через fetchObject()
$repo->getItemWhere("status = 'open'"); // T|false|null, произвольное условие (без плейсхолдеров)
$repo->getList('*', ['status' => 'open']); // T[]|false; $where — 'col' => 'value' (или 'IS NULL'/'IS NOT NULL')
$repo->create(['title' => 'Q3', 'amount' => 1000]); // lastInsertId
$repo->update(['id' => 1, 'amount' => 1200]); // primary_col обязателен в $data
$repo->delete(1);
$repo->deleteWhere('status = ?', ['closed']);
$repo->transaction(fn () => /* несколько операций одной транзакцией */ null); // begin/commit/rollBack — делегируют в PdoDriver (там же и SAVEPOINT-логика)
```
`public string $primary_col = 'id'` и `public bool $is_auto_increment = true` — переопределяются в
наследнике при необходимости. `processData()` (protected) — общая сборка bind-параметров для
`create()`/`update()` с автоопределением `PDO::PARAM_*` по типу значения (массив кодируется в JSON).
`getList()` по умолчанию — `$fetch = PDO::FETCH_CLASS`, и в этом случае в `fetchAll()` явно передаётся
`$this->obj_class` (не generic `stdClass`) — так же, как в `get()`/`getItemWhere()`. Это сознательное
отличие от паттерна, с которого портировали этот класс (`eoffice_v3/System/Classes/Repository.php`):
там по умолчанию `PDO::FETCH_KEY_PAIR` (падает, если `$cols` — не ровно 2 колонки — плохой дефолт
для общего метода) и `FETCH_CLASS` не прокидывает `obj_class` в `fetchAll()` вовсе (реальная
нестыковка между PHPDoc и кодом в оригинале). Транзакции по той же причине не портированы
один-в-один: там свой счётчик вложенности в каждом `Repository`, что ломается при двух репозиториях
на одном соединении (оба думают, что они «внешние», оба зовут `PDO::beginTransaction()` — исключение);
в Bicycle транзакции делегируются в `PdoDriver`, у которого счётчик один на всё подключение.
`Model` (`Services\DataBase\Model`, `#[AllowDynamicProperties]`) — лёгкий пассивный носитель данных,
это класс по умолчанию для `T` в `Repository`. Свойства настоящие (не спрятаны за внутренним массивом) —
это принципиально: `PDO::FETCH_CLASS`/`fetchObject()` выставляет их напрямую, **минуя `__set()`**
(внутренний механизм PDO, до вызова конструктора), поэтому попытка перехватить запись через `__set()`
в скрытый массив не сработает — `toArray()` вернул бы пусто. Не ActiveRecord — чтение/запись в БД
делает `Repository`.
### Elasticsearch (Services\Elasticsearch)
Тонкая REST-обёртка на `System\Classes\HTTP\Client\Curl` — без тяжёлой composer-зависимости
`elasticsearch/elasticsearch`. Конфиг — `Config::get('elasticsearch', $name)` (`base_url`, `timeout`,
опционально `username`/`password` для Basic Auth).
```php
use Services\Elasticsearch;
$client = Elasticsearch::instance();
$client->index('logs', ['level' => 'error', 'message' => 'boom'], 'log-1');
$client->get('logs', 'log-1');
$client->search('logs', ['query' => ['match' => ['message' => 'boom']]]);
$client->delete('logs', 'log-1');
$client->ping(); // false вместо исключения, если кластер недоступен — удобно для graceful skip
```
Контейнер `elasticsearch` в текущей dev-инфраструктуре не поднят (см. `docker-dev/docker-compose.yml`) —
`ElasticsearchClientTest` пропускает тесты через `markTestSkipped()`, если `ping()` вернул `false`;
оживает сам, без правки кода, как только контейнер появится.
### Mongo (Services\Mongo)
Обёртка над `MongoDB\Client` (пакет `mongodb/mongodb`, уже в `composer.json`). **Требует PHP-расширение
`ext-mongodb`**, которого пока нет в `docker/php-apache/8.2.8/Dockerfile` (общий для нескольких проектов —
трогать его нельзя, см. раздел Apache; расширение добавляет пользователь сам). Код безопасен без
расширения — падает только при реальном `new MongoDB\Client()` внутри `MongoDriver::__construct()`,
не при простом подключении файла/автозагрузке, поэтому `Mongo::instance()` не вызывать эагерно из
bootstrap. Конфиг — `Config::get('mongo', $name)` (`uri`, `database`):
```php
use Services\Mongo;
$driver = Mongo::instance();
$driver->collection('logs')->insertOne(['level' => 'error']);
$driver->database()->listCollections();
```
`MongoDriverTest` пропускает тесты через `markTestSkipped()`, если `!extension_loaded('mongodb')` —
оживает сам, без правки кода, как только пользователь добавит расширение и поднимет контейнер `mongo`.
### ProfilerToolbar (System\Classes\ProfilerToolbar)
Debug-панель внизу страницы (аналог [ProfilerToolbar для Kohana](https://github.com/Alert/profilertoolbar)) —
портирована не один-в-один: FireBug-вывод (`firebug()`/FirePHP) и подсветка исходников (`debugSource()`/
GeSHi) из оригинала не переносились — устаревшие технологии, не нужны здесь. Вкладки:
- **SQL** — из `Services\DataBase\Classes\Profiler` (см. раздел DataBase выше): время, текст запроса, параметры,
`EXPLAIN` для SELECT-запросов (прогоняется заново через `Database::instance()` в момент рендера панели,
не при исполнении самого запроса; ошибка `EXPLAIN` → `—`, не ломает панель). Сам `EXPLAIN` выполняется
с `Statement::$skip_profiling = true` — иначе попал бы в тот же `Profiler` и засорял бы свой же счётчик.
- **Vars** — `GET`/`POST`/`COOKIE`/`SESSION`/`SERVER`; чувствительные ключи (`Log::$mask_keys`:
`password`, `pass`, `csrf_token`, `token`) маскируются `***` — та же маска, что и в `Log`.
- **Files** — все подключённые к запросу файлы (`get_included_files()`) с размером и общим итогом.
- **Route** — текущие `uri`/`method`/`controller`/`action`/`params` из `Request::$current`.
- **Custom** — появляется, только если код вызвал `ProfilerToolbar::addData($data, $tab = 'custom')`
(аналог `addData()` в оригинале) — свободная вкладка для точечной отладки из любого места кода.
Подключена прямо в `App/view/layout.html` (`<?= \System\Classes\ProfilerToolbar::render() ?>` перед
`</body>`) — рендерится для любой страницы сайта, но `render()` отдаёт пустую строку всем, кроме
`role === 'admin'` (проверка через `Auth::instance()->getUser()`, как и остальные ролевые проверки
в проекте). Верхняя строка сворачивает/разворачивает панель, кнопки переключают вкладки — инлайн
`onclick` + один `<script>`/`<style>` внутри самого `render()`, без внешних файлов — панель самодостаточна.
**Ajax/API-ответы панель не видят** — `renderContent()`/`json()` не проходят через `layout.html`, где она
подключена. Вместо HTML-панели `Controller::after()` (см. таблицу Core classes) для admin ставит заголовок
`X-Profiler` — JSON с полной картиной запроса (`time_ms`, `memory_mb`, `sql_count`, `sql_time_ms`, список
`sql` с временем каждого) — виден в devtools → Network → заголовки ответа для любого запроса, включая ajax;
тело ответа не трогается. `EXPLAIN` панели (см. выше) не участвует — `skip_profiling`.
**На странице ошибки панель тоже есть** — `System/view/exception/error.html` (см. `MyException::handler()`,
показывается только в DEVELOPMENT) заканчивается тем же `<?= \System\Classes\ProfilerToolbar::render() ?>`,
что и `layout.html` — видно, какие SQL-запросы успели выполниться до падения. Аналог `ProfilerToolbar::render(true)`
в конце `views/kohana/error.php` у оригинала; в отличие от оригинала — без своей подсветки исходников через
GeSHi и без разбора стека вручную, это уже даёт `$xdebug_message` (готовая таблица от Xdebug: файл/строка/
память/время по каждому кадру), если расширение включено.
**Панель полностью самодостаточна по стилям — общий сброс `#profiler-toolbar, #profiler-toolbar * {...}`**
(font-family/font-size/color/margin/padding и т.д. явно на каждом потомке), не полагаясь на наследование.
Понадобилось из-за двух независимых багов, которые проявлялись по-разному на разных страницах:
- на странице ошибки нет `<!DOCTYPE html>` (`error.html` рендерится как голый фрагмент, без layout) —
браузер в quirks mode, где `<table>` в некоторых движках не наследует `color` от предков → текст в
таблицах панели становился чёрным на тёмном фоне именно там;
- на обычных страницах (`layout.html`) подключён Bootstrap, который стилизует голые теги без класса —
`code {color:#d63384}` (розовый), `h4 {font-size:1.5rem}` (крупный) — эти правила побеждали
наследование от `#profiler-toolbar`, потому что *явное* правило на самом элементе всегда сильнее
унаследованного значения, независимо от специфичности правила у предка.
Розовый акцент для `<code>` (SQL/пути к файлам/ключи) — теперь свой, явный (`#profiler-toolbar code {color:#ff79c6}`),
не случайно унаследованный от Bootstrap — поэтому одинаково выглядит что на обычной странице, что на странице ошибки.
### 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(...);
```
- Вкл/выкл целиком — `Log::$enabled` → `Config::get('log','enabled')` → `true` по умолчанию;
`false` — `write()` ничего не пишет ни в один файл, независимо от уровня/порога.
- Уровни: `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
user=login params=… query=… post=…`) из `Request::$current`; `user` — логин из `Auth::provider()->user()`
(`'guest'`, если не авторизован); чувствительные ключи (`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: `App/view/{layout}.html` — единый шаблон на весь сайт (app-специфичный — не в `System`, там только нейтральные fallback-страницы вроде `errors/`, `exception/`)
- `Core::findFile($dir, $file, 'html')` ищет в `APPPATH/{dir}/{file}.html`, затем в `SYSPATH/{dir}/{file}.html`
- `$_paths` инициализируется лениво при первом вызове `findFile()`
- `Controller::render()` автоматически определяет `$dir` из имени класса (`App\Controller\FooController` → `view/Foo`)
### Собственные CSS/JS во view (System\Classes\View)
Файлы — в `App/media/css/` и `App/media/js/`. Content-шаблон подключает свои прямо в себе:
```php
<?php
$this->setStyle('about.css');
$this->setScript('about.js');
?>
```
`layout.html` выводит накопленное — `<?php $this->getStyles(); ?>` в `<head>`, `<?php $this->getScripts(); ?>`
перед `</body>` (есть также `setManifest()` для PWA-манифеста, выводится в `getStyles()`). `$this` внутри
шаблона — это объект `View`, который его рендерит (шаблон — `include` внутри `View::render()`), поэтому
методы вызываются как `$this->...`, как и `CSRF`/`View`-хелперы в других шаблонах.
Хранилище (`$_styles`/`$_scripts`/`$_manifest`) — статическое, общее на весь запрос: content и layout —
разные объекты `View` (content рендерится первым внутри `Controller::render()`, до создания layout-View),
но регистрация в content должна быть видна при выводе в layout. Файл ищется через `Core::findFile()`
(`APPPATH` → `SYSPATH`) — если не найден, в месте вызова `setStyle()`/`setScript()`/`setManifest()`
выводится `<div class="alert alert-danger">`.
### Controller hierarchy
Расстановка имён — как в проекте eoffice_v3:
- `System\Classes\BaseController` (abstract) — голое ядро: жизненный цикл `executeAction()` (`before()` → экшен → `after()`), хуки по умолчанию пустые. Нейтрально к вебу/API.
- `System\Classes\Controller extends BaseController` — веб: `render()` (layout + верхнее/боковое меню + content) и `renderContent()` (только content, без layout и меню — для ajax-фрагментов), авто-CSRF в `before()`, `json($data, $status)` для JSON-ответов. Меню — отдельные хуки: `menuTop()`/`menuSide()` отдают пункты (массив `[['title','url','active'], ...]`), `renderMenuTop()`/`renderMenuSide()` рендерят их через `App/view/menu_top.html`/`menu_side.html` в готовый HTML *до* передачи в layout (`renderMenuSide()` — пустая строка, если пунктов нет). Оба хука — общие для всего сайта (не переопределяются в конкретных контроллерах), активный пункт — по текущему URI, видны на любой странице любому пользователю независимо от роли/авторизации (сейчас в `menuSide()` заглушка — «Главная», «О нас», «Новости»; для последних двух заведены `AboutController`/`NewsController`). Layout — `App/view/layout.html`: верхнее меню всегда, боковое — колонка `aside` (Bootstrap grid) появляется, только если `$menu_side` не пуст; иначе `content` на всю ширину `<main>`. Никакой проверки `isAjax()` нет — экшен сам решает, вызывать `render()` или `renderContent()`. CSS/JS — Bootstrap, Bootstrap Icons, jQuery из `/vendor/`. Единый layout для всего сайта, включая админку. **Контроллеры приложения наследуют `Controller` напрямую.**
API-контроллер делается не отдельным классом, а флагом: `extends Controller` + `$_csrf_protection = false` + ответы через `json()` (так же, как в eoffice_v3).
**Админка** (`App/Controller/Admin/`): базовый `App\Controller\Admin\AdminController extends Controller` своего layout и меню не задаёт — рендерится в том же `App/view/layout.html`, что и весь сайт, с тем же общим `Controller::menuSide()` (не связан с админкой — заглушка на весь сайт). Включает `$_auth_protection = true` — доступ только авторизованным (см. [Auth](#auth-servicesauth)), неавторизованный редиректится на `/login`. Конкретные страницы (`LogsController` и т.п.) наследуют `AdminController`.
`Request::execute()` вызывает `executeAction({action}Action)`, поэтому `before()/after()` работают для любого контроллера прозрачно. Авторизация конкретного контроллера включается/выключается флагом `$_auth_protection` (см. раздел Auth), а не переопределением `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, `setStyle()`/`setScript()`/`setManifest()` + `getStyles()`/`getScripts()` |
| `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/ControllerAfterTest.php` | `after()` веб-`Controller`: заголовок `X-Profiler` только для admin, содержит SQL-сводку — через `xdebug_get_headers()`, иначе `markTestSkipped()` |
| `tests/Unit/LogTest.php` | уровни/порог, `enabled` (выкл — ничего не пишет), каналы (action/error), формат, append, `strtr`, маскировка, `requestInfo()` |
| `tests/Unit/FileLogReaderTest.php` | парсинг, фильтры (level/q/channel), newest-first, missing file, `dates()` |
| `tests/Unit/FileAuthDriverTest.php` | `login()` верно/неверно/неизвестный логин, `loggedIn()`, `getUser()` без пароля, `logout()`, `checkPassword()`, отсутствие файла |
| `tests/Unit/AuthTest.php` | `instance()` драйвер по умолчанию/явный, кэширование по драйверу, неизвестный драйвер → исключение |
| `tests/Unit/DatabaseTest.php` | `instance()` кэш по имени подключения, неизвестное подключение/драйвер → исключение |
| `tests/Unit/PdoDriverTest.php` | failover (не требует БД), `query()`, транзакции/вложенные SAVEPOINT, `transaction()`, профилирование ручного `prepare()+execute()` — требует живую MariaDB, иначе `markTestSkipped()` |
| `tests/Unit/StatementTest.php` | `showQuery()`/`sq()` — подстановка позиционных/именованных параметров — требует живую MariaDB |
| `tests/Unit/ProfilerTest.php` | `log()/entries()/totalTime()/count()/reset()` |
| `tests/Unit/RepositoryTest.php` | `get()/getItemWhere()/getList()` (в т.ч. дефолтный `FETCH_CLASS` + `obj_class`)/`create()/update()/delete()/deleteWhere()`, `transaction()` с rollback — требует живую MariaDB |
| `tests/Unit/ModelTest.php` | `__get/__set/__isset/toArray()`, `PDO::FETCH_CLASS`/`fetchObject()` выставляет настоящие свойства (не через `__set()`) |
| `tests/Unit/ElasticsearchTest.php` | `instance()` кэш по имени подключения, неизвестное подключение → исключение |
| `tests/Unit/ElasticsearchClientTest.php` | `index()/get()/delete()/exists()` — требует живой Elasticsearch, иначе `markTestSkipped()` через `ping()` |
| `tests/Unit/MongoTest.php` | неизвестное подключение → исключение (без ext-mongodb — остальное см. MongoDriverTest) |
| `tests/Unit/MongoDriverTest.php` | `instance()`, `collection()`, `database()` — требует `ext-mongodb`, иначе `markTestSkipped()` |
| `tests/Unit/ProfilerToolbarTest.php` | `render()` пусто для гостя/не-admin, панель с временем/памятью/SQL для admin, вкладки Vars/Files/Route, маскировка чувствительных ключей, вкладка Custom только при `addData()`, `EXPLAIN` для реального SELECT / пропуск для не-SELECT |
**248 тестов, 386 assertion — все проходят (8 skipped: Elasticsearch/Mongo без живой инфраструктуры — MariaDB подключена и все её тесты реально проходят, см. разделы DataBase/Elasticsearch/Mongo выше).**
### Frontend dependencies (через Composer)
Bootstrap 5.3, Bootstrap Icons 1.13, jQuery 3.7.1, jQuery UI 1.12, Select2 4.1 — из `vendor/`.
## Дополнительно
Узкие темы (конвенции именования, правила работы с Claude Code, roadmap и т.п.) — не здесь, а в
`.claude/memory/` (по одному файлу на тему, индекс — `.claude/memory/MEMORY.md`). Этот файл — только
общая архитектура и команды.