dev
This commit is contained in:
parent
e6e8844f2b
commit
b3c8188ee3
52
App/Controller/FeedbackController.php
Normal file
52
App/Controller/FeedbackController.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
37
App/view/Feedback/feedback.html
Normal file
37
App/view/Feedback/feedback.html
Normal 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>
|
||||
13
App/view/Feedback/feedback_ok.html
Normal file
13
App/view/Feedback/feedback_ok.html
Normal 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>
|
||||
53
CLAUDE.md
53
CLAUDE.md
@ -74,7 +74,8 @@ PhpStorm может показывать предупреждение «Namespac
|
||||
| Класс | Роль |
|
||||
|---|---|
|
||||
| `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\Route` | Автоматический роутинг: парсит URI, ищет контроллер в `App/Controller/` |
|
||||
| `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\Config` | Статический конфиг: lazy-load `App/config/config.php` + merge `config.local.php` |
|
||||
| `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()` |
|
||||
@ -102,7 +104,7 @@ PhpStorm может показывать предупреждение «Namespac
|
||||
index.php
|
||||
→ Core::init()
|
||||
→ 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
|
||||
→ 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`).
|
||||
|
||||
### 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)
|
||||
|
||||
```php
|
||||
@ -240,6 +274,17 @@ $response = HTTPException::factory(403)->getResponse();
|
||||
- `$_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-ответов. **Контроллеры приложения наследуют его.**
|
||||
|
||||
API-контроллер делается не отдельным классом, а флагом: `extends Controller` + `$_csrf_protection = false` + ответы через `json()` (так же, как в eoffice_v3).
|
||||
|
||||
`Request::execute()` вызывает `executeAction({action}Action)`, поэтому `before()/after()` работают для любого контроллера прозрачно. Авторизацию добавлять в `before()` конкретных контроллеров (логина пока нет).
|
||||
|
||||
### Environment
|
||||
|
||||
Среда задаётся через `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/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 хуки |
|
||||
|
||||
**135 тестов, 215 assertion — все проходят.**
|
||||
**145 тестов, 229 assertion — все проходят.**
|
||||
|
||||
### Frontend dependencies (через Composer)
|
||||
|
||||
|
||||
46
System/Classes/BaseController.php
Normal file
46
System/Classes/BaseController.php
Normal 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
69
System/Classes/CSRF.php
Normal 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() . '">';
|
||||
}
|
||||
}
|
||||
@ -8,14 +8,43 @@
|
||||
|
||||
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 */
|
||||
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.
|
||||
* Если $dir пуст — определяется автоматически из имени класса
|
||||
@ -30,12 +59,29 @@ class Controller
|
||||
protected function render(string $template, array $data = [], string $dir = ''): string
|
||||
{
|
||||
if ($dir === '') {
|
||||
$class = substr(get_class($this), strlen('App\\Controller\\')); // [Admin\]FooController
|
||||
$dir = 'view/' . str_replace('\\', '/', substr($class, 0, -10)); // view/[Admin/]Foo
|
||||
$class = substr(get_class($this), strlen('App\\Controller\\')); // [Admin\]FooController
|
||||
$dir = 'view/' . str_replace('\\', '/', substr($class, 0, -10)); // view/[Admin/]Foo
|
||||
}
|
||||
|
||||
return (new View($this->_layout, 'view/views', [
|
||||
'content' => (new View($template, $dir, $data))->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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -129,7 +129,7 @@ class Request implements HTTPRequest
|
||||
return HTTPException::factory(404)->getResponse();
|
||||
}
|
||||
|
||||
return (new Response())->body((new $class())->$method());
|
||||
return (new Response())->body((new $class())->executeAction($method));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Loading…
Reference in New Issue
Block a user