commit f3a71e8d12daaf36acb268f19b49e5683655ff04 Author: Flummi Date: Fri Mar 7 10:04:42 2025 +0000 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e771744 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +vendor +.env +public/adminer \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/app/Core/container.php b/app/Core/container.php new file mode 100644 index 0000000..053380a --- /dev/null +++ b/app/Core/container.php @@ -0,0 +1,17 @@ +instances[$key] = $factory; + } + + public function get(string $key): mixed { + if(!isset($this->instances[$key])) { + throw new Exception("No instance found for {$key}"); + } + return $this->instances[$key]($this); + } +} diff --git a/app/Core/router.php b/app/Core/router.php new file mode 100644 index 0000000..e3e86d5 --- /dev/null +++ b/app/Core/router.php @@ -0,0 +1,48 @@ +[^/]+)', $path); + $this->routes[] = compact("method", "path", "handler", "middlewares"); + } + + public function dispatch(Request $req, Response $res): void { + $method = $req->getMethod(); + $uri = $req->getPath(); + + foreach($this->routes as $route) { + if($route['method'] === $method && preg_match("~^" . $route['path'] . "$~", $uri, $matches)) { + array_shift($matches); + $params = array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY); + if(!$this->handleMiddlewares($route['middlewares'], $req, $res)) + return; + call_user_func_array($route['handler'], array_merge([ $req, $res ], $params)); + $res->send(); + return; + } + } + + $res + ->setStatus(404) + ->getBody() + ->write("404 - Not Found") + ->send(); + } + + private function handleMiddlewares(array $middlewares, Request $req, Response $res): bool { + foreach($middlewares as $middleware) { + $middlewareInstance = is_string($middleware) ? new $middleware() : $middleware; + if($middlewareInstance instanceof MiddlewareInterface) + if(!$middlewareInstance->handle($req, $res)) + return false; + } + return true; + } +} diff --git a/app/Database/database.php b/app/Database/database.php new file mode 100644 index 0000000..7b22ae6 --- /dev/null +++ b/app/Database/database.php @@ -0,0 +1,29 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_OBJ, + PDO::ATTR_PERSISTENT => true + ] + ); + } catch(PDOException $e) { + die("Datenbankverbindung fehlgeschlagen: " . $e->getMessage()); + } + } + return self::$pdo; + } +} diff --git a/app/Entity/post.php b/app/Entity/post.php new file mode 100644 index 0000000..f480597 --- /dev/null +++ b/app/Entity/post.php @@ -0,0 +1,32 @@ +id; + } + + public function getTitle() { + return $this->title; + } + + public function getContent($maxlength = null) { + return $maxlength ? mb_strimwidth($this->content, 0, $maxlength, "...") : $this->content; + } + + public function getAuthor() { + return $this->author; + } + + public function getDateTime() { + return $this->stamp; + } +} diff --git a/app/Entity/user.php b/app/Entity/user.php new file mode 100644 index 0000000..a30ba10 --- /dev/null +++ b/app/Entity/user.php @@ -0,0 +1,26 @@ +id = $id; + $this->username = $username; + $this->password = $password; + } + + public function getId() { + return $this->id; + } + + public function getUsername() { + return $this->username; + } + + public function getPassword() { + return $this->password; + } +} diff --git a/app/Http/request.php b/app/Http/request.php new file mode 100644 index 0000000..1754417 --- /dev/null +++ b/app/Http/request.php @@ -0,0 +1,49 @@ +method = strtoupper($method); + $this->path = parse_url($uri, PHP_URL_PATH) ?? '/'; + $this->postData = $postData ?: $_POST; + } + + public function getMethod(): string { + return $this->method; + } + + public function getPath(): string { + return $this->path; + } + + public function getPost(string $key, $default = null): mixed { + return $this->postData[$key] ?? $default; + } + + public function allPost(): array { + return $this->postData; + } + + public function getQuery(string $key, $default = null): mixed { + $query = []; + parse_str(parse_url($this->path, PHP_URL_QUERY) ?? '', $query); + return $query[$key] ?? $default; + } + + public function getHeader(string $key): ?string { + $headerKey = 'HTTP_' . strtoupper(str_replace('-', '_', $key)); + return $_SERVER[$headerKey] ?? null; + } + + public function getRawInput(): string { + return file_get_contents('php://input'); + } + + public function getJson(): array { + return json_decode($this->getRawInput(), true) ?? []; + } +} diff --git a/app/Http/response.php b/app/Http/response.php new file mode 100644 index 0000000..d91cd2c --- /dev/null +++ b/app/Http/response.php @@ -0,0 +1,47 @@ +status = $status; + return $this; + } + + public function addHeader(string $key, string $val): self { + $this->headers[$key] = $val; + return $this; + } + + public function getBody(): self { + return $this; + } + + public function write(string $content): self { + $this->body .= $content; + return $this; + } + + public function send(): void { + http_response_code($this->status); + foreach($this->headers as $key => $val) + header("{$key}: {$val}"); + echo $this->body; + } + + public function json(array $data, int $status = 200): self { + $this->setStatus($status); + header("Content-Type: application/json"); + $this->body = json_encode($data); + return $this; + } + + public function redirect(string $url, int $status = 302): void { + http_response_code($status); + header("Location: {$url}"); + exit; + } +} diff --git a/app/Middleware/authMiddleware.php b/app/Middleware/authMiddleware.php new file mode 100644 index 0000000..6c2093d --- /dev/null +++ b/app/Middleware/authMiddleware.php @@ -0,0 +1,42 @@ +setStatus(403) + ->getBody() + ->write("403 - Forbidden") + ->send(); + return false; + } + + if($request->getMethod() !== 'GET' && !$this->validateCSRFToken($request)) { + $response + ->setStatus(419) + ->getBody() + ->write("419 - Session expired or invalid CSRF token.") + ->send(); + return false; + } + + return true; + } + + private function validateCSRFToken(Request $request): bool { + $token = $request->getPost('_csrf_token') ?? ''; + return hash_equals($_SESSION['_csrf_token'] ?? '', $token); + } + + public static function generateCSRFToken(): string { + if(!isset($_SESSION['_csrf_token'])) { + $_SESSION['_csrf_token'] = bin2hex(random_bytes(32)); + } + return $_SESSION['_csrf_token']; + } +} diff --git a/app/Middleware/middlewareInterface.php b/app/Middleware/middlewareInterface.php new file mode 100644 index 0000000..b657406 --- /dev/null +++ b/app/Middleware/middlewareInterface.php @@ -0,0 +1,9 @@ +db = Database::getConnection(); + } + + public function getPosts(): array { + $posts = []; + $query = $this->db->query(<<rowCount()) + throw new Exception("no entries available"); + + while($row = $query->fetchObject()) { + $posts[] = new Post($row->id, $row->title, $row->content, $row->username, $row->stamp); + } + return $posts; + } + + public function getPost($id): ?Post { + $query = $this->db->prepare(<<bindParam(":id", $id, $this->db::PARAM_INT); + $query->execute(); + + if(!$query->rowCount()) + throw new Exception("no entry found"); + + $row = $query->fetchObject(); + return new Post($row->id, $row->title, $row->content, $row->username, $row->stamp); + } +} diff --git a/app/Model/userModel.php b/app/Model/userModel.php new file mode 100644 index 0000000..52e5842 --- /dev/null +++ b/app/Model/userModel.php @@ -0,0 +1,31 @@ +db = Database::getConnection(); + } + + public function getUserByUsername(string $username):?User { + $query = $this->db->prepare(<<bindParam(':username', $username); + $query->execute(); + + $row = $query->fetchObject(); + if(!$row) + return null; + + return new User($row->id, $row->username, $row->password); + } +} diff --git a/app/Template/twig.php b/app/Template/twig.php new file mode 100644 index 0000000..a421284 --- /dev/null +++ b/app/Template/twig.php @@ -0,0 +1,85 @@ +viewsPath = rtrim($viewsPath, '/') . '/'; + $this->debugMode = $debugMode; + } + + public function render($file, $data = []) { + try { + $code = $this->includeFiles($file); + $code = $this->compileCode($code); + extract($data, EXTR_SKIP); + + ob_start(); + eval('?>' . $code); + return ob_get_clean(); + } catch(Exception $e) { + if($this->debugMode) + echo "Error rendering template: " . $e->getMessage(); + throw $e; + } + } + + private function compileCode($code) { + $code = $this->compileBlock($code); + $code = $this->compileYield($code); + $code = $this->compileEchos($code); + $code = $this->compilePHP($code); + return $code; + } + + private function includeFiles($file) { + $filePath = $this->viewsPath . preg_replace("/\.twig$/", "", $file) . ".twig"; + + if(!file_exists($filePath)) + throw new Exception("View file not found: {$filePath}"); + + $code = file_get_contents($filePath); + preg_match_all('/{% ?(extends|include) ?\'?(.*?)\'? ?%}/i', $code, $matches, PREG_SET_ORDER); + + foreach($matches as $match) { + $includedCode = $this->includeFiles($match[2]); + $code = str_replace($match[0], $includedCode, $code); + } + return preg_replace('/{% ?(extends|include) ?\'?(.*?)\'? ?%}/i', '', $code); + } + + private function compilePHP($code) { + return preg_replace('~\{%\s*(.+?)\s*%}~is', '', $code); + } + + private function compileEchos($code) { + $code = preg_replace('~\{\{\s*(.+?)\s*\}\}~is', '', $code); + return preg_replace('~\{\{\{\s*(.+?)\s*\}\}\}~is', '', $code); + } + + private function compileEscapedEchos($code) { + return preg_replace('~\{{{\s*(.+?)\s*}}}~is', '', $code); + } + + private function compileBlock($code) { + preg_match_all('/{% ?block ?(.*?) ?%}(.*?){% ?endblock ?%}/is', $code, $matches, PREG_SET_ORDER); + foreach($matches as $match) { + if(!isset($this->blocks[$match[1]])) + $this->blocks[$match[1]] = ''; + $this->blocks[$match[1]] = str_replace('@parent', $this->blocks[$match[1]], $match[2]); + $code = str_replace($match[0], '', $code); + } + return $code; + } + + private function compileYield($code) { + foreach($this->blocks as $block => $value) + $code = preg_replace('/{% ?yield ?' . $block . ' ?%}/', $value, $code); + return preg_replace('/{% ?yield ?(.*?) ?%}/i', '', $code); + } +} diff --git a/app/Utils/authHelper.php b/app/Utils/authHelper.php new file mode 100644 index 0000000..351159f --- /dev/null +++ b/app/Utils/authHelper.php @@ -0,0 +1,43 @@ +getUserByUsername($username); + if(!$user || !password_verify($password, $user->getPassword())) { + return false; + } + + $_SESSION['user'] = [ + 'id' => $user->getId(), + 'username' => $user->getUsername() + ]; + + return true; + } + + public static function logout(): void { + session_unset(); + session_destroy(); + } + + public static function isLoggedIn(): bool { + return isset($_SESSION['user']); + } + + public static function getCurrentUser(): ?array { + return $_SESSION['user'] ?? null; + } +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..0c52955 --- /dev/null +++ b/composer.json @@ -0,0 +1,7 @@ +{ + "autoload": { + "psr-4": { + "Blog\\": "app/" + } + } +} diff --git a/public/css/blog.css b/public/css/blog.css new file mode 100644 index 0000000..7daef40 --- /dev/null +++ b/public/css/blog.css @@ -0,0 +1,47 @@ +body { + background-color: #121212; + color: #e0e0e0; + font-family: Arial, sans-serif; + margin: 0; + padding: 0; + display: flex; + justify-content: center; +} + +.container { + width: 90%; + max-width: 800px; + padding: 20px; +} + +.post { + padding: 15px; + border-bottom: 1px solid #333; +} + +.post a { + color: #bb86fc; + text-decoration: none; +} + +.post a:hover { + text-decoration: underline; +} + +.back { + display: block; + margin: 20px 0; + color: #bb86fc; + text-decoration: none; +} + +.back:hover { + text-decoration: underline; +} + +@media (max-width: 600px) { + .container { + width: 100%; + padding: 10px; + } +} diff --git a/public/index.php b/public/index.php new file mode 100644 index 0000000..dcf16c5 --- /dev/null +++ b/public/index.php @@ -0,0 +1,34 @@ + 86400, + 'cookie_secure' => true, + 'cookie_httponly' => true, + 'cookie_samesite' => 'Strict' +]); + +if(empty($_SESSION['token'])) + $_SESSION['token'] = bin2hex(random_bytes(32)); + +require_once __DIR__ . "/../vendor/autoload.php"; + +use Blog\Core\router; +use Blog\Core\container; +use Blog\Template\twig; +use Blog\Utils\authHelper; + +AuthHelper::initialize(new Blog\Model\userModel()); +$router = new Router(); +$container = new Container(); + +$container->set('twig', fn() => new Twig(__DIR__ . "/../views")); +$container->set('postModel', fn() => new Blog\Model\postModel()); + +require_once __DIR__ . "/../routes/web.php"; + +$router->dispatch( + new Blog\Http\request($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI']), + new Blog\Http\response +); diff --git a/routes/web.php b/routes/web.php new file mode 100644 index 0000000..7daf7c4 --- /dev/null +++ b/routes/web.php @@ -0,0 +1,78 @@ +addRoute('GET', '/', function(Request $req, Response $res) use($container) { + $twig = $container->get('twig'); + $postModel = $container->get('postModel'); + + $posts = $postModel->getPosts(); + $res->getBody()->write( + $twig->render("blogmain", [ + "posts" => $posts + ]) + ); + return $res; +}); + +$router->addRoute('GET', '/post/{id}', function(Request $req, Response $res, $id) use($container) { + $twig = $container->get('twig'); + $postModel = $container->get('postModel'); + + $post = $postModel->getPost($id); + + $res->getBody()->write( + $twig->render("blogpost", [ + "post" => $post + ]) + ); + + return $res; +}); + +$router->addRoute('GET', '/login', function(Request $req, Response $res) use($container) { + $twig = $container->get('twig'); + + $res->getBody()->write( + $twig->render("login", [ + "csrf" => Blog\Middleware\authMiddleware::generateCSRFToken() + ]) + ); + + return $res; +}); + +$router->addRoute('POST', '/login', function(Request $req, Response $res) use($container) { + $twig = $container->get('twig'); + $userModel = $container->get('userModel'); + + $username = $req->getPost('username'); + $password = $req->getPost('password'); + $csrfToken = $req->getPost('_csrf_token'); + + if(!Blog\Middleware\authMiddleware::validateCSRFToken($csrfToken)) { + return $res + ->setStatus(419) + ->getBody() + ->write("419 - Session abgelaufen oder ungültiger CSRF-Token.") + ->send(); + } + + if(!AuthHelper::login($username, $password)) { + $res + ->setStatus(401) + ->getBody() + ->write("401 - Unauthorized: Invalid login information."); + return $res; + } + + return $res->redirect('/'); +}); + +$router->addRoute('GET', '/logout', function(Request $req, Response $res) use($container) { + AuthHelper::logout(); + return $res->redirect('/'); +}); diff --git a/views/blogmain.twig b/views/blogmain.twig new file mode 100644 index 0000000..64a2718 --- /dev/null +++ b/views/blogmain.twig @@ -0,0 +1,17 @@ +{% extends layout %} + +{% block title %}Blog{% endblock %} + +{% block content %} +
+

Blog Overview

+ {% foreach($posts as $post): %} +
+ {{ $post->getTitle() }} +

{{ $post->getContent(50) }}

+

Datum: 2025-03-04

+

Autor: Beispielautor

+
+ {% endforeach; %} +
+{% endblock %} diff --git a/views/blogpost.twig b/views/blogpost.twig new file mode 100644 index 0000000..1a2a316 --- /dev/null +++ b/views/blogpost.twig @@ -0,0 +1,13 @@ +{% extends layout %} + +{% block title %}{{ $post->getTitle() }}{% endblock %} + +{% block content %} +
+

{{ $post->getTitle() }}

+
+

{{ $post->getContent() }}

+
+ back +
+{% endblock %} diff --git a/views/layout.twig b/views/layout.twig new file mode 100644 index 0000000..5d08715 --- /dev/null +++ b/views/layout.twig @@ -0,0 +1,14 @@ + + + + {% yield title %} + + + + + +
+ {% yield content %} +
+ + diff --git a/views/login.twig b/views/login.twig new file mode 100644 index 0000000..9a50103 --- /dev/null +++ b/views/login.twig @@ -0,0 +1,12 @@ +{% extends layout %} + +{% block title %}Login{% endblock %} + +{% block content %} +
+ + + + +
+{% endblock %}