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