chore: complete update

This commit is contained in:
2025-07-17 16:24:20 +02:00
parent 899227b0a4
commit 64a7051137
1300 changed files with 85570 additions and 2756 deletions

View File

@@ -0,0 +1,340 @@
<?php
namespace App\Application\Admin;
use App\Framework\Attributes\Route;
use App\Framework\Auth\Auth;
use App\Framework\Config\TypedConfiguration;
use App\Framework\Core\VersionInfo;
use App\Framework\DI\DefaultContainer;
use App\Framework\Http\HttpResponse;
use App\Framework\Http\Method;
use App\Framework\Http\Response;
use App\Framework\Meta\MetaData;
use App\Framework\Meta\OpenGraphTypeWebsite;
use App\Framework\Performance\MemoryUsageTracker;
use App\Framework\Router\Result\ViewResult;
use App\Framework\Http\Session\SessionManager;
use App\Framework\Http\Status;
use Dom\HTMLDocument;
final readonly class Dashboard
{
public function __construct(
private DefaultContainer $container,
private VersionInfo $versionInfo,
private MemoryUsageTracker $memoryTracker,
private TypedConfiguration $config,
) {}
#[Auth]
#[Route(path: '/admin', method: Method::GET)]
public function show(): ViewResult
{
$stats = [
'frameworkVersion' => $this->versionInfo->getVersion(),
'phpVersion' => PHP_VERSION,
'memoryUsage' => $this->formatBytes(memory_get_usage(true)),
'peakMemoryUsage' => $this->formatBytes(memory_get_peak_usage(true)),
'serverInfo' => $_SERVER['SERVER_SOFTWARE'] ?? 'Unknown',
'serverTime' => date('Y-m-d H:i:s'),
'timezone' => date_default_timezone_get(),
'operatingSystem' => PHP_OS,
'loadedExtensions' => $this->getLoadedExtensions(),
'sessionCount' => $this->getActiveSessionCount(),
'uptime' => $this->getServerUptime(),
'servicesCount' => 4,#count($this->container->getServiceIds()),
];
#debug($stats);
return new ViewResult(
template: 'dashboard',
metaData: new MetaData('Admin Dashboard'),
data: [
'title' => 'Admin Dashboard',
'stats' => $stats
]
);
}
#[Auth]
#[Route(path: '/admin/routes', method: Method::GET)]
public function routes(): ViewResult
{
$routeRegistry = $this->container->get('App\Framework\Router\RouteRegistry');
$routes = $routeRegistry->getRoutes();
// Sort routes by path for better readability
usort($routes, function($a, $b) {
return strcmp($a->path, $b->path);
});
return new ViewResult(
template: 'admin/routes',
metaData: new MetaData('', ''),
data: [
'title' => 'Routen-Übersicht',
'routes' => $routes
]
);
}
#[Auth]
#[Route(path: '/admin/services', method: Method::GET)]
public function services(): ViewResult
{
$services = $this->container->getServiceIds();
sort($services);
return new ViewResult(
template: 'admin/services',
data: [
'title' => 'Registrierte Dienste',
'services' => $services
]
);
}
#[Auth]
#[Route(path: '/admin/environment', method: Method::GET)]
public function environment(): ViewResult
{
$env = [];
foreach ($_ENV as $key => $value) {
// Maskiere sensible Daten
if (str_contains(strtolower($key), 'password') ||
str_contains(strtolower($key), 'secret') ||
str_contains(strtolower($key), 'key')) {
$value = '********';
}
$env[$key] = $value;
}
ksort($env);
dd($env);
return new ViewResult(
template: 'admin/environment',
metaData: new MetaData('', ''),
data: [
'title' => 'Umgebungsvariablen',
'env' => $env
]
);
}
#[Auth]
#[Route('/admin/phpinfo')]
#[Route(path: '/admin/phpinfo/{mode}', method: Method::GET)]
public function phpInfo(int $mode = 1): Response
{
ob_start();
phpinfo($mode);
$phpinfo = ob_get_clean();
// Extraktion des <body> Inhalts, um nur den relevanten Teil anzuzeigen
preg_match('/<body[^>]*>(.*?)<\/body>/si', $phpinfo, $matches);
$body = $matches[1] ?? $phpinfo;
// Entfernen der Navigation-Links am Anfang
#$body = preg_replace('/<div class="center">(.*?)<\/div>/si', '', $body, 1);
#debug($body);
// Hinzufügen von eigenen Styles
$customStyles = '<style>
.phpinfo { font-family: system-ui, sans-serif; line-height: 1.5; }
.phpinfo table { border-collapse: collapse; width: 100%; margin-bottom: 1rem; }
.phpinfo td, .phpinfo th { padding: 0.5rem; border: 1px solid #ddd; }
.phpinfo h1, .phpinfo h2 { margin-bottom: 1rem; }
.phpinfo hr { margin: 2rem 0; }
</style>';
$responseBody = '<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PHP Info</title>
<link rel="stylesheet" href="/css/admin.css">
' . $customStyles . '
</head>
<body class="admin-page">
<div class="admin-header">
<h1>PHP Info</h1>
<a href="/admin" class="btn">Zurück zum Dashboard</a>
</div>
<div class="admin-content">
<div class="phpinfo">' . $body . '</div>
</div>
</body>
</html>';
echo $responseBody;
die();
return new HttpResponse(status: Status::OK, body: $responseBody);
}
#[Auth]
#[Route(path: '/admin/performance', method: Method::GET)]
public function performance(): ViewResult
{
$performanceData = [
'currentMemoryUsage' => $this->formatBytes(memory_get_usage(true)),
'peakMemoryUsage' => $this->formatBytes(memory_get_peak_usage(true)),
'memoryLimit' => $this->formatBytes($this->getMemoryLimitInBytes()),
'memoryUsagePercentage' => round((memory_get_usage(true) / $this->getMemoryLimitInBytes()) * 100, 2),
'loadAverage' => function_exists('sys_getloadavg') ? sys_getloadavg() : ['N/A', 'N/A', 'N/A'],
'opcacheEnabled' => function_exists('opcache_get_status') ? 'Ja' : 'Nein',
'executionTime' => number_format(microtime(true) - $_SERVER['REQUEST_TIME_FLOAT'], 4) . ' Sekunden',
'includedFiles' => count(get_included_files()),
];
if (function_exists('opcache_get_status')) {
try {
$opcacheStatus = opcache_get_status(false);
if ($opcacheStatus !== false) {
$performanceData['opcacheMemoryUsage'] = $this->formatBytes($opcacheStatus['memory_usage']['used_memory']);
$performanceData['opcacheCacheHits'] = number_format($opcacheStatus['opcache_statistics']['hits']);
$performanceData['opcacheMissRate'] = number_format($opcacheStatus['opcache_statistics']['misses'] /
($opcacheStatus['opcache_statistics']['hits'] + $opcacheStatus['opcache_statistics']['misses']) * 100, 2) . '%';
}
} catch (\Throwable $e) {
// OPCache Status konnte nicht abgerufen werden
$performanceData['opcacheError'] = $e->getMessage();
}
}
return new ViewResult(
template: 'performance',
data: [
'title' => 'Performance-Daten',
'performance' => $performanceData
]
);
}
#[Auth]
#[Route(path: '/admin/redis', method: Method::GET)]
public function redisInfo(): ViewResult
{
$redisInfo = [];
try {
$redis = $this->container->get('Predis\Client');
$info = $redis->info();
$redisInfo['status'] = 'Verbunden';
$redisInfo['version'] = $info['redis_version'];
$redisInfo['uptime'] = $this->formatUptime((int)$info['uptime_in_seconds']);
$redisInfo['memory'] = $this->formatBytes((int)$info['used_memory']);
$redisInfo['peak_memory'] = $this->formatBytes((int)$info['used_memory_peak']);
$redisInfo['clients'] = $info['connected_clients'];
$redisInfo['keys'] = $redis->dbsize();
// Einige Schlüssel auflisten (begrenzt auf 50)
$keys = $redis->keys('*');
$redisInfo['key_sample'] = array_slice($keys, 0, 50);
} catch (\Throwable $e) {
$redisInfo['status'] = 'Fehler: ' . $e->getMessage();
}
return new ViewResult(
template: 'admin/redis',
data: [
'title' => 'Redis Information',
'redis' => $redisInfo
]
);
}
private function formatBytes(int $bytes, int $precision = 2): string
{
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
$bytes /= (1 << (10 * $pow));
return round($bytes, $precision) . ' ' . $units[$pow];
}
private function getMemoryLimitInBytes(): int
{
$memoryLimit = ini_get('memory_limit');
if ($memoryLimit === '-1') {
return PHP_INT_MAX;
}
$value = (int) $memoryLimit;
$unit = strtolower($memoryLimit[strlen($memoryLimit)-1]);
switch($unit) {
case 'g':
$value *= 1024;
case 'm':
$value *= 1024;
case 'k':
$value *= 1024;
break;
}
return $value;
}
private function getLoadedExtensions(): array
{
$extensions = get_loaded_extensions();
sort($extensions);
return $extensions;
}
private function getActiveSessionCount(): int
{
try {
if ($this->container->has(SessionManager::class)) {
$sessionManager = $this->container->get(SessionManager::class);
// Diese Methode müsste implementiert werden
return $sessionManager->getActiveSessionCount();
}
} catch (\Throwable $e) {
// Silent fail
}
return 0;
}
private function getServerUptime(): string
{
// Für Linux-Systeme
if (function_exists('shell_exec') && stripos(PHP_OS, 'Linux') !== false) {
$uptime = shell_exec('uptime -p');
if ($uptime) {
return $uptime;
}
}
// Fallback
return 'Nicht verfügbar';
}
private function formatUptime(int $seconds): string
{
$days = floor($seconds / 86400);
$hours = floor(($seconds % 86400) / 3600);
$minutes = floor(($seconds % 3600) / 60);
$remainingSeconds = $seconds % 60;
$result = '';
if ($days > 0) {
$result .= "$days Tage, ";
}
return $result . sprintf('%02d:%02d:%02d', $hours, $minutes, $remainingSeconds);
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Application\Admin;
use App\Domain\Media\Image;
use App\Domain\Media\ImageRepository;
use App\Framework\Attributes\Route;
use App\Framework\Auth\Auth;
final readonly class Images
{
#[Auth]
#[Route('/admin/images')]
public function showAll(ImageRepository $imageRepository)
{
$images = $imageRepository->findAll();
echo "<div style='background-color: #222; display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); grid-gap: 16px; padding: 16px;'>";
/** @var Image $image */
foreach($images as $image) {
echo sprintf("<a href='/images/%s'>", $image->filename);
echo "<img src='/images/" . $image->filename . "' style='width: 400px; aspect-ratio: 1; object-fit: cover; border-radius: 16px;'/>";
echo "</a>";
var_dump($image->variants[0]->filename ?? 'no variant');
}
echo "</div>";
debug($images);
die();
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Application\Admin;
use App\Framework\View\Template;
#[Template('routes')]
class RoutesViewModel
{
public string $name = 'Michael';
public string $title = 'Routes';
public function __construct(
public array $routes = []
)
{
}
}

View File

@@ -0,0 +1,37 @@
<?php
namespace App\Application\Admin;
use App\Framework\Attributes\Route;
use App\Framework\Auth\Auth;
use App\Framework\Cache\Cache;
use App\Framework\Discovery\Results\DiscoveryResults;
use App\Framework\View\TemplateDiscoveryVisitor;
class ShowDiscovery
{
public function __construct(
private DiscoveryResults $results,
)
{
}
#[Auth]
#[Route('/admin/discovery')]
public function show(
#Cache $cache
)
{
$attributes = $this->results->getAllAttributeResults();
foreach ($attributes as $name => $attribute) {
echo "Attribute: $name <br/>";
echo "<ul>";
foreach ($attribute as $result) {
echo "<li>" . $result['class'] . '::'.($result['method'] ?? '').'()</li>';
};
echo "</ul>";
};
}
}

View File

@@ -0,0 +1,86 @@
<?php
namespace App\Application\Admin;
use App\Domain\Media\ImageSlot;
use App\Domain\Media\ImageSlotRepository;
use App\Framework\Attributes\Route;
use App\Framework\Auth\Auth;
use App\Framework\Http\Method;
use App\Framework\Http\Request;
use App\Framework\Meta\MetaData;
use App\Framework\Router\Result\ViewResult;
class ShowImageSlots
{
public function __construct(
private ImageSlotRepository $imageSlotRepository,
) {}
#[Auth]
#[Route('/admin/imageslots')]
public function show()
{
$slots = $this->imageSlotRepository->getSlots();
/** @var ImageSlot $slot */
foreach($slots as $slot) {
#echo $slot->slotName . '<br/>';
if($slot->image !== null) {
# echo $slot->image->filename . '<br/>';
}
$slotName = $slot->slotName;
}
return new ViewResult('imageslots', new MetaData('Image Slots', 'Image Slots'), [
'slotName' => $slotName,
'slots' => $slots,
]);
}
#[Auth]
#[Route('/admin/imageslots/{slotName}', method: Method::POST)]
public function update(string $slotName): ViewResult
{
$slot = $this->imageSlotRepository->findBySlotName(urldecode($slotName));
$slotName = $slot->slotName;
#echo "<input type='text' value='$slotName' />";
return new ViewResult('imageslot', new MetaData('Image Slot', 'Image Slots'), [
'slotName' => $slotName,
'id' => $slot->id,
]);
}
#[Auth]
#[Route('/admin/imageslots/create', method: Method::POST)]
public function create(Request $request)
{
$name = $request->parsedBody->get('slotName');
$slot = new ImageSlot(0, $name, '');
$this->imageSlotRepository->save($slot);
debug($name);
}
#[Auth]
#[Route('/admin/imageslots/edit/{id}', method: Method::PUT)]
public function edit(Request $request, string $id)
{
$name = $request->parsedBody->get('slotName');
$slot = $this->imageSlotRepository->findById((int)$id);
$slot = new ImageSlot($slot->id, $name, $slot->imageId);
$this->imageSlotRepository->save($slot);
}
}

View File

@@ -0,0 +1,146 @@
<?php
namespace App\Application\Admin;
use App\Domain\Media\Image;
use App\Domain\Media\ImageRepository;
use App\Domain\Media\ImageResizer;
use App\Domain\Media\ImageVariantRepository;
use App\Domain\Media\SaveImageFile;
use App\Framework\Attributes\Route;
use App\Framework\Auth\Auth;
use App\Framework\Core\PathProvider;
use App\Framework\Database\Transaction;
use App\Framework\DateTime\SystemClock;
use App\Framework\Http\Method;
use App\Framework\Http\Request;
use App\Framework\Http\UploadedFile;
use App\Framework\Ulid\StringConverter;
use App\Framework\Ulid\Ulid;
use Media\Services\ImageService;
use function imagecopyresampled;
use function imagecreatefromjpeg;
use function imagecreatetruecolor;
use function imagedestroy;
use function imagejpeg;
class ShowImageUpload
{
public function __construct(
private PathProvider $pathProvider,
private StringConverter $stringConverter,
) {}
#[Auth]
#[Route('/upload')]
public function __invoke()
{
$html = <<<HTML
<form action="/upload" method="post" enctype="multipart/form-data">
<label for="image">Bild hochladen:</label>
<input type="file" id="image" name="image" accept="image/*" required/>
<input type="submit" value="Upload" />
</form>
HTML;
echo $html;
die();
}
#[Auth]
#[Route('/upload', Method::POST)]
public function upload(Request $request, Ulid $ulid, ImageRepository $imageRepository, ImageVariantRepository $imageVariantRepository,)
{
try {
/** @var UploadedFile $file */
$file = $request->files->get('image');
$storageFolder = $this->pathProvider->resolvePath('/storage');
// Todo: Use Clock instead of date();
$uploadDirectory = sprintf('uploads/%s/%s/%s', date('Y'), date('m'), date('d'));
$ulid = (string)$ulid; //$this->stringConverter->encodeBase32($ulid);
$id = $ulid;
// Remove Timestamp
$id = substr($id, 10);
$hash = hash_file('sha256', $file->tmpName);
// Prüfen, ob ein Bild mit diesem Hash bereits existiert
$existingImage = $imageRepository->findByHash($hash);
if ($existingImage !== null) {
echo "<h2>Bild bereits vorhanden</h2>";
echo "<p>Dieses Bild wurde bereits hochgeladen.</p>";
echo "<p>Bild-ID: " . htmlspecialchars($existingImage->ulid) . "</p>";
return;
}
$idStr = str_pad((string)$id, 9, '0', STR_PAD_LEFT);
$filePathPattern = sprintf('%s/%s/%s',
substr($idStr, 0, 3),
substr($idStr, 3, 3),
substr($idStr, 6, 3),
);
$path = $storageFolder . '/' . $uploadDirectory . '/' . $filePathPattern . "/";
$filename = $idStr . '_' . $hash . "_";
#dd($path . $filename . 'variant.png');
$smallPath = $path . $filename . 'small.png';
[$width, $height] = getimagesize($file->tmpName);
$image = new Image(
ulid : $ulid,
filename : $filename . 'original.jpg',
originalFilename: $file->name,
mimeType : $file->getMimeType(),
fileSize : $file->size,
width : $width,
height : $height,
hash : $hash,
path : $path,
altText : 'Some alt text',
);
$imageRepository->save($image, $file->tmpName);
#$image = $imageRepository->findById("0197B2CD759501F08D60312AE62ACCFC");
#mkdir($path, 0755, true);
$variant = new ImageResizer()($image, 50, 50);
$imageVariantRepository->save($variant);;
$href = "/images/".$variant->filename;
echo "<a href='$href'>$href</a>";
#new SaveImageFile()($image, $file->tmpName);;
debug($variant->filename);
dd($image);
} catch (\Exception $e) {
echo "<h2>Fehler beim Upload:</h2>";
echo "<p>" . htmlspecialchars($e->getMessage()) . "</p>";
debug($e);
}
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Application\Admin;
use App\Framework\Attributes\Route;
use App\Framework\Auth\Auth;
use App\Framework\Core\PathProvider;
use App\Framework\Core\RouteCache;
use App\Framework\Discovery\Results\DiscoveryResults;
use App\Framework\Meta\MetaData;
use App\Framework\Router\Result\ViewResult;
final readonly class ShowRoutes
{
public function __construct(
private PathProvider $pathProvider,
private DiscoveryResults $processedResults
){}
#[Auth]
#[Route('/admin/routes')]
public function show(): ViewResult
{
$routes = $this->processedResults->get(Route::class);
sort($routes);
/*
echo '<div style="font-family: monospace; font-size: 12px;">';
echo str_repeat('-', 50) . "<br/>";
foreach($routes as $route) {
$path = $route['path'];
$line = "|-- $path";
$times = 50 - mb_strlen($line);
$line .= str_repeat(' .', $times) . "|<br/>";
echo $line;
}
echo str_repeat('-', 50) . "<br/></div>";*/
return new ViewResult('routes',
metaData: new MetaData('Routes', 'Routes'),
data: [
'name' => 'Michael',
'title' => 'Routes',
],
model: new RoutesViewModel($routes));
}
}

View File

@@ -0,0 +1,27 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport"
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Admin</title>
<meta name="description" content="Admin">
<meta property="og:type" content="Admin">
</head>
<body>
<!--<header>
<h2>Admin</h2>
</header>-->
<menu>
<li><a href="/admin">Dashboard</a></li>
</menu>
<main></main>
</body>
</html>

View File

@@ -0,0 +1,146 @@
<layout src="admin-main"/>
<a href="/admin/routes">Routes</a>
<br>
<a href="/admin/imageslots">Image Slots</a>
<div class="section">
<h2>Basis-Cards (nur semantische Selektoren)</h2>
<div class="demo-grid">
<article class="card">
<header>
<div>
<h3>Projekt Alpha</h3>
<small>Erstellt am 10. Juli 2025</small>
</div>
<span role="status">Aktiv</span>
</header>
<main>
<p>Diese Card nutzt nur semantische HTML-Elemente. Das Styling erfolgt über Selektoren wie <code>.card > header</code> und <code>.card h3</code>.</p>
<p>Weniger Klassen, sauberer HTML-Code.</p>
</main>
<footer>
<small>Letztes Update: heute</small>
<div>
<button>Öffnen</button>
<button>Teilen</button>
</div>
</footer>
</article>
<article class="card">
<header>
<h3>Einfache Card</h3>
</header>
<main>
<p>Minimaler HTML-Code, maximale Semantik.</p>
</main>
<footer>
<div>
<button>Action</button>
</div>
</footer>
</article>
</div>
</div>
<div class="section">
<h2>Status-Varianten (Klassen für Varianten)</h2>
<div class="demo-grid">
<article class="card card--success">
<header>
<h3>Erfolg</h3>
<span role="status">Abgeschlossen</span>
</header>
<main>
<p>Success-Variante durch eine einzige Modifier-Klasse.</p>
</main>
<footer>
<div>
<button>Details</button>
</div>
</footer>
</article>
<article class="card card--error">
<header>
<h3>Fehler</h3>
<span role="status">Problem</span>
</header>
<main>
<p>Error-Variante mit systematischen Farben.</p>
</main>
<footer>
<div>
<button>Beheben</button>
<button>Ignorieren</button>
</div>
</footer>
</article>
</div>
</div>
<div class="section">
<h2>Größen-Varianten</h2>
<div class="demo-grid">
<article class="card card--compact">
<header>
<h3>Kompakt</h3>
</header>
<main>
<p>Weniger Padding durch Modifier-Klasse.</p>
</main>
</article>
<article class="card card--spacious">
<header>
<h3>Großzügig</h3>
</header>
<main>
<p>Mehr Weißraum für wichtige Inhalte.</p>
</main>
<footer>
<div>
<button>Hauptaktion</button>
</div>
</footer>
</article>
</div>
</div>
<div class="section">
<h2>Layout-Varianten</h2>
<div class="demo-grid demo-grid--wide">
<article class="card card--horizontal">
<header>
<h3>Horizontal</h3>
</header>
<main>
<p>Horizontales Layout durch Modifier.</p>
</main>
<footer>
<div>
<button>Action</button>
</div>
</footer>
</article>
<article class="card card--media">
<img src="https://picsum.photos/400/200?random=2" alt="Demo">
<header>
<h3>Mit Media</h3>
</header>
<main>
<p>Bild wird durch Selector <code>.card--media img</code> gestylt.</p>
</main>
<footer>
<div>
<button>Ansehen</button>
</div>
</footer>
</article>
</div>
</div>

View File

@@ -0,0 +1,11 @@
<layout src="admin-main"/>
<form action='/admin/imageslots/edit/{{ id }}' method='post'>
<input type='hidden' name='_method' value='PUT'/>
<label>Slot Name:
<input type='text' name='slotName' value='{{ slotName }}'/>
</label>
<input type='submit' value='Update'/>
</form>

View File

@@ -0,0 +1,21 @@
<layout src="admin-main"/>
<for var="slot" in="slots">
<form action='/admin/imageslots/{{ slot.slotName }}' method='post'>
<h3>{{ slot.slotName }}</h3>
<input type='submit' value='Update'/>
</form>
</for>
<form action='/admin/imageslots/create' method='post'>
<label>Slot Name:
<input type='text' name='slotName' value=''/>
</label>
<input type='submit' value='Create'/>
</form>

View File

@@ -0,0 +1,11 @@
<layout src="admin-main"/>
<div>Routes Page</div>
<p>{{ name }}</p>
<ul>
<for var="route" in="routes">
<li><a href="{{ route.path }}">{{ route.path }} </a> <i> ({{ route.class }}) {{ route.attributes.0 }} </i> </li>
</for>
</ul>

View File

@@ -0,0 +1,96 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= $title ?></title>
<link rel="stylesheet" href="/css/admin.css">
</head>
<body class="admin-page">
<div class="admin-header">
<h1>Framework Admin Dashboard</h1>
</div>
<div class="admin-nav">
<a href="/admin" class="active">Dashboard</a>
<a href="/admin/routes">Routen</a>
<a href="/admin/services">Dienste</a>
<a href="/admin/environment">Umgebung</a>
<a href="/admin/performance">Performance</a>
<a href="/admin/redis">Redis</a>
<a href="/admin/phpinfo">PHP Info</a>
</div>
<div class="admin-content">
<div class="dashboard-stats">
<div class="stat-box">
<h3>Framework Version</h3>
<div class="stat-value"><?= $stats['frameworkVersion'] ?></div>
</div>
<div class="stat-box">
<h3>PHP Version</h3>
<div class="stat-value"><?= $stats['phpVersion'] ?></div>
</div>
<div class="stat-box">
<h3>Speicherverbrauch</h3>
<div class="stat-value"><?= $stats['memoryUsage'] ?></div>
</div>
<div class="stat-box">
<h3>Max. Speicherverbrauch</h3>
<div class="stat-value"><?= $stats['peakMemoryUsage'] ?></div>
</div>
<div class="stat-box">
<h3>Server</h3>
<div class="stat-value"><?= $stats['serverInfo'] ?></div>
</div>
<div class="stat-box">
<h3>Serverzeit</h3>
<div class="stat-value"><?= $stats['serverTime'] ?></div>
</div>
<div class="stat-box">
<h3>Zeitzone</h3>
<div class="stat-value"><?= $stats['timezone'] ?></div>
</div>
<div class="stat-box">
<h3>Betriebssystem</h3>
<div class="stat-value"><?= $stats['operatingSystem'] ?></div>
</div>
<div class="stat-box">
<h3>Server Uptime</h3>
<div class="stat-value"><?= $stats['uptime'] ?></div>
</div>
<div class="stat-box">
<h3>Aktive Sessions</h3>
<div class="stat-value"><?= $stats['sessionCount'] ?></div>
</div>
<div class="stat-box">
<h3>Registrierte Dienste</h3>
<div class="stat-value"><?= $stats['servicesCount'] ?></div>
</div>
</div>
<div class="admin-section">
<h2>PHP Erweiterungen</h2>
<div class="extensions-list">
<?php foreach($stats['loadedExtensions'] as $extension): ?>
<span class="extension-badge"><?= $extension ?></span>
<?php endforeach; ?>
</div>
</div>
</div>
<div class="admin-footer">
<p>&copy; <?= date('Y') ?> Framework Admin</p>
</div>
</body>
</html>

View File

@@ -0,0 +1,86 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= $title ?></title>
<link rel="stylesheet" href="/css/admin.css">
</head>
<body class="admin-page">
<div class="admin-header">
<h1>Umgebungsvariablen</h1>
</div>
<div class="admin-nav">
<a href="/admin">Dashboard</a>
<a href="/admin/routes">Routen</a>
<a href="/admin/services">Dienste</a>
<a href="/admin/environment" class="active">Umgebung</a>
<a href="/admin/performance">Performance</a>
<a href="/admin/redis">Redis</a>
<a href="/admin/phpinfo">PHP Info</a>
</div>
<div class="admin-content">
<div class="admin-tools">
<input type="text" id="envFilter" placeholder="Variablen filtern..." class="search-input">
<div class="filter-tags">
<button class="filter-tag" data-prefix="APP_">APP_</button>
<button class="filter-tag" data-prefix="DB_">DB_</button>
<button class="filter-tag" data-prefix="REDIS_">REDIS_</button>
<button class="filter-tag" data-prefix="RATE_LIMIT_">RATE_LIMIT_</button>
<button class="filter-tag" data-prefix="PHP_">PHP_</button>
<button class="filter-tag active" data-prefix="">Alle</button>
</div>
</div>
<table class="admin-table" id="envTable">
<thead>
<tr>
<th>Variable</th>
<th>Wert</th>
</tr>
</thead>
<tbody>
<?php foreach($env as $key => $value): ?>
<tr>
<td><?= $key ?></td>
<td><?= is_array($value) ? json_encode($value) : $value ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div class="admin-footer">
<p>&copy; <?= date('Y') ?> Framework Admin</p>
</div>
<script>
// Filterung der Umgebungsvariablen
document.getElementById('envFilter').addEventListener('input', filterTable);
// Tag-Filter
document.querySelectorAll('.filter-tag').forEach(tag => {
tag.addEventListener('click', function() {
document.querySelectorAll('.filter-tag').forEach(t => t.classList.remove('active'));
this.classList.add('active');
const prefix = this.getAttribute('data-prefix');
document.getElementById('envFilter').value = prefix;
filterTable();
});
});
function filterTable() {
const filterValue = document.getElementById('envFilter').value.toLowerCase();
const rows = document.querySelectorAll('#envTable tbody tr');
rows.forEach(row => {
const key = row.cells[0].textContent.toLowerCase();
row.style.display = key.includes(filterValue) ? '' : 'none';
});
}
</script>
</body>
</html>

View File

@@ -0,0 +1,125 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= $title ?></title>
<link rel="stylesheet" href="/css/admin.css">
</head>
<body class="admin-page">
<div class="admin-header">
<h1>Performance-Übersicht</h1>
</div>
<div class="admin-nav">
<a href="/admin">Dashboard</a>
<a href="/admin/routes">Routen</a>
<a href="/admin/services">Dienste</a>
<a href="/admin/environment">Umgebung</a>
<a href="/admin/performance" class="active">Performance</a>
<a href="/admin/redis">Redis</a>
<a href="/admin/phpinfo">PHP Info</a>
</div>
<div class="admin-content">
<div class="dashboard-stats">
<div class="stat-box">
<h3>Aktueller Speicherverbrauch</h3>
<div class="stat-value"><?= $performance['currentMemoryUsage'] ?></div>
</div>
<div class="stat-box">
<h3>Maximaler Speicherverbrauch</h3>
<div class="stat-value"><?= $performance['peakMemoryUsage'] ?></div>
</div>
<div class="stat-box">
<h3>Speicherlimit</h3>
<div class="stat-value"><?= $performance['memoryLimit'] ?></div>
</div>
<div class="stat-box">
<h3>Speicherauslastung</h3>
<div class="stat-value">
<div class="progress-bar">
<div class="progress" style="width: <?= $performance['memoryUsagePercentage'] ?>%"></div>
</div>
<div class="progress-value"><?= $performance['memoryUsagePercentage'] ?>%</div>
</div>
</div>
<div class="stat-box">
<h3>Systemlast (1/5/15 min)</h3>
<div class="stat-value">
<?= $performance['loadAverage'][0] ?> /
<?= $performance['loadAverage'][1] ?> /
<?= $performance['loadAverage'][2] ?>
</div>
</div>
<div class="stat-box">
<h3>OPCache aktiviert</h3>
<div class="stat-value"><?= $performance['opcacheEnabled'] ?></div>
</div>
<?php if (isset($performance['opcacheMemoryUsage'])): ?>
<div class="stat-box">
<h3>OPCache Speicherverbrauch</h3>
<div class="stat-value"><?= $performance['opcacheMemoryUsage'] ?></div>
</div>
<div class="stat-box">
<h3>OPCache Cache Hits</h3>
<div class="stat-value"><?= $performance['opcacheCacheHits'] ?></div>
</div>
<div class="stat-box">
<h3>OPCache Miss Rate</h3>
<div class="stat-value"><?= $performance['opcacheMissRate'] ?></div>
</div>
<?php endif; ?>
<div class="stat-box">
<h3>Ausführungszeit</h3>
<div class="stat-value"><?= $performance['executionTime'] ?></div>
</div>
<div class="stat-box">
<h3>Geladene Dateien</h3>
<div class="stat-value"><?= $performance['includedFiles'] ?></div>
</div>
</div>
<div class="admin-section">
<h2>Geladene Dateien</h2>
<div class="admin-tools">
<input type="text" id="fileFilter" placeholder="Dateien filtern..." class="search-input">
</div>
<div class="file-list" id="fileList">
<?php foreach(get_included_files() as $file): ?>
<div class="file-item">
<?= $file ?>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
<div class="admin-footer">
<p>&copy; <?= date('Y') ?> Framework Admin</p>
</div>
<script>
document.getElementById('fileFilter').addEventListener('input', function() {
const filterValue = this.value.toLowerCase();
const items = document.querySelectorAll('#fileList .file-item');
items.forEach(item => {
const text = item.textContent.toLowerCase();
item.style.display = text.includes(filterValue) ? '' : 'none';
});
});
</script>
</body>
</html>

View File

@@ -0,0 +1,105 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= $title ?></title>
<link rel="stylesheet" href="/css/admin.css">
</head>
<body class="admin-page">
<div class="admin-header">
<h1>Redis-Informationen</h1>
</div>
<div class="admin-nav">
<a href="/admin">Dashboard</a>
<a href="/admin/routes">Routen</a>
<a href="/admin/services">Dienste</a>
<a href="/admin/environment">Umgebung</a>
<a href="/admin/performance">Performance</a>
<a href="/admin/redis" class="active">Redis</a>
<a href="/admin/phpinfo">PHP Info</a>
</div>
<div class="admin-content">
<?php if (isset($redis['status']) && $redis['status'] === 'Verbunden'): ?>
<div class="dashboard-stats">
<div class="stat-box">
<h3>Status</h3>
<div class="stat-value status-connected"><?= $redis['status'] ?></div>
</div>
<div class="stat-box">
<h3>Version</h3>
<div class="stat-value"><?= $redis['version'] ?></div>
</div>
<div class="stat-box">
<h3>Uptime</h3>
<div class="stat-value"><?= $redis['uptime'] ?></div>
</div>
<div class="stat-box">
<h3>Speicherverbrauch</h3>
<div class="stat-value"><?= $redis['memory'] ?></div>
</div>
<div class="stat-box">
<h3>Max. Speicherverbrauch</h3>
<div class="stat-value"><?= $redis['peak_memory'] ?></div>
</div>
<div class="stat-box">
<h3>Verbundene Clients</h3>
<div class="stat-value"><?= $redis['clients'] ?></div>
</div>
<div class="stat-box">
<h3>Anzahl Schlüssel</h3>
<div class="stat-value"><?= $redis['keys'] ?></div>
</div>
</div>
<div class="admin-section">
<h2>Schlüssel (max. 50 angezeigt)</h2>
<div class="admin-tools">
<input type="text" id="keyFilter" placeholder="Schlüssel filtern..." class="search-input">
</div>
<div class="key-list" id="keyList">
<?php if (empty($redis['key_sample'])): ?>
<div class="empty-message">Keine Schlüssel vorhanden</div>
<?php else: ?>
<?php foreach($redis['key_sample'] as $key): ?>
<div class="key-item">
<?= $key ?>
</div>
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
<?php else: ?>
<div class="error-message">
<h2>Redis-Verbindung fehlgeschlagen</h2>
<p><?= $redis['status'] ?? 'Unbekannter Fehler' ?></p>
</div>
<?php endif; ?>
</div>
<div class="admin-footer">
<p>&copy; <?= date('Y') ?> Framework Admin</p>
</div>
<script>
document.getElementById('keyFilter')?.addEventListener('input', function() {
const filterValue = this.value.toLowerCase();
const items = document.querySelectorAll('#keyList .key-item');
items.forEach(item => {
const text = item.textContent.toLowerCase();
item.style.display = text.includes(filterValue) ? '' : 'none';
});
});
</script>
</body>
</html>

View File

@@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= $title ?></title>
<link rel="stylesheet" href="/css/admin.css">
</head>
<body class="admin-page">
<div class="admin-header">
<h1>Routen-Übersicht</h1>
</div>
<div class="admin-nav">
<a href="/admin">Dashboard</a>
<a href="/admin/routes" class="active">Routen</a>
<a href="/admin/services">Dienste</a>
<a href="/admin/environment">Umgebung</a>
<a href="/admin/performance">Performance</a>
<a href="/admin/redis">Redis</a>
<a href="/admin/phpinfo">PHP Info</a>
</div>
<div class="admin-content">
<div class="admin-tools">
<input type="text" id="routeFilter" placeholder="Routen filtern..." class="search-input">
</div>
<table class="admin-table" id="routesTable">
<thead>
<tr>
<th>Pfad</th>
<th>Methode</th>
<th>Controller</th>
<th>Aktion</th>
<th>Name</th>
</tr>
</thead>
<tbody>
<?php foreach($routes as $route): ?>
<tr>
<td><?= $route->path ?></td>
<td><?= $route->method ?></td>
<td><?= $route->controllerClass ?></td>
<td><?= $route->methodName ?></td>
<td><?= $route->name ?? '-' ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div class="admin-footer">
<p>&copy; <?= date('Y') ?> Framework Admin</p>
</div>
<script>
document.getElementById('routeFilter').addEventListener('input', function() {
const filterValue = this.value.toLowerCase();
const rows = document.querySelectorAll('#routesTable tbody tr');
rows.forEach(row => {
const text = row.textContent.toLowerCase();
row.style.display = text.includes(filterValue) ? '' : 'none';
});
});
</script>
</body>
</html>

View File

@@ -0,0 +1,72 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= $title ?></title>
<link rel="stylesheet" href="/css/admin.css">
</head>
<body class="admin-page">
<div class="admin-header">
<h1>Registrierte Dienste</h1>
</div>
<div class="admin-nav">
<a href="/admin">Dashboard</a>
<a href="/admin/routes">Routen</a>
<a href="/admin/services" class="active">Dienste</a>
<a href="/admin/environment">Umgebung</a>
<a href="/admin/performance">Performance</a>
<a href="/admin/redis">Redis</a>
<a href="/admin/phpinfo">PHP Info</a>
</div>
<div class="admin-content">
<div class="admin-tools">
<input type="text" id="serviceFilter" placeholder="Dienste filtern..." class="search-input">
<span class="services-count"><?= count($services) ?> Dienste insgesamt</span>
</div>
<div class="service-list" id="serviceList">
<?php foreach($services as $service): ?>
<div class="service-item">
<div class="service-name"><?= $service ?></div>
<?php
$parts = explode('\\', $service);
$category = $parts[1] ?? 'Unknown';
$subCategory = $parts[2] ?? '';
?>
<div class="service-category">
<span class="category-badge"><?= $category ?></span>
<?php if ($subCategory): ?>
<span class="subcategory-badge"><?= $subCategory ?></span>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<div class="admin-footer">
<p>&copy; <?= date('Y') ?> Framework Admin</p>
</div>
<script>
document.getElementById('serviceFilter').addEventListener('input', function() {
const filterValue = this.value.toLowerCase();
const items = document.querySelectorAll('#serviceList .service-item');
let visibleCount = 0;
items.forEach(item => {
const text = item.textContent.toLowerCase();
const isVisible = text.includes(filterValue);
item.style.display = isVisible ? '' : 'none';
if (isVisible) visibleCount++;
});
document.querySelector('.services-count').textContent =
visibleCount + ' von ' + <?= count($services) ?> + ' Diensten';
});
</script>
</body>
</html>

View File

@@ -1,21 +0,0 @@
<?php
namespace App\Application\Api;
use App\Framework\Attributes\Route;
use App\Framework\Router\ActionResult;
class IrkEndpoint
{
#[Route(method: 'GET', path: '/irk-impressum')]
public function impressum()
{
return;
}
#[Route(method: 'GET', path: '/irk-datenschutz')]
public function datenschutz()
{
return;
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Application\Auth;
use App\Framework\Http\ControllerRequest;
use App\Framework\Validation\Rules\Email;
final readonly class LoginRequest implements ControllerRequest
{
#[Email]
public string $email;
public string $password;
}

View File

@@ -0,0 +1,13 @@
<?php
namespace App\Application\Auth;
class LoginUser
{
public function __construct(
public string $email,
public string $password
)
{
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Application\Auth;
use App\Framework\CommandBus\CommandHandler;
class LoginUserHandler
{
#[CommandHandler]
public function __invoke(LoginUser $loginUser)
{
var_dump($loginUser);
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Application\Auth;
use App\Framework\Attributes\Route;
use App\Framework\Auth\Auth;
use App\Framework\CommandBus\CommandBus;
use App\Framework\Http\Method;
use App\Framework\Router\Result\ViewResult;
class ShowLogin
{
public function __construct(
private CommandBus $commandBus,
)
{
}
#[Auth]
#[Route('/login')]
public function __invoke()
{
return new ViewResult('loginform');
}
#[Auth]
#[Route('/login', Method::POST)]
public function login(LoginRequest $request)
{
$login = new LoginUser($request->email, $request->password);
$this->commandBus->dispatch($login);
dd($request);
}
}

View File

@@ -0,0 +1,9 @@
<layout src="loginform"/>
<form action="/login" method="post">
<label for="email">Email:</label>
<input type="email" id="email" name="email" required/>
<label for="password">Password:</label>
<input type="password" id="password" name="password" required/>
<input type="submit" value="Login" />
</form>

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Application\Backend\RapidMail;
use App\Framework\Attributes\Route;
use App\Infrastructure\Api\RapidMail\Commands\UpdateRecipientCommand;
use App\Infrastructure\Api\RapidMail\RapidMailClient;
use App\Infrastructure\Api\RapidMail\Recipient;
use App\Infrastructure\Api\RapidMail\RecipientId;
final readonly class Dashbord
{
public function __construct(
private RapidMailClient $rapidMailClient
)
{
}
#[Route(path: '/rapidmail', name: 'rapidmail')]
public function __invoke()
{
$all = $this->rapidMailClient->recipients->get(RecipientId::fromInt(629237));
#debug($all);
#debug($all->email);
$command = new UpdateRecipientCommand(
$all->id,
$all->email,
'Test',
);
$user = $this->rapidMailClient->recipients->updateWithCommand($command);
#$user = $this->rapidMailClient->recipients->update(629237, Recipient::fromArray($array));
#$data = $this->rapidMailClient->recipientLists->getAll(); //->get(776);
#$name = $data[0]->name;
#$data = $this->rapidMailClient->statistics->getMailingStats(776);
debug($user);
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace App\Application\Contact;
use App\Framework\Http\ControllerRequest;
use App\Framework\Validation\Rules\Email;
class ContactRequest implements ControllerRequest
{
public string $name;
#[Email]
public string $email;
public string $message;
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Application\Contact;
use App\Framework\Attributes\Route;
use App\Framework\CommandBus\CommandBus;
use App\Framework\Http\Method;
use App\Framework\Meta\Keywords;
use App\Framework\Meta\StaticPageMetaResolver;
use App\Framework\Router\ActionResult;
use App\Framework\Router\Result\ContentNegotiationResult;
use App\Framework\Router\Result\ViewResult;
final readonly class ShowContact
{
#[Route(path: '/kontakt', name: 'contact')]
public function __invoke(): ViewResult
{
return new ViewResult('contact',
new StaticPageMetaResolver(
'Kontakt',
'Kontaktseite!',
Keywords::fromStrings('Kontakt', 'Welt')
)(),);
}
#[Route(path: '/kontakt', method: Method::POST)]
public function senden(ContactRequest $request, CommandBus $commandBus): ActionResult
{
$command = new StoreContact(
$request->email,
$request->name,
$request->subject ?? 'Kein Betreff angegeben',
$request->message,
);
$commandBus->dispatch($command);
dd($request);
return new ContentNegotiationResult(
);
#return new ViewResult('contact-senden');
}
}

View File

@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Application\Contact;
final class StoreContact
{
public function __construct(
public string $name,
public string $email,
public string $subject,
public string $message,
) {}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Application\Contact;
use App\Domain\Contact\ContactMessage;
use App\Domain\Contact\ContactRepository;
use App\Framework\CommandBus\CommandHandler;
final readonly class StoreContactHandler
{
public function __construct(
private ContactRepository $contactRepository,
) {}
#[CommandHandler]
public function __invoke(StoreContact $command): void
{
$message = new ContactMessage($command->name, $command->email, $command->message);
$this->contactRepository->save($message);
}
}

View File

@@ -0,0 +1,74 @@
<layout src="main"/>
<section>
<h1>Kontakt</h1>
<form action="/kontakt" method="post">
<!-- Feld ist für Bots gedacht normaler User sieht es nicht -->
<div style="position: absolute; left: -10000px; top: auto;" aria-hidden="true">
<label for="website">Ihre Website:</label>
<input type="text" name="website" id="website" tabindex="-1" autocomplete="off">
</div>
<div role="alert">
Hallo Welt
</div>
<label for="name">Name:</label>
<input type="text" name="name" id="name">
<label for="email">E-Mail:</label>
<input type="email" name="email" id="email">
<label for="subject">Betreff:</label>
<select name="subject" id="subject">
<option value="1">Anfrage</option>
<option value="2">Beschwerde</option>
<option value="3">Anregung</option>
<option value="4">Sonstiges</option>
</select>
<label for="message">Nachricht:</label>
<textarea name="message" id="message"></textarea>
<input type="submit" value="Senden">
</form>
</section>
<style>
div.box {
background: red;
}
div.outer {
--outer-radius: 24px;
--padding: 8px;
--inner-radius: calc(var(--outer-radius) - var(--padding));
border-radius: var(--outer-radius);
padding: var(--padding);
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.2);
box-sizing: border-box;
}
.inner {
border-radius: var(--inner-radius);
--padding: 1rem;
padding:inherit;
}
</style>
<div class="box outer" style="background: grey;">
Testbox
</div>

View File

@@ -4,13 +4,15 @@ namespace App\Application\EPK;
use App\Framework\Attributes\Route;
use App\Framework\Router\ActionResult;
use App\Framework\Router\GenericActionResult;
use App\Framework\Router\Result\ViewResult;
use App\Framework\Router\ResultType;
class ShowEpk
{
#[Route(path: '/epk')]
#[Route(path: '/epk', name: 'epk')]
public function epk(): ActionResult
{
return new ActionResult(ResultType::Html, 'epk', ['text' => 'EPK!']);
return new ViewResult('epk', new MetaData('title'));
}
}

View File

@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace App\Application\Http\Controllers;
use App\Framework\Attributes\Route;
use App\Framework\Auth\Auth;
use App\Framework\Http\Method;
use App\Framework\Http\WebSocketConnection;
use App\Framework\Router\Result\WebSocketResult;
final class ChatController
{
private array $connections = [];
#[Auth]
#[Route(path: '/chat/websocket', method: Method::GET)]
public function chatWebSocket(): WebSocketResult
{
return new WebSocketResult()
->onConnect(function(WebSocketConnection $connection) {
$this->connections[$connection->getId()] = $connection;
// Willkommensnachricht senden
$connection->sendJson([
'type' => 'system',
'message' => 'Willkommen im Chat!',
'timestamp' => time()
]);
// Andere Benutzer benachrichtigen
$this->broadcast([
'type' => 'user_joined',
'message' => 'Ein neuer Benutzer ist dem Chat beigetreten',
'timestamp' => time()
], $connection->getId());
})
->onMessage(function(WebSocketConnection $connection, string $message) {
$data = json_decode($message, true);
if (!$data || !isset($data['type'])) {
$connection->sendJson(['error' => 'Invalid message format']);
return;
}
switch ($data['type']) {
case 'chat_message':
$this->handleChatMessage($connection, $data);
break;
case 'ping':
$connection->sendJson(['type' => 'pong']);
break;
default:
$connection->sendJson(['error' => 'Unknown message type']);
}
})
->onClose(function(WebSocketConnection $connection, int $code, string $reason) {
unset($this->connections[$connection->getId()]);
// Andere Benutzer benachrichtigen
$this->broadcast([
'type' => 'user_left',
'message' => 'Ein Benutzer hat den Chat verlassen',
'timestamp' => time()
]);
})
->onError(function(WebSocketConnection $connection, \Throwable $error) {
error_log("WebSocket error: " . $error->getMessage());
$connection->close(1011, 'Internal server error');
})
->withSubprotocols(['chat'])
->withMaxMessageSize(10240) // 10KB
->withPingInterval(30); // 30 Sekunden
}
private function handleChatMessage(WebSocketConnection $sender, array $data): void
{
if (!isset($data['message'])) {
$sender->sendJson(['error' => 'Message content required']);
return;
}
$message = [
'type' => 'chat_message',
'user_id' => $sender->getId(),
'message' => $data['message'],
'timestamp' => time()
];
// Nachricht an alle Verbindungen senden
$this->broadcast($message);
}
private function broadcast(array $message, ?string $excludeId = null): void
{
foreach ($this->connections as $id => $connection) {
if ($excludeId && $id === $excludeId) {
continue;
}
if ($connection->isConnected()) {
$connection->sendJson($message);
} else {
unset($this->connections[$id]);
}
}
}
}

View File

@@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
namespace App\Application\Http\Controllers;
use App\Framework\Attributes\Route;
use App\Framework\Auth\Auth;
use App\Framework\Http\Method;
use App\Framework\Http\SseStream;
use App\Framework\Http\Status;
use App\Framework\Router\Result\SseResult;
use App\Framework\Router\Result\SseResultWithCallback;
/**
* Controller für Echtzeit-Benachrichtigungen über Server-Sent Events
*/
final class NotificationController
{
/**
* Stellt einen SSE-Stream für allgemeine Benachrichtigungen bereit
*/
#[Auth]
#[Route(path: '/api/notifications/stream', method: Method::GET)]
public function notificationStream(): SseResult
{
// SSE-Result mit 3 Sekunden Retry-Intervall
$result = new SseResult(Status::OK, 3000);
// Initiale Verbindungsbestätigung
$result->addJsonEvent(
['message' => 'Verbunden mit dem Benachrichtigungsstream'],
'connected',
'conn-' . uniqid()
);
// Einige Beispiel-Benachrichtigungen beim Start senden
$result->addJsonEvent(
[
'type' => 'info',
'title' => 'Willkommen',
'message' => 'Sie sind jetzt mit Echtzeit-Updates verbunden.',
'timestamp' => time()
],
'notification',
'notif-' . uniqid()
);
return $result;
}
/**
* Stellt einen benutzer-spezifischen SSE-Stream bereit
*/
#[Auth]
#[Route(path: '/api/notifications/user/{userId}', method: Method::GET)]
public function userNotifications(int $userId): SseResult
{
// SSE-Result mit benutzerdefinierten Headern
$result = new SseResult(Status::OK, 3000, [
'X-User-ID' => (string)$userId
]);
// Verbindungsbestätigung mit Benutzer-ID
$result->addJsonEvent(
[
'message' => 'Verbunden mit dem Benutzer-Stream',
'userId' => $userId,
'timestamp' => time()
],
'connected',
'conn-user-' . $userId
);
// Aktuelle Benachrichtigungen für den Benutzer laden
$notifications = $this->getUserNotifications($userId);
// Benachrichtigungen zum Stream hinzufügen
foreach ($notifications as $notification) {
$result->addJsonEvent($notification, 'notification', $notification['id']);
}
return $result;
}
/**
* Gibt Benachrichtigungen für einen bestimmten Benutzer zurück
* In einer realen Anwendung würde diese Methode Daten aus einer Datenbank laden
*/
private function getUserNotifications(int $userId): array
{
// Beispiel-Benachrichtigungen
return [
[
'id' => 'notif-' . uniqid(),
'type' => 'message',
'title' => 'Neue Nachricht',
'message' => 'Sie haben eine neue Nachricht erhalten',
'timestamp' => time() - 300 // Vor 5 Minuten
],
[
'id' => 'notif-' . uniqid(),
'type' => 'system',
'title' => 'System-Update',
'message' => 'Das System wurde aktualisiert',
'timestamp' => time() - 3600 // Vor 1 Stunde
]
];
}
/**
* Stellt einen Live-Stream mit dynamischen Updates bereit
*/
#[Auth]
#[Route(path: '/api/notifications/live', method: Method::GET)]
public function liveNotifications(): SseResult
{
// Erweiterte SSE-Konfiguration mit Callback
#$result = new SseResultWithCallback(Status::OK, 3000);
// Callback für dynamische Updates festlegen
$callback = function(SseStream $stream) {
// Simuliere neue Benachrichtigungen (mit 10% Wahrscheinlichkeit)
if (rand(1, 10) === 1) {
$notificationTypes = ['info', 'warning', 'update', 'message'];
$type = $notificationTypes[array_rand($notificationTypes)];
$notification = [
'id' => 'live-notif-' . uniqid(),
'type' => $type,
'title' => 'Neue ' . ucfirst($type) . '-Benachrichtigung',
'message' => 'Dies ist eine dynamisch generierte Benachrichtigung vom Typ ' . $type,
'timestamp' => time()
];
$stream->sendJson($notification, 'notification', $notification['id']);
// Kleine Pause nach dem Senden, um das Testszenario zu simulieren
sleep(1);
}
};
$result = new SseResult(
retryInterval: 500,
callback: $callback,
maxDuration: 300,
heartbeatInterval: 15,
);
// Initiale Verbindungsbestätigung
$result->addJsonEvent(
['message' => 'Verbunden mit dem Live-Stream', 'timestamp' => time()],
'connected',
'live-' . uniqid()
);
return $result;
}
}

View File

@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\Application\Http\Controllers;
use App\Application\Service\QrCodeService;
use App\Domain\QrCode\ValueObject\ErrorCorrectionLevel;
use App\Framework\Attributes\Route;
use App\Framework\Auth\Auth;
use App\Framework\Http\Method;
use App\Framework\Http\Status;
use App\Framework\Http\Response;
final class QrCodeController
{
public function __construct(
private readonly QrCodeService $qrCodeService
) {
}
/**
* Generiert einen QR-Code als SVG
*/
#[Auth]
#[Route(path: '/api/qrcode/svg', method: Method::GET)]
public function generateSvg(): Response
{
$data = $_GET['data'] ?? 'https://example.com';
$errorLevel = $this->getErrorLevel($_GET['error_level'] ?? 'M');
$moduleSize = (int) ($_GET['module_size'] ?? 4);
$margin = (int) ($_GET['margin'] ?? 4);
$foreground = $_GET['foreground'] ?? '#000000';
$background = $_GET['background'] ?? '#FFFFFF';
$config = new QrCodeConfig($moduleSize, $margin, $foreground, $background);
$svg = $this->qrCodeService->generateSvg($data, $errorLevel, $config);
return new Response(
body: $svg,
status: Status::OK,
headers: ['Content-Type' => 'image/svg+xml']
);
}
/**
* Generiert einen QR-Code als PNG
*/
#[Auth]
#[Route(path: '/api/qrcode/png', method: Method::GET)]
public function generatePng(): Response
{
$data = $_GET['data'] ?? 'https://example.com';
$errorLevel = $this->getErrorLevel($_GET['error_level'] ?? 'M');
$moduleSize = (int) ($_GET['module_size'] ?? 4);
$margin = (int) ($_GET['margin'] ?? 4);
$config = new QrCodeConfig($moduleSize, $margin);
$png = $this->qrCodeService->generatePng($data, $errorLevel, $config);
return new Response(
body: $png,
status: Status::OK,
headers: ['Content-Type' => 'image/png']
);
}
/**
* Generiert einen QR-Code als ASCII-Art
*/
#[Auth]
#[Route(path: '/api/qrcode/ascii', method: Method::GET)]
public function generateAscii(): Response
{
$data = $_GET['data'] ?? 'https://example.com';
$errorLevel = $this->getErrorLevel($_GET['error_level'] ?? 'M');
$ascii = $this->qrCodeService->generateAscii($data, $errorLevel);
return new Response(
body: "<pre>$ascii</pre>",
status: Status::OK,
headers: ['Content-Type' => 'text/html; charset=utf-8']
);
}
/**
* Konvertiert einen String in ein ErrorCorrectionLevel-Enum
*/
private function getErrorLevel(string $level): ErrorCorrectionLevel
{
return match (strtoupper($level)) {
'L' => ErrorCorrectionLevel::L,
'Q' => ErrorCorrectionLevel::Q,
'H' => ErrorCorrectionLevel::H,
default => ErrorCorrectionLevel::M,
};
}
}

View File

@@ -0,0 +1,146 @@
<?php
namespace App\Application\Http;
use App\Framework\Attributes\Route;
use App\Framework\CommandBus\CommandBus;
use App\Framework\Http\HeaderKey;
use App\Framework\Http\Request;
use App\Framework\Http\Method;
use App\Framework\Http\Status;
use App\Framework\Router\ActionResult;
use App\Framework\Router\Result\ViewResult;
use App\Framework\Router\Result\Redirect;
use App\Framework\Meta\MetaData;
use App\Framework\Smartlinks\Actions\ActionRegistry;
use App\Framework\Smartlinks\Commands\ExecuteSmartlinkCommand;
use App\Framework\Smartlinks\Commands\GenerateSmartlinkCommand;
use App\Framework\Smartlinks\Commands\GenerateSmartlinkHandler;
use App\Framework\Smartlinks\Services\SmartlinkService;
use App\Framework\Smartlinks\SmartLinkToken;
use App\Framework\Smartlinks\TokenAction;
final readonly class Smartlink
{
public function __construct(
private CommandBus $commandBus,
private ActionRegistry $actionRegistry,
private SmartlinkService $smartlinkService,
private GenerateSmartlinkHandler $handler,
) {}
#[Route('/smartlink/{token}', method: Method::GET)]
#[Route('/smartlink/{token}', method: Method::POST)]
public function execute(string $token, Request $request): ActionResult
{
$command = new GenerateSmartlinkCommand(
action: new TokenAction('email_verification'),
payload: ['user_id' => 123, 'email' => 'user@example.com'],
baseUrl: 'https://localhost'
);
#debug($this->handler->handle($command));
try {
$smartlinkToken = new SmartlinkToken($token);
// Token validieren
$smartlinkData = $this->smartlinkService->validate($smartlinkToken);
if (!$smartlinkData) {
return new ViewResult(
template: 'smartlinks-error',
metaData: new MetaData(
title: 'Ungültiger Link',
description: 'Der Link ist ungültig oder abgelaufen'
),
data: [
'error' => 'Ungültiger oder abgelaufener Link',
'error_code' => 'INVALID_TOKEN'
],
status: Status::NOT_FOUND
);
}
// Action holen
$action = $this->actionRegistry->get($smartlinkData->action);
if (!$action) {
return new ViewResult(
template: 'smartlinks-error',
metaData: new MetaData(
title: 'Unbekannte Aktion',
description: 'Die angeforderte Aktion ist nicht verfügbar'
),
data: [
'error' => 'Unbekannte Aktion',
'error_code' => 'UNKNOWN_ACTION'
],
status: Status::BAD_REQUEST
);
}
// Context für Action vorbereiten
$context = [
'request_method' => $request->method->value,
'request_data' => $request->parsedBody ?? [],
'query_params' => $request->queryParameters ?? [],
#'ip_address' => $request->serverEnvironment->ipAddress?->value,
'user_agent' => $request->headers->getFirst(HeaderKey::USER_AGENT),
'headers' => $request->headers
];
// Command ausführen
$command = new ExecuteSmartlinkCommand($smartlinkToken, $context);
$result = $this->commandBus->dispatch($command);
// Ergebnis verarbeiten
if (!$result->isSuccess()) {
return new ViewResult(
template: $action->getErrorTemplate(),
metaData: new MetaData(
title: 'Fehler bei Ausführung',
description: $result->message
),
data: [
'error' => $result->message,
'errors' => $result->errors,
'action' => $action->getName()
],
status: Status::BAD_REQUEST
);
}
// Redirect oder View
if ($result->hasRedirect()) {
return new Redirect($result->redirectUrl);
}
return new ViewResult(
template: $action->getViewTemplate(),
metaData: new MetaData(
title: $action->getName(),
description: $result->message
),
data: [
'result' => $result,
'action' => $action->getName(),
'token' => $token
]
);
} catch (\Exception $e) {
return new ViewResult(
template: 'smartlinks-error',
metaData: new MetaData(
title: 'Systemfehler',
description: 'Ein unerwarteter Fehler ist aufgetreten'
),
data: [
'error' => 'Ein Fehler ist aufgetreten',
'error_code' => 'SYSTEM_ERROR',
'debug_message' => $e->getMessage() // Nur für Development
],
status: Status::INTERNAL_SERVER_ERROR
);
}
}
}

View File

@@ -0,0 +1,9 @@
<layout src="main"/>
<div class="error-container">
<h1>Fehler</h1>
<p>{{ error }}</p>
<?php if (isset($error_code)): ?>
<p><small>Fehlercode: <?= htmlspecialchars($error_code) ?></small></p>
<?php endif; ?>
</div>

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Application\Media;
use App\Framework\Attributes\Route;
use App\Framework\Http\Responses\AdaptiveStreamResponse;
use App\Framework\Http\Responses\StreamResponse;
use App\Framework\Http\Streaming\AdaptiveStreamingController;
final readonly class AdaptiveVideoStream
{
public function __construct(
private AdaptiveStreamingController $adaptiveStreamingController,
) {
}
#[Route('/videos/{videoId}/manifest')]
public function manifestAutoFormat(string $videoId): AdaptiveStreamResponse
{
return $this->adaptiveStreamingController->manifest($videoId);
}
#[Route('/videos/{videoId}/manifest.m3u8')]
public function hlsManifest(string $videoId): AdaptiveStreamResponse
{
return $this->adaptiveStreamingController->hlsManifest($videoId);
}
#[Route('/videos/{videoId}/manifest.mpd')]
public function dashManifest(string $videoId): AdaptiveStreamResponse
{
return $this->adaptiveStreamingController->dashManifest($videoId);
}
#[Route('/videos/{videoId}/{quality}.m3u8')]
public function hlsMediaPlaylist(string $videoId, string $quality): AdaptiveStreamResponse
{
return $this->adaptiveStreamingController->hlsMediaPlaylist($videoId, $quality);
}
#[Route('/videos/{videoId}/{quality}/{segment}/')]
public function segment(string $videoId, string $quality, string $segment): StreamResponse
{
return $this->adaptiveStreamingController->segment($videoId, $quality, $segment);
}
}

View File

@@ -0,0 +1,93 @@
<?php
namespace App\Application\Media;
use App\Domain\Media\ImageRepository;
use App\Domain\Media\ImageVariantRepository;
use App\Framework\Attributes\Route;
use App\Framework\Core\PathProvider;
use App\Framework\Router\Result\FileResult;
final readonly class ShowImage
{
public function __construct(
private PathProvider $pathProvider,
private ImageRepository $imageRepository,
private ImageVariantRepository $imageVariantRepository,
) {}
#[Route('/images/{filename}')]
public function __invoke($filename): FileResult
{
$path = $this->pathProvider->resolvePath('storage/uploads/');
$image = $this->imageRepository->findByFilename($filename);
if($image === null) {
$image = $this->imageVariantRepository->findByFilename($filename);
}
if($image === null) {
throw new \Exception('Image not found');
}
$file = $image->path . $image->filename;
if (file_exists($file)) {
header('Content-Type: image/jpeg');
header('Content-Length: '.filesize($file));
// optional: Caching-Header
header('Cache-Control: public, max-age=86400');
// Hier: Download-Dateiname vorschlagen
// inline = im Browser anzeigen; attachment = Download erzwingen
header('Content-Disposition: inline; filename="' . $image->filename . '-2025"');
// oder
// header('Content-Disposition: attachment; filename="' . basename($filename) . '"');
// 1. Last-Modified & ETag (Conditional Requests)
$lastModified = gmdate('D, d M Y H:i:s', filemtime($file)).' GMT';
$eTag = '"'.md5_file($file).'"';
header("Last-Modified: $lastModified");
header("ETag: $eTag");
// 304 Not Modified wenn Browser-Cache gültig
if (
(isset($_SERVER['HTTP_IF_NONE_MATCH']) && trim($_SERVER['HTTP_IF_NONE_MATCH']) === $eTag) ||
(isset($_SERVER['HTTP_IF_MODIFIED_SINCE']) && $_SERVER['HTTP_IF_MODIFIED_SINCE'] === $lastModified)
) {
http_response_code(304);
exit;
}
// 2. Expires (optional, zusätzlich zu Cache-Control)
$expireTime = gmdate('D, d M Y H:i:s', time() + 86400).' GMT';
header("Expires: $expireTime");
// 3. Accept-Ranges (für Partial-Requests / Resuming)
header('Accept-Ranges: bytes');
// 4. Vary (bei Content-Negotiation, z. B. für WebP)
header('Vary: Accept');
// 5. Security-Header
// Verhindert MIME-Sniffing
header('X-Content-Type-Options: nosniff');
// Schutz gegen Clickjacking (falls du Bilder in iframes erlauben willst, ggf. anpassen)
header('X-Frame-Options: SAMEORIGIN');
// Referrer-Policy für Bildanfragen
header('Referrer-Policy: no-referrer-when-downgrade');
// 6. Content-Security-Policy (CSP) strengere Kontrolle, wenn du das Bild per <img> einbindest
// Hier als Beispiel nur für Bilder aus eigenem Domain
header("Content-Security-Policy: default-src 'self'; img-src 'self';");
readfile($file);
exit;
}
return new FileResult($file);
}
}

View File

@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace App\Application\Media;
use App\Framework\Attributes\Route;
use App\Framework\Core\PathProvider;
use App\Framework\Http\Request;
use App\Framework\Http\Responses\MediaType;
use App\Framework\Http\Responses\StreamResponse;
use App\Framework\Http\Streaming\MimeTypeDetector;
use App\Framework\Http\Streaming\RangeParser;
final readonly class ShowVideo
{
public function __construct(
private PathProvider $pathProvider,
) {}
#[Route('/videos/{filename}')]
public function __invoke(string $filename): StreamResponse
{
$filePath = $this->pathProvider->resolvePath('/storage/uploads/videos/'.$filename);
#dd($path);
#$filePath = "/media/{$filename}";
$fileSize = filesize($filePath);
$mimeType = MimeTypeDetector::detect($filePath);
$range = null;
if (isset($_SERVER['HTTP_RANGE'])) {
$range = RangeParser::parseRange($_SERVER['HTTP_RANGE'], $fileSize);
}
return new StreamResponse(
filePath : $filePath,
fileSize : $fileSize,
mimeType : $mimeType,
range : $range,
mediaType: MediaType::fromMimeType($mimeType)
);
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Application\Newsletter\SignUp;
use App\Framework\EventBus\EventHandler;
/**
* Beispiel für einen Event-Handler für das UserWasSignedUp-Event
*/
final readonly class EventHandlerExample
{
/**
* Behandelt das UserWasSignedUp-Event
*
* @param UserWasSignedUp $event Das Event
* @return void
*/
#[EventHandler]
public function handleUserSignedUp(UserWasSignedUp $event): void
{
// Hier kann die Verarbeitung des Events implementiert werden
// Zum Beispiel: E-Mail senden, Statistiken aktualisieren, etc.
error_log($event->email . ' wurde für den Newsletter angemeldet.');
#var_dump($event);
}
}

View File

@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace App\Application\Newsletter\SignUp;
use App\Framework\Attributes\Route;
use App\Framework\CommandBus\CommandBus;
use App\Framework\CommandBus\DefaultCommandBus;
use App\Framework\Http\Method;
use App\Framework\Http\Status;
use App\Framework\Router\Result\ContentNegotiationResult;
use App\Framework\Router\Result\JsonResult;
final readonly class NewsletterSignup
{
public function __construct(
public CommandBus $commandBus,
) {}
#[Route(path: '/newsletter/register', method: Method::POST)]
public function __invoke(NewsletterSignupRequest $request): ContentNegotiationResult
{
// Den internen Command ausführen
$command = new SignupUserToNewsletter($request->name, $request->email);
$result = $this->commandBus->dispatch($command);
// Hier könnten Sie das Ergebnis weiterverarbeiten oder loggen
return new ContentNegotiationResult(
jsonPayload: [
'success' => true,
'message' => 'Anmeldung erfolgreich!',
'data' => [
$request->name,
$request->email,
],
],
redirectTo: '/'
);
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Application\Newsletter\SignUp;
use App\Framework\CommandBus\CommandHandler;
use App\Framework\EventBus\EventBus;
use App\Infrastructure\Api\RapidMailClient;
use Archive\Config\ApiConfig;
final readonly class NewsletterSignupHandler
{
public function __construct(
private EventBus $eventBus,
) {}
#[CommandHandler]
public function __invoke(SignupUserToNewsletter $command):void
{
// RapidMail-Client erstellen und konfigurieren
$client = new RapidMailClient(
ApiConfig::RAPIDMAIL_USERNAME->value,
ApiConfig::RAPIDMAIL_PASSWORD->value
);
// Abonnent zur Liste hinzufügen
$result = $client->addRecipient(
$command->name,
$command->email,
ApiConfig::getRapidmailListId()
);
error_log('CommandHandler für: '.$command->email);;
$this->eventBus->dispatch(new UserWasSignedUp($command->email));
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Application\Newsletter\SignUp;
use App\Framework\Http\ControllerRequest;
use App\Framework\Validation\Rules\Email;
use App\Framework\Validation\Rules\IsTrue;
use App\Framework\Validation\Rules\Required;
use App\Framework\Validation\Rules\StringLength;
final readonly class NewsletterSignupRequest implements ControllerRequest
{
#[Email]
public string $email;
#[StringLength(min: 3, max: 255)]
public string $name;
#[IsTrue]
public bool $consent;
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Application\Newsletter\SignUp;
use App\Framework\CommandBus\ShouldQueue;
#[ShouldQueue]
final readonly class SignupUserToNewsletter
{
public function __construct(
public string $name,
public string $email,
) {}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Application\Newsletter\SignUp;
/**
* Event, das ausgelöst wird, wenn ein Benutzer für den Newsletter angemeldet wurde
*/
final readonly class UserWasSignedUp
{
public function __construct(public string $email)
{
// Event-Daten können hier hinzugefügt werden
}
}

View File

@@ -0,0 +1,8 @@
### POST request to example server
POST https://localhost/newsletter/register
Content-Type: application/x-www-form-urlencoded
email = michael.schiemer@gmail.com &
consent = 1
###

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Application\Security\Events\Access;
use App\Application\Security\SecurityEvent;
use App\Application\Security\SecurityEventType;
final class CsrfViolation implements SecurityEvent
{
public function __construct(
public readonly string $requestPath,
public readonly string $method,
) {}
public SecurityEventType $type {
get {
return SecurityEventType::CSRF_VIOLATION;
}
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Application\Security\Events\Auth;
use App\Application\Security\ValueObjects\{OWASPEventIdentifier, OWASPLogLevel};
final class AccountLockedEvent
{
public function __construct(
public readonly string $email,
public readonly string $reason,
public readonly int $failedAttempts
) {}
public function getOWASPEventIdentifier(): OWASPEventIdentifier
{
return OWASPEventIdentifier::accountLocked($this->maskEmail($this->email));
}
public function getOWASPLogLevel(): OWASPLogLevel
{
return OWASPLogLevel::ERROR;
}
public function getDescription(): string
{
return "Account {$this->maskEmail($this->email)} locked";
}
public function getEventData(): array
{
return [
'username' => $this->maskEmail($this->email),
'lock_reason' => $this->reason,
'failed_attempts' => $this->failedAttempts
];
}
private function maskEmail(string $email): string
{
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
return $email;
}
[$local, $domain] = explode('@', $email, 2);
$maskedLocal = substr($local, 0, 2) . str_repeat('*', max(0, strlen($local) - 2));
return $maskedLocal . '@' . $domain;
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Application\Security\Events\Auth;
use App\Application\Security\{OWASPSecurityEvent};
use App\Application\Security\ValueObjects\{OWASPEventIdentifier, OWASPLogLevel, MaskedEmail};
final class AuthenticationFailedEvent implements OWASPSecurityEvent
{
private MaskedEmail $maskedEmail;
public function __construct(
public readonly string $email,
public readonly ?string $reason = null,
public readonly int $failedAttempts = 1
) {
$this->maskedEmail = MaskedEmail::fromString($this->email);
}
public function getOWASPEventIdentifier(): OWASPEventIdentifier
{
return OWASPEventIdentifier::authenticationFailure($this->maskedEmail->toString());
}
public function getOWASPLogLevel(): OWASPLogLevel
{
return OWASPLogLevel::WARN;
}
public function getDescription(): string
{
return "User {$this->maskedEmail->toString()} login failed" .
($this->reason ? " - {$this->reason}" : '');
}
public function getEventData(): array
{
return [
'email' => $this->maskedEmail->toString(),
'reason' => $this->reason,
'failed_attempts' => $this->failedAttempts,
'failure_reason' => $this->reason ?? 'invalid_credentials'
];
}
public function getMaskedEmail(): MaskedEmail
{
return $this->maskedEmail;
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Application\Security\Events\Auth;
use App\Application\Security\{OWASPSecurityEvent};
use App\Application\Security\ValueObjects\{OWASPEventIdentifier, OWASPLogLevel, MaskedEmail};
final class AuthenticationSuccessEvent implements OWASPSecurityEvent
{
private MaskedEmail $maskedEmail;
public function __construct(
public readonly string $email,
public readonly string $sessionId,
public readonly ?string $method = 'password'
) {
$this->maskedEmail = MaskedEmail::fromString($this->email);
}
public function getOWASPEventIdentifier(): OWASPEventIdentifier
{
return OWASPEventIdentifier::authenticationSuccess($this->maskedEmail->toString());
}
public function getOWASPLogLevel(): OWASPLogLevel
{
return OWASPLogLevel::INFO;
}
public function getDescription(): string
{
return "User {$this->maskedEmail->toString()} login successfully";
}
public function getEventData(): array
{
return [
'username' => $this->maskedEmail->toString(),
'session_id' => hash('sha256', $this->sessionId), // Session-ID hashen für Sicherheit
'method' => $this->method
];
}
public function getMaskedEmail(): MaskedEmail
{
return $this->maskedEmail;
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Application\Security\Events\Auth;
use App\Application\Security\SecurityEvent;
use App\Application\Security\SecurityEventType;
final class LoginFailed implements SecurityEvent
{
public function __construct(
public string $email
) {}
public SecurityEventType $type {
get {
return SecurityEventType::LOGIN_FAILED;
}
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Application\Security\Events\Auth;
use App\Application\Security\ValueObjects\{OWASPEventIdentifier, OWASPLogLevel};
final class PasswordChangedEvent
{
public function __construct(
public readonly string $email,
public readonly string $method = 'self_service'
) {}
public function getOWASPEventIdentifier(): OWASPEventIdentifier
{
return OWASPEventIdentifier::passwordChange($this->maskEmail($this->email));
}
public function getOWASPLogLevel(): OWASPLogLevel
{
return OWASPLogLevel::INFO;
}
public function getDescription(): string
{
return "User {$this->maskEmail($this->email)} changed password";
}
public function getEventData(): array
{
return [
'username' => $this->maskEmail($this->email),
'change_method' => $this->method
];
}
private function maskEmail(string $email): string
{
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
return $email;
}
[$local, $domain] = explode('@', $email, 2);
$maskedLocal = substr($local, 0, 2) . str_repeat('*', max(0, strlen($local) - 2));
return $maskedLocal . '@' . $domain;
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\Application\Security\Events\Auth;
use App\Application\Security\{OWASPSecurityEvent};
use App\Application\Security\ValueObjects\{OWASPEventIdentifier, OWASPLogLevel, MaskedEmail};
final class SessionTerminatedEvent implements OWASPSecurityEvent
{
private MaskedEmail $maskedEmail;
public function __construct(
public readonly string $email,
public readonly string $sessionId,
public readonly string $reason = 'logout'
) {
$this->maskedEmail = MaskedEmail::fromString($this->email);
}
public function getOWASPEventIdentifier(): OWASPEventIdentifier
{
return OWASPEventIdentifier::sessionTermination($this->maskedEmail->toString());
}
public function getOWASPLogLevel(): OWASPLogLevel
{
return OWASPLogLevel::INFO;
}
public function getDescription(): string
{
return "User {$this->maskedEmail->toString()} logged out";
}
public function getEventData(): array
{
return [
'username' => $this->maskedEmail->toString(),
'session_id' => hash('sha256', $this->sessionId),
'termination_reason' => $this->reason
];
}
public function getMaskedEmail(): MaskedEmail
{
return $this->maskedEmail;
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Application\Security\Events\Authorization;
use App\Application\Security\ValueObjects\{OWASPEventIdentifier, OWASPLogLevel};
final class AccessDeniedEvent
{
public function __construct(
public readonly string $email,
public readonly string $resource,
public readonly string $action,
public readonly ?string $requiredPermission = null
) {}
public function getOWASPEventIdentifier(): OWASPEventIdentifier
{
return OWASPEventIdentifier::authorizationFailure(
$this->maskEmail($this->email),
$this->resource
);
}
public function getOWASPLogLevel(): OWASPLogLevel
{
return OWASPLogLevel::WARN;
}
public function getDescription(): string
{
return "Access denied for user {$this->maskEmail($this->email)} to resource {$this->resource}";
}
public function getEventData(): array
{
return [
'username' => $this->maskEmail($this->email),
'resource' => $this->resource,
'action' => $this->action,
'required_permission' => $this->requiredPermission
];
}
private function maskEmail(string $email): string
{
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
return $email;
}
[$local, $domain] = explode('@', $email, 2);
$maskedLocal = substr($local, 0, 2) . str_repeat('*', max(0, strlen($local) - 2));
return $maskedLocal . '@' . $domain;
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Application\Security\Events\Authorization;
use App\Application\Security\{OWASPSecurityEvent};
use App\Application\Security\ValueObjects\{OWASPEventIdentifier, OWASPLogLevel, MaskedEmail};
final class PrivilegeEscalationEvent implements OWASPSecurityEvent
{
private MaskedEmail $maskedEmail;
public function __construct(
public readonly string $email,
public readonly string $fromRole,
public readonly string $toRole,
public readonly string $method,
public readonly bool $successful = false
) {
$this->maskedEmail = MaskedEmail::fromString($this->email);
}
public function getOWASPEventIdentifier(): OWASPEventIdentifier
{
return OWASPEventIdentifier::privilegeEscalation(
$this->maskedEmail->toString(),
$this->fromRole,
$this->toRole
);
}
public function getOWASPLogLevel(): OWASPLogLevel
{
return OWASPLogLevel::FATAL;
}
public function getDescription(): string
{
$status = $this->successful ? 'successful' : 'attempted';
return "Privilege escalation {$status} by user {$this->maskedEmail->toString()}";
}
public function getEventData(): array
{
return [
'username' => $this->maskedEmail->toString(),
'from_role' => $this->fromRole,
'to_role' => $this->toRole,
'escalation_method' => $this->method,
'successful' => $this->successful
];
}
public function getMaskedEmail(): MaskedEmail
{
return $this->maskedEmail;
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Application\Security\Events\Crypto;
use App\Application\Security\{OWASPSecurityEvent};
use App\Application\Security\ValueObjects\{OWASPEventIdentifier, OWASPLogLevel, MaskedEmail};
final class CryptographicFailureEvent implements OWASPSecurityEvent
{
private ?MaskedEmail $maskedEmail;
public function __construct(
public readonly string $operation,
public readonly string $algorithm,
public readonly string $errorMessage,
public readonly ?string $email = null
) {
$this->maskedEmail = $this->email ? MaskedEmail::fromString($this->email) : null;
}
public function getOWASPEventIdentifier(): OWASPEventIdentifier
{
return OWASPEventIdentifier::cryptographicFailure($this->operation);
}
public function getOWASPLogLevel(): OWASPLogLevel
{
return OWASPLogLevel::ERROR;
}
public function getDescription(): string
{
return "Cryptographic failure in {$this->operation}";
}
public function getEventData(): array
{
return [
'operation' => $this->operation,
'algorithm' => $this->algorithm,
'error_message' => $this->errorMessage,
'username' => $this->maskedEmail?->toString() ?? 'system'
];
}
public function getMaskedEmail(): ?MaskedEmail
{
return $this->maskedEmail;
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Application\Security\Events\File;
use App\Application\Security\{OWASPSecurityEvent};
use App\Application\Security\ValueObjects\{OWASPEventIdentifier, OWASPLogLevel, MaskedEmail};
final class SuspiciousFileUploadEvent implements OWASPSecurityEvent
{
private ?MaskedEmail $maskedEmail;
public function __construct(
public readonly string $filename,
public readonly string $mimeType,
public readonly int $fileSize,
public readonly string $suspicionReason,
public readonly ?string $email = null
) {
$this->maskedEmail = $this->email ? MaskedEmail::fromString($this->email) : null;
}
public function getOWASPEventIdentifier(): OWASPEventIdentifier
{
return OWASPEventIdentifier::fileUploadFailure($this->filename);
}
public function getOWASPLogLevel(): OWASPLogLevel
{
return OWASPLogLevel::ERROR;
}
public function getDescription(): string
{
return "Suspicious file upload: {$this->filename}";
}
public function getEventData(): array
{
return [
'filename' => $this->sanitizeFilename($this->filename),
'mime_type' => $this->mimeType,
'file_size' => $this->fileSize,
'suspicion_reason' => $this->suspicionReason,
'username' => $this->maskedEmail?->toString() ?? 'anonymous'
];
}
public function getMaskedEmail(): ?MaskedEmail
{
return $this->maskedEmail;
}
private function sanitizeFilename(string $filename): string
{
return preg_replace('/[^\w\.\-]/', '_', $filename);
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Application\Security\Events\Input;
use App\Application\Security\{OWASPSecurityEvent};
use App\Application\Security\ValueObjects\{OWASPEventIdentifier, OWASPLogLevel, MaskedEmail};
final class InputValidationFailureEvent implements OWASPSecurityEvent
{
private ?MaskedEmail $maskedEmail;
public function __construct(
public readonly string $fieldName,
public readonly string $invalidValue,
public readonly string $validationRule,
public readonly ?string $email = null
) {
$this->maskedEmail = $this->email ? MaskedEmail::fromString($this->email) : null;
}
public function getOWASPEventIdentifier(): OWASPEventIdentifier
{
return OWASPEventIdentifier::inputValidationFailure($this->fieldName);
}
public function getOWASPLogLevel(): OWASPLogLevel
{
return OWASPLogLevel::WARN;
}
public function getDescription(): string
{
return "Input validation failure for field {$this->fieldName}";
}
public function getEventData(): array
{
return [
'field_name' => $this->fieldName,
'invalid_value' => $this->sanitizeForLog($this->invalidValue),
'validation_rule' => $this->validationRule,
'username' => $this->maskedEmail?->toString() ?? 'anonymous'
];
}
public function getMaskedEmail(): ?MaskedEmail
{
return $this->maskedEmail;
}
private function sanitizeForLog(string $value): string
{
// Maximal 100 Zeichen und gefährliche Zeichen entfernen
$sanitized = substr($value, 0, 100);
return preg_replace('/[^\w\s\.\-@]/', '***', $sanitized);
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Application\Security\Events\Input;
use App\Application\Security\{OWASPSecurityEvent};
use App\Application\Security\ValueObjects\{OWASPEventIdentifier, OWASPLogLevel, MaskedEmail};
final class MaliciousInputDetectedEvent implements OWASPSecurityEvent
{
private ?MaskedEmail $maskedEmail;
public function __construct(
public readonly string $fieldName,
public readonly string $attackPattern,
public readonly string $sanitizedValue,
public readonly ?string $email = null
) {
$this->maskedEmail = $this->email ? MaskedEmail::fromString($this->email) : null;
}
public function getOWASPEventIdentifier(): OWASPEventIdentifier
{
return OWASPEventIdentifier::maliciousInput($this->attackPattern);
}
public function getOWASPLogLevel(): OWASPLogLevel
{
return OWASPLogLevel::ERROR;
}
public function getDescription(): string
{
return "Malicious input detected: {$this->attackPattern}";
}
public function getEventData(): array
{
return [
'field_name' => $this->fieldName,
'attack_pattern' => $this->attackPattern,
'sanitized_value' => $this->sanitizedValue,
'username' => $this->maskedEmail?->toString() ?? 'anonymous'
];
}
public function getMaskedEmail(): ?MaskedEmail
{
return $this->maskedEmail;
}
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Application\Security\Events\Input;
use App\Application\Security\{OWASPSecurityEvent};
use App\Application\Security\ValueObjects\{OWASPEventIdentifier, OWASPLogLevel, MaskedEmail};
final class SqlInjectionAttemptEvent implements OWASPSecurityEvent
{
private ?MaskedEmail $maskedEmail;
public function __construct(
public readonly string $attackPayload,
public readonly string $targetField,
public readonly string $detectionMethod,
public readonly ?string $email = null
) {
$this->maskedEmail = $this->email ? MaskedEmail::fromString($this->email) : null;
}
public function getOWASPEventIdentifier(): OWASPEventIdentifier
{
return OWASPEventIdentifier::maliciousInput('sql_injection');
}
public function getOWASPLogLevel(): OWASPLogLevel
{
return OWASPLogLevel::ERROR;
}
public function getDescription(): string
{
return "SQL injection attempt detected";
}
public function getEventData(): array
{
return [
'attack_payload' => $this->sanitizePayload($this->attackPayload),
'target_field' => $this->targetField,
'detection_method' => $this->detectionMethod,
'username' => $this->maskedEmail?->toString() ?? 'anonymous'
];
}
public function getMaskedEmail(): ?MaskedEmail
{
return $this->maskedEmail;
}
private function sanitizePayload(string $payload): string
{
// SQL-Injection-Payload nur teilweise loggen für Analyse
return substr(preg_replace('/[^\w\s\'\";=\-\(\)]/', '***', $payload), 0, 200);
}
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Application\Security\Events\Input;
use App\Application\Security\{OWASPSecurityEvent};
use App\Application\Security\ValueObjects\{OWASPEventIdentifier, OWASPLogLevel, MaskedEmail};
final class XssAttemptEvent implements OWASPSecurityEvent
{
private ?MaskedEmail $maskedEmail;
public function __construct(
public readonly string $attackPayload,
public readonly string $targetField,
public readonly string $xssType,
public readonly ?string $email = null
) {
$this->maskedEmail = $this->email ? MaskedEmail::fromString($this->email) : null;
}
public function getOWASPEventIdentifier(): OWASPEventIdentifier
{
return OWASPEventIdentifier::maliciousInput('xss_attempt');
}
public function getOWASPLogLevel(): OWASPLogLevel
{
return OWASPLogLevel::ERROR;
}
public function getDescription(): string
{
return "XSS attempt detected: {$this->xssType}";
}
public function getEventData(): array
{
return [
'attack_payload' => $this->sanitizePayload($this->attackPayload),
'target_field' => $this->targetField,
'xss_type' => $this->xssType,
'username' => $this->maskedEmail?->toString() ?? 'anonymous'
];
}
public function getMaskedEmail(): ?MaskedEmail
{
return $this->maskedEmail;
}
private function sanitizePayload(string $payload): string
{
// HTML-Tags entfernen aber Struktur beibehalten für Analyse
return substr(htmlspecialchars($payload, ENT_QUOTES, 'UTF-8'), 0, 200);
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Application\Security\Events\Network;
use App\Application\Security\{OWASPSecurityEvent};
use App\Application\Security\ValueObjects\{OWASPEventIdentifier, OWASPLogLevel, MaskedEmail};
final class SuspiciousNetworkActivityEvent implements OWASPSecurityEvent
{
private ?MaskedEmail $maskedEmail;
public function __construct(
public readonly string $sourceIp,
public readonly string $activityType,
public readonly int $requestCount,
public readonly string $timeWindow,
public readonly ?string $email = null
) {
$this->maskedEmail = $this->email ? MaskedEmail::fromString($this->email) : null;
}
public function getOWASPEventIdentifier(): OWASPEventIdentifier
{
return OWASPEventIdentifier::suspiciousNetworkActivity($this->activityType);
}
public function getOWASPLogLevel(): OWASPLogLevel
{
return OWASPLogLevel::WARN;
}
public function getDescription(): string
{
return "Suspicious network activity detected: {$this->activityType}";
}
public function getEventData(): array
{
return [
'source_ip' => $this->sourceIp,
'activity_type' => $this->activityType,
'request_count' => $this->requestCount,
'time_window' => $this->timeWindow,
'username' => $this->maskedEmail?->toString() ?? 'anonymous'
];
}
public function getMaskedEmail(): ?MaskedEmail
{
return $this->maskedEmail;
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Application\Security\Events\Session;
use App\Application\Security\{OWASPSecurityEvent};
use App\Application\Security\ValueObjects\{OWASPEventIdentifier, OWASPLogLevel, MaskedEmail};
final class SessionFixationEvent implements OWASPSecurityEvent
{
private ?MaskedEmail $maskedEmail;
public function __construct(
public readonly ?string $email,
public readonly string $oldSessionId,
public readonly string $newSessionId,
public readonly string $attackVector
) {
$this->maskedEmail = $this->email ? MaskedEmail::fromString($this->email) : null;
}
public function getOWASPEventIdentifier(): OWASPEventIdentifier
{
return OWASPEventIdentifier::sessionFixation($this->maskedEmail?->toString() ?? 'anonymous');
}
public function getOWASPLogLevel(): OWASPLogLevel
{
return OWASPLogLevel::FATAL;
}
public function getDescription(): string
{
return "Session fixation attack detected for user {$this->maskedEmail?->toString() ?? 'anonymous'}";
}
public function getEventData(): array
{
return [
'username' => $this->maskedEmail?->toString() ?? 'anonymous',
'old_session_id' => hash('sha256', $this->oldSessionId),
'new_session_id' => hash('sha256', $this->newSessionId),
'attack_vector' => $this->attackVector
];
}
public function getMaskedEmail(): ?MaskedEmail
{
return $this->maskedEmail;
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Application\Security\Events\Session;
use App\Application\Security\ValueObjects\{OWASPEventIdentifier, OWASPLogLevel};
final class SessionHijackingDetectedEvent
{
public function __construct(
public readonly string $email,
public readonly string $sessionId,
public readonly string $evidence,
public readonly ?string $suspiciousIp = null
) {}
public function getOWASPEventIdentifier(): OWASPEventIdentifier
{
return OWASPEventIdentifier::sessionHijacking($this->maskEmail($this->email));
}
public function getOWASPLogLevel(): OWASPLogLevel
{
return OWASPLogLevel::FATAL;
}
public function getDescription(): string
{
return "Session hijacking detected for user {$this->maskEmail($this->email)}";
}
public function getEventData(): array
{
return [
'username' => $this->maskEmail($this->email),
'session_id' => hash('sha256', $this->sessionId),
'evidence' => $this->evidence,
'suspicious_ip' => $this->suspiciousIp
];
}
private function maskEmail(string $email): string
{
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
return $email;
}
[$local, $domain] = explode('@', $email, 2);
$maskedLocal = substr($local, 0, 2) . str_repeat('*', max(0, strlen($local) - 2));
return $maskedLocal . '@' . $domain;
}
}

View File

@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace App\Application\Security\Events\Session;
use App\Application\Security\{OWASPSecurityEvent};
use App\Application\Security\ValueObjects\{OWASPEventIdentifier, OWASPLogLevel, MaskedEmail};
final class SessionTimeoutEvent implements OWASPSecurityEvent
{
private ?MaskedEmail $maskedEmail;
public function __construct(
public readonly ?string $email,
public readonly string $sessionId,
public readonly int $inactivityDuration,
public readonly string $reason = 'timeout'
) {
$this->maskedEmail = $this->email ? MaskedEmail::fromString($this->email) : null;
}
public function getOWASPEventIdentifier(): OWASPEventIdentifier
{
return OWASPEventIdentifier::sessionTimeout($this->maskedEmail?->toString() ?? 'anonymous');
}
public function getOWASPLogLevel(): OWASPLogLevel
{
return OWASPLogLevel::WARN;
}
public function getDescription(): string
{
return "Session timeout for user {$this->maskedEmail?->toString() ?? 'anonymous'}";
}
public function getEventData(): array
{
return [
'username' => $this->maskedEmail?->toString() ?? 'anonymous',
'session_id' => hash('sha256', $this->sessionId),
'inactivity_duration' => $this->inactivityDuration,
'timeout_reason' => $this->reason
];
}
public function getMaskedEmail(): ?MaskedEmail
{
return $this->maskedEmail;
}
}

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Application\Security\Events\System;
use App\Application\Security\{OWASPSecurityEvent};
use App\Application\Security\ValueObjects\{OWASPEventIdentifier, OWASPLogLevel, MaskedEmail};
final class SystemAnomalyEvent implements OWASPSecurityEvent
{
public function __construct(
public readonly string $anomalyType,
public readonly string $description,
public readonly array $metrics,
public readonly string $severity
) {}
public function getOWASPEventIdentifier(): OWASPEventIdentifier
{
return OWASPEventIdentifier::systemAnomaly($this->anomalyType);
}
public function getOWASPLogLevel(): OWASPLogLevel
{
return match ($this->severity) {
'low' => OWASPLogLevel::INFO,
'medium' => OWASPLogLevel::WARN,
'high' => OWASPLogLevel::ERROR,
'critical' => OWASPLogLevel::FATAL,
default => OWASPLogLevel::WARN
};
}
public function getDescription(): string
{
return "System anomaly detected: {$this->anomalyType}";
}
public function getEventData(): array
{
return [
'anomaly_type' => $this->anomalyType,
'description' => $this->description,
'metrics' => $this->metrics,
'severity' => $this->severity
];
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Application\Security\Events\Web;
use App\Application\Security\{OWASPSecurityEvent};
use App\Application\Security\ValueObjects\{OWASPEventIdentifier, OWASPLogLevel, MaskedEmail};
final class CsrfViolationEvent implements OWASPSecurityEvent
{
private ?MaskedEmail $maskedEmail;
public function __construct(
public readonly string $requestPath,
public readonly string $method,
public readonly ?string $expectedToken = null,
public readonly ?string $providedToken = null,
public readonly ?string $email = null
) {
$this->maskedEmail = $this->email ? MaskedEmail::fromString($this->email) : null;
}
public function getOWASPEventIdentifier(): OWASPEventIdentifier
{
return OWASPEventIdentifier::csrfViolation($this->requestPath);
}
public function getOWASPLogLevel(): OWASPLogLevel
{
return OWASPLogLevel::ERROR;
}
public function getDescription(): string
{
return "CSRF token validation failed for {$this->method} {$this->requestPath}";
}
public function getEventData(): array
{
return [
'request_path' => $this->requestPath,
'method' => $this->method,
'expected_token_hash' => $this->expectedToken ? hash('sha256', $this->expectedToken) : null,
'provided_token_hash' => $this->providedToken ? hash('sha256', $this->providedToken) : null,
'username' => $this->maskedEmail?->toString() ?? 'anonymous'
];
}
public function getMaskedEmail(): ?MaskedEmail
{
return $this->maskedEmail;
}
}

View File

@@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace App\Application\Security\ExceptionHandlers;
use App\Application\Security\Events\{
Authorization\AccessDeniedEvent,
Input\InputValidationFailureEvent,
System\SystemAnomalyEvent,
Crypto\CryptographicFailureEvent
};
use App\Framework\Core\Events\EventDispatcher;
use App\Framework\Core\Exceptions\{
ValidationException,
CryptographicException
};
use Psr\Log\LoggerInterface;
use Symfony\Component\Finder\Exception\AccessDeniedException;
use Throwable;
final class SecurityExceptionHandler
{
public function __construct(
private EventDispatcher $eventDispatcher,
private LoggerInterface $logger
) {}
public function handle(Throwable $exception): void
{
match (true) {
$exception instanceof AccessDeniedException => $this->handleAccessDenied($exception),
$exception instanceof ValidationException => $this->handleValidationError($exception),
$exception instanceof CryptographicException => $this->handleCryptographicError($exception),
$exception instanceof \Error => $this->handleSystemError($exception),
default => $this->handleGenericSecurityIssue($exception)
};
}
private function handleAccessDenied(AccessDeniedException $exception): void
{
$this->eventDispatcher->dispatch(new AccessDeniedEvent(
email: $this->getCurrentUserEmail(),
resource: $exception->getResource(),
action: $exception->getAction(),
requiredPermission: $exception->getRequiredPermission()
));
}
private function handleValidationError(ValidationException $exception): void
{
foreach ($exception->getErrors() as $field => $errors) {
$this->eventDispatcher->dispatch(new InputValidationFailureEvent(
fieldName: $field,
invalidValue: $exception->getInvalidValue($field) ?? '',
validationRule: implode(', ', $errors),
email: $this->getCurrentUserEmail()
));
}
}
private function handleCryptographicError(CryptographicException $exception): void
{
$this->eventDispatcher->dispatch(new CryptographicFailureEvent(
operation: $exception->getOperation(),
algorithm: $exception->getAlgorithm(),
errorMessage: $exception->getMessage(),
email: $this->getCurrentUserEmail()
));
}
private function handleSystemError(\Error $exception): void
{
$this->eventDispatcher->dispatch(new SystemAnomalyEvent(
anomalyType: 'system_error',
description: $exception->getMessage(),
metrics: [
'error_type' => get_class($exception),
'file' => $exception->getFile(),
'line' => $exception->getLine(),
'memory_usage' => memory_get_usage(true)
],
severity: 'high'
));
}
private function handleGenericSecurityIssue(Throwable $exception): void
{
// Nur bei tatsächlich sicherheitsrelevanten Exceptions loggen
if ($this->isSecurityRelevant($exception)) {
$this->logger->warning('Security-relevant exception detected', [
'exception_class' => get_class($exception),
'message' => $exception->getMessage(),
'user_email' => $this->getCurrentUserEmail(),
'security_context' => [
'ip' => $_SERVER['REMOTE_ADDR'] ?? 'unknown',
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? null,
'request_uri' => $_SERVER['REQUEST_URI'] ?? null
]
]);
}
}
private function isSecurityRelevant(Throwable $exception): bool
{
$securityRelevantClasses = [
'AccessDeniedException',
'AuthenticationException',
'AuthorizationException',
'ValidationException',
'CryptographicException',
'FileUploadException',
'SessionException'
];
$className = get_class($exception);
foreach ($securityRelevantClasses as $securityClass) {
if (str_contains($className, $securityClass)) {
return true;
}
}
// Sicherheitsrelevante Nachrichten prüfen
$message = strtolower($exception->getMessage());
$securityKeywords = [
'access denied', 'unauthorized', 'permission', 'authentication',
'authorization', 'csrf', 'xss', 'injection', 'validation failed'
];
foreach ($securityKeywords as $keyword) {
if (str_contains($message, $keyword)) {
return true;
}
}
return false;
}
private function getCurrentUserEmail(): ?string
{
return $_SESSION['user_email'] ?? null;
}
}

View File

@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace App\Application\Security\Guards;
use App\Framework\Database\Example\UserRepository;
use App\Application\Security\Events\Auth\{
AuthenticationSuccessEvent,
AuthenticationFailedEvent,
AccountLockedEvent
};
use App\Framework\Core\Events\EventDispatcher;
use App\Domain\User\{User};
final class AuthenticationGuard
{
private const MAX_FAILED_ATTEMPTS = 5;
private const LOCKOUT_DURATION = 900; // 15 Minuten
public function __construct(
private EventDispatcher $eventDispatcher,
private UserRepository $userRepository
) {}
public function authenticate(string $email, string $password): ?User
{
try {
$user = $this->userRepository->findByEmail($email);
if (!$user) {
$this->dispatchFailedAttempt($email, 'user_not_found');
return null;
}
if ($this->isAccountLocked($user)) {
$this->dispatchAccountLocked($email, 'too_many_failed_attempts', $user->failed_attempts);
return null;
}
if (!$this->verifyPassword($password, $user->password_hash)) {
$this->handleFailedAttempt($user);
return null;
}
$this->handleSuccessfulLogin($user);
return $user;
} catch (\Exception $e) {
$this->dispatchFailedAttempt($email, 'authentication_error');
throw $e;
}
}
public function logout(User $user): void
{
$sessionId = session_id();
// Session invalidieren
session_destroy();
$this->eventDispatcher->dispatch(new \App\Application\Security\Events\Auth\SessionTerminatedEvent(
email: $user->email,
sessionId: $sessionId,
reason: 'manual_logout'
));
}
private function handleSuccessfulLogin(User $user): void
{
// Failed attempts zurücksetzen
$user->failed_attempts = 0;
$user->last_login = new \DateTimeImmutable();
$this->userRepository->save($user);
// Session regenerieren
session_regenerate_id(true);
$_SESSION['user_id'] = $user->id;
$this->eventDispatcher->dispatch(new AuthenticationSuccessEvent(
email: $user->email,
sessionId: session_id(),
method: 'password'
));
}
private function handleFailedAttempt(User $user): void
{
$user->failed_attempts++;
$user->last_failed_attempt = new \DateTimeImmutable();
if ($user->failed_attempts >= self::MAX_FAILED_ATTEMPTS) {
$user->locked_until = new \DateTimeImmutable('+' . self::LOCKOUT_DURATION . ' seconds');
$this->userRepository->save($user);
$this->dispatchAccountLocked($user->email, 'max_attempts_exceeded', $user->failed_attempts);
} else {
$this->userRepository->save($user);
$this->dispatchFailedAttempt($user->email, 'invalid_password', $user->failed_attempts);
}
}
private function dispatchFailedAttempt(string $email, string $reason, int $attempts = 1): void
{
$this->eventDispatcher->dispatch(new AuthenticationFailedEvent(
email: $email,
reason: $reason,
failedAttempts: $attempts
));
}
private function dispatchAccountLocked(string $email, string $reason, int $attempts): void
{
$this->eventDispatcher->dispatch(new AccountLockedEvent(
email: $email,
reason: $reason,
failedAttempts: $attempts
));
}
private function isAccountLocked(User $user): bool
{
return $user->locked_until && $user->locked_until > new \DateTimeImmutable();
}
private function verifyPassword(string $password, string $hash): bool
{
return password_verify($password, $hash);
}
}

View File

@@ -0,0 +1,106 @@
<?php
declare(strict_types=1);
namespace App\Application\Security\Middleware;
use App\Application\Security\Events\{
Web\CsrfViolationEvent,
Network\SuspiciousNetworkActivityEvent,
Input\InputValidationFailureEvent
};
use App\Framework\Core\Events\EventDispatcher;
use Psr\Http\Message\{ServerRequestInterface, ResponseInterface};
use Psr\Http\Server\{MiddlewareInterface, RequestHandlerInterface};
final class SecurityEventMiddleware implements MiddlewareInterface
{
private array $requestCounts = [];
private const RATE_LIMIT_THRESHOLD = 100;
private const TIME_WINDOW = 300; // 5 Minuten
public function __construct(
private EventDispatcher $eventDispatcher
) {}
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
{
$this->checkRateLimit($request);
$this->validateCsrfToken($request);
$response = $handler->handle($request);
$this->analyzeResponse($request, $response);
return $response;
}
private function checkRateLimit(ServerRequestInterface $request): void
{
$clientIp = $this->getClientIp($request);
$currentTime = time();
// Rate Limiting Check
if (!isset($this->requestCounts[$clientIp])) {
$this->requestCounts[$clientIp] = [];
}
// Alte Einträge entfernen
$this->requestCounts[$clientIp] = array_filter(
$this->requestCounts[$clientIp],
fn($timestamp) => $currentTime - $timestamp < self::TIME_WINDOW
);
$this->requestCounts[$clientIp][] = $currentTime;
if (count($this->requestCounts[$clientIp]) > self::RATE_LIMIT_THRESHOLD) {
$this->eventDispatcher->dispatch(new SuspiciousNetworkActivityEvent(
sourceIp: $clientIp,
activityType: 'rate_limit_exceeded',
requestCount: count($this->requestCounts[$clientIp]),
timeWindow: self::TIME_WINDOW . 's'
));
}
}
private function validateCsrfToken(ServerRequestInterface $request): void
{
$method = $request->getMethod();
if (!in_array($method, ['POST', 'PUT', 'DELETE', 'PATCH'])) {
return;
}
$sessionToken = $_SESSION['csrf_token'] ?? null;
$requestToken = $request->getParsedBody()['csrf_token'] ??
$request->getHeaderLine('X-CSRF-Token');
if (!$sessionToken || !$requestToken || !hash_equals($sessionToken, $requestToken)) {
$this->eventDispatcher->dispatch(new CsrfViolationEvent(
requestPath: $request->getUri()->getPath(),
method: $method,
expectedToken: $sessionToken,
providedToken: $requestToken
));
}
}
private function analyzeResponse(ServerRequestInterface $request, ResponseInterface $response): void
{
// Suspicious response patterns
if ($response->getStatusCode() === 403) {
// Access denied - könnte bereits von Authorization Middleware gehandhabt werden
}
if ($response->getStatusCode() >= 500) {
// Server errors - könnte auf Angriffe hindeuten
}
}
private function getClientIp(ServerRequestInterface $request): string
{
$serverParams = $request->getServerParams();
return $serverParams['HTTP_X_FORWARDED_FOR'] ??
$serverParams['HTTP_X_REAL_IP'] ??
$serverParams['REMOTE_ADDR'] ??
'unknown';
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Application\Security;
use App\Application\Security\ValueObjects\{OWASPEventIdentifier, OWASPLogLevel};
interface OWASPSecurityEvent
{
/**
* Gibt den OWASP-konformen Event-Identifier zurück
*/
public function getOWASPEventIdentifier(): OWASPEventIdentifier;
/**
* Gibt das entsprechende Log-Level zurück
*/
public function getOWASPLogLevel(): OWASPLogLevel;
/**
* Gibt eine menschenlesbare Beschreibung des Events zurück
*/
public function getDescription(): string;
/**
* Gibt strukturierte Event-Daten für das Logging zurück
*/
public function getEventData(): array;
}

View File

@@ -0,0 +1,210 @@
<?php
declare(strict_types=1);
namespace App\Application\Security;
use App\Application\Security\ValueObjects\{
SecurityContext,
RequestContext,
OWASPLogFormat,
OWASPEventIdentifier,
OWASPLogLevel
};
final class OWASPSecurityEventFactory
{
public function __construct(
private string $applicationId = 'app.security'
) {}
public function createFromSecurityEvent(
SecurityEvent $event,
SecurityContext $context,
RequestContext $requestContext
): OWASPLogFormat {
$eventIdentifier = $this->createEventIdentifier($event);
$logLevel = OWASPLogLevel::fromSecurityEventType($event->type);
$description = $this->createDescription($event, $eventIdentifier);
return OWASPLogFormat::create(
$this->applicationId,
$eventIdentifier->toString(),
$logLevel->value,
$description,
$context,
$requestContext
);
}
private function createEventIdentifier(SecurityEvent $event): OWASPEventIdentifier
{
return match ($event->type) {
SecurityEventType::LOGIN_FAILED => $this->createLoginFailedIdentifier($event),
SecurityEventType::LOGIN_SUCCESS => $this->createLoginSuccessIdentifier($event),
SecurityEventType::LOGOUT => $this->createLogoutIdentifier($event),
SecurityEventType::PASSWORD_CHANGE => $this->createPasswordChangeIdentifier($event),
SecurityEventType::ACCOUNT_LOCKED => $this->createAccountLockedIdentifier($event),
SecurityEventType::ACCESS_DENIED => $this->createAccessDeniedIdentifier($event),
SecurityEventType::PRIVILEGE_ESCALATION => $this->createPrivilegeEscalationIdentifier($event),
SecurityEventType::DATA_ACCESS => $this->createDataAccessIdentifier($event),
SecurityEventType::INJECTION_ATTEMPT => $this->createInjectionAttemptIdentifier($event),
SecurityEventType::FILE_UPLOAD => $this->createFileUploadIdentifier($event),
SecurityEventType::SESSION_HIJACK => $this->createSessionHijackIdentifier($event),
SecurityEventType::SESSION_TIMEOUT => $this->createSessionTimeoutIdentifier($event),
SecurityEventType::MALWARE_DETECTED => $this->createMalwareDetectedIdentifier($event),
SecurityEventType::AUDIT_FAILURE => $this->createAuditFailureIdentifier($event),
};
}
private function createLoginFailedIdentifier(SecurityEvent $event): OWASPEventIdentifier
{
$username = $this->extractUsername($event);
return OWASPEventIdentifier::authenticationFailure($username);
}
private function createLoginSuccessIdentifier(SecurityEvent $event): OWASPEventIdentifier
{
$username = $this->extractUsername($event);
return OWASPEventIdentifier::authenticationSuccess($username);
}
private function createLogoutIdentifier(SecurityEvent $event): OWASPEventIdentifier
{
$username = $this->extractUsername($event);
return OWASPEventIdentifier::sessionTermination($username);
}
private function createPasswordChangeIdentifier(SecurityEvent $event): OWASPEventIdentifier
{
$username = $this->extractUsername($event);
return OWASPEventIdentifier::passwordChange($username);
}
private function createAccountLockedIdentifier(SecurityEvent $event): OWASPEventIdentifier
{
$username = $this->extractUsername($event);
return OWASPEventIdentifier::accountLocked($username);
}
private function createAccessDeniedIdentifier(SecurityEvent $event): OWASPEventIdentifier
{
$username = $this->extractUsername($event);
$resource = $this->extractProperty($event, 'resource', 'unknown_resource');
return OWASPEventIdentifier::authorizationFailure($username, $resource);
}
private function createPrivilegeEscalationIdentifier(SecurityEvent $event): OWASPEventIdentifier
{
$username = $this->extractUsername($event);
$fromRole = $this->extractProperty($event, 'fromRole', 'user');
$toRole = $this->extractProperty($event, 'toRole', 'admin');
return OWASPEventIdentifier::privilegeEscalation($username, $fromRole, $toRole);
}
private function createDataAccessIdentifier(SecurityEvent $event): OWASPEventIdentifier
{
$field = $this->extractProperty($event, 'field', 'unknown');
return OWASPEventIdentifier::inputValidationFailure($field);
}
private function createInjectionAttemptIdentifier(SecurityEvent $event): OWASPEventIdentifier
{
$attackType = $this->extractProperty($event, 'attackType', 'injection');
return OWASPEventIdentifier::maliciousInput($attackType);
}
private function createFileUploadIdentifier(SecurityEvent $event): OWASPEventIdentifier
{
$filename = $this->extractProperty($event, 'filename', 'unknown.file');
return OWASPEventIdentifier::fileUploadFailure($filename);
}
private function createSessionHijackIdentifier(SecurityEvent $event): OWASPEventIdentifier
{
$username = $this->extractUsername($event);
return OWASPEventIdentifier::sessionHijacking($username);
}
private function createSessionTimeoutIdentifier(SecurityEvent $event): OWASPEventIdentifier
{
$username = $this->extractUsername($event);
return OWASPEventIdentifier::sessionTimeout($username);
}
private function createMalwareDetectedIdentifier(SecurityEvent $event): OWASPEventIdentifier
{
$malwareType = $this->extractProperty($event, 'malwareType', 'unknown');
return OWASPEventIdentifier::malwareDetected($malwareType);
}
private function createAuditFailureIdentifier(SecurityEvent $event): OWASPEventIdentifier
{
$eventType = $this->extractProperty($event, 'eventType', $event->type->value);
return OWASPEventIdentifier::auditFailure($eventType);
}
private function createDescription(SecurityEvent $event, OWASPEventIdentifier $identifier): string
{
return match ($event->type) {
SecurityEventType::LOGIN_FAILED => "User {$this->extractUsername($event)} login failed",
SecurityEventType::LOGIN_SUCCESS => "User {$this->extractUsername($event)} login successfully",
SecurityEventType::LOGOUT => "User {$this->extractUsername($event)} logged out",
SecurityEventType::PASSWORD_CHANGE => "User {$this->extractUsername($event)} changed password",
SecurityEventType::ACCOUNT_LOCKED => "Account {$this->extractUsername($event)} locked",
SecurityEventType::ACCESS_DENIED => "Access denied for user {$this->extractUsername($event)} to resource {$this->extractProperty($event, 'resource', 'unknown_resource')}",
SecurityEventType::PRIVILEGE_ESCALATION => "Privilege escalation attempt by user {$this->extractUsername($event)}",
SecurityEventType::DATA_ACCESS => "Input validation failure for field {$this->extractProperty($event, 'field', 'unknown')}",
SecurityEventType::INJECTION_ATTEMPT => "Malicious input detected: {$this->extractProperty($event, 'attackType', 'injection')}",
SecurityEventType::FILE_UPLOAD => "File upload failed: {$this->extractProperty($event, 'filename', 'unknown.file')}",
SecurityEventType::SESSION_HIJACK => "Session hijacking detected for user {$this->extractUsername($event)}",
SecurityEventType::SESSION_TIMEOUT => "Session timeout for user {$this->extractUsername($event)}",
SecurityEventType::MALWARE_DETECTED => "Malware detected: {$this->extractProperty($event, 'malwareType', 'unknown')}",
SecurityEventType::AUDIT_FAILURE => "Audit logging failure for event {$event->type->value}",
};
}
private function extractUsername(SecurityEvent $event): string
{
$usernameFields = ['username', 'email', 'user', 'userId'];
foreach ($usernameFields as $field) {
$value = $this->extractProperty($event, $field);
if ($value !== null) {
return $this->maskEmail($value);
}
}
return 'anonymous';
}
private function extractProperty(SecurityEvent $event, string $propertyName, ?string $default = null): ?string
{
try {
$reflection = new \ReflectionObject($event);
if ($reflection->hasProperty($propertyName)) {
$property = $reflection->getProperty($propertyName);
$property->setAccessible(true);
$value = $property->getValue($event);
return is_string($value) ? $value : (string)$value;
}
} catch (\Exception) {
// Property nicht gefunden oder nicht lesbar
}
return $default;
}
private function maskEmail(string $value): string
{
if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {
return $value;
}
[$local, $domain] = explode('@', $value, 2);
$maskedLocal = substr($local, 0, 2) . str_repeat('*', max(0, strlen($local) - 2));
return $maskedLocal . '@' . $domain;
}
}

View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace App\Application\Security;
use App\Application\Security\ValueObjects\{
SecurityContext,
RequestContext,
OWASPLogFormat,
OWASPLogLevel
};
use App\Framework\Core\Events\OnEvent;
use Psr\Log\LoggerInterface;
use Throwable;
final class OWASPSecurityEventLogger
{
public function __construct(
private LoggerInterface $logger,
private string $applicationId = 'app.security'
) {}
/**
* Universeller Event-Handler für alle OWASP Security Events
*/
#[OnEvent]
public function logSecurityEvent(OWASPSecurityEvent $event): void
{
try {
$securityContext = SecurityContext::fromGlobals();
$requestContext = RequestContext::fromGlobals();
$owaspLogFormat = $this->createOWASPLogFormat(
$event,
$securityContext,
$requestContext
);
// OWASP-konformes JSON-Log
$this->logger->log(
$this->mapLogLevel($event->getOWASPLogLevel()),
$event->getDescription(),
[
'owasp_format' => $owaspLogFormat->toArray(),
'event_class' => $event::class,
'event_data' => $event->getEventData()
]
);
} catch (Throwable $e) {
// Fallback logging - niemals Security-Events verlieren
$this->logger->critical('AUDIT_audit_failure:owasp_logger', [
'datetime' => date('c'),
'appid' => $this->applicationId,
'event' => 'AUDIT_audit_failure:owasp_logger',
'level' => 'FATAL',
'description' => 'OWASP security event logging failed: ' . $e->getMessage(),
'original_event_class' => $event::class,
'error_trace' => $e->getTraceAsString()
]);
}
}
private function createOWASPLogFormat(
OWASPSecurityEvent $event,
SecurityContext $securityContext,
RequestContext $requestContext
): OWASPLogFormat {
return OWASPLogFormat::create(
$this->applicationId,
$event->getOWASPEventIdentifier()->toString(),
$event->getOWASPLogLevel()->value,
$event->getDescription(),
$securityContext,
$requestContext
);
}
private function mapLogLevel(OWASPLogLevel $owaspLevel): string
{
return match ($owaspLevel) {
OWASPLogLevel::DEBUG => 'debug',
OWASPLogLevel::INFO => 'info',
OWASPLogLevel::WARN => 'warning',
OWASPLogLevel::ERROR => 'error',
OWASPLogLevel::FATAL => 'critical'
};
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Application\Security;
/**
* Beispiel für Logeintrag:
* {
* 'timestamp': '2025-06-01T00:52:10Z',
* 'event': 'auth.login.failed',
* 'ip': '203.0.113.42',
* 'user_agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)',
* 'request_uri': '/login',
* 'method': 'POST',
* 'user_id': null,
* 'email': 'j***@example.com',
* 'session_id': 'abc123',
* 'referrer': 'https://example.com/login',
* 'result': 'fail',
* 'request_id': 'req-3d92fcb45e'
* }
*
*/
final readonly class SecurityContext
{
public function __construct(
public string $ip,
public ?string $userAgent,
public string $method,
public string $uri,
public \DateTimeImmutable $timestamp,
public ?string $userId = null,
public ?string $sessionId = null,
public ?string $referrer = null,
public ?string $requestId = null
) {}
public static function fromGlobals(): self
{
return new self(
ip: $_SERVER['REMOTE_ADDR'] ?? 'unknown',
userAgent: $_SERVER['HTTP_USER_AGENT'] ?? null,
method: $_SERVER['REQUEST_METHOD'] ?? 'CLI',
uri: $_SERVER['REQUEST_URI'] ?? '-',
timestamp: new \DateTimeImmutable(),
userId: $_SESSION['user_id'] ?? null,
sessionId: session_id() ?: null,
referrer: $_SERVER['HTTP_REFERER'] ?? null,
requestId: $_SERVER['HTTP_X_REQUEST_ID'] ?? bin2hex(random_bytes(8))
);
}
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Application\Security;
interface SecurityEvent
{
public SecurityEventType $type {
get;
}
}

View File

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Application\Security;
use App\Application\Security\ValueObjects\{SecurityContext as SecurityContextVO, RequestContext};
use App\Framework\Core\Events\OnEvent;
use Psr\Log\LoggerInterface;
use Throwable;
final class SecurityEventLogger
{
public function __construct(
private LoggerInterface $logger,
private ?OWASPSecurityEventFactory $eventFactory = null
) {
$this->eventFactory ??= new OWASPSecurityEventFactory();
}
#[OnEvent]
public function log(SecurityEvent $event): void
{
try {
// OWASP-konforme Logging
$this->logOWASPFormat($event);
// Fallback: Ursprüngliches Format beibehalten
$this->logLegacyFormat($event);
} catch (Throwable $e) {
// Fallback logging - niemals Security-Events verlieren
$this->logger->critical('AUDIT_audit_failure:security_logger', [
'datetime' => date('c'),
'appid' => 'app.security',
'event' => 'AUDIT_audit_failure:security_logger',
'level' => 'FATAL',
'description' => 'Security event logging failed: ' . $e->getMessage(),
'original_event_type' => $event->type->value ?? 'UNKNOWN',
'error_trace' => $e->getTraceAsString()
]);
}
}
private function logOWASPFormat(SecurityEvent $event): void
{
$securityContext = SecurityContextVO::fromGlobals();
$requestContext = RequestContext::fromGlobals();
$owaspLogFormat = $this->eventFactory->createFromSecurityEvent(
$event,
$securityContext,
$requestContext
);
// Als strukturiertes JSON loggen
$this->logger->info($owaspLogFormat->getDescription(), [
'owasp_format' => $owaspLogFormat->toArray()
]);
}
private function logLegacyFormat(SecurityEvent $event): void
{
$context = SecurityContext::fromGlobals();
$payload = $this->extractPayload($event);
$this->logger->warning($event->type->value, [
...$payload,
'ip' => $context->ip,
'user_agent' => $context->userAgent,
'timestamp' => $context->timestamp->format('c'),
]);
}
private function extractPayload(SecurityEvent $event): array
{
$data = [];
$ref = new \ReflectionObject($event);
foreach ($ref->getProperties() as $prop) {
$prop->setAccessible(true);
$data[$prop->getName()] = $prop->getValue($event);
}
return $data;
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace App\Application\Security;
enum SecurityEventType: string
{
case LOGIN_FAILED = 'auth.login.failed';
case LOGIN_SUCCEEDED = 'auth.login.succeeded';
case LOGOUT = 'auth.logout';
case PASSWORD_CHANGED = 'account.password.changed';
case EMAIL_CHANGED = 'account.email.changed';
case USER_DELETED = 'account.deleted';
case ACCESS_DENIED = 'access.denied';
case CSRF_VIOLATION = 'access.csrf.violation';
case ADMIN_ACTION = 'system.admin.action';
case CONFIG_CHANGED = 'system.config.changed';
}

View File

@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace App\Application\Security\Services;
use App\Application\Security\Events\File\SuspiciousFileUploadEvent;
use App\Framework\Core\Events\EventDispatcher;
final class FileUploadSecurityService
{
private const ALLOWED_MIME_TYPES = [
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
'application/pdf', 'text/plain', 'text/csv',
'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
];
private const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
private const DANGEROUS_EXTENSIONS = [
'php', 'phtml', 'php3', 'php4', 'php5', 'pl', 'py', 'jsp', 'asp', 'sh', 'cgi',
'exe', 'bat', 'com', 'scr', 'vbs', 'js', 'jar', 'war'
];
private const MALWARE_SIGNATURES = [
'eval(', 'base64_decode(', 'system(', 'exec(', 'shell_exec(',
'<?php', '<%', '<script', 'javascript:'
];
public function __construct(
private EventDispatcher $eventDispatcher
) {}
public function validateUpload(array $file): bool
{
$userEmail = $_SESSION['user_email'] ?? null;
$filename = $file['name'] ?? '';
$tmpName = $file['tmp_name'] ?? '';
$size = $file['size'] ?? 0;
$error = $file['error'] ?? UPLOAD_ERR_NO_FILE;
// Upload-Fehler prüfen
if ($error !== UPLOAD_ERR_OK) {
$this->dispatchSuspiciousUpload($filename, 'unknown', $size, 'upload_error', $userEmail);
return false;
}
// Dateigröße prüfen
if ($size > self::MAX_FILE_SIZE) {
$this->dispatchSuspiciousUpload($filename, 'unknown', $size, 'file_too_large', $userEmail);
return false;
}
// Dateiendung prüfen
$extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
if (in_array($extension, self::DANGEROUS_EXTENSIONS)) {
$this->dispatchSuspiciousUpload($filename, 'unknown', $size, 'dangerous_extension', $userEmail);
return false;
}
// MIME-Type prüfen
$mimeType = mime_content_type($tmpName);
if (!in_array($mimeType, self::ALLOWED_MIME_TYPES)) {
$this->dispatchSuspiciousUpload($filename, $mimeType, $size, 'forbidden_mime_type', $userEmail);
return false;
}
// Dateiinhalt auf Malware-Signaturen prüfen
if ($this->containsMalwareSignatures($tmpName)) {
$this->dispatchSuspiciousUpload($filename, $mimeType, $size, 'malware_signatures_detected', $userEmail);
return false;
}
// Double-Extension prüfen (z.B. file.jpg.php)
if ($this->hasDoubleExtension($filename)) {
$this->dispatchSuspiciousUpload($filename, $mimeType, $size, 'double_extension', $userEmail);
return false;
}
return true;
}
private function containsMalwareSignatures(string $filePath): bool
{
$content = file_get_contents($filePath);
if ($content === false) {
return false;
}
foreach (self::MALWARE_SIGNATURES as $signature) {
if (stripos($content, $signature) !== false) {
return true;
}
}
return false;
}
private function hasDoubleExtension(string $filename): bool
{
$parts = explode('.', $filename);
if (count($parts) < 3) {
return false;
}
// Prüfe ob vorletzte "Extension" gefährlich ist
$secondLastExtension = strtolower($parts[count($parts) - 2]);
return in_array($secondLastExtension, self::DANGEROUS_EXTENSIONS);
}
private function dispatchSuspiciousUpload(
string $filename,
string $mimeType,
int $size,
string $reason,
?string $userEmail
): void {
$this->eventDispatcher->dispatch(new SuspiciousFileUploadEvent(
filename: $filename,
mimeType: $mimeType,
fileSize: $size,
suspicionReason: $reason,
email: $userEmail
));
}
}

View File

@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
namespace App\Application\Security\Services;
use App\Application\Security\Events\Input\{
InputValidationFailureEvent,
SqlInjectionAttemptEvent,
XssAttemptEvent,
MaliciousInputDetectedEvent
};
use App\Framework\Core\Events\EventDispatcher;
final class InputValidationService
{
private const SQL_INJECTION_PATTERNS = [
'/(\bunion\b.*\bselect\b)/i',
'/(\bdrop\b.*\btable\b)/i',
'/(\binsert\b.*\binto\b)/i',
'/(\bdelete\b.*\bfrom\b)/i',
'/(\bupdate\b.*\bset\b)/i',
'/(\b(or|and)\b.*[\'"].*[\'"].*=.*[\'"].*[\'"])/i'
];
private const XSS_PATTERNS = [
'/<script[^>]*>.*?<\/script>/is',
'/<iframe[^>]*>.*?<\/iframe>/is',
'/javascript:/i',
'/on\w+\s*=/i',
'/<object[^>]*>.*?<\/object>/is'
];
public function __construct(
private EventDispatcher $eventDispatcher
) {}
public function validateInput(string $fieldName, mixed $value, array $rules = []): bool
{
$stringValue = (string) $value;
$userEmail = $_SESSION['user_email'] ?? null;
// SQL Injection Detection
if ($this->detectSqlInjection($stringValue)) {
$this->eventDispatcher->dispatch(new SqlInjectionAttemptEvent(
attackPayload: $stringValue,
targetField: $fieldName,
detectionMethod: 'pattern_matching',
email: $userEmail
));
return false;
}
// XSS Detection
if ($this->detectXss($stringValue)) {
$this->eventDispatcher->dispatch(new XssAttemptEvent(
attackPayload: $stringValue,
targetField: $fieldName,
xssType: 'reflected_xss',
email: $userEmail
));
return false;
}
// Standard Validation Rules
foreach ($rules as $rule => $parameter) {
if (!$this->applyRule($rule, $stringValue, $parameter)) {
$this->eventDispatcher->dispatch(new InputValidationFailureEvent(
fieldName: $fieldName,
invalidValue: $stringValue,
validationRule: $rule,
email: $userEmail
));
return false;
}
}
return true;
}
public function sanitizeInput(string $input, string $context = 'html'): string
{
return match ($context) {
'html' => htmlspecialchars($input, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'sql' => addslashes($input),
'url' => urlencode($input),
'javascript' => json_encode($input, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT),
default => filter_var($input, FILTER_SANITIZE_SPECIAL_CHARS)
};
}
private function detectSqlInjection(string $input): bool
{
foreach (self::SQL_INJECTION_PATTERNS as $pattern) {
if (preg_match($pattern, $input)) {
return true;
}
}
return false;
}
private function detectXss(string $input): bool
{
foreach (self::XSS_PATTERNS as $pattern) {
if (preg_match($pattern, $input)) {
return true;
}
}
return false;
}
private function applyRule(string $rule, string $value, mixed $parameter): bool
{
return match ($rule) {
'required' => !empty($value),
'email' => filter_var($value, FILTER_VALIDATE_EMAIL) !== false,
'min_length' => strlen($value) >= $parameter,
'max_length' => strlen($value) <= $parameter,
'numeric' => is_numeric($value),
'alpha' => ctype_alpha($value),
'alphanumeric' => ctype_alnum($value),
'url' => filter_var($value, FILTER_VALIDATE_URL) !== false,
default => true
};
}
}

View File

@@ -0,0 +1,121 @@
<?php
declare(strict_types=1);
namespace App\Application\Security\ValueObjects;
final class MaskedEmail
{
private function __construct(
private string $maskedValue,
private string $original
) {}
public static function fromString(string $email): self
{
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
// Nicht-E-Mail-Strings nur minimal maskieren
return new self(
self::maskGenericString($email),
$email
);
}
return new self(
self::maskEmailAddress($email),
$email
);
}
public static function fromStringWithStrategy(string $email, MaskingStrategy $strategy): self
{
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
return new self(
self::maskGenericString($email),
$email
);
}
$maskedValue = match ($strategy) {
MaskingStrategy::STANDARD => self::maskEmailAddress($email),
MaskingStrategy::PARTIAL => self::maskEmailPartial($email),
MaskingStrategy::DOMAIN_ONLY => self::maskDomainOnly($email),
MaskingStrategy::HASH => self::hashEmail($email)
};
return new self($maskedValue, $email);
}
public function getMaskedValue(): string
{
return $this->maskedValue;
}
public function getOriginal(): string
{
return $this->original;
}
public function toString(): string
{
return $this->maskedValue;
}
public function __toString(): string
{
return $this->maskedValue;
}
private static function maskEmailAddress(string $email): string
{
[$local, $domain] = explode('@', $email, 2);
if (strlen($local) <= 2) {
return str_repeat('*', strlen($local)) . '@' . $domain;
}
$maskedLocal = substr($local, 0, 2) . str_repeat('*', strlen($local) - 2);
return $maskedLocal . '@' . $domain;
}
private static function maskEmailPartial(string $email): string
{
[$local, $domain] = explode('@', $email, 2);
if (strlen($local) <= 3) {
return $local[0] . str_repeat('*', max(0, strlen($local) - 1)) . '@' . $domain;
}
$maskedLocal = $local[0] . str_repeat('*', strlen($local) - 2) . substr($local, -1);
return $maskedLocal . '@' . $domain;
}
private static function maskDomainOnly(string $email): string
{
[$local, $domain] = explode('@', $email, 2);
// Domain verschleiern, aber Local-Part zeigen
$domainParts = explode('.', $domain);
if (count($domainParts) > 1) {
$tld = array_pop($domainParts);
$maskedDomain = str_repeat('*', strlen(implode('.', $domainParts))) . '.' . $tld;
} else {
$maskedDomain = str_repeat('*', strlen($domain));
}
return $local . '@' . $maskedDomain;
}
private static function hashEmail(string $email): string
{
return 'user_' . substr(hash('sha256', $email), 0, 8);
}
private static function maskGenericString(string $input): string
{
if (strlen($input) <= 3) {
return str_repeat('*', strlen($input));
}
return substr($input, 0, 2) . str_repeat('*', strlen($input) - 2);
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Application\Security\ValueObjects;
enum MaskingStrategy: string
{
case STANDARD = 'standard'; // jo***@example.com
case PARTIAL = 'partial'; // j***n@example.com
case DOMAIN_ONLY = 'domain_only'; // john@*******.com
case HASH = 'hash'; // user_a1b2c3d4
public function getDescription(): string
{
return match ($this) {
self::STANDARD => 'Standard email masking (first 2 chars + asterisks)',
self::PARTIAL => 'Partial masking (first + last char visible)',
self::DOMAIN_ONLY => 'Mask domain only, keep local part',
self::HASH => 'Replace with hash-based identifier'
};
}
}

View File

@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace App\Application\Security\ValueObjects;
final class OWASPEventIdentifier
{
private function __construct(
private string $category,
private string $action,
private ?string $subject = null
) {}
public static function authenticationSuccess(string $username): self
{
return new self('AUTHN', 'login_success', $username);
}
public static function authenticationFailure(string $username): self
{
return new self('AUTHN', 'login_failure', $username);
}
public static function sessionTermination(string $username): self
{
return new self('AUTHN', 'logout', $username);
}
public static function passwordChange(string $username): self
{
return new self('AUTHN', 'password_change', $username);
}
public static function accountLocked(string $username): self
{
return new self('AUTHN', 'account_locked', $username);
}
public static function authorizationFailure(string $username, string $resource): self
{
return new self('AUTHZ', 'access_denied', "{$username}:{$resource}");
}
public static function privilegeEscalation(string $username, string $fromRole, string $toRole): self
{
return new self('AUTHZ', 'privilege_escalation', "{$username}:{$fromRole}>{$toRole}");
}
public static function inputValidationFailure(string $field): self
{
return new self('INPUT', 'validation_failure', $field);
}
public static function maliciousInput(string $attackType): self
{
return new self('INPUT', 'malicious_input', $attackType);
}
public static function fileUploadFailure(string $filename): self
{
return new self('INPUT', 'file_upload_failure', $filename);
}
public static function sessionHijacking(string $username): self
{
return new self('SESS', 'session_hijacking', $username);
}
public static function sessionTimeout(string $username): self
{
return new self('SESS', 'session_timeout', $username);
}
public static function malwareDetected(string $malwareType): self
{
return new self('MALICIOUS', 'malware_detected', $malwareType);
}
public static function auditFailure(string $eventType): self
{
return new self('AUDIT', 'audit_failure', $eventType);
}
public static function sessionFixation(string $username): self
{
return new self('SESS', 'session_fixation', $username);
}
public static function cryptographicFailure(string $operation): self
{
return new self('CRYPTO', 'crypto_failure', $operation);
}
public static function suspiciousNetworkActivity(string $activityType): self
{
return new self('NETWORK', 'suspicious_activity', $activityType);
}
public static function systemAnomaly(string $anomalyType): self
{
return new self('SYSTEM', 'anomaly_detected', $anomalyType);
}
public static function csrfViolation(string $requestPath): self
{
return new self('WEB', 'csrf_violation', $requestPath);
}
public function toString(): string
{
$identifier = "{$this->category}_{$this->action}";
if ($this->subject !== null) {
$identifier .= ":{$this->subject}";
}
return $identifier;
}
}

View File

@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace App\Application\Security\ValueObjects;
use DateTimeImmutable;
use DateTimeInterface;
final class OWASPLogFormat
{
public function __construct(
private DateTimeImmutable $datetime,
private string $appid,
private string $event,
private string $level,
private string $description,
private ?string $useragent = null,
private ?string $sourceIp = null,
private ?string $hostIp = null,
private ?string $hostname = null,
private ?string $protocol = null,
private ?string $port = null,
private ?string $requestUri = null,
private ?string $requestMethod = null,
private ?string $region = null,
private ?string $geo = null
) {}
public static function create(
string $appid,
string $event,
string $level,
string $description,
SecurityContext $context,
RequestContext $requestContext
): self {
return new self(
new DateTimeImmutable(),
$appid,
$event,
$level,
$description,
$context->getUserAgent(),
$context->getSourceIp(),
$requestContext->getHostIp(),
$requestContext->getHostname(),
$requestContext->getProtocol(),
$requestContext->getPort(),
$requestContext->getRequestUri(),
$requestContext->getRequestMethod(),
$requestContext->getRegion(),
$requestContext->getGeo()
);
}
public function toArray(): array
{
$data = [
'datetime' => $this->datetime->format(DateTimeInterface::ATOM),
'appid' => $this->appid,
'event' => $this->event,
'level' => $this->level,
'description' => $this->description
];
// Nur nicht-null Werte hinzufügen
if ($this->useragent !== null) {
$data['useragent'] = $this->useragent;
}
if ($this->sourceIp !== null) {
$data['source_ip'] = $this->sourceIp;
}
if ($this->hostIp !== null) {
$data['host_ip'] = $this->hostIp;
}
if ($this->hostname !== null) {
$data['hostname'] = $this->hostname;
}
if ($this->protocol !== null) {
$data['protocol'] = $this->protocol;
}
if ($this->port !== null) {
$data['port'] = $this->port;
}
if ($this->requestUri !== null) {
$data['request_uri'] = $this->requestUri;
}
if ($this->requestMethod !== null) {
$data['request_method'] = $this->requestMethod;
}
if ($this->region !== null) {
$data['region'] = $this->region;
}
if ($this->geo !== null) {
$data['geo'] = $this->geo;
}
return $data;
}
public function toJson(): string
{
return json_encode($this->toArray(), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES);
}
public function getDescription(): string
{
return $this->description;
}
}

View File

@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Application\Security\ValueObjects;
use App\Application\Security\SecurityEventType;
enum OWASPLogLevel: string
{
case DEBUG = 'DEBUG';
case INFO = 'INFO';
case WARN = 'WARN';
case ERROR = 'ERROR';
case FATAL = 'FATAL';
public static function fromSecurityEventType(SecurityEventType $type): self
{
return match ($type) {
SecurityEventType::LOGIN_SUCCESS,
SecurityEventType::LOGOUT,
SecurityEventType::PASSWORD_CHANGE => self::INFO,
SecurityEventType::LOGIN_FAILED,
SecurityEventType::ACCESS_DENIED,
SecurityEventType::SESSION_TIMEOUT => self::WARN,
SecurityEventType::INJECTION_ATTEMPT,
SecurityEventType::MALWARE_DETECTED,
SecurityEventType::ACCOUNT_LOCKED,
SecurityEventType::FILE_UPLOAD => self::ERROR,
SecurityEventType::PRIVILEGE_ESCALATION,
SecurityEventType::SESSION_HIJACK,
SecurityEventType::AUDIT_FAILURE => self::FATAL,
default => self::INFO
};
}
}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Application\Security\ValueObjects;
final class RequestContext
{
private function __construct(
private ?string $hostIp,
private ?string $hostname,
private ?string $protocol,
private ?string $port,
private ?string $requestUri,
private ?string $requestMethod,
private ?string $region,
private ?string $geo
) {}
public static function fromGlobals(): self
{
return new self(
$_SERVER['SERVER_ADDR'] ?? null,
$_SERVER['HTTP_HOST'] ?? $_SERVER['SERVER_NAME'] ?? null,
isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' ? 'https' : 'http',
$_SERVER['SERVER_PORT'] ?? null,
$_SERVER['REQUEST_URI'] ?? null,
$_SERVER['REQUEST_METHOD'] ?? null,
$_ENV['AWS_REGION'] ?? $_ENV['REGION'] ?? null,
$_ENV['GEO_REGION'] ?? null
);
}
public function getHostIp(): ?string
{
return $this->hostIp;
}
public function getHostname(): ?string
{
return $this->hostname;
}
public function getProtocol(): ?string
{
return $this->protocol;
}
public function getPort(): ?string
{
return $this->port;
}
public function getRequestUri(): ?string
{
return $this->requestUri;
}
public function getRequestMethod(): ?string
{
return $this->requestMethod;
}
public function getRegion(): ?string
{
return $this->region;
}
public function getGeo(): ?string
{
return $this->geo;
}
}

View File

@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
namespace App\Application\Security\ValueObjects;
use DateTimeImmutable;
final class SecurityContext
{
private function __construct(
private ?string $sourceIp,
private ?string $userAgent,
private ?string $sessionId,
private ?string $requestId,
private ?string $userId,
private DateTimeImmutable $timestamp
) {}
public static function fromGlobals(): self
{
return new self(
$_SERVER['REMOTE_ADDR'] ?? null,
$_SERVER['HTTP_USER_AGENT'] ?? null,
session_id() ?: null,
$_SERVER['HTTP_X_REQUEST_ID'] ?? null,
$_SESSION['user_id'] ?? null,
new DateTimeImmutable()
);
}
public static function create(
?string $sourceIp = null,
?string $userAgent = null,
?string $sessionId = null,
?string $requestId = null,
?string $userId = null
): self {
return new self(
$sourceIp,
$userAgent,
$sessionId,
$requestId,
$userId,
new DateTimeImmutable()
);
}
public function getSourceIp(): ?string
{
return $this->sourceIp;
}
public function getUserAgent(): ?string
{
return $this->userAgent;
}
public function getSessionId(): ?string
{
return $this->sessionId;
}
public function getRequestId(): ?string
{
return $this->requestId;
}
public function getUserId(): ?string
{
return $this->userId;
}
public function getTimestamp(): DateTimeImmutable
{
return $this->timestamp;
}
}

View File

@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace App\Application\Service;
use App\Domain\QrCode\QrCode;
use App\Domain\QrCode\Service\QrCodeEncoder;
use App\Domain\QrCode\Service\QrCodeGenerator;
use App\Domain\QrCode\Service\QrCodeRenderer;
use App\Domain\QrCode\ValueObject\ErrorCorrectionLevel;
use App\Domain\QrCode\ValueObject\QrCodeConfig;
use App\Domain\QrCode\ValueObject\QrCodeMatrix;
use App\Domain\QrCode\ValueObject\QrCodeVersion;
readonly class QrCodeService
{
public function __construct(
private QrCodeGenerator $generator
) {
}
/**
* Generiert einen QR-Code für die angegebenen Daten
*
* @param string $data Die zu kodierenden Daten
* @param ErrorCorrectionLevel $errorLevel Fehlerkorrektur-Level
* @param int|null $version Spezifische QR-Code-Version (optional)
* @return QrCodeMatrix Die generierte QR-Code-Matrix
*/
public function generateQrCode(
string $data,
ErrorCorrectionLevel $errorLevel = ErrorCorrectionLevel::M,
?int $version = null
): QrCodeMatrix {
$qrCodeVersion = $version ? new QrCodeVersion($version) : null;
$qrCode = new QrCode($data, $errorLevel, $qrCodeVersion);
return $this->generator->generate($qrCode);
}
/**
* Generiert einen QR-Code als SVG
*
* @param string $data Die zu kodierenden Daten
* @param ErrorCorrectionLevel $errorLevel Fehlerkorrektur-Level
* @param QrCodeConfig|null $config Konfigurationseinstellungen
* @return string SVG-Darstellung des QR-Codes
*/
public function generateSvg(
string $data,
ErrorCorrectionLevel $errorLevel = ErrorCorrectionLevel::M,
?QrCodeConfig $config = null
): string {
$matrix = $this->generateQrCode($data, $errorLevel);
$config = $config ?? new QrCodeConfig();
return $matrix->toSvg(
$config->getModuleSize(),
$config->getMargin(),
$config->getForegroundColor(),
$config->getBackgroundColor()
);
}
/**
* Generiert einen QR-Code als PNG
*
* @param string $data Die zu kodierenden Daten
* @param ErrorCorrectionLevel $errorLevel Fehlerkorrektur-Level
* @param QrCodeConfig|null $config Konfigurationseinstellungen
* @return string PNG-Binärdaten des QR-Codes
*/
public function generatePng(
string $data,
ErrorCorrectionLevel $errorLevel = ErrorCorrectionLevel::M,
?QrCodeConfig $config = null
): string {
$matrix = $this->generateQrCode($data, $errorLevel);
$config = $config ?? new QrCodeConfig();
return $matrix->toPng(
$config->getModuleSize(),
$config->getMargin()
);
}
/**
* Generiert einen QR-Code als ASCII-Art
*
* @param string $data Die zu kodierenden Daten
* @param ErrorCorrectionLevel $errorLevel Fehlerkorrektur-Level
* @return string ASCII-Darstellung des QR-Codes
*/
public function generateAscii(
string $data,
ErrorCorrectionLevel $errorLevel = ErrorCorrectionLevel::M
): string {
$matrix = $this->generateQrCode($data, $errorLevel);
return $matrix->toAscii();
}
}

View File

@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace App\Application\Shopify;
use App\Framework\Api\ApiException;
use App\Framework\Attributes\Route;
use App\Framework\Http\Method;
use App\Framework\Http\Status;
use App\Framework\Router\Result\JsonResult;
use App\Infrastructure\Api\ShopifyClient;
use Archive\Config\ApiConfig;
final class CustomerController
{
private ShopifyClient $client;
public function __construct()
{
$this->client = new ShopifyClient(
ApiConfig::SHOPIFY_SHOP_DOMAIN->value,
ApiConfig::SHOPIFY_ACCESS_TOKEN->value,
ApiConfig::SHOPIFY_API_VERSION->value
);
}
/**
* Ruft alle Kunden ab
*/
#[Route(path: '/api/shopify/customers', method: Method::GET)]
public function listCustomers(): JsonResult
{
try {
$options = [
'limit' => 50,
'fields' => 'id,email,first_name,last_name,orders_count,total_spent'
];
$customers = $this->client->getCustomers($options);
return new JsonResult([
'success' => true,
'data' => $customers
]);
} catch (ApiException $e) {
$result = new JsonResult([
'success' => false,
'error' => $e->getMessage()
]);
$result->status = Status::from($e->getCode() ?: 500);
return $result;
}
}
/**
* Ruft einen einzelnen Kunden ab
*/
#[Route(path: '/api/shopify/customers/{id}', method: Method::GET)]
public function getCustomer(int $id): JsonResult
{
try {
$customer = $this->client->getCustomer($id);
return new JsonResult([
'success' => true,
'data' => $customer
]);
} catch (ApiException $e) {
$result = new JsonResult([
'success' => false,
'error' => $e->getMessage()
]);
$result->status = Status::from($e->getCode() ?: 500);
return $result;
}
}
/**
* Erstellt einen neuen Kunden
*/
#[Route(path: '/api/shopify/customers', method: Method::POST)]
public function createCustomer(CustomerRequest $request): JsonResult
{
try {
$customerData = [
'first_name' => $request->firstName,
'last_name' => $request->lastName,
'email' => $request->email,
'phone' => $request->phone ?? null,
'verified_email' => $request->verifiedEmail ?? true,
'addresses' => $request->addresses ?? [],
'password' => $request->password ?? null,
'password_confirmation' => $request->password ?? null,
'send_email_welcome' => $request->sendWelcomeEmail ?? false
];
// Entferne leere Felder
$customerData = array_filter($customerData, fn($value) => $value !== null);
$customer = $this->client->createCustomer($customerData);
$result = new JsonResult([
'success' => true,
'data' => $customer
]);
$result->status = Status::CREATED;
return $result;
} catch (ApiException $e) {
$result = new JsonResult([
'success' => false,
'error' => $e->getMessage()
]);
$result->status = Status::from($e->getCode() ?: 500);
return $result;
}
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Application\Shopify;
final readonly class CustomerRequest
{
public function __construct(
public string $firstName,
public string $lastName,
public string $email,
public ?string $phone = null,
public ?bool $verifiedEmail = null,
public ?array $addresses = null,
public ?string $password = null,
public ?bool $sendWelcomeEmail = null
) {}
}

View File

@@ -0,0 +1,118 @@
<?php
declare(strict_types=1);
namespace App\Application\Shopify;
use App\Framework\Api\ApiException;
use App\Framework\Attributes\Route;
use App\Framework\Http\Method;
use App\Framework\Http\Status;
use App\Framework\Router\Result\JsonResult;
use App\Infrastructure\Api\ShopifyClient;
use Archive\Config\ApiConfig;
final class OrderController
{
private ShopifyClient $client;
public function __construct()
{
$this->client = new ShopifyClient(
ApiConfig::SHOPIFY_SHOP_DOMAIN->value,
ApiConfig::SHOPIFY_ACCESS_TOKEN->value,
ApiConfig::SHOPIFY_API_VERSION->value
);
}
/**
* Ruft alle Bestellungen ab
*/
#[Route(path: '/api/shopify/orders', method: Method::GET)]
public function listOrders(): JsonResult
{
try {
$options = [
'limit' => 50,
'status' => 'any',
'fields' => 'id,order_number,customer,total_price,created_at,financial_status'
];
$orders = $this->client->getOrders($options);
return new JsonResult([
'success' => true,
'data' => $orders
]);
} catch (ApiException $e) {
$result = new JsonResult([
'success' => false,
'error' => $e->getMessage()
]);
$result->status = Status::from($e->getCode() ?: 500);
return $result;
}
}
/**
* Ruft eine einzelne Bestellung ab
*/
#[Route(path: '/api/shopify/orders/{id}', method: Method::GET)]
public function getOrder(int $id): JsonResult
{
try {
$order = $this->client->getOrder($id);
return new JsonResult([
'success' => true,
'data' => $order
]);
} catch (ApiException $e) {
$result = new JsonResult([
'success' => false,
'error' => $e->getMessage()
]);
$result->status = Status::from($e->getCode() ?: 500);
return $result;
}
}
/**
* Erstellt eine neue Bestellung
*/
#[Route(path: '/api/shopify/orders', method: Method::POST)]
public function createOrder(OrderRequest $request): JsonResult
{
try {
$orderData = [
'line_items' => $request->lineItems,
'customer' => $request->customer ?? null,
'shipping_address' => $request->shippingAddress ?? null,
'billing_address' => $request->billingAddress ?? null,
'financial_status' => $request->financialStatus ?? 'pending',
'fulfillment_status' => $request->fulfillmentStatus ?? null,
'tags' => $request->tags ?? '',
'note' => $request->note ?? null,
'email' => $request->email ?? null
];
// Entferne leere Felder
$orderData = array_filter($orderData, fn($value) => $value !== null);
$order = $this->client->createOrder($orderData);
$result = new JsonResult([
'success' => true,
'data' => $order
]);
$result->status = Status::CREATED;
return $result;
} catch (ApiException $e) {
$result = new JsonResult([
'success' => false,
'error' => $e->getMessage()
]);
$result->status = Status::from($e->getCode() ?: 500);
return $result;
}
}
}

View File

@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Application\Shopify;
final class OrderRequest
{
public function __construct(
public readonly array $lineItems,
public readonly ?array $customer = null,
public readonly ?array $shippingAddress = null,
public readonly ?array $billingAddress = null,
public readonly ?string $financialStatus = null,
public readonly ?string $fulfillmentStatus = null,
public readonly ?string $tags = null,
public readonly ?string $note = null,
public readonly ?string $email = null
) {}
}

View File

@@ -0,0 +1,192 @@
<?php
declare(strict_types=1);
namespace App\Application\Shopify;
use App\Framework\Api\ApiException;
use App\Framework\Attributes\Route;
use App\Framework\Http\Method;
use App\Framework\Http\Status;
use App\Framework\Router\Result\JsonResult;
use App\Infrastructure\Api\ShopifyClient;
use Archive\Config\ApiConfig;
final class ProductController
{
private ShopifyClient $client;
public function __construct()
{
$this->client = new ShopifyClient(
ApiConfig::SHOPIFY_SHOP_DOMAIN->value,
ApiConfig::SHOPIFY_ACCESS_TOKEN->value,
ApiConfig::SHOPIFY_API_VERSION->value
);
}
/**
* Ruft alle Produkte aus dem Shopify-Shop ab
*/
#[Route(path: '/api/shopify/products', method: Method::GET)]
public function listProducts(): JsonResult
{
try {
$options = [
'limit' => 50,
'fields' => 'id,title,variants,images,status,published_at'
];
$products = $this->client->getProducts($options);
return new JsonResult([
'success' => true,
'data' => $products
]);
} catch (ApiException $e) {
$result = new JsonResult([
'success' => false,
'error' => $e->getMessage()
]);
$result->status = Status::from($e->getCode() ?: 500);
return $result;
}
}
/**
* Ruft ein einzelnes Produkt anhand seiner ID ab
*/
#[Route(path: '/api/shopify/products/{id}', method: Method::GET)]
public function getProduct(int $id): JsonResult
{
try {
$product = $this->client->getProduct($id);
return new JsonResult([
'success' => true,
'data' => $product
]);
} catch (ApiException $e) {
$result = new JsonResult([
'success' => false,
'error' => $e->getMessage()
]);
$result->status = Status::from($e->getCode() ?: 500);
return $result;
}
}
/**
* Erstellt ein neues Produkt
*/
#[Route(path: '/api/shopify/products', method: Method::POST)]
public function createProduct(ProductRequest $request): JsonResult
{
try {
$product = $this->client->createProduct([
'title' => $request->title,
'body_html' => $request->description ?? '',
'vendor' => $request->vendor ?? 'Custom Shop',
'product_type' => $request->productType ?? '',
'tags' => $request->tags ?? '',
'variants' => $request->variants ?? [],
'options' => $request->options ?? [],
'images' => $request->images ?? []
]);
$result = new JsonResult([
'success' => true,
'data' => $product
]);
$result->status = Status::CREATED;
return $result;
} catch (ApiException $e) {
$result = new JsonResult([
'success' => false,
'error' => $e->getMessage()
]);
$result->status = Status::from($e->getCode() ?: 500);
return $result;
}
}
/**
* Aktualisiert ein bestehendes Produkt
*/
#[Route(path: '/api/shopify/products/{id}', method: Method::PUT)]
public function updateProduct(int $id, ProductRequest $request): JsonResult
{
try {
$productData = [];
// Nur die Felder aktualisieren, die tatsächlich im Request enthalten sind
if (isset($request->title)) $productData['title'] = $request->title;
if (isset($request->description)) $productData['body_html'] = $request->description;
if (isset($request->vendor)) $productData['vendor'] = $request->vendor;
if (isset($request->productType)) $productData['product_type'] = $request->productType;
if (isset($request->tags)) $productData['tags'] = $request->tags;
if (isset($request->variants)) $productData['variants'] = $request->variants;
if (isset($request->options)) $productData['options'] = $request->options;
if (isset($request->images)) $productData['images'] = $request->images;
$product = $this->client->updateProduct($id, $productData);
return new JsonResult([
'success' => true,
'data' => $product
]);
} catch (ApiException $e) {
$result = new JsonResult([
'success' => false,
'error' => $e->getMessage()
]);
$result->status = Status::from($e->getCode() ?: 500);
return $result;
}
}
/**
* Löscht ein Produkt
*/
#[Route(path: '/api/shopify/products/{id}', method: Method::DELETE)]
public function deleteProduct(int $id): JsonResult
{
try {
$success = $this->client->deleteProduct($id);
return new JsonResult([
'success' => $success,
'message' => $success ? 'Produkt erfolgreich gelöscht' : 'Produkt konnte nicht gelöscht werden'
]);
} catch (ApiException $e) {
$result = new JsonResult([
'success' => false,
'error' => $e->getMessage()
]);
$result->status = Status::from($e->getCode() ?: 500);
return $result;
}
}
/**
* Sucht nach Produkten
*/
#[Route(path: '/api/shopify/products/search', method: Method::GET)]
public function searchProducts(string $query): JsonResult
{
try {
$products = $this->client->searchProducts($query);
return new JsonResult([
'success' => true,
'data' => $products
]);
} catch (ApiException $e) {
$result = new JsonResult([
'success' => false,
'error' => $e->getMessage()
]);
$result->status = Status::from($e->getCode() ?: 500);
return $result;
}
}
}

View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Application\Shopify;
final class ProductRequest
{
public function __construct(
public readonly string $title,
public readonly ?string $description = null,
public readonly ?string $vendor = null,
public readonly ?string $productType = null,
public readonly ?string $tags = null,
public readonly ?array $variants = null,
public readonly ?array $options = null,
public readonly ?array $images = null
) {}
}

View File

@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\Application\Shopify;
use App\Framework\Api\ApiException;
use App\Framework\Attributes\Route;
use App\Framework\Http\Method;
use App\Framework\Http\Status;
use App\Framework\Router\Result\JsonResult;
use App\Infrastructure\Api\ShopifyClient;
use Archive\Config\ApiConfig;
final class ShopController
{
private ShopifyClient $client;
public function __construct()
{
$this->client = new ShopifyClient(
ApiConfig::SHOPIFY_SHOP_DOMAIN->value,
ApiConfig::SHOPIFY_ACCESS_TOKEN->value,
ApiConfig::SHOPIFY_API_VERSION->value
);
}
/**
* Ruft Informationen über den Shop ab
*/
#[Route(path: '/api/shopify/shop', method: Method::GET)]
public function getShopInfo(): JsonResult
{
try {
$shopInfo = $this->client->getShopInfo();
return new JsonResult([
'success' => true,
'data' => $shopInfo
]);
} catch (ApiException $e) {
$result = new JsonResult([
'success' => false,
'error' => $e->getMessage()
]);
$result->status = Status::from($e->getCode() ?: 500);
return $result;
}
}
/**
* Ruft die Webhooks des Shops ab
*/
#[Route(path: '/api/shopify/webhooks', method: Method::GET)]
public function getWebhooks(): JsonResult
{
try {
$webhooks = $this->client->getWebhooks();
return new JsonResult([
'success' => true,
'data' => $webhooks
]);
} catch (ApiException $e) {
$result = new JsonResult([
'success' => false,
'error' => $e->getMessage()
]);
$result->status = Status::from($e->getCode() ?: 500);
return $result;
}
}
/**
* Erstellt einen neuen Webhook
*/
#[Route(path: '/api/shopify/webhooks', method: Method::POST)]
public function createWebhook(WebhookRequest $request): JsonResult
{
try {
$webhook = $this->client->createWebhook(
$request->topic,
$request->address,
$request->format ?? 'json'
);
$result = new JsonResult([
'success' => true,
'data' => $webhook
]);
$result->status = Status::CREATED;
return $result;
} catch (ApiException $e) {
$result = new JsonResult([
'success' => false,
'error' => $e->getMessage()
]);
$result->status = Status::from($e->getCode() ?: 500);
return $result;
}
}
}

View File

@@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace App\Application\Shopify;
use App\Framework\Attributes\Route;
use App\Framework\Http\Method;
use App\Framework\Http\Request;
use App\Framework\Http\Status;
use App\Framework\Router\Result\JsonResult;
final class ShopifyWebhookHandler
{
/**
* Verarbeitet eingehende Shopify-Webhooks
*
* Hinweis: Shopify überprüft die Authentizität von Webhooks mit dem X-Shopify-Hmac-Sha256 Header
*/
#[Route(path: '/webhook/shopify', method: Method::POST)]
public function handleWebhook(Request $request): JsonResult
{
// Webhook-Thema aus dem Header lesen
$topic = $request->headers->get('X-Shopify-Topic')[0] ?? null;
$shopDomain = $request->headers->get('X-Shopify-Shop-Domain')[0] ?? null;
$hmac = $request->headers->get('X-Shopify-Hmac-Sha256')[0] ?? null;
// Validiere den HMAC, um sicherzustellen, dass der Request von Shopify kommt
$rawData = $request->body;
if (!$this->validateWebhookHmac($hmac, $rawData)) {
$result = new JsonResult(['error' => 'Ungültiger HMAC']);
$result->status = Status::UNAUTHORIZED;
return $result;
}
// Daten verarbeiten
$data = json_decode($rawData, true);
// Je nach Topic unterschiedlich verarbeiten
switch ($topic) {
case 'orders/create':
$this->processOrderCreated($data);
break;
case 'products/create':
case 'products/update':
$this->processProductUpdate($data);
break;
case 'customers/create':
$this->processCustomerCreated($data);
break;
// Weitere Webhook-Themen...
default:
// Unbekanntes Thema, loggen oder ignorieren
break;
}
// Shopify erwartet eine erfolgreiche Antwort (2xx)
return new JsonResult(['success' => true]);
}
/**
* Validiert den HMAC-Header
*/
private function validateWebhookHmac(?string $hmac, string $data): bool
{
if (!$hmac) {
return false;
}
// Das Shared Secret sollte eigentlich in ApiConfig sein
$secret = 'dein_webhook_shared_secret'; // ODER aus ApiConfig holen
$calculatedHmac = base64_encode(hash_hmac('sha256', $data, $secret, true));
return hash_equals($calculatedHmac, $hmac);
}
/**
* Verarbeitet eine neu erstellte Bestellung
*/
private function processOrderCreated(array $orderData): void
{
// Hier die Logik für neue Bestellungen implementieren
// z.B. in eigenes System übertragen, E-Mails versenden, etc.
}
/**
* Verarbeitet Produkt-Updates
*/
private function processProductUpdate(array $productData): void
{
// Hier die Logik für Produkt-Updates implementieren
// z.B. Lagerbestand in eigenem System aktualisieren
}
/**
* Verarbeitet einen neu erstellten Kunden
*/
private function processCustomerCreated(array $customerData): void
{
// Hier die Logik für neue Kunden implementieren
// z.B. in CRM-System übertragen, Newsletter-Anmeldung
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Application\Shopify;
final class WebhookRequest
{
public function __construct(
public readonly string $topic,
public readonly string $address,
public readonly ?string $format = 'json'
) {}
}

View File

@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Application\System;
use App\Framework\Core\Events\ApplicationBooted;
use App\Framework\Core\Events\OnEvent;
use App\Framework\Logging\DefaultLogger;
final readonly class BootLogger
{
public function __construct(
private DefaultLogger $logger
) {}
#[OnEvent]
public function log(ApplicationBooted $event): void
{
#echo "[Boot] {$event->bootTime->format('H:i:s')} | Env: {$event->environment}\n";
$this->logger->info('Application booted', [
'boot_time' => $event->bootTime->format('Y-m-d H:i:s.u'),
'environment' => $event->environment,
'memory_usage' => memory_get_usage(true),
'peak_memory' => memory_get_peak_usage(true)
]);
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Application\System;
use App\Framework\Core\Events\ErrorOccurred;
use App\Framework\Core\Events\OnEvent;
final readonly class ErrorLogger
{
/**
* Logger für aufgetretene Fehler
*/
#[OnEvent(priority: 10)]
public function logError(ErrorOccurred $event): void
{
$time = $event->occurredAt->format('Y-m-d H:i:s');
$requestId = $event->requestId ? "[Request: {$event->requestId}]" : '';
$message = "[{$time}] ERROR {$requestId} {$event->context}: {$event->error->getMessage()}";
// In einer produktiven Umgebung würden wir hier in eine Datei oder einen Service loggen
error_log($message);
}
}

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Application\System;
use App\Framework\Core\Events\OnEvent;
use App\Framework\Core\Events\UserRegistered;
/**
* Logger für Benutzerregistrierungen
*/
final readonly class UserRegistrationLogger
{
/**
* Protokolliert die Registrierung eines neuen Benutzers
*/
#[OnEvent]
public function logUserRegistration(UserRegistered $event): void
{
$message = sprintf(
"[%s] Neuer Benutzer registriert: %s (%s)",
$event->occurredAt->format('Y-m-d H:i:s'),
$event->username,
$event->email
);
// In der Produktion würden wir in eine Datei oder einen Service loggen
error_log($message);
}
}

View File

@@ -0,0 +1,9 @@
<?php
declare(strict_types=1);
namespace App\Application\Website;
final class DemoQuery
{
}

Some files were not shown because too many files have changed in this diff Show More