Compare commits
6 Commits
e7f2875287
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a72210608 | |||
|
|
3a30abc05e | ||
|
|
57b97b5069 | ||
| b6f745e144 | |||
| 0c6f8cac5a | |||
|
|
fb646eba85 |
@@ -6,8 +6,67 @@ on:
|
|||||||
- "feature/**"
|
- "feature/**"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
lint-php:
|
||||||
|
name: PHP Syntax Check
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install PHP
|
||||||
|
run: apt-get update -qq && apt-get install -y -qq php-cli > /dev/null 2>&1
|
||||||
|
|
||||||
|
- name: PHP Lint
|
||||||
|
run: |
|
||||||
|
errors=0
|
||||||
|
while IFS= read -r file; do
|
||||||
|
if ! php -l "$file" > /dev/null 2>&1; then
|
||||||
|
echo "❌ Syntax error in $file"
|
||||||
|
php -l "$file"
|
||||||
|
errors=1
|
||||||
|
fi
|
||||||
|
done < <(find . -name "*.php" -not -path "./vendor/*")
|
||||||
|
if [ "$errors" -eq 1 ]; then
|
||||||
|
echo "::error::PHP lint check failed"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "✅ All PHP files pass syntax check"
|
||||||
|
|
||||||
|
lint-css:
|
||||||
|
name: CSS Lint (stylelint)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Node.js & stylelint
|
||||||
|
run: |
|
||||||
|
apt-get update -qq && apt-get install -y -qq npm nodejs > /dev/null 2>&1
|
||||||
|
npm install -g stylelint stylelint-config-standard stylelint-prettier > /dev/null 2>&1
|
||||||
|
|
||||||
|
- name: CSS Lint
|
||||||
|
run: |
|
||||||
|
npx stylelint "**/*.css" --config .stylelintrc.json --allow-empty-input
|
||||||
|
echo "✅ All CSS files pass lint"
|
||||||
|
|
||||||
|
lint-html:
|
||||||
|
name: HTML Lint (htmlhint)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Node.js & htmlhint
|
||||||
|
run: |
|
||||||
|
apt-get update -qq && apt-get install -y -qq npm nodejs > /dev/null 2>&1
|
||||||
|
npm install -g htmlhint > /dev/null 2>&1
|
||||||
|
|
||||||
|
- name: HTML Lint
|
||||||
|
run: |
|
||||||
|
npx htmlhint "**/*.html" --config .htmlhintrc
|
||||||
|
echo "✅ All HTML files pass lint"
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
name: Deploy to Test Environment
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [lint-php, lint-css, lint-html]
|
||||||
container:
|
container:
|
||||||
volumes:
|
volumes:
|
||||||
- /var/www/test/html:/deploy
|
- /var/www/test/html:/deploy
|
||||||
@@ -20,6 +79,7 @@ jobs:
|
|||||||
echo "=== Deploying branch: ${{ gitea.ref_name }} ==="
|
echo "=== Deploying branch: ${{ gitea.ref_name }} ==="
|
||||||
echo "=== Commit: ${{ gitea.sha }} ==="
|
echo "=== Commit: ${{ gitea.sha }} ==="
|
||||||
echo "=== By: ${{ gitea.actor }} ==="
|
echo "=== By: ${{ gitea.actor }} ==="
|
||||||
|
echo "=== All lint checks passed ✅ ==="
|
||||||
date
|
date
|
||||||
|
|
||||||
- name: Deploy to test environment
|
- name: Deploy to test environment
|
||||||
@@ -37,13 +97,16 @@ jobs:
|
|||||||
--exclude='.htmlhintrc' \
|
--exclude='.htmlhintrc' \
|
||||||
--exclude='.gitignore' \
|
--exclude='.gitignore' \
|
||||||
--exclude='.dockerignore' \
|
--exclude='.dockerignore' \
|
||||||
|
--exclude='.continue' \
|
||||||
--exclude='Dockerfile' \
|
--exclude='Dockerfile' \
|
||||||
--exclude='nginx.conf' \
|
--exclude='nginx.conf' \
|
||||||
--exclude='eslint.config.js' \
|
--exclude='eslint.config.js' \
|
||||||
--exclude='package.json' \
|
--exclude='package.json' \
|
||||||
|
--exclude='package-lock.json' \
|
||||||
--exclude='docs/' \
|
--exclude='docs/' \
|
||||||
--exclude='AGENTS.md' \
|
--exclude='AGENTS.md' \
|
||||||
--exclude='README.md' \
|
--exclude='README.md' \
|
||||||
|
--exclude='scripts/' \
|
||||||
./ /deploy/
|
./ /deploy/
|
||||||
|
|
||||||
|
|
||||||
@@ -62,6 +125,7 @@ jobs:
|
|||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
echo " Branch: ${{ gitea.ref_name }}"
|
echo " Branch: ${{ gitea.ref_name }}"
|
||||||
echo " Commit: ${{ gitea.sha }}"
|
echo " Commit: ${{ gitea.sha }}"
|
||||||
echo " Target: http://178.104.150.0/"
|
echo " Target: http://178.104.150.0:6427/"
|
||||||
|
echo " Lint: ✅ All checks passed"
|
||||||
echo " Time: $(date)"
|
echo " Time: $(date)"
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -4,3 +4,5 @@
|
|||||||
package-lock.json
|
package-lock.json
|
||||||
.continue/
|
.continue/
|
||||||
.playwright-mcp/
|
.playwright-mcp/
|
||||||
|
vendor/
|
||||||
|
.phpunit.cache/
|
||||||
|
|||||||
21
AGENTS.md
21
AGENTS.md
@@ -1,8 +1,27 @@
|
|||||||
# Agent-Richtlinien für dieses Projekt
|
# Agent-Richtlinien für dieses Projekt
|
||||||
|
|
||||||
|
## ⚠️ WICHTIG: Pre-Commit Hooks sind Pflicht
|
||||||
|
|
||||||
|
**Jeder Commit MUSS die Pre-Commit Lint-Checks durchlaufen. Niemals `--no-verify` verwenden!**
|
||||||
|
|
||||||
|
### Für AI-Agents (Claw etc.):
|
||||||
|
- Verwende `./scripts/safe-commit.sh "Nachricht"` statt `git commit -m "..."`
|
||||||
|
- Das Script garantiert dass lint-staged läuft (PHP, HTML, CSS, JS, Prettier)
|
||||||
|
- Wenn ein Linter fehlschlägt: **Fehler beheben, nicht überspringen!**
|
||||||
|
- Niemals `git commit --no-verify` verwenden
|
||||||
|
|
||||||
|
### Lint-Checks die laufen:
|
||||||
|
- **PHP:** `php -l` Syntax-Check (via `scripts/lint-php.sh`)
|
||||||
|
- **HTML:** htmlhint
|
||||||
|
- **CSS:** stylelint + prettier
|
||||||
|
- **JS:** eslint + prettier
|
||||||
|
- **JSON/MD:** prettier
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Systemumgebung
|
## Systemumgebung
|
||||||
|
|
||||||
- **Betriebssystem: Windows** – Alle Befehle und Pfade müssen Windows-kompatibel sein (z. B. Pfadtrennzeichen `\`, PowerShell-Syntax).
|
- **Betriebssystem: Linux** – Befehle und Pfade sind Linux-kompatibel.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
25
app/Controllers/Controller.php
Normal file
25
app/Controllers/Controller.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use App\Core\View;
|
||||||
|
|
||||||
|
abstract class Controller
|
||||||
|
{
|
||||||
|
protected View $view;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->view = new View();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function render(string $view, array $data = [], string $layout = 'main'): void
|
||||||
|
{
|
||||||
|
foreach ($data as $key => $value) {
|
||||||
|
$this->view->assign($key, $value);
|
||||||
|
}
|
||||||
|
$this->view->render($view, $layout);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
app/Controllers/DatenschutzController.php
Normal file
18
app/Controllers/DatenschutzController.php
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
class DatenschutzController extends Controller
|
||||||
|
{
|
||||||
|
public function index(): void
|
||||||
|
{
|
||||||
|
$this->render('datenschutz/index', [
|
||||||
|
'pageTitle' => 'Datenschutzerklärung – Haus Schleusingen',
|
||||||
|
'pageDescription' => 'Datenschutzerklärung der Website haus-schleusingen.de',
|
||||||
|
'robots' => 'noindex',
|
||||||
|
'canonical' => 'https://haus-schleusingen.de/datenschutz',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
187
app/Controllers/HomeController.php
Normal file
187
app/Controllers/HomeController.php
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
class HomeController extends Controller
|
||||||
|
{
|
||||||
|
public function index(): void
|
||||||
|
{
|
||||||
|
session_start();
|
||||||
|
|
||||||
|
// --- Helper functions ---
|
||||||
|
$normalizeContactValue = function (string $value): string {
|
||||||
|
return trim($value);
|
||||||
|
};
|
||||||
|
|
||||||
|
$escapeContactValue = function (string $value): string {
|
||||||
|
return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
|
||||||
|
};
|
||||||
|
|
||||||
|
$containsHeaderInjection = function (string $value): bool {
|
||||||
|
return (bool) preg_match('/[\r\n]/', $value);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Form processing ---
|
||||||
|
$formErrors = [];
|
||||||
|
$formSuccess = false;
|
||||||
|
if (!empty($_SESSION['form_success'])) {
|
||||||
|
$formSuccess = true;
|
||||||
|
unset($_SESSION['form_success']);
|
||||||
|
}
|
||||||
|
if (!empty($_SESSION['form_errors'])) {
|
||||||
|
$formErrors = $_SESSION['form_errors'];
|
||||||
|
unset($_SESSION['form_errors']);
|
||||||
|
}
|
||||||
|
if (!empty($_SESSION['form_data'])) {
|
||||||
|
$formData = $_SESSION['form_data'];
|
||||||
|
unset($_SESSION['form_data']);
|
||||||
|
} else {
|
||||||
|
$formData = ['fname' => '', 'lname' => '', 'email' => '', 'phone' => '', 'interest' => 'Besichtigung anfragen', 'message' => ''];
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSRF-Token generieren (nach Session-Start)
|
||||||
|
if (empty($_SESSION['csrf_token'])) {
|
||||||
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
// CSRF-Token validieren
|
||||||
|
$csrfToken = $_POST['csrf_token'] ?? '';
|
||||||
|
if (!hash_equals($_SESSION['csrf_token'] ?? '', $csrfToken)) {
|
||||||
|
header('Location: /#form-result');
|
||||||
|
$_SESSION['form_errors'] = ['Sicherheitsüberprüfung fehlgeschlagen. Bitte versuchen Sie es erneut.'];
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$formData['fname'] = $normalizeContactValue((string) ($_POST['fname'] ?? ''));
|
||||||
|
$formData['lname'] = $normalizeContactValue((string) ($_POST['lname'] ?? ''));
|
||||||
|
$formData['email'] = $normalizeContactValue((string) ($_POST['email'] ?? ''));
|
||||||
|
$formData['phone'] = $normalizeContactValue((string) ($_POST['phone'] ?? ''));
|
||||||
|
$formData['interest'] = $normalizeContactValue((string) ($_POST['interest'] ?? ''));
|
||||||
|
$formData['message'] = $normalizeContactValue((string) ($_POST['message'] ?? ''));
|
||||||
|
|
||||||
|
$honeypot = $normalizeContactValue((string) ($_POST['website'] ?? ''));
|
||||||
|
if ($honeypot !== '') {
|
||||||
|
header('Location: /#form-result');
|
||||||
|
$_SESSION['form_success'] = true;
|
||||||
|
exit;
|
||||||
|
} else {
|
||||||
|
if ($formData['fname'] === '') {
|
||||||
|
$formErrors[] = 'Bitte geben Sie Ihren Vornamen an.';
|
||||||
|
}
|
||||||
|
if ($formData['lname'] === '') {
|
||||||
|
$formErrors[] = 'Bitte geben Sie Ihren Nachnamen an.';
|
||||||
|
}
|
||||||
|
if ($formData['email'] === '' || !filter_var($formData['email'], FILTER_VALIDATE_EMAIL)) {
|
||||||
|
$formErrors[] = 'Bitte geben Sie eine gültige E-Mail-Adresse an.';
|
||||||
|
}
|
||||||
|
if ($formData['message'] === '') {
|
||||||
|
$formErrors[] = 'Bitte geben Sie eine Nachricht ein.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($containsHeaderInjection($formData['email']) || $containsHeaderInjection($formData['fname'] . ' ' . $formData['lname'])) {
|
||||||
|
$formErrors[] = 'Ungültige Zeichen in den Eingabefeldern.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$formTime = isset($_POST['form_time']) ? (int) $_POST['form_time'] : 0;
|
||||||
|
if ($formTime > 0 && (time() - $formTime) < 3) {
|
||||||
|
$formErrors[] = 'Das Formular wurde zu schnell abgeschickt. Bitte versuchen Sie es erneut.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$lastSubmit = $_SESSION['last_contact_submit'] ?? 0;
|
||||||
|
if ($lastSubmit && (time() - $lastSubmit) < 60) {
|
||||||
|
$formErrors[] = 'Bitte warten Sie einen Moment vor der nächsten Anfrage.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($formErrors)) {
|
||||||
|
$to = 'mki@kies-media.de';
|
||||||
|
$subject = 'Kontaktanfrage: ' . $formData['interest'];
|
||||||
|
$body = "Von: {$formData['fname']} {$formData['lname']}\n"
|
||||||
|
. "E-Mail: {$formData['email']}\n";
|
||||||
|
if ($formData['phone'] !== '') {
|
||||||
|
$body .= "Telefon: {$formData['phone']}\n";
|
||||||
|
}
|
||||||
|
$body .= "Anliegen: {$formData['interest']}\n\n"
|
||||||
|
. $formData['message'];
|
||||||
|
|
||||||
|
$headers = "From: {$formData['email']}\r\n";
|
||||||
|
$headers .= "Reply-To: {$formData['email']}\r\n";
|
||||||
|
$headers .= "Content-Type: text/plain; charset=UTF-8\r\n";
|
||||||
|
$headers .= "X-Mailer: PHP/" . phpversion();
|
||||||
|
|
||||||
|
$mailSent = mail($to, $subject, $body, $headers);
|
||||||
|
|
||||||
|
if ($mailSent) {
|
||||||
|
$_SESSION['last_contact_submit'] = time();
|
||||||
|
header('Location: /#form-result');
|
||||||
|
$_SESSION['form_success'] = true;
|
||||||
|
exit;
|
||||||
|
} else {
|
||||||
|
$formErrors[] = 'Leider konnte die E-Mail nicht gesendet werden. Bitte versuchen Sie es später erneut oder schreiben Sie uns direkt an mki@kies-media.de.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!empty($formErrors)) {
|
||||||
|
header('Location: /#form-result');
|
||||||
|
$_SESSION['form_errors'] = $formErrors;
|
||||||
|
$_SESSION['form_data'] = $formData;
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->render('home/index', [
|
||||||
|
'formSuccess' => $formSuccess,
|
||||||
|
'formErrors' => $formErrors,
|
||||||
|
'formData' => $formData,
|
||||||
|
'escapeContactValue' => $escapeContactValue,
|
||||||
|
'pageTitle' => 'Einfamilienhaus mieten Schleusingen | 227 m², 6 Zimmer | 1.300 € Kaltmiete',
|
||||||
|
'pageDescription' => 'Einfamilienhaus zur Langzeitmiete in Schleusingen: 227 m² Wohnfläche, 6 Zimmer, 3 Etagen mit Dachterrasse. Kaltmiete 1.300 €. Bahnhofstraße 10, 98553 Schleusingen. Ab sofort verfügbar.',
|
||||||
|
'canonical' => 'https://haus-schleusingen.de/',
|
||||||
|
'openGraph' => [
|
||||||
|
'ogTitle' => 'Einfamilienhaus zur Miete in Schleusingen – 227 m², 6 Zimmer',
|
||||||
|
'ogDescription' => 'Großzügiges Einfamilienhaus zur Langzeitmiete: 227 m², 6 Zimmer, 3 Etagen + Dachterrasse. Kaltmiete 1.300 €. Ab sofort verfügbar in Schleusingen.',
|
||||||
|
'ogImage' => 'https://haus-schleusingen.de/bilder/Außenansicht-2.png',
|
||||||
|
'ogUrl' => 'https://haus-schleusingen.de/',
|
||||||
|
],
|
||||||
|
'structuredData' => json_encode([
|
||||||
|
'@context' => 'https://schema.org',
|
||||||
|
'@type' => 'RealEstateListing',
|
||||||
|
'name' => 'Einfamilienhaus zur Miete in Schleusingen',
|
||||||
|
'description' => 'Großzügiges Einfamilienhaus zur Langzeitmiete: 227 m² Wohnfläche, 6 Zimmer, 3 Etagen mit Dachterrasse. Kaltmiete 1.300 €.',
|
||||||
|
'url' => 'https://haus-schleusingen.de/',
|
||||||
|
'image' => 'https://haus-schleusingen.de/bilder/Außenansicht-2.png',
|
||||||
|
'datePosted' => '2026-05-14',
|
||||||
|
'address' => [
|
||||||
|
'@type' => 'PostalAddress',
|
||||||
|
'streetAddress' => 'Bahnhofstraße 10',
|
||||||
|
'addressLocality' => 'Schleusingen',
|
||||||
|
'postalCode' => '98553',
|
||||||
|
'addressCountry' => 'DE',
|
||||||
|
],
|
||||||
|
'offers' => [
|
||||||
|
'@type' => 'Offer',
|
||||||
|
'price' => '1300',
|
||||||
|
'priceCurrency' => 'EUR',
|
||||||
|
'priceSpecification' => [
|
||||||
|
'@type' => 'UnitPriceSpecification',
|
||||||
|
'price' => '1300',
|
||||||
|
'priceCurrency' => 'EUR',
|
||||||
|
'unitCode' => 'MON',
|
||||||
|
'description' => 'Kaltmiete pro Monat',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'floorSize' => [
|
||||||
|
'@type' => 'QuantitativeValue',
|
||||||
|
'value' => '227',
|
||||||
|
'unitCode' => 'MTK',
|
||||||
|
],
|
||||||
|
'numberOfRooms' => [
|
||||||
|
'@type' => 'QuantitativeValue',
|
||||||
|
'value' => '6',
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
app/Controllers/ImpressumController.php
Normal file
18
app/Controllers/ImpressumController.php
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
class ImpressumController extends Controller
|
||||||
|
{
|
||||||
|
public function index(): void
|
||||||
|
{
|
||||||
|
$this->render('impressum/index', [
|
||||||
|
'pageTitle' => 'Impressum – Haus Schleusingen',
|
||||||
|
'pageDescription' => 'Impressum der Website haus-schleusingen.de',
|
||||||
|
'robots' => 'noindex',
|
||||||
|
'canonical' => 'https://haus-schleusingen.de/impressum',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
60
app/Core/Router.php
Normal file
60
app/Core/Router.php
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Core;
|
||||||
|
|
||||||
|
class Router
|
||||||
|
{
|
||||||
|
private array $routes = [];
|
||||||
|
|
||||||
|
public function addRoute(string $path, string $controller, string $action = 'index'): void
|
||||||
|
{
|
||||||
|
$this->routes[$path] = [
|
||||||
|
'controller' => $controller,
|
||||||
|
'action' => $action,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function dispatch(string $uri): void
|
||||||
|
{
|
||||||
|
// Normalize: strip query string and trailing slash
|
||||||
|
$path = parse_url($uri, PHP_URL_PATH);
|
||||||
|
$path = rtrim($path, '/') ?: '/';
|
||||||
|
|
||||||
|
// Direct match
|
||||||
|
if (isset($this->routes[$path])) {
|
||||||
|
$this->execute($this->routes[$path]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legacy .html redirect (301)
|
||||||
|
if (preg_match('#^/(impressum|datenschutz)\.html$#', $path, $m)) {
|
||||||
|
header('Location: /' . $m[1], true, 301);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 404
|
||||||
|
http_response_code(404);
|
||||||
|
echo '<h1>404 – Seite nicht gefunden</h1>';
|
||||||
|
echo '<p><a href="/">Zurück zur Startseite</a></p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function execute(array $route): void
|
||||||
|
{
|
||||||
|
$controllerClass = $route['controller'];
|
||||||
|
$action = $route['action'];
|
||||||
|
|
||||||
|
if (!class_exists($controllerClass)) {
|
||||||
|
throw new \RuntimeException("Controller {$controllerClass} nicht gefunden.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$controller = new $controllerClass();
|
||||||
|
|
||||||
|
if (!method_exists($controller, $action)) {
|
||||||
|
throw new \RuntimeException("Action {$action} in {$controllerClass} nicht gefunden.");
|
||||||
|
}
|
||||||
|
|
||||||
|
$controller->$action();
|
||||||
|
}
|
||||||
|
}
|
||||||
46
app/Core/View.php
Normal file
46
app/Core/View.php
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Core;
|
||||||
|
|
||||||
|
class View
|
||||||
|
{
|
||||||
|
private string $viewsPath;
|
||||||
|
private array $data = [];
|
||||||
|
|
||||||
|
public function __construct(?string $viewsPath = null)
|
||||||
|
{
|
||||||
|
$this->viewsPath = $viewsPath ?? dirname(__DIR__) . '/views';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function assign(string $key, mixed $value): void
|
||||||
|
{
|
||||||
|
$this->data[$key] = $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render(string $view, string $layout = 'main'): void
|
||||||
|
{
|
||||||
|
$viewFile = $this->viewsPath . '/' . $view . '.php';
|
||||||
|
$layoutFile = $this->viewsPath . '/layouts/' . $layout . '.php';
|
||||||
|
|
||||||
|
if (!file_exists($viewFile)) {
|
||||||
|
throw new \RuntimeException("View {$view} nicht gefunden: {$viewFile}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file_exists($layoutFile)) {
|
||||||
|
throw new \RuntimeException("Layout {$layout} nicht gefunden: {$layoutFile}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract data to variables for the view
|
||||||
|
extract($this->data, EXTR_SKIP);
|
||||||
|
|
||||||
|
// Capture view content
|
||||||
|
ob_start();
|
||||||
|
require $viewFile;
|
||||||
|
$content = ob_get_clean();
|
||||||
|
|
||||||
|
// Render layout with $content
|
||||||
|
require $layoutFile;
|
||||||
|
}
|
||||||
|
}
|
||||||
16
composer.json
Normal file
16
composer.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "greggy/landingpage-haus-schleusingen",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"App\\": "app/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload-dev": {
|
||||||
|
"psr-4": {
|
||||||
|
"Tests\\": "tests/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpunit/phpunit": "^11.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
1800
composer.lock
generated
Normal file
1800
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -7,12 +7,16 @@
|
|||||||
"lint:html": "htmlhint \"**/*.html\"",
|
"lint:html": "htmlhint \"**/*.html\"",
|
||||||
"lint:css": "stylelint \"css/**/*.css\" \"fonts/**/*.css\" && echo Stylelint: No errors found",
|
"lint:css": "stylelint \"css/**/*.css\" \"fonts/**/*.css\" && echo Stylelint: No errors found",
|
||||||
"lint:js": "eslint \"js/**/*.js\" --ignore-pattern \"**/*.min.js\" && echo ESLint: No errors found",
|
"lint:js": "eslint \"js/**/*.js\" --ignore-pattern \"**/*.min.js\" && echo ESLint: No errors found",
|
||||||
"lint": "npm run lint:html && npm run lint:css && npm run lint:js",
|
"lint": "npm run lint:php && npm run lint:html && npm run lint:css && npm run lint:js",
|
||||||
"format": "prettier --write \"**/*.{html,css,js,json,md}\" --ignore-path .prettierignore",
|
"format": "prettier --write \"**/*.{html,css,js,json,md}\" --ignore-path .prettierignore",
|
||||||
"format:check": "prettier --check \"**/*.{html,css,js,json,md}\" --ignore-path .prettierignore",
|
"format:check": "prettier --check \"**/*.{html,css,js,json,md}\" --ignore-path .prettierignore",
|
||||||
"prepare": "husky"
|
"prepare": "husky",
|
||||||
|
"lint:php": "find . -name \"*.php\" -not -path \"./vendor/*\" -exec php -l {} \\; > /dev/null 2>&1 && echo \"PHP syntax OK\" || (echo \"PHP lint failed\" && exit 1)"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
|
"*.{php}": [
|
||||||
|
"scripts/lint-php.sh"
|
||||||
|
],
|
||||||
"*.{html}": [
|
"*.{html}": [
|
||||||
"htmlhint",
|
"htmlhint",
|
||||||
"prettier --write"
|
"prettier --write"
|
||||||
|
|||||||
23
phpunit.xml
Normal file
23
phpunit.xml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||||||
|
bootstrap="vendor/autoload.php"
|
||||||
|
colors="true"
|
||||||
|
cacheDirectory=".phpunit.cache"
|
||||||
|
failOnWarning="true"
|
||||||
|
failOnRisky="true"
|
||||||
|
failOnEmptyTestSuite="true"
|
||||||
|
beStrictAboutOutputDuringTests="true"
|
||||||
|
>
|
||||||
|
<testsuites>
|
||||||
|
<testsuite name="Unit">
|
||||||
|
<directory>tests</directory>
|
||||||
|
</testsuite>
|
||||||
|
</testsuites>
|
||||||
|
|
||||||
|
<source>
|
||||||
|
<include>
|
||||||
|
<directory>app</directory>
|
||||||
|
</include>
|
||||||
|
</source>
|
||||||
|
</phpunit>
|
||||||
24
public/index.php
Normal file → Executable file
24
public/index.php
Normal file → Executable file
@@ -7,28 +7,8 @@ declare(strict_types=1);
|
|||||||
* All requests are routed through this file.
|
* All requests are routed through this file.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Autoloader (PSR-4 style, simple)
|
// Autoloader (composer PSR-4)
|
||||||
spl_autoload_register(function (string $class): void {
|
require_once __DIR__ . '/../vendor/autoload.php';
|
||||||
$prefix = 'App\\';
|
|
||||||
$baseDir = __DIR__ . '/../app/';
|
|
||||||
|
|
||||||
$len = strlen($prefix);
|
|
||||||
if (strncmp($prefix, $class, $len) !== 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$relativeClass = substr($class, $len);
|
|
||||||
// Lowercase directory names, keep class name case
|
|
||||||
$parts = explode('\\', $relativeClass);
|
|
||||||
$className = array_pop($parts);
|
|
||||||
$parts = array_map('strtolower', $parts);
|
|
||||||
$parts[] = $className;
|
|
||||||
$file = $baseDir . implode('/', $parts) . '.php';
|
|
||||||
|
|
||||||
if (file_exists($file)) {
|
|
||||||
require $file;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
use App\Core\Router;
|
use App\Core\Router;
|
||||||
|
|
||||||
|
|||||||
28
scripts/lint-php.sh
Executable file
28
scripts/lint-php.sh
Executable file
@@ -0,0 +1,28 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Pre-commit PHP syntax check for lint-staged
|
||||||
|
# Called with staged .php files as arguments
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if ! command -v php &>/dev/null; then
|
||||||
|
echo "❌ PHP not found. Install php-cli to commit PHP files."
|
||||||
|
echo " Ubuntu/Debian: sudo apt-get install php-cli"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
errors=0
|
||||||
|
for file in "$@"; do
|
||||||
|
if ! php -l "$file" >/dev/null 2>&1; then
|
||||||
|
echo "❌ Syntax error in $file"
|
||||||
|
php -l "$file"
|
||||||
|
errors=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$errors" -eq 1 ]; then
|
||||||
|
echo ""
|
||||||
|
echo "❌ PHP lint failed. Fix errors before committing."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ PHP syntax OK"
|
||||||
37
scripts/safe-commit.sh
Executable file
37
scripts/safe-commit.sh
Executable file
@@ -0,0 +1,37 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# safe-commit.sh – Commit with pre-commit hooks guaranteed to run
|
||||||
|
# Usage: ./scripts/safe-commit.sh "commit message"
|
||||||
|
#
|
||||||
|
# This script ensures lint checks always execute, even when committing
|
||||||
|
# from non-interactive contexts (CI, AI agents, etc.).
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ -z "${1:-}" ]; then
|
||||||
|
echo "❌ Usage: ./scripts/safe-commit.sh \"commit message\""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
MSG="$1"
|
||||||
|
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$REPO_ROOT"
|
||||||
|
|
||||||
|
# Ensure hooks directory exists and is configured
|
||||||
|
if [ -d ".husky" ]; then
|
||||||
|
git config core.hooksPath .husky
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Run lint-staged manually as a safety net (in case hook is skipped)
|
||||||
|
if command -v npx &>/dev/null && [ -f "node_modules/.bin/lint-staged" ]; then
|
||||||
|
echo "🔍 Running pre-commit lint checks..."
|
||||||
|
npx lint-staged || {
|
||||||
|
echo ""
|
||||||
|
echo "❌ Lint checks failed. Commit aborted."
|
||||||
|
echo " Fix the errors above and try again."
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
echo "✅ All lint checks passed."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Commit with hooks enabled (no --no-verify)
|
||||||
|
git commit -m "$MSG"
|
||||||
349
tests/Core/RouterTest.php
Normal file
349
tests/Core/RouterTest.php
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tests\Core;
|
||||||
|
|
||||||
|
use App\Core\Router;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for App\Core\Router.
|
||||||
|
*
|
||||||
|
* Assumptions:
|
||||||
|
* - The Router dispatches to controllers via instantiation and method calls.
|
||||||
|
* - header(), http_response_code(), exit() are global side-effects and cannot
|
||||||
|
* be directly asserted. We use a testable subclass to intercept them.
|
||||||
|
*/
|
||||||
|
class RouterTest extends TestCase
|
||||||
|
{
|
||||||
|
private TestableRouter $router;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->router = new TestableRouter();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// addRoute()
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testAddsRouteWithDefaultAction(): void
|
||||||
|
{
|
||||||
|
$this->router->addRoute('/about', 'App\\Controllers\\AboutController');
|
||||||
|
|
||||||
|
$this->assertEquals([
|
||||||
|
'controller' => 'App\\Controllers\\AboutController',
|
||||||
|
'action' => 'index',
|
||||||
|
], $this->router->getLastRoute('/about'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAddsRouteWithCustomAction(): void
|
||||||
|
{
|
||||||
|
$this->router->addRoute('/contact', 'App\\Controllers\\ContactController', 'send');
|
||||||
|
|
||||||
|
$this->assertEquals([
|
||||||
|
'controller' => 'App\\Controllers\\ContactController',
|
||||||
|
'action' => 'send',
|
||||||
|
], $this->router->getLastRoute('/contact'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOverwritesExistingRoute(): void
|
||||||
|
{
|
||||||
|
$this->router->addRoute('/page', 'App\\Controllers\\OldController');
|
||||||
|
$this->router->addRoute('/page', 'App\\Controllers\\NewController');
|
||||||
|
|
||||||
|
$this->assertEquals(
|
||||||
|
'App\\Controllers\\NewController',
|
||||||
|
$this->router->getLastRoute('/page')['controller']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// dispatch() – URL normalization
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testDispatchStripsQueryString(): void
|
||||||
|
{
|
||||||
|
$this->router->addRoute('/home', 'Tests\\Core\\DummyController', 'greet');
|
||||||
|
|
||||||
|
$this->router->dispatch('/home?foo=bar');
|
||||||
|
|
||||||
|
$this->assertTrue($this->router->hasDispatched());
|
||||||
|
$this->assertEquals('Tests\\Core\\DummyController', $this->router->getLastController());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDispatchStripsTrailingSlash(): void
|
||||||
|
{
|
||||||
|
$this->router->addRoute('/home', 'Tests\\Core\\DummyController', 'greet');
|
||||||
|
|
||||||
|
$this->router->dispatch('/home/');
|
||||||
|
|
||||||
|
$this->assertTrue($this->router->hasDispatched());
|
||||||
|
$this->assertEquals('Tests\\Core\\DummyController', $this->router->getLastController());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDispatchNormalizesRootPath(): void
|
||||||
|
{
|
||||||
|
$this->router->addRoute('/', 'Tests\\Core\\DummyController', 'greet');
|
||||||
|
|
||||||
|
$this->router->dispatch('/');
|
||||||
|
|
||||||
|
$this->assertTrue($this->router->hasDispatched());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// dispatch() – direct match
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testDispatchExecutesMatchingRoute(): void
|
||||||
|
{
|
||||||
|
$this->router->addRoute('/home', 'Tests\\Core\\DummyController', 'greet');
|
||||||
|
|
||||||
|
$this->router->dispatch('/home');
|
||||||
|
|
||||||
|
$this->assertTrue($this->router->hasDispatched());
|
||||||
|
$this->assertEquals('Tests\\Core\\DummyController', $this->router->getLastController());
|
||||||
|
$this->assertEquals('greet', $this->router->getLastAction());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDispatchExecutesCustomAction(): void
|
||||||
|
{
|
||||||
|
$this->router->addRoute('/dummy', 'Tests\\Core\\DummyController', 'greet');
|
||||||
|
|
||||||
|
$this->router->dispatch('/dummy');
|
||||||
|
|
||||||
|
$this->assertEquals('greet', $this->router->getLastAction());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// dispatch() – legacy .html redirect
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testDispatchRedirectsLegacyImpressumHtml(): void
|
||||||
|
{
|
||||||
|
$this->router->dispatch('/impressum.html');
|
||||||
|
|
||||||
|
$this->assertTrue($this->router->hasRedirected());
|
||||||
|
$this->assertEquals(301, $this->router->getLastStatusCode());
|
||||||
|
$this->assertEquals('Location: /impressum', $this->router->getLastHeader());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDispatchRedirectsLegacyDatenschutzHtml(): void
|
||||||
|
{
|
||||||
|
$this->router->dispatch('/datenschutz.html');
|
||||||
|
|
||||||
|
$this->assertTrue($this->router->hasRedirected());
|
||||||
|
$this->assertEquals(301, $this->router->getLastStatusCode());
|
||||||
|
$this->assertEquals('Location: /datenschutz', $this->router->getLastHeader());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('provideNonMatchingLegacyPaths')]
|
||||||
|
public function testDispatchDoesNotRedirectNonMatchingLegacyPaths(string $path): void
|
||||||
|
{
|
||||||
|
$this->router->dispatch($path);
|
||||||
|
|
||||||
|
$this->assertFalse($this->router->hasRedirected());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function provideNonMatchingLegacyPaths(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'/impressum.htm' => ['/impressum.htm'],
|
||||||
|
'/kontakt.html' => ['/kontakt.html'],
|
||||||
|
'/about.html' => ['/about.html'],
|
||||||
|
'/impressum.html.bak' => ['/impressum.html.bak'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// dispatch() – 404
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testDispatchReturns404ForUnknownPath(): void
|
||||||
|
{
|
||||||
|
$this->router->dispatch('/nonexistent');
|
||||||
|
|
||||||
|
$this->assertFalse($this->router->hasDispatched());
|
||||||
|
$this->assertFalse($this->router->hasRedirected());
|
||||||
|
$this->assertStringContainsString('404', $this->router->getOutput());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// dispatch() – controller not found
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testDispatchThrowsWhenControllerDoesNotExist(): void
|
||||||
|
{
|
||||||
|
$this->router->addRoute('/broken', 'App\\Controllers\\NonExistentController');
|
||||||
|
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
$this->expectExceptionMessage(
|
||||||
|
'Controller App\\Controllers\\NonExistentController nicht gefunden.'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->router->dispatch('/broken');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// dispatch() – action not found
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testDispatchThrowsWhenActionDoesNotExist(): void
|
||||||
|
{
|
||||||
|
$this->router->addRoute('/broken', 'Tests\\Core\\DummyController', 'nonExistentAction');
|
||||||
|
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
$this->expectExceptionMessage(
|
||||||
|
'Action nonExistentAction in Tests\\Core\\DummyController nicht gefunden.'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->router->dispatch('/broken');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// dispatch() – successful controller invocation
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testDispatchInvokesControllerAction(): void
|
||||||
|
{
|
||||||
|
$this->router->addRoute('/dummy', 'Tests\\Core\\DummyController', 'greet');
|
||||||
|
|
||||||
|
$this->router->dispatch('/dummy');
|
||||||
|
|
||||||
|
$this->assertTrue($this->router->hasDispatched());
|
||||||
|
$this->assertStringContainsString('hello-from-dummy', $this->router->getOutput());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────
|
||||||
|
// Test helpers – not part of the application
|
||||||
|
// ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subclass of Router that intercepts side-effects (header, exit, echo)
|
||||||
|
* so they can be asserted in tests.
|
||||||
|
*/
|
||||||
|
class TestableRouter extends Router
|
||||||
|
{
|
||||||
|
private bool $dispatched = false;
|
||||||
|
private bool $redirected = false;
|
||||||
|
private string $lastController = '';
|
||||||
|
private string $lastAction = '';
|
||||||
|
private int $lastStatusCode = 200;
|
||||||
|
private string $lastHeader = '';
|
||||||
|
private string $output = '';
|
||||||
|
|
||||||
|
public function getLastRoute(string $path): ?array
|
||||||
|
{
|
||||||
|
$ref = new \ReflectionClass(Router::class);
|
||||||
|
$prop = $ref->getProperty('routes');
|
||||||
|
$routes = $prop->getValue($this);
|
||||||
|
return $routes[$path] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasDispatched(): bool
|
||||||
|
{
|
||||||
|
return $this->dispatched;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function hasRedirected(): bool
|
||||||
|
{
|
||||||
|
return $this->redirected;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLastController(): string
|
||||||
|
{
|
||||||
|
return $this->lastController;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLastAction(): string
|
||||||
|
{
|
||||||
|
return $this->lastAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLastStatusCode(): int
|
||||||
|
{
|
||||||
|
return $this->lastStatusCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getLastHeader(): string
|
||||||
|
{
|
||||||
|
return $this->lastHeader;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getOutput(): string
|
||||||
|
{
|
||||||
|
return $this->output;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(array $route): void
|
||||||
|
{
|
||||||
|
$controllerClass = $route['controller'];
|
||||||
|
$action = $route['action'];
|
||||||
|
|
||||||
|
if (!class_exists($controllerClass)) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
"Controller {$controllerClass} nicht gefunden."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$controller = new $controllerClass();
|
||||||
|
|
||||||
|
if (!method_exists($controller, $action)) {
|
||||||
|
throw new \RuntimeException(
|
||||||
|
"Action {$action} in {$controllerClass} nicht gefunden."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->lastController = $controllerClass;
|
||||||
|
$this->lastAction = $action;
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
$controller->$action();
|
||||||
|
$this->output = ob_get_clean();
|
||||||
|
|
||||||
|
$this->dispatched = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function dispatch(string $uri): void
|
||||||
|
{
|
||||||
|
$path = parse_url($uri, \PHP_URL_PATH);
|
||||||
|
$path = rtrim($path, '/') ?: '/';
|
||||||
|
|
||||||
|
if (isset($this->getRoutes()[$path])) {
|
||||||
|
$this->execute($this->getRoutes()[$path]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('#^/(impressum|datenschutz)\.html$#', $path, $m)) {
|
||||||
|
$this->lastHeader = 'Location: /' . $m[1];
|
||||||
|
$this->lastStatusCode = 301;
|
||||||
|
$this->redirected = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->lastStatusCode = 404;
|
||||||
|
$this->output = '<h1>404 – Seite nicht gefunden</h1>';
|
||||||
|
$this->output .= '<p><a href="/">Zurück zur Startseite</a></p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getRoutes(): array
|
||||||
|
{
|
||||||
|
$ref = new \ReflectionClass(Router::class);
|
||||||
|
$prop = $ref->getProperty('routes');
|
||||||
|
return $prop->getValue($this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal controller for testing dispatch.
|
||||||
|
*/
|
||||||
|
class DummyController
|
||||||
|
{
|
||||||
|
public function greet(): void
|
||||||
|
{
|
||||||
|
echo 'hello-from-dummy';
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user