This commit is contained in:
Egor Isaev 2026-06-23 16:30:58 +03:00
parent e6e8844f2b
commit b3c8188ee3
8 changed files with 318 additions and 8 deletions

View File

@ -0,0 +1,52 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description FeedbackController.php
* @copyright (c) 23/06/2026
*/
namespace App\Controller;
use System\Classes\Controller;
use System\Classes\Request;
use System\Classes\Validation;
use System\Classes\HTTP\Request as HTTPRequest;
/**
* Демонстрация формы с валидацией.
* CSRF проверяется автоматически в Controller::before().
* Один экшен и показывает форму (GET), и обрабатывает её (POST).
*/
class FeedbackController extends Controller
{
/**
* @return string
* @throws \System\Classes\MyException
*/
public function indexAction(): string
{
$request = Request::$current;
$errors = [];
if ($request->method() === HTTPRequest::POST) {
// CSRF уже проверен в BaseController::before(). Здесь — только данные.
$validation = Validation::factory($request->post())
->label('email', 'E-mail')
->label('message', 'Сообщение')
->rule('email', 'required')
->rule('email', 'email')
->rule('message', 'required')
->rule('message', 'min_length', [10]);
if ($validation->check()) {
// Данные чистые — здесь было бы сохранение/отправка письма.
return $this->render('feedback_ok', ['email' => $request->post('email')]);
}
$errors = $validation->errors();
}
return $this->render('feedback', ['errors' => $errors]);
}
}

View File

@ -0,0 +1,37 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description feedback.html
* @copyright (c) 23/06/2026
*
* @var array $errors Ошибки валидации [поле => сообщение]
*/
use System\Classes\CSRF;
?>
<form method="post" action="/feedback">
<?= CSRF::field() ?>
<p>
<label>E-mail<br>
<input type="email" name="email">
</label>
<?php if (isset($errors['email'])): ?>
<span style="color:red"><?= $errors['email'] ?></span>
<?php endif; ?>
</p>
<p>
<label>Сообщение<br>
<textarea name="message" rows="4"></textarea>
</label>
<?php if (isset($errors['message'])): ?>
<span style="color:red"><?= $errors['message'] ?></span>
<?php endif; ?>
</p>
<button type="submit">Отправить</button>
</form>

View File

@ -0,0 +1,13 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description feedback_ok.html
* @copyright (c) 23/06/2026
*
* @var string $email Адрес, с которого пришло сообщение
*/
?>
<p>Спасибо! Сообщение от <?= htmlspecialchars($email, ENT_QUOTES, 'UTF-8') ?> принято.</p>

View File

@ -74,7 +74,8 @@ PhpStorm может показывать предупреждение «Namespac
| Класс | Роль | | Класс | Роль |
|---|---| |---|---|
| `System\Classes\Core` | Bootstrap, обработка ошибок, поиск файлов (`findFile`), константы среды | | `System\Classes\Core` | Bootstrap, обработка ошибок, поиск файлов (`findFile`), константы среды |
| `System\Classes\Controller` | Базовый контроллер; `render()` строит двухуровневый вывод: layout + content | | `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\View` | Рендеринг `.html`-шаблонов через `ob_start` + `extract` + `include` |
| `System\Classes\Route` | Автоматический роутинг: парсит URI, ищет контроллер в `App/Controller/` | | `System\Classes\Route` | Автоматический роутинг: парсит URI, ищет контроллер в `App/Controller/` |
| `System\Classes\Request` | Входящий HTTP-запрос; реализует `HTTP\Request`; `post()`, `query()`, `body()`, `isAjax()` | | `System\Classes\Request` | Входящий HTTP-запрос; реализует `HTTP\Request`; `post()`, `query()`, `body()`, `isAjax()` |
@ -84,6 +85,7 @@ PhpStorm может показывать предупреждение «Namespac
| `System\Classes\Session` | Синглтон сессии: `instance(string $name)`, `get/set/delete/destroy/regenerate` | | `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\Config` | Статический конфиг: lazy-load `App/config/config.php` + merge `config.local.php` |
| `System\Classes\Validation` | Валидация входных данных по декларативным правилам; `factory()`, `rule()`, `check()`, `errors()` | | `System\Classes\Validation` | Валидация входных данных по декларативным правилам; `factory()`, `rule()`, `check()`, `errors()` |
| `System\Classes\CSRF` | Защита форм от CSRF; токен в сессии: `token()`, `validate()`, `field()` |
| `System\Classes\HTTP` | `redirect()`, `requestHeaders()` | | `System\Classes\HTTP` | `redirect()`, `requestHeaders()` |
| `System\Classes\HTTP\Header` | Extends `ArrayObject`; `send()`, `__toString()` → RFC-формат | | `System\Classes\HTTP\Header` | Extends `ArrayObject`; `send()`, `__toString()` → RFC-формат |
| `System\Classes\HTTP\Message` | Интерфейс: `protocol()`, `headers()`, `body()`, `render()` | | `System\Classes\HTTP\Message` | Интерфейс: `protocol()`, `headers()`, `body()`, `render()` |
@ -102,7 +104,7 @@ PhpStorm может показывать предупреждение «Namespac
index.php index.php
→ Core::init() → Core::init()
→ Request::factory() # detectUri(), Route::resolve(), заполняет post/query/body/method → Request::factory() # detectUri(), Route::resolve(), заполняет post/query/body/method
→ Request::execute() # require_once контроллера, вызывает {action}Action() → Request::execute() # require_once контроллера, вызывает executeAction({action}Action)
→ Response->body() # строка с HTML → Response->body() # строка с HTML
→ echo → echo
``` ```
@ -206,6 +208,38 @@ if ($validation->check()) {
Правила: `required`, `email`, `url`, `numeric`, `digit`, `min_length`, `max_length`, `exact_length`, `matches`, `in`, `regex`. Правила одного поля проверяются по порядку до первой ошибки. Пустое необязательное поле (нет `required`) остальные правила пропускает. Сообщения — шаблоны с плейсхолдерами `:field`/`:param1`/`:param2` (подстановка через `strtr`, как в `MyException`). Правила: `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`).
### HTTPException (System\Classes\HTTP\HTTPException) ### HTTPException (System\Classes\HTTP\HTTPException)
```php ```php
@ -240,6 +274,17 @@ $response = HTTPException::factory(403)->getResponse();
- `$_paths` инициализируется лениво при первом вызове `findFile()` - `$_paths` инициализируется лениво при первом вызове `findFile()`
- `Controller::render()` автоматически определяет `$dir` из имени класса (`App\Controller\FooController` → `view/Foo`) - `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-ответов. **Контроллеры приложения наследуют его.**
API-контроллер делается не отдельным классом, а флагом: `extends Controller` + `$_csrf_protection = false` + ответы через `json()` (так же, как в eoffice_v3).
`Request::execute()` вызывает `executeAction({action}Action)`, поэтому `before()/after()` работают для любого контроллера прозрачно. Авторизацию добавлять в `before()` конкретных контроллеров (логина пока нет).
### Environment ### Environment
Среда задаётся через `SetEnv APP_ENV` в `.htaccess` (PRODUCTION / STAGING / TESTING / DEVELOPMENT). Среда задаётся через `SetEnv APP_ENV` в `.htaccess` (PRODUCTION / STAGING / TESTING / DEVELOPMENT).
@ -271,8 +316,10 @@ PHPUnit 11 в Docker-контейнере `bicycle`. Bootstrap: `tests/bootstrap
| `tests/Unit/ConfigTest.php` | `get()`, `set()`, `merge()`, lazy-load | | `tests/Unit/ConfigTest.php` | `get()`, `set()`, `merge()`, lazy-load |
| `tests/Unit/HTTPExceptionTest.php` | `factory()`, subclasses, `getResponse()`, codes | | `tests/Unit/HTTPExceptionTest.php` | `factory()`, subclasses, `getResponse()`, codes |
| `tests/Unit/ValidationTest.php` | правила, `matches`, пропуск пустых, first-error, label/плейсхолдеры, fluent | | `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 хуки |
**135 тестов, 215 assertion — все проходят.** **145 тестов, 229 assertion — все проходят.**
### Frontend dependencies (через Composer) ### Frontend dependencies (через Composer)

View File

@ -0,0 +1,46 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description BaseController.php
* @copyright (c) 23/06/2026
*/
namespace System\Classes;
/**
* Голое ядро контроллера жизненный цикл экшена: before() {action} after().
* Нейтрально к вебу/API. Наследник Controller добавляет рендеринг и политику.
*/
abstract class BaseController
{
/**
* Хук перед экшеном. Переопределяется наследниками
* (проверка CSRF, авторизация и т.п.).
*
* @return void
*/
protected function before(): void {}
/**
* Хук после экшена.
*
* @return void
*/
protected function after(): void {}
/**
* Выполняет экшен в обёртке before()/after().
*
* @param string $action Имя метода экшена (например 'indexAction')
* @return string Тело ответа
*/
public function executeAction(string $action): string
{
$this->before();
$body = $this->$action();
$this->after();
return $body;
}
}

69
System/Classes/CSRF.php Normal file
View File

@ -0,0 +1,69 @@
<?php
/**
* @package Bicycle
* @author Egor Isaev
* @description CSRF.php
* @copyright (c) 23/06/2026
*/
namespace System\Classes;
/**
* Защита форм от CSRF. Токен хранится в сессии, встраивается в форму
* скрытым полем и проверяется при обработке запроса.
*
* <form method="post">
* <?= CSRF::field() ?>
* ...
* </form>
*
* if (!CSRF::validate($request->post('csrf_token'))) {
* throw HTTPException::factory(403);
* }
*/
class CSRF
{
/** @var string Имя поля/ключа сессии для токена */
public static string $key = 'csrf_token';
/**
* Возвращает токен текущей сессии, создавая его при первом обращении.
*
* @return string
*/
public static function token(): string
{
$session = Session::instance();
$token = $session->get(self::$key);
if (!$token) {
$token = bin2hex(random_bytes(32));
$session->set(self::$key, $token);
}
return $token;
}
/**
* Проверяет присланный токен против токена из сессии.
*
* @param string|null $value Значение из Request::post(CSRF::$key)
* @return bool
*/
public static function validate(?string $value): bool
{
$token = Session::instance()->get(self::$key);
return is_string($value) && is_string($token) && hash_equals($token, $value);
}
/**
* Возвращает готовое скрытое поле с токеном для вставки в форму.
*
* @return string
*/
public static function field(): string
{
return '<input type="hidden" name="' . self::$key . '" value="' . self::token() . '">';
}
}

View File

@ -8,14 +8,43 @@
namespace System\Classes; namespace System\Classes;
use System\Classes\HTTP\HTTPException;
use System\Classes\HTTP\Request as HTTPRequest;
/** /**
* Базовый контроллер. render() строит двухуровневый вывод: layout + content. * Веб-контроллер: рендеринг layout + content, авто-проверка CSRF на небезопасных
* методах и JSON-ответы. Контроллеры приложения наследуют его.
*/ */
class Controller class Controller extends BaseController
{ {
/** @var string Имя layout-шаблона в System/view/views */ /** @var string Имя layout-шаблона в System/view/views */
protected string $_layout = 'layout'; protected string $_layout = 'layout';
/** @var bool Проверять ли CSRF-токен на небезопасных методах */
protected bool $_csrf_protection = true;
/**
* Проверяет CSRF-токен на POST/PUT/PATCH/DELETE.
* Отключается флагом $_csrf_protection (например, для API/вебхуков).
*
* @return void
* @throws HTTPException 403, если токен не прошёл
*/
protected function before(): void
{
if (!$this->_csrf_protection) {
return;
}
$request = Request::$current;
$method = $request?->method() ?? HTTPRequest::GET;
$unsafe = [HTTPRequest::POST, HTTPRequest::PUT, HTTPRequest::PATCH, HTTPRequest::DELETE];
if (in_array($method, $unsafe, true) && !CSRF::validate($request->post(CSRF::$key))) {
throw HTTPException::factory(403);
}
}
/** /**
* Рендерит шаблон контента внутри layout. * Рендерит шаблон контента внутри layout.
* Если $dir пуст определяется автоматически из имени класса * Если $dir пуст определяется автоматически из имени класса
@ -30,12 +59,29 @@ class Controller
protected function render(string $template, array $data = [], string $dir = ''): string protected function render(string $template, array $data = [], string $dir = ''): string
{ {
if ($dir === '') { if ($dir === '') {
$class = substr(get_class($this), strlen('App\\Controller\\')); // [Admin\]FooController $class = substr(get_class($this), strlen('App\\Controller\\')); // [Admin\]FooController
$dir = 'view/' . str_replace('\\', '/', substr($class, 0, -10)); // view/[Admin/]Foo $dir = 'view/' . str_replace('\\', '/', substr($class, 0, -10)); // view/[Admin/]Foo
} }
return (new View($this->_layout, 'view/views', [ return (new View($this->_layout, 'view/views', [
'content' => (new View($template, $dir, $data))->render() 'content' => (new View($template, $dir, $data))->render()
]))->render(); ]))->render();
} }
/**
* Формирует JSON-ответ: ставит статус и Content-Type, кодирует данные.
*
* @param mixed $data Данные ответа
* @param int $status HTTP-статус
* @return string JSON
*/
protected function json(mixed $data, int $status = 200): string
{
http_response_code($status);
if (!headers_sent()) {
header('Content-Type: application/json; charset=utf-8');
}
return json_encode($data, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
}
} }

View File

@ -129,7 +129,7 @@ class Request implements HTTPRequest
return HTTPException::factory(404)->getResponse(); return HTTPException::factory(404)->getResponse();
} }
return (new Response())->body((new $class())->$method()); return (new Response())->body((new $class())->executeAction($method));
} }
/** /**