From 831487c34a0c1ce610003098655eb91f95d1dc46 Mon Sep 17 00:00:00 2001 From: Zura Sekhniashvili Date: Sun, 26 Jul 2020 17:10:45 +0400 Subject: [PATCH] Initial commit --- Application.php | 84 ++++++++++++++++++ Controller.php | 49 +++++++++++ Model.php | 141 +++++++++++++++++++++++++++++++ Request.php | 59 +++++++++++++ Response.php | 28 ++++++ Router.php | 76 +++++++++++++++++ Session.php | 74 ++++++++++++++++ UserModel.php | 21 +++++ View.php | 43 ++++++++++ db/Database.php | 97 +++++++++++++++++++++ db/DbModel.php | 59 +++++++++++++ exception/ForbiddenException.php | 23 +++++ exception/NotFoundException.php | 21 +++++ form/BaseField.php | 54 ++++++++++++ form/Field.php | 58 +++++++++++++ form/Form.php | 41 +++++++++ form/TextareaField.php | 27 ++++++ middlewares/AuthMiddleware.php | 37 ++++++++ middlewares/BaseMiddleware.php | 20 +++++ 19 files changed, 1012 insertions(+) create mode 100644 Application.php create mode 100644 Controller.php create mode 100644 Model.php create mode 100644 Request.php create mode 100644 Response.php create mode 100644 Router.php create mode 100644 Session.php create mode 100644 UserModel.php create mode 100644 View.php create mode 100644 db/Database.php create mode 100644 db/DbModel.php create mode 100644 exception/ForbiddenException.php create mode 100644 exception/NotFoundException.php create mode 100644 form/BaseField.php create mode 100644 form/Field.php create mode 100644 form/Form.php create mode 100644 form/TextareaField.php create mode 100644 middlewares/AuthMiddleware.php create mode 100644 middlewares/BaseMiddleware.php diff --git a/Application.php b/Application.php new file mode 100644 index 0000000..457b0e9 --- /dev/null +++ b/Application.php @@ -0,0 +1,84 @@ + + * @package app + */ +class Application +{ + public static Application $app; + public static string $ROOT_DIR; + public string $userClass; + public string $layout = 'main'; + public Router $router; + public Request $request; + public Response $response; + public ?Controller $controller = null; + public Database $db; + public Session $session; + public View $view; + public ?UserModel $user; + + public function __construct($rootDir, $config) + { + $this->user = null; + $this->userClass = $config['userClass']; + self::$ROOT_DIR = $rootDir; + self::$app = $this; + $this->request = new Request(); + $this->response = new Response(); + $this->router = new Router($this->request, $this->response); + $this->db = new Database($config['db']); + $this->session = new Session(); + $this->view = new View(); + + $userId = Application::$app->session->get('user'); + if ($userId) { + $key = $this->userClass::primaryKey(); + $this->user = $this->userClass::findOne([$key => $userId]); + } + } + + public static function isGuest() + { + return !self::$app->user; + } + + public function login(UserModel $user) + { + $this->user = $user; + $primaryKey = $user->primaryKey(); + $value = $user->{$primaryKey}; + Application::$app->session->set('user', $value); + + return true; + } + + public function logout() + { + $this->user = null; + self::$app->session->remove('user'); + } + + public function run() + { + try { + echo $this->router->resolve(); + } catch (\Exception $e) { + echo $this->router->renderView('_error', [ + 'exception' => $e, + ]); + } + } +} \ No newline at end of file diff --git a/Controller.php b/Controller.php new file mode 100644 index 0000000..580ab22 --- /dev/null +++ b/Controller.php @@ -0,0 +1,49 @@ + + * @package app\core + */ +class Controller +{ + public string $layout = 'main'; + public string $action = ''; + + /** + * @var \app\core\BaseMiddleware[] + */ + protected array $middlewares = []; + + public function setLayout($layout): void + { + $this->layout = $layout; + } + + public function render($view, $params = []): string + { + return Application::$app->router->renderView($view, $params); + } + + public function registerMiddleware(BaseMiddleware $middleware) + { + $this->middlewares[] = $middleware; + } + + /** + * @return \app\core\middlewares\BaseMiddleware[] + */ + public function getMiddlewares(): array + { + return $this->middlewares; + } +} \ No newline at end of file diff --git a/Model.php b/Model.php new file mode 100644 index 0000000..547c8b9 --- /dev/null +++ b/Model.php @@ -0,0 +1,141 @@ + + * @package app\core + */ +class Model +{ + const RULE_REQUIRED = 'required'; + const RULE_EMAIL = 'email'; + const RULE_MIN = 'min'; + const RULE_MAX = 'max'; + const RULE_MATCH = 'match'; + const RULE_UNIQUE = 'unique'; + + public array $errors = []; + + public function loadData($data) + { + foreach ($data as $key => $value) { + if (property_exists($this, $key)) { + $this->{$key} = $value; + } + } + } + + public function attributes() + { + return []; + } + + public function labels() + { + return []; + } + + public function getLabel($attribute) + { + return $this->labels()[$attribute] ?? $attribute; + } + + public function rules() + { + return []; + } + + public function validate() + { + foreach ($this->rules() as $attribute => $rules) { + $value = $this->{$attribute}; + foreach ($rules as $rule) { + $ruleName = $rule; + if (!is_string($rule)) { + $ruleName = $rule[0]; + } + if ($ruleName === self::RULE_REQUIRED && !$value) { + $this->addErrorByRule($attribute, self::RULE_REQUIRED); + } + if ($ruleName === self::RULE_EMAIL && !filter_var($value, FILTER_VALIDATE_EMAIL)) { + $this->addErrorByRule($attribute, self::RULE_EMAIL); + } + if ($ruleName === self::RULE_MIN && strlen($value) < $rule['min']) { + $this->addErrorByRule($attribute, self::RULE_MIN, ['min' => $rule['min']]); + } + if ($ruleName === self::RULE_MAX && strlen($value) > $rule['max']) { + $this->addErrorByRule($attribute, self::RULE_MAX); + } + if ($ruleName === self::RULE_MATCH && $value !== $this->{$rule['match']}) { + $this->addErrorByRule($attribute, self::RULE_MATCH, ['match' => $rule['match']]); + } + if ($ruleName === self::RULE_UNIQUE) { + $className = $rule['class']; + $uniqueAttr = $rule['attribute'] ?? $attribute; + $tableName = $className::tableName(); + $db = Application::$app->db; + $statement = $db->prepare("SELECT * FROM $tableName WHERE $uniqueAttr = :$uniqueAttr"); + $statement->bindValue(":$uniqueAttr", $value); + $statement->execute(); + $record = $statement->fetchObject(); + if ($record) { + $this->addErrorByRule($attribute, self::RULE_UNIQUE); + } + } + } + } + return empty($this->errors); + } + + public function errorMessages() + { + return [ + self::RULE_REQUIRED => 'This field is required', + self::RULE_EMAIL => 'This field must be valid email address', + self::RULE_MIN => 'Min length of this field must be {min}', + self::RULE_MAX => 'Max length of this field must be {max}', + self::RULE_MATCH => 'This field must be the same as {match}', + self::RULE_UNIQUE => 'Record with with this {field} already exists', + ]; + } + + public function errorMessage($rule) + { + return $this->errorMessages()[$rule]; + } + + protected function addErrorByRule(string $attribute, string $rule, $params = []) + { + $params['field'] ??= $attribute; + $errorMessage = $this->errorMessage($rule); + foreach ($params as $key => $value) { + $errorMessage = str_replace("{{$key}}", $value, $errorMessage); + } + $this->errors[$attribute][] = $errorMessage; + } + + public function addError(string $attribute, string $message) + { + $this->errors[$attribute][] = $message; + } + + public function hasError($attribute) + { + return $this->errors[$attribute] ?? false; + } + + public function getFirstError($attribute) + { + $errors = $this->errors[$attribute] ?? []; + return $errors[0] ?? ''; + } +} \ No newline at end of file diff --git a/Request.php b/Request.php new file mode 100644 index 0000000..9e1f726 --- /dev/null +++ b/Request.php @@ -0,0 +1,59 @@ + + * @package thecodeholic\mvc + */ +class Request +{ + public function getMethod() + { + return strtolower($_SERVER['REQUEST_METHOD']); + } + + public function getUrl() + { + $path = $_SERVER['REQUEST_URI']; + $position = strpos($path, '?'); + if ($position !== false) { + $path = substr($path, 0, $position); + } + return $path; + } + + public function isGet() + { + return $this->getMethod() === 'get'; + } + + public function isPost() + { + return $this->getMethod() === 'post'; + } + + public function getBody() + { + $data = []; + if ($this->isGet()) { + foreach ($_GET as $key => $value) { + $data[$key] = filter_input(INPUT_GET, $key, FILTER_SANITIZE_SPECIAL_CHARS); + } + } + if ($this->isPost()) { + foreach ($_POST as $key => $value) { + $data[$key] = filter_input(INPUT_POST, $key, FILTER_SANITIZE_SPECIAL_CHARS); + } + } + return $data; + } +} \ No newline at end of file diff --git a/Response.php b/Response.php new file mode 100644 index 0000000..58249d8 --- /dev/null +++ b/Response.php @@ -0,0 +1,28 @@ + + * @package app\core + */ +class Response +{ + public function statusCode(int $code) + { + http_response_code($code); + } + + public function redirect($url) + { + header("Location: $url"); + } +} \ No newline at end of file diff --git a/Router.php b/Router.php new file mode 100644 index 0000000..db7b02f --- /dev/null +++ b/Router.php @@ -0,0 +1,76 @@ + + * @package thecodeholic\mvc + */ +class Router +{ + private Request $request; + private Response $response; + private array $routeMap = []; + + public function __construct(Request $request, Response $response) + { + $this->request = $request; + $this->response = $response; + } + + public function get(string $url, $callback) + { + $this->routeMap['get'][$url] = $callback; + } + + public function post(string $url, $callback) + { + $this->routeMap['post'][$url] = $callback; + } + + public function resolve() + { + $method = $this->request->getMethod(); + $url = $this->request->getUrl(); + $callback = $this->routeMap[$method][$url] ?? false; + if (!$callback) { + throw new NotFoundException(); + } + if (is_string($callback)) { + return $this->renderView($callback); + } + if (is_array($callback)) { + /** + * @var $controller \app\core\Controller + */ + $controller = new $callback[0]; + $controller->action = $callback[1]; + Application::$app->controller = $controller; + $middlewares = $controller->getMiddlewares(); + foreach ($middlewares as $middleware) { + $middleware->execute(); + } + $callback[0] = $controller; + } + return call_user_func($callback, $this->request, $this->response); + } + + public function renderView($view, $params = []) + { + return Application::$app->view->renderView($view, $params); + } + + public function renderViewOnly($view, $params = []) + { + return Application::$app->view->renderViewOnly($view, $params); + } +} \ No newline at end of file diff --git a/Session.php b/Session.php new file mode 100644 index 0000000..5d10c63 --- /dev/null +++ b/Session.php @@ -0,0 +1,74 @@ + + * @package app\core + */ +class Session +{ + protected const FLASH_KEY = 'flash_messages'; + + public function __construct() + { + session_start(); + $flashMessages = $_SESSION[self::FLASH_KEY] ?? []; + foreach ($flashMessages as $key => &$flashMessage) { + $flashMessage['remove'] = true; + } + $_SESSION[self::FLASH_KEY] = $flashMessages; + } + + public function setFlash($key, $message) + { + $_SESSION[self::FLASH_KEY][$key] = [ + 'remove' => false, + 'value' => $message + ]; + } + + public function getFlash($key) + { + return $_SESSION[self::FLASH_KEY][$key]['value'] ?? false; + } + + public function set($key, $value) + { + $_SESSION[$key] = $value; + } + + public function get($key) + { + return $_SESSION[$key] ?? false; + } + + public function remove($key) + { + unset($_SESSION[$key]); + } + + public function __destruct() + { + $this->removeFlashMessages(); + } + + private function removeFlashMessages() + { + $flashMessages = $_SESSION[self::FLASH_KEY] ?? []; + foreach ($flashMessages as $key => $flashMessage) { + if ($flashMessage['remove']) { + unset($flashMessages[$key]); + } + } + $_SESSION[self::FLASH_KEY] = $flashMessages; + } +} \ No newline at end of file diff --git a/UserModel.php b/UserModel.php new file mode 100644 index 0000000..70a4a75 --- /dev/null +++ b/UserModel.php @@ -0,0 +1,21 @@ + + * @package app\core + */ +abstract class UserModel extends DbModel +{ + abstract public function getDisplayName(): string; +} \ No newline at end of file diff --git a/View.php b/View.php new file mode 100644 index 0000000..478bd90 --- /dev/null +++ b/View.php @@ -0,0 +1,43 @@ + + * @package app\core + */ +class View +{ + public string $title = ''; + + public function renderView($view, array $params) + { + $layoutName = Application::$app->layout; + if (Application::$app->controller) { + $layoutName = Application::$app->controller->layout; + } + $viewContent = $this->renderViewOnly($view, $params); + ob_start(); + include_once Application::$ROOT_DIR."/views/layouts/$layoutName.php"; + $layoutContent = ob_get_clean(); + return str_replace('{{content}}', $viewContent, $layoutContent); + } + + public function renderViewOnly($view, array $params) + { + foreach ($params as $key => $value) { + $$key = $value; + } + ob_start(); + include_once Application::$ROOT_DIR."/views/$view.php"; + return ob_get_clean(); + } +} \ No newline at end of file diff --git a/db/Database.php b/db/Database.php new file mode 100644 index 0000000..9113f17 --- /dev/null +++ b/db/Database.php @@ -0,0 +1,97 @@ + + * @package app\core + */ +class Database +{ + public \PDO $pdo; + + public function __construct($dbConfig = []) + { + $dbDsn = $dbConfig['dsn'] ?? ''; + $username = $dbConfig['user'] ?? ''; + $password = $dbConfig['password'] ?? ''; + + $this->pdo = new \PDO($dbDsn, $username, $password); + $this->pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); + } + + public function applyMigrations() + { + $this->createMigrationsTable(); + $appliedMigrations = $this->getAppliedMigrations(); + + $newMigrations = []; + $files = scandir(Application::$ROOT_DIR . '/migrations'); + $toApplyMigrations = array_diff($files, $appliedMigrations); + foreach ($toApplyMigrations as $migration) { + if ($migration === '.' || $migration === '..') { + continue; + } + + require_once Application::$ROOT_DIR . '/migrations/' . $migration; + $className = pathinfo($migration, PATHINFO_FILENAME); + $instance = new $className(); + $this->log("Applying migration $migration"); + $instance->up(); + $this->log("Applied migration $migration"); + $newMigrations[] = $migration; + } + + if (!empty($newMigrations)) { + $this->saveMigrations($newMigrations); + } else { + $this->log("There are no migrations to apply"); + } + } + + protected function createMigrationsTable() + { + $this->pdo->exec("CREATE TABLE IF NOT EXISTS migrations ( + id INT AUTO_INCREMENT PRIMARY KEY, + migration VARCHAR(255), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) ENGINE=INNODB;"); + } + + protected function getAppliedMigrations() + { + $statement = $this->pdo->prepare("SELECT migration FROM migrations"); + $statement->execute(); + + return $statement->fetchAll(\PDO::FETCH_COLUMN); + } + + protected function saveMigrations(array $newMigrations) + { + $str = implode(',', array_map(fn($m) => "('$m')", $newMigrations)); + $statement = $this->pdo->prepare("INSERT INTO migrations (migration) VALUES + $str + "); + $statement->execute(); + } + + public function prepare($sql): \PDOStatement + { + return $this->pdo->prepare($sql); + } + + private function log($message) + { + echo "[" . date("Y-m-d H:i:s") . "] - " . $message . PHP_EOL; + } +} \ No newline at end of file diff --git a/db/DbModel.php b/db/DbModel.php new file mode 100644 index 0000000..f3fcd8f --- /dev/null +++ b/db/DbModel.php @@ -0,0 +1,59 @@ + + * @package app\core + */ +abstract class DbModel extends Model +{ + abstract public static function tableName(): string; + + public function primaryKey(): string + { + return 'id'; + } + + public function save() + { + $tableName = $this->tableName(); + $attributes = $this->attributes(); + $params = array_map(fn($attr) => ":$attr", $attributes); + $statement = self::prepare("INSERT INTO $tableName (" . implode(",", $attributes) . ") + VALUES (" . implode(",", $params) . ")"); + foreach ($attributes as $attribute) { + $statement->bindValue(":$attribute", $this->{$attribute}); + } + $statement->execute(); + return true; + } + + public static function prepare($sql): \PDOStatement + { + return Application::$app->db->prepare($sql); + } + + public static function findOne($where) + { + $tableName = static::tableName(); + $attributes = array_keys($where); + $sql = implode("AND", array_map(fn($attr) => "$attr = :$attr", $attributes)); + $statement = self::prepare("SELECT * FROM $tableName WHERE $sql"); + foreach ($where as $key => $item) { + $statement->bindValue(":$key", $item); + } + $statement->execute(); + return $statement->fetchObject(static::class); + } +} \ No newline at end of file diff --git a/exception/ForbiddenException.php b/exception/ForbiddenException.php new file mode 100644 index 0000000..9a931c3 --- /dev/null +++ b/exception/ForbiddenException.php @@ -0,0 +1,23 @@ + + * @package app\core\exception + */ +class ForbiddenException extends \Exception +{ + protected $message = 'You don\'t have permission to access this page'; + protected $code = 403; +} \ No newline at end of file diff --git a/exception/NotFoundException.php b/exception/NotFoundException.php new file mode 100644 index 0000000..c083a66 --- /dev/null +++ b/exception/NotFoundException.php @@ -0,0 +1,21 @@ + + * @package app\core\exception + */ +class NotFoundException extends \Exception +{ + protected $message = 'Page not found'; + protected $code = 404; +} \ No newline at end of file diff --git a/form/BaseField.php b/form/BaseField.php new file mode 100644 index 0000000..375d442 --- /dev/null +++ b/form/BaseField.php @@ -0,0 +1,54 @@ + + * @package app\core\form + */ +abstract class BaseField +{ + + public Model $model; + public string $attribute; + public string $type; + + /** + * Field constructor. + * + * @param \app\core\Model $model + * @param string $attribute + */ + public function __construct(Model $model, string $attribute) + { + $this->model = $model; + $this->attribute = $attribute; + } + + public function __toString() + { + return sprintf('
+ + %s +
+ %s +
+
', + $this->model->getLabel($this->attribute), + $this->renderInput(), + $this->model->getFirstError($this->attribute) + ); + } + + abstract public function renderInput(); +} \ No newline at end of file diff --git a/form/Field.php b/form/Field.php new file mode 100644 index 0000000..1e2dd6b --- /dev/null +++ b/form/Field.php @@ -0,0 +1,58 @@ + + * @package core\form + */ +class Field extends BaseField +{ + const TYPE_TEXT = 'text'; + const TYPE_PASSWORD = 'password'; + const TYPE_FILE = 'file'; + + /** + * Field constructor. + * + * @param \app\core\Model $model + * @param string $attribute + */ + public function __construct(Model $model, string $attribute) + { + $this->type = self::TYPE_TEXT; + parent::__construct($model, $attribute); + } + + public function renderInput() + { + return sprintf('', + $this->type, + $this->model->hasError($this->attribute) ? ' is-invalid' : '', + $this->attribute, + $this->model->{$this->attribute}, + ); + } + + public function passwordField() + { + $this->type = self::TYPE_PASSWORD; + return $this; + } + + public function fileField() + { + $this->type = self::TYPE_FILE; + return $this; + } +} \ No newline at end of file diff --git a/form/Form.php b/form/Form.php new file mode 100644 index 0000000..e208b5b --- /dev/null +++ b/form/Form.php @@ -0,0 +1,41 @@ + + * @package core\form + */ +class Form +{ + public static function begin($action, $method, $options = []) + { + $attributes = []; + foreach ($options as $key => $value) { + $attributes[] = "$key=\"$value\""; + } + echo sprintf('
', $action, $method, implode(" ", $attributes)); + return new Form(); + } + + public static function end() + { + echo '
'; + } + + public function field(Model $model, $attribute) + { + return new Field($model, $attribute); + } + +} \ No newline at end of file diff --git a/form/TextareaField.php b/form/TextareaField.php new file mode 100644 index 0000000..19524ce --- /dev/null +++ b/form/TextareaField.php @@ -0,0 +1,27 @@ + + * @package app\core\form + */ +class TextareaField extends BaseField +{ + public function renderInput() + { + return sprintf('', + $this->model->hasError($this->attribute) ? ' is-invalid' : '', + $this->attribute, + $this->model->{$this->attribute}, + ); + } +} \ No newline at end of file diff --git a/middlewares/AuthMiddleware.php b/middlewares/AuthMiddleware.php new file mode 100644 index 0000000..6dfd7f3 --- /dev/null +++ b/middlewares/AuthMiddleware.php @@ -0,0 +1,37 @@ + + * @package app\core + */ +class AuthMiddleware extends BaseMiddleware +{ + protected array $actions = []; + + public function __construct($actions = []) + { + $this->actions = $actions; + } + + public function execute() + { + if (Application::isGuest()) { + if (empty($this->actions) || in_array(Application::$app->controller->action, $this->actions)) { + throw new ForbiddenException(); + } + } + } +} \ No newline at end of file diff --git a/middlewares/BaseMiddleware.php b/middlewares/BaseMiddleware.php new file mode 100644 index 0000000..d8263ee --- /dev/null +++ b/middlewares/BaseMiddleware.php @@ -0,0 +1,20 @@ + + * @package app\core + */ +abstract class BaseMiddleware +{ + abstract public function execute(); +} \ No newline at end of file