first commit

This commit is contained in:
Flummi 2025-03-07 10:04:42 +00:00
commit f3a71e8d12
23 changed files with 732 additions and 0 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
vendor
.env
public/adminer

0
README.md Normal file
View File

17
app/Core/container.php Normal file
View File

@ -0,0 +1,17 @@
<?php
namespace Blog\Core;
class Container {
private array $instances = [];
public function set(string $key, callable $factory): void {
$this->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);
}
}

48
app/Core/router.php Normal file
View File

@ -0,0 +1,48 @@
<?php
namespace Blog\Core;
use Blog\Middleware\middlewareInterface;
use Blog\Http\request;
use Blog\Http\response;
class Router {
private array $routes = [];
public function addRoute(string $method, string $path, callable $handler, array $middlewares = []): void {
$path = preg_replace('/{(\w+)}/', '(?P<$1>[^/]+)', $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;
}
}

29
app/Database/database.php Normal file
View File

@ -0,0 +1,29 @@
<?php
namespace Blog\Database;
use PDO;
use PDOException;
class Database {
private static ?PDO $pdo = null;
public static function getConnection(): PDO {
if(self::$pdo === null) {
$config = parse_ini_file(__DIR__ . "/../../.env");
try {
self::$pdo = new PDO(
"mysql:host={$config['DB_HOST']};dbname={$config['DB_NAME']};charset=utf8mb4",
$config['DB_USER'],
$config['DB_PASS'], [
PDO::ATTR_ERRMODE => 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;
}
}

32
app/Entity/post.php Normal file
View File

@ -0,0 +1,32 @@
<?php
namespace Blog\Entity;
class Post {
public function __construct(
private int $id,
private string $title,
private string $content,
private string $author,
private int $stamp
) {}
public function getId() {
return $this->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;
}
}

26
app/Entity/user.php Normal file
View File

@ -0,0 +1,26 @@
<?php
namespace Blog\Entity;
class User {
private $id;
private $username;
private $password;
public function __construct($id, $username, $password) {
$this->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;
}
}

49
app/Http/request.php Normal file
View File

@ -0,0 +1,49 @@
<?php
namespace Blog\Http;
class Request {
private string $method;
private string $path;
private array $postData;
public function __construct(string $method, string $uri, array $postData = []) {
$this->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) ?? [];
}
}

47
app/Http/response.php Normal file
View File

@ -0,0 +1,47 @@
<?php
namespace Blog\Http;
class Response {
private int $status = 200;
private array $headers = [];
private string $body = "";
public function setStatus(int $status): self {
$this->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;
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace Blog\Middleware;
use Blog\Middleware\middlewareInterface;
use Blog\Http\request;
use Blog\Http\response;
class AuthMiddleware implements MiddlewareInterface {
public function handle(Request $request, Response $response): bool {
if(!isset($_SESSION['user'])) {
$response
->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'];
}
}

View File

@ -0,0 +1,9 @@
<?php
namespace Blog\Middleware;
use Blog\Http\request;
use Blog\Http\response;
interface MiddlewareInterface {
public function handle(Request $request, Response $response): bool;
}

49
app/Model/postModel.php Normal file
View File

@ -0,0 +1,49 @@
<?php
namespace Blog\Model;
use Blog\Entity\post;
use Blog\Database\database;
use Exception;
class PostModel {
private $db;
public function __construct() {
$this->db = Database::getConnection();
}
public function getPosts(): array {
$posts = [];
$query = $this->db->query(<<<SQL
SELECT p.id, u.username, p.title, p.content, p.stamp
FROM posts p
JOIN users u ON u.id = p.author_id
SQL);
if(!$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(<<<SQL
SELECT p.id, u.username, p.title, p.content, p.stamp
FROM posts p
JOIN users u ON u.id = p.author_id
WHERE p.id = :id
LIMIT 1
SQL);
$query->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);
}
}

31
app/Model/userModel.php Normal file
View File

@ -0,0 +1,31 @@
<?php
namespace Blog\Model;
use Blog\Entity\user;
use Blog\Database\database;
use PDO;
class UserModel {
private $db;
public function __construct() {
$this->db = Database::getConnection();
}
public function getUserByUsername(string $username):?User {
$query = $this->db->prepare(<<<SQL
SELECT id, username, password
FROM users
WHERE username = :username
LIMIT 1
SQL);
$query->bindParam(':username', $username);
$query->execute();
$row = $query->fetchObject();
if(!$row)
return null;
return new User($row->id, $row->username, $row->password);
}
}

85
app/Template/twig.php Normal file
View File

@ -0,0 +1,85 @@
<?php
namespace Blog\Template;
use Exception;
class Twig {
private $blocks = [];
private $viewsPath;
private $debugMode;
public function __construct($viewsPath, $debugMode = false) {
$this->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', '<?php $1 ?>', $code);
}
private function compileEchos($code) {
$code = preg_replace('~\{\{\s*(.+?)\s*\}\}~is', '<?=$1?>', $code);
return preg_replace('~\{\{\{\s*(.+?)\s*\}\}\}~is', '<?=htmlspecialchars($1, ENT_QUOTES, "UTF-8")?>', $code);
}
private function compileEscapedEchos($code) {
return preg_replace('~\{{{\s*(.+?)\s*}}}~is', '<?=htmlentities($1, ENT_QUOTES, "UTF-8")?>', $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);
}
}

43
app/Utils/authHelper.php Normal file
View File

@ -0,0 +1,43 @@
<?php
namespace Blog\Utils;
use Blog\Model\userModel;
use Exception;
class AuthHelper {
private static ?UserModel $userModel = null;
public static function initialize(UserModel $userModel): void {
self::$userModel = $userModel;
}
public static function login(string $username, string $password): bool {
if(!self::$userModel)
throw new Exception("AuthHelper was not initialized. Please pass UserModel.");
$user = self::$userModel->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;
}
}

7
composer.json Normal file
View File

@ -0,0 +1,7 @@
{
"autoload": {
"psr-4": {
"Blog\\": "app/"
}
}
}

47
public/css/blog.css Normal file
View File

@ -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;
}
}

34
public/index.php Normal file
View File

@ -0,0 +1,34 @@
<?php
error_reporting(E_ALL);
ini_set("display_errors", 1);
session_start([
'cookie_lifetime' => 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
);

78
routes/web.php Normal file
View File

@ -0,0 +1,78 @@
<?php
use Blog\Core\router;
use Blog\Core\container;
use Blog\Utils\authHelper;
use Blog\Http\request;
use Blog\Http\response;
$router->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('/');
});

17
views/blogmain.twig Normal file
View File

@ -0,0 +1,17 @@
{% extends layout %}
{% block title %}Blog{% endblock %}
{% block content %}
<div id="overview">
<h1>Blog Overview</h1>
{% foreach($posts as $post): %}
<div class="post">
<a href="/post/{{ $post->getId() }}">{{ $post->getTitle() }}</a>
<p>{{ $post->getContent(50) }}</p>
<p>Datum: 2025-03-04</p>
<p>Autor: Beispielautor</p>
</div>
{% endforeach; %}
</div>
{% endblock %}

13
views/blogpost.twig Normal file
View File

@ -0,0 +1,13 @@
{% extends layout %}
{% block title %}{{ $post->getTitle() }}{% endblock %}
{% block content %}
<div id="overview">
<h1>{{ $post->getTitle() }}</h1>
<section class="post-view">
<p>{{ $post->getContent() }}</p>
</section>
<a href="/">back</a>
</div>
{% endblock %}

14
views/layout.twig Normal file
View File

@ -0,0 +1,14 @@
<!doctype html>
<html>
<head>
<title>{% yield title %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<base href="https://blog.flumm.io">
<link rel="stylesheet" href="./css/blog.css?v={{ filemtime(__dir__ . '/../../public/css/blog.css') }}">
</head>
<body>
<div class="container" id="blog">
{% yield content %}
</div>
</body>
</html>

12
views/login.twig Normal file
View File

@ -0,0 +1,12 @@
{% extends layout %}
{% block title %}Login{% endblock %}
{% block content %}
<form method="POST" action="/login">
<input type="text" name="username" placeholder="Benutzername" required>
<input type="password" name="password" placeholder="Passwort" required>
<input type="hidden" name="_csrf_token" value="{{ $csrf }}">
<button type="submit">Einloggen</button>
</form>
{% endblock %}