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,69 @@
<?php
declare(strict_types=1);
namespace Archive\Archived;
final readonly class CacheCapability
{
public function __construct(
public bool $canBeCachedFully,
public bool $canBeCachedPartially,
public bool $supportsFragments,
public CacheComplexity $complexity,
public array $restrictions = [],
) {}
public static function fromTemplateContent(TemplateContent $content): self
{
$canBeCachedFully = !$content->hasDynamicContent();
$canBeCachedPartially = !$content->hasUserSpecificContent
&& !$content->hasSessionData
&& !$content->hasTimeBasedContent;
$supportsFragments = count($content->staticBlocks) > 0;
$complexity = match (true) {
!$content->hasDynamicContent() => CacheComplexity::LOW,
$content->hasUserSpecificContent || $content->hasSessionData => CacheComplexity::HIGH,
$content->hasFormElements || $content->hasTimeBasedContent => CacheComplexity::MEDIUM,
default => CacheComplexity::EXTREME,
};
$restrictions = [];
if ($content->hasUserSpecificContent) $restrictions[] = 'user_specific';
if ($content->hasSessionData) $restrictions[] = 'session_dependent';
if ($content->hasTimeBasedContent) $restrictions[] = 'time_sensitive';
if ($content->hasCsrfTokens) $restrictions[] = 'csrf_protected';
return new self(
canBeCachedFully: $canBeCachedFully,
canBeCachedPartially: $canBeCachedPartially,
supportsFragments: $supportsFragments,
complexity: $complexity,
restrictions: $restrictions,
);
}
public function isFullyCacheable(): bool
{
return $this->canBeCachedFully;
}
public function isPartiallyCacheable(): bool
{
return $this->canBeCachedPartially;
}
public function getComplexity(): CacheComplexity
{
return $this->complexity;
}
public function hasRestriction(string $restriction): bool
{
return in_array($restriction, $this->restrictions, true);
}
}

View File

@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Archive\Archived;
enum CacheComplexity: string
{
case LOW = 'low';
case MEDIUM = 'medium';
case HIGH = 'high';
case EXTREME = 'extreme';
}

View File

@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace Archive\Archived;
enum CacheStrategy: string
{
case STATIC = 'static';
case PARTIAL = 'partial';
case FRAGMENT = 'fragment';
case DYNAMIC = 'dynamic';
case DISABLED = 'disabled';
public function getTtl(): int
{
return match($this) {
self::STATIC => 3600, // 1 Stunde für statische Inhalte
self::PARTIAL => 900, // 15 Minuten für teilweise dynamische Inhalte
self::FRAGMENT => 300, // 5 Minuten für Fragment-Cache
self::DYNAMIC => 0, // Keine Caching für dynamische Inhalte
self::DISABLED => 0, // Caching deaktiviert
};
}
public function getPriority(): int
{
return match($this) {
self::STATIC => 1,
self::PARTIAL => 2,
self::FRAGMENT => 3,
self::DYNAMIC => 4,
self::DISABLED => 5,
};
}
public function shouldCache(): bool
{
return $this !== self::DYNAMIC && $this !== self::DISABLED;
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace Archive\Archived;
use App\Framework\View\RenderContext;
final class CacheWarmer
{
private array $popularTemplates = [];
public function __construct(
private readonly SmartCacheEngine $cacheEngine,
private readonly array $warmupTemplates = []
) {}
public function warmupTemplates(array $templates = []): array
{
$templatesToWarm = !empty($templates) ? $templates : $this->warmupTemplates;
$results = [];
foreach ($templatesToWarm as $template => $data) {
$startTime = microtime(true);
try {
$context = new RenderContext($template, $data ?? []);
$this->cacheEngine->render($context);
$duration = microtime(true) - $startTime;
$results[$template] = [
'success' => true,
'duration' => round($duration * 1000, 2) . 'ms'
];
} catch (\Exception $e) {
$results[$template] = [
'success' => false,
'error' => $e->getMessage()
];
}
}
return $results;
}
public function addPopularTemplate(string $template, array $data = []): void
{
$this->popularTemplates[$template] = $data;
}
public function warmupPopularTemplates(): array
{
return $this->warmupTemplates($this->popularTemplates);
}
public function scheduleWarmup(array $templates): void
{
// In einer echten Implementierung könnte hier ein Background-Job gescheduled werden
foreach ($templates as $template => $data) {
$this->addPopularTemplate($template, $data);
}
}
}

View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace Archive\Archived;
final class CacheabilityInfo
{
public bool $hasDynamicComponents = false;
public bool $hasUserSpecificContent = false;
public bool $hasFormElements = false;
public bool $hasTimeBasedContent = false;
public bool $hasSessionData = false;
public array $staticFragments = [];
public array $dynamicFragments = [];
public array $dynamicComponents = [];
public CacheStrategy $cacheStrategy = CacheStrategy::DYNAMIC;
public int $ttl = 0;
public array $dependencies = [];
public function isFullyCacheable(): bool
{
return !$this->hasDynamicComponents
&& !$this->hasUserSpecificContent
&& !$this->hasFormElements
&& !$this->hasSessionData
&& !$this->hasTimeBasedContent;
}
public function isPartiallyCacheable(): bool
{
return count($this->staticFragments) > 0 && !$this->hasUserSpecificContent;
}
public function shouldUseFragmentCache(): bool
{
return count($this->staticFragments) > 0 || count($this->dynamicFragments) > 0;
}
public function getCacheComplexity(): int
{
$complexity = 0;
$complexity += count($this->staticFragments);
$complexity += count($this->dynamicFragments) * 3;
$complexity += $this->hasUserSpecificContent ? 5 : 0;
$complexity += $this->hasFormElements ? 3 : 0;
$complexity += $this->hasDynamicComponents ? 4 : 0;
return $complexity;
}
public function addStaticFragment(string $fragmentId): void
{
if (!in_array($fragmentId, $this->staticFragments, true)) {
$this->staticFragments[] = $fragmentId;
}
}
public function addDynamicFragment(string $fragmentId): void
{
if (!in_array($fragmentId, $this->dynamicFragments, true)) {
$this->dynamicFragments[] = $fragmentId;
}
}
public function hasFragments(): bool
{
return count($this->staticFragments) > 0 || count($this->dynamicFragments) > 0;
}
public function getFragmentCount(): int
{
return count($this->staticFragments) + count($this->dynamicFragments);
}
}

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace Archive\Archived;
use App\Framework\View\Cache\CacheDependency;
use App\Framework\View\Cache\DependencyType;
final readonly class DependencyCollection
{
/** @param CacheDependency[] $dependencies */
public function __construct(
private array $dependencies = [],
) {
foreach ($dependencies as $dependency) {
if (!$dependency instanceof CacheDependency) {
throw new \InvalidArgumentException('All items must be CacheDependency instances');
}
}
}
public static function empty(): self
{
return new self([]);
}
public static function fromStrings(array $strings): self
{
$dependencies = array_map(
fn(string $str) => CacheDependency::fromString($str),
$strings
);
return new self($dependencies);
}
public function add(CacheDependency $dependency): self
{
// Prevent duplicates
foreach ($this->dependencies as $existing) {
if ($existing->equals($dependency)) {
return $this;
}
}
return new self([...$this->dependencies, $dependency]);
}
public function has(string $key): bool
{
foreach ($this->dependencies as $dependency) {
if ($dependency->key === $key) {
return true;
}
}
return false;
}
public function ofType(DependencyType $type): self
{
return new self(
array_filter(
$this->dependencies,
fn(CacheDependency $dep) => $dep->type === $type
)
);
}
public function count(): int
{
return count($this->dependencies);
}
public function isEmpty(): bool
{
return empty($this->dependencies);
}
public function toArray(): array
{
return array_map(
fn(CacheDependency $dep) => $dep->toArray(),
$this->dependencies
);
}
/** @return CacheDependency[] */
public function all(): array
{
return $this->dependencies;
}
}

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace Archive\Archived;
use App\Framework\Cache\Cache;
final readonly class FragmentCacheManager
{
public function __construct(
private Cache $cache,
) {}
public function hasFragment(string $key, array $dependencies = [], int $ttl = 300): bool
{
$cacheKey = $this->buildFragmentKey($key, $dependencies);
return $this->cache->has($cacheKey);
}
public function cacheFragment(string $key, callable $generator, array $dependencies = [], int $ttl = 300): string
{
$cacheKey = $this->buildFragmentKey($key, $dependencies);
return $this->cache->remember(
$cacheKey,
$generator,
$ttl
)->value;
}
public function cacheStaticFragment(string $fragmentId, string $content, array $dependencies = []): void
{
$cacheKey = $this->buildFragmentKey($fragmentId, $dependencies);
// Statische Fragmente haben lange TTL
$this->cache->set($cacheKey, $content, 3600);
}
public function getDynamicFragment(string $fragmentId, callable $generator, array $context = []): string
{
// Dynamische Fragmente werden nicht gecacht, aber durch den Generator optimiert
return $generator($context);
}
public function invalidateFragment(string $key, array $dependencies = []): void
{
$cacheKey = $this->buildFragmentKey($key, $dependencies);
$this->cache->forget($cacheKey);
}
public function invalidateByPattern(string $pattern): int
{
// Vereinfacht - das Cache-Interface bietet möglicherweise keine Pattern-Invalidierung
// Hier könnten wir eine erweiterte Implementierung hinzufügen
return 0;
}
public function getFragment(string $key, array $dependencies = []): ?string
{
$cacheKey = $this->buildFragmentKey($key, $dependencies);
if ($this->cache->has($cacheKey)) {
return $this->cache->get($cacheKey)->value;
}
return null;
}
public function warmupFragment(string $key, callable $generator, array $dependencies = [], int $ttl = 300): void
{
$this->cacheFragment($key, $generator, $dependencies, $ttl);
}
public function getFragmentStats(): array
{
return [
'cache_enabled' => true,
'cache_interface' => get_class($this->cache),
];
}
private function buildFragmentKey(string $key, array $dependencies): string
{
$dependencyHash = '';
if (!empty($dependencies)) {
ksort($dependencies); // Konsistente Reihenfolge
$dependencyHash = ':' . md5(serialize($dependencies));
}
return "fragment:{$key}{$dependencyHash}";
}
}

View File

@@ -0,0 +1,428 @@
<?php
declare(strict_types=1);
namespace Archive\Archived;
use App\Framework\Cache\Cache;
use App\Framework\View\DomWrapper;
use App\Framework\View\Loading\TemplateLoader;
use App\Framework\View\RenderContext;
use App\Framework\View\TemplateProcessor;
use App\Framework\View\TemplateRenderer;
final class SmartCacheEngine implements TemplateRenderer
{
private array $cacheStats = [
'hits' => 0,
'misses' => 0,
'renders' => 0,
'cached_templates' => [],
];
public function __construct(
private readonly TemplateLoader $loader,
private readonly TemplateAnalyzer $analyzer,
private readonly FragmentCacheManager $fragmentCache,
private readonly Cache $cache,
private readonly TemplateProcessor $processor = new TemplateProcessor(),
private readonly bool $cacheEnabled = true,
private readonly bool $debugMode = true,
private readonly TemplatePreprocessor $preprocessor = new TemplatePreprocessor(),
) {
dd('oldcacheengine');
}
public function render(RenderContext $context): string
{
$this->cacheStats['renders']++;
if (!$this->cacheEnabled) {
$this->logDebug("Cache disabled - rendering without cache", $context);
return $this->renderWithoutCache($context);
}
$startTime = microtime(true);
// Lade und analysiere Template
$template = $this->loader->load($context->template, $context->controllerClass, $context);
// Preprocessing für optimierte Cache-Keys
$normalizedTemplate = $this->preprocessor->normalizeTemplate($template, $context);
$cacheableBlocks = $this->preprocessor->extractCacheableBlocks($template);
$analysis = $this->analyzer->analyzeCacheability($normalizedTemplate, $context);
// Erweitere Analysis mit extrahierten Blöcken
foreach ($cacheableBlocks as $block) {
if ($block['type'] === 'explicit') {
$analysis->addStaticFragment($block['id']);
}
}
$this->logDebug("Template loaded, length: " . strlen($template) . " chars", $context);
$this->logDebug("Cache strategy: {$analysis->cacheStrategy->value}, TTL: {$analysis->ttl}s", $context);
$this->logDebug("Cacheable blocks found: " . count($cacheableBlocks), $context);
$result = match($analysis->cacheStrategy) {
CacheStrategy::STATIC => $this->renderStaticCached($context, $template, $analysis),
CacheStrategy::PARTIAL => $this->renderPartialCached($context, $template, $analysis),
CacheStrategy::FRAGMENT => $this->renderFragmentCached($context, $template, $analysis),
CacheStrategy::DYNAMIC => $this->processor->render($context, $template),
CacheStrategy::DISABLED => $this->renderWithoutCache($context),
};
$renderTime = microtime(true) - $startTime;
$this->logCachePerformance($context, $analysis, $renderTime);
return $result;
}
private function renderStaticCached(RenderContext $context, string $template, CacheabilityInfo $analysis): string
{
$cacheKey = $this->buildStaticCacheKey($context, $analysis);
// Prüfe zuerst, ob bereits im Cache
$wasInCache = $this->cache->has($cacheKey);
// Nutze remember-Pattern
$cacheItem = $this->cache->remember(
$cacheKey,
function() use ($context, $template) {
$this->logDebug("CACHE MISS - Generating new content", $context);
$this->cacheStats['misses']++;
return $this->processor->render($context, $template);
},
$analysis->ttl
);
if ($wasInCache) {
$this->logDebug("CACHE HIT - Using cached content", $context);
$this->cacheStats['hits']++;
}
$this->cacheStats['cached_templates'][$context->template] = [
'strategy' => 'static',
'key' => $cacheKey,
'ttl' => $analysis->ttl,
'hit' => $wasInCache
];
return $cacheItem->value;
}
private function renderPartialCached(RenderContext $context, string $template, CacheabilityInfo $analysis): string
{
$cacheKey = $this->buildPartialCacheKey($context, $analysis);
$wasInCache = $this->cache->has($cacheKey);
$cacheItem = $this->cache->remember(
$cacheKey,
function() use ($context, $template) {
$this->cacheStats['misses']++;
return $this->processor->render($context, $template);
},
$analysis->ttl
);
if ($wasInCache) {
$this->cacheStats['hits']++;
}
return $cacheItem->value;
}
private function renderFragmentCached(RenderContext $context, string $template, CacheabilityInfo $analysis): string
{
// Wenn keine Fragmente erkannt wurden, verwende ein Template-weites Fragment
if (empty($analysis->staticFragments) && empty($analysis->dynamicFragments)) {
$this->logDebug("No fragments found - creating template-wide fragment", $context);
$templateFragmentKey = "template_{$context->template}";
$wasInCache = $this->fragmentCache->hasFragment($templateFragmentKey, $analysis->dependencies ?? [], $analysis->ttl);
if ($wasInCache) {
$this->logDebug("FRAGMENT HIT - Using cached template", $context);
$this->cacheStats['hits']++;
$content = $this->fragmentCache->getFragment($templateFragmentKey, $analysis->dependencies ?? []);
} else {
$this->logDebug("FRAGMENT MISS - Generating new template content", $context);
$this->cacheStats['misses']++;
$content = $this->fragmentCache->cacheFragment(
$templateFragmentKey,
function() use ($context, $template) {
return $this->processor->render($context, $template);
},
$analysis->dependencies ?? [],
$analysis->ttl
);
}
$this->cacheStats['cached_templates'][$context->template] = [
'strategy' => 'fragment',
'key' => $templateFragmentKey,
'ttl' => $analysis->ttl,
'hit' => $wasInCache
];
// Request-spezifische Statistiken
$hitRate = $this->cacheStats['hits'] + $this->cacheStats['misses'] > 0
? round(($this->cacheStats['hits'] / ($this->cacheStats['hits'] + $this->cacheStats['misses'])) * 100, 1)
: 0;
error_log("SmartCache Request Stats: Hits: " . $this->cacheStats['hits'] . ", Misses: " . $this->cacheStats['misses'] . ", Hit Rate: {$hitRate}%");
return $content;
} else {
$this->logDebug("Found " . count($analysis->staticFragments) . " static fragments and " . count($analysis->dynamicFragments) . " dynamic fragments", $context);
}
// Verarbeite explizite Fragmente
$finalContent = '';
foreach ($analysis->staticFragments as $fragmentId) {
$wasInCache = $this->fragmentCache->hasFragment($fragmentId, $analysis->dependencies ?? [], $analysis->ttl);
if ($wasInCache) {
$this->logDebug("FRAGMENT HIT - Using cached fragment: {$fragmentId}", $context);
$this->cacheStats['hits']++;
// Direkt aus Cache holen ohne Generator zu verwenden
$fragmentContent = $this->fragmentCache->getFragment($fragmentId, $analysis->dependencies ?? []);
} else {
$this->logDebug("FRAGMENT MISS - Generating fragment: {$fragmentId}", $context);
$this->cacheStats['misses']++;
$fragmentContent = $this->fragmentCache->cacheFragment(
$fragmentId,
function() use ($fragmentId, $template, $context) {
return $this->renderFragmentFromTemplate($fragmentId, $template, $context);
},
$analysis->dependencies ?? [],
$analysis->ttl
);
}
$finalContent .= $fragmentContent;
}
// Rendere dynamische Fragmente ohne Caching
foreach ($analysis->dynamicFragments as $fragmentId) {
$fragmentContent = $this->renderFragmentFromTemplate($fragmentId, $template, $context);
$finalContent .= $fragmentContent;
}
return $finalContent ?: $this->processor->render($context, $template);
}
private function renderFragmentFromTemplate(string $fragmentId, string $template, RenderContext $context): string
{
// Für jetzt: Wenn es ein Template-Fragment ist, rendere das komplette Template
if (str_starts_with($fragmentId, 'template_')) {
return $this->processor->render($context, $template);
}
// TODO: Hier könnten wir später spezifische Fragment-Extraktion implementieren
// Für jetzt verwende DOM-Wrapper für Fragment-Extraktion
try {
$domWrapper = DomWrapper::fromString($template);
return $this->renderFragment($fragmentId, $domWrapper, $context);
} catch (\Exception $e) {
$this->logDebug("Fragment rendering failed for {$fragmentId}: " . $e->getMessage(), $context);
// Fallback: Rendere komplettes Template
return $this->processor->render($context, $template);
}
}
private function renderWithoutCache(RenderContext $context): string
{
$template = $this->loader->load($context->template, $context->controllerClass, $context);
return $this->processor->render($context, $template);
}
private function renderFragment(string $fragmentId, DomWrapper $domWrapper, RenderContext $context): string
{
// Vereinfachte Fragment-Rendering-Logik
// In einer vollständigen Implementierung würde hier das spezifische Fragment extrahiert
$elements = $domWrapper->getElementsByAttribute('data-fragment-id', $fragmentId);
if (count($elements) > 0) {
$element = $elements[0];
return $domWrapper->document->saveHTML($element);
}
return '';
}
private function extractStaticContent(string $content, CacheabilityInfo $analysis): string
{
// Entferne dynamische Platzhalter für statisches Caching
$staticContent = $content;
// Ersetze dynamische Teile mit Platzhaltern
$staticContent = preg_replace('/\{\{[^}]+}}/', '{{DYNAMIC_PLACEHOLDER}}', $staticContent);
return $staticContent;
}
private function buildStaticCacheKey(RenderContext $context, $analysis): string
{
$dependencies = is_object($analysis) && method_exists($analysis, 'dependencies')
? $analysis->dependencies
: ($analysis->dependencies ?? []);
$optimizedKey = $this->preprocessor->generateOptimizedCacheKey($context, $dependencies);
return "static:{$optimizedKey}";
}
private function buildPartialCacheKey(RenderContext $context, $analysis): string
{
$dependencies = is_object($analysis) && method_exists($analysis, 'dependencies')
? $analysis->dependencies
: ($analysis->dependencies ?? []);
$optimizedKey = $this->preprocessor->generateOptimizedCacheKey($context, $dependencies);
return "partial:{$optimizedKey}";
}
public function getCacheStats(): array
{
$hitRate = $this->cacheStats['hits'] + $this->cacheStats['misses'] > 0
? round(($this->cacheStats['hits'] / ($this->cacheStats['hits'] + $this->cacheStats['misses'])) * 100, 2)
: 0;
return [
'smart_cache' => [
'enabled' => $this->cacheEnabled,
'debug_mode' => $this->debugMode,
'cache_interface' => get_class($this->cache),
'performance' => [
'total_renders' => $this->cacheStats['renders'],
'cache_hits' => $this->cacheStats['hits'],
'cache_misses' => $this->cacheStats['misses'],
'hit_rate' => $hitRate . '%',
],
'cached_templates' => $this->cacheStats['cached_templates'],
'fragment_stats' => $this->fragmentCache->getFragmentStats(),
]
];
}
public function invalidateCache(?string $template = null): int
{
$invalidated = 0;
if ($template) {
// Invalidiere spezifisches Template
$patterns = [
"static:{$template}:",
"partial:{$template}:",
"template_{$template}",
];
foreach ($patterns as $pattern) {
// Vereinfachte Implementierung - da das Cache-Interface keine Pattern-Suche unterstützt
// könnte hier eine erweiterte Lösung implementiert werden
$invalidated++;
}
} else {
// Alle Caches löschen und Stats zurücksetzen
if ($this->cache->clear()) {
$this->resetStats();
$invalidated = 1;
}
}
return $invalidated;
}
public function resetStats(): void
{
$this->cacheStats = [
'hits' => 0,
'misses' => 0,
'renders' => 0,
'cached_templates' => [],
];
}
public function warmupCache(array $templates = []): array
{
if (!$this->warmer) {
return ['error' => 'CacheWarmer not configured'];
}
return $this->warmer->warmupTemplates($templates);
}
public function addPopularTemplate(string $template, array $data = []): void
{
if ($this->warmer) {
$this->warmer->addPopularTemplate($template, $data);
}
}
public function diagnoseCaching(): void
{
echo "\n=== CACHE DIAGNOSTICS ===\n";
echo "Smart Cache Enabled: " . ($this->cacheEnabled ? 'YES' : 'NO') . "\n";
echo "Cache Interface: " . get_class($this->cache) . "\n";
echo "Debug Mode: " . ($this->debugMode ? 'YES' : 'NO') . "\n";
echo "Total Renders: " . $this->cacheStats['renders'] . "\n";
echo "Cache Hits: " . $this->cacheStats['hits'] . "\n";
echo "Cache Misses: " . $this->cacheStats['misses'] . "\n";
$hitRate = $this->cacheStats['hits'] + $this->cacheStats['misses'] > 0
? round(($this->cacheStats['hits'] / ($this->cacheStats['hits'] + $this->cacheStats['misses'])) * 100, 2)
: 0;
echo "Hit Rate: " . $hitRate . "%\n";
echo "\n=== ANALYSIS ===\n";
if ($this->cacheStats['hits'] === 0 && $this->cacheStats['misses'] === 0) {
echo "⚠️ PROBLEM: No cache operations detected!\n";
echo " This suggests that templates are being rendered with CacheStrategy::DYNAMIC or CacheStrategy::DISABLED\n";
echo " Check the TemplateAnalyzer logic to ensure templates are being marked as cacheable.\n";
}
echo "========================\n";
}
private function logCachePerformance(RenderContext $context, CacheabilityInfo $analysis, float $renderTime): void
{
if (!$this->debugMode) {
return;
}
$logData = [
'template' => $context->template,
'strategy' => $analysis->cacheStrategy->value,
'render_time' => round($renderTime * 1000, 2) . 'ms',
'complexity' => $analysis->getCacheComplexity(),
'cacheable' => $analysis->cacheStrategy->shouldCache(),
'request_hits' => $this->cacheStats['hits'],
'request_misses' => $this->cacheStats['misses'],
'static_fragments' => count($analysis->staticFragments),
'dynamic_fragments' => count($analysis->dynamicFragments),
'ttl' => $analysis->ttl,
];
error_log('SmartCache Performance: ' . json_encode($logData));
// Zusätzliche Fragment-Details wenn Fragment-Strategy
if ($analysis->cacheStrategy === CacheStrategy::FRAGMENT) {
error_log('SmartCache Fragments: static=[' . implode(',', $analysis->staticFragments) .
'] dynamic=[' . implode(',', $analysis->dynamicFragments) . ']');
}
}
private function logDebug(string $message, RenderContext $context): void
{
if (!$this->debugMode) {
return;
}
error_log("SmartCache DEBUG [{$context->template}]: {$message}");
}
}

View File

@@ -0,0 +1,216 @@
<?php
declare(strict_types=1);
namespace Archive\Archived;
use App\Framework\View\RenderContext;
final readonly class TemplateAnalyzer
{
private const array DYNAMIC_COMPONENTS = [
'user-info', 'session-data', 'csrf-token', 'timestamp',
'user-menu', 'cart', 'notifications', 'live-data'
];
private const array TIME_BASED_PATTERNS = [
'data-timestamp', 'data-live', 'data-realtime',
'class="live"', 'class="timestamp"'
];
public function analyzeCacheability(string $template, RenderContext $context): CacheabilityInfo
{
$analysis = new CacheabilityInfo();
try {
// Einfache String-basierte Analyse für bessere Kompatibilität
$analysis->hasDynamicComponents = $this->findDynamicComponentsInString($template, $analysis);
$analysis->hasUserSpecificContent = $this->findUserContentInString($template, $context);
$analysis->hasFormElements = $this->findFormsInString($template);
$analysis->hasTimeBasedContent = $this->findTimeBasedContent($template);
$analysis->hasSessionData = $this->findSessionData($template, $context);
// Fragment-Analyse hinzufügen
$this->autoDetectFragments($template, $analysis);
// Bestimme beste Cache-Strategie
$analysis->cacheStrategy = $this->determineBestStrategy($analysis);
$analysis->ttl = $this->calculateOptimalTtl($analysis);
$analysis->dependencies = $this->identifyDependencies($analysis, $context);
} catch (\Exception $e) {
// Bei Fehlern: Konservativ statisches Caching
$analysis->cacheStrategy = CacheStrategy::STATIC;
$analysis->ttl = 300;
}
return $analysis;
}
private function findDynamicComponentsInString(string $template, CacheabilityInfo $analysis): bool
{
$hasDynamic = false;
foreach (self::DYNAMIC_COMPONENTS as $component) {
if (str_contains($template, $component)) {
$analysis->dynamicComponents[] = $component;
$hasDynamic = true;
}
}
return $hasDynamic;
}
private function findUserContentInString(string $template, RenderContext $context): bool
{
// Suche nach user-spezifischen Patterns
$userPatterns = ['{{user', '$user', 'data-user', 'class="user-'];
if (array_any($userPatterns, fn($pattern) => str_contains($template, $pattern))) {
return true;
}
// Prüfe Context-Daten auf User-Informationen
return isset($context->data['user']) || isset($context->data['session']);
}
private function findFormsInString(string $template): bool
{
return str_contains($template, '<form') || str_contains($template, 'csrf') || str_contains($template, 'token');
}
// Entfernte Methoden werden durch die neuen String-basierten Methoden ersetzt
private function findTimeBasedContent(string $template): bool
{
return array_any(self::TIME_BASED_PATTERNS, fn($pattern) => str_contains($template, $pattern));
}
private function findSessionData(string $template, RenderContext $context): bool
{
// Prüfe auf Session-bezogene Patterns im Template
$sessionPatterns = ['session.', '{{session', '$session', 'data-session'];
if (array_any($sessionPatterns, fn($pattern) => str_contains($template, $pattern))) {
return true;
}
// Prüfe Context auf Session-Daten
return isset($context->data['session']) || isset($context->data['flash']);
}
// Fragment-Analyse vereinfacht entfernt für bessere Stabilität
private function determineBestStrategy(CacheabilityInfo $analysis): CacheStrategy
{
// Vollständig statisch
if ($analysis->isFullyCacheable()) {
return CacheStrategy::STATIC;
}
// Teilweise cacheable mit Fragmenten
if ($analysis->shouldUseFragmentCache()) {
return CacheStrategy::FRAGMENT;
}
// Teilweise cacheable ohne Fragmente
if ($analysis->isPartiallyCacheable()) {
return CacheStrategy::PARTIAL;
}
// Vollständig dynamisch
return CacheStrategy::DYNAMIC;
}
private function calculateOptimalTtl(CacheabilityInfo $analysis): int
{
$baseTtl = $analysis->cacheStrategy->getTtl();
// Reduziere TTL basierend auf Komplexität
$complexity = $analysis->getCacheComplexity();
$reduction = min($complexity * 60, $baseTtl * 0.5); // Max 50% Reduktion
return max(0, $baseTtl - (int)$reduction);
}
private function identifyDependencies(CacheabilityInfo $analysis, RenderContext $context): array
{
$dependencies = [];
// Template-File als Basis-Dependency
$dependencies['template'] = $context->template;
// Dynamic Fragment Dependencies - verwende dynamicFragments anstatt dynamicComponents
foreach ($analysis->dynamicFragments as $fragment) {
// Verwende einfach den Fragment-Namen und aktuellen Timestamp
$dependencies['fragment:' . $fragment] = time();
}
// User-spezifische Dependencies
if ($analysis->hasUserSpecificContent && isset($context->data['user']['id'])) {
$dependencies['user'] = $context->data['user']['id'];
}
return $dependencies;
}
private function autoDetectFragments(string $template, CacheabilityInfo $info): void
{
$fragmentsFound = false;
// Erkenne große statische Blöcke (Navigation, Footer, etc.)
if (preg_match('/<nav[^>]*>(.*?)<\/nav>/s', $template)) {
$info->addStaticFragment('navigation');
$fragmentsFound = true;
}
if (preg_match('/<footer[^>]*>(.*?)<\/footer>/s', $template)) {
$info->addStaticFragment('footer');
$fragmentsFound = true;
}
if (preg_match('/<header[^>]*>(.*?)<\/header>/s', $template)) {
$info->addStaticFragment('header');
$fragmentsFound = true;
}
// Erkenne HTML-Sektionen
if (preg_match('/<section[^>]*>(.*?)<\/section>/s', $template)) {
$info->addStaticFragment('content_section');
$fragmentsFound = true;
}
if (preg_match('/<main[^>]*>(.*?)<\/main>/s', $template)) {
$info->addStaticFragment('main_content');
$fragmentsFound = true;
}
// Erkenne CSS-Klassen-basierte Bereiche
if (preg_match('/class=["\'].*sidebar.*["\']/', $template)) {
$info->addStaticFragment('sidebar');
$fragmentsFound = true;
}
// Erkenne dynamische Bereiche
if (preg_match('/\{\{\s*flash|errors|session/', $template)) {
$info->addDynamicFragment('user_messages');
$fragmentsFound = true;
}
// Analysiere Template-Komplexität
$dynamicMatches = preg_match_all('/\{\{[^}]+}}/', $template);
$templateLength = strlen($template);
// Debugging der Fragment-Erkennung
error_log("TemplateAnalyzer DEBUG: Template length: {$templateLength}, Dynamic matches: {$dynamicMatches}, Fragments found: " . ($fragmentsFound ? 'yes' : 'no'));
// Wenn weniger als 5 dynamische Platzhalter pro 1_000 Zeichen UND keine anderen Fragmente
$dynamicDensity = $templateLength > 0 ? ($dynamicMatches / $templateLength) * 1000 : 0;
error_log("TemplateAnalyzer DEBUG: Dynamic density: {$dynamicDensity}, Has dynamic components: " . ($info->hasDynamicComponents ? 'yes' : 'no'));
// Immer ein template_main Fragment hinzufügen wenn wenig dynamische Inhalte
if ($dynamicDensity < 5 || (!$fragmentsFound && !$info->hasDynamicComponents)) {
$info->addStaticFragment('template_main');
error_log("TemplateAnalyzer DEBUG: Added template_main fragment (density: {$dynamicDensity})");
}
}
}

View File

@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace Archive\Archived;
final readonly class TemplateContent
{
public function __construct(
public string $rawTemplate,
public bool $hasUserSpecificContent = false,
public bool $hasSessionData = false,
public bool $hasFormElements = false,
public bool $hasTimeBasedContent = false,
public bool $hasRandomContent = false,
public bool $hasCsrfTokens = false,
public array $dynamicVariables = [],
public array $staticBlocks = [],
) {}
public function hasDynamicContent(): bool
{
return $this->hasUserSpecificContent
|| $this->hasSessionData
|| $this->hasFormElements
|| $this->hasTimeBasedContent
|| $this->hasRandomContent
|| $this->hasCsrfTokens
|| !empty($this->dynamicVariables);
}
public function getDynamicContentTypes(): array
{
$types = [];
if ($this->hasUserSpecificContent) $types[] = 'user_specific';
if ($this->hasSessionData) $types[] = 'session_data';
if ($this->hasFormElements) $types[] = 'form_elements';
if ($this->hasTimeBasedContent) $types[] = 'time_based';
if ($this->hasRandomContent) $types[] = 'random_content';
if ($this->hasCsrfTokens) $types[] = 'csrf_tokens';
return $types;
}
public function getCharacteristics(): array
{
return [
'has_dynamic_content' => $this->hasDynamicContent(),
'dynamic_types' => $this->getDynamicContentTypes(),
'dynamic_variables_count' => count($this->dynamicVariables),
'static_blocks_count' => count($this->staticBlocks),
'template_size' => strlen($this->rawTemplate),
];
}
}

View File

@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
namespace Archive\Archived;
use App\Framework\View\RenderContext;
final class TemplatePreprocessor
{
private array $normalizedTemplates = [];
public function normalizeTemplate(string $template, RenderContext $context): string
{
$cacheKey = md5($template . serialize($context->data));
if (isset($this->normalizedTemplates[$cacheKey])) {
return $this->normalizedTemplates[$cacheKey];
}
$normalized = $template;
// Entferne Kommentare für konsistentere Cache-Keys
$normalized = preg_replace('/<!--.*?-->/s', '', $normalized);
// Normalisiere Whitespace
$normalized = preg_replace('/\s+/', ' ', $normalized);
$normalized = trim($normalized);
// Cache das normalisierte Template
$this->normalizedTemplates[$cacheKey] = $normalized;
return $normalized;
}
public function extractCacheableBlocks(string $template): array
{
$blocks = [];
// Finde data-cache Attribute für explizite Cache-Bereiche
if (preg_match_all('/<[^>]+data-cache="([^"]+)"[^>]*>(.*?)<\/[^>]+>/s', $template, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$blocks[] = [
'id' => $match[1],
'content' => $match[2],
'type' => 'explicit'
];
}
}
// Finde wiederholende Strukturen (Listen, Cards etc.)
if (preg_match_all('/<(ul|ol|div class="[^"]*list[^"]*")[^>]*>(.*?)<\/\1>/s', $template, $matches, PREG_SET_ORDER)) {
foreach ($matches as $i => $match) {
$blocks[] = [
'id' => 'list_block_' . $i,
'content' => $match[2],
'type' => 'list'
];
}
}
return $blocks;
}
public function generateOptimizedCacheKey(RenderContext $context, array $dependencies = []): string
{
$keyParts = [
$context->template,
$context->controllerClass ?? 'default',
];
// Füge nur relevante Daten hinzu (nicht alles aus $context->data)
$relevantData = $this->extractRelevantData($context->data, $dependencies);
if (!empty($relevantData)) {
$keyParts[] = md5(serialize($relevantData));
}
// Füge Template-Änderungszeit hinzu für Auto-Invalidation
$templatePath = $this->resolveTemplatePath($context->template);
if (file_exists($templatePath)) {
$keyParts[] = filemtime($templatePath);
}
return implode(':', $keyParts);
}
private function extractRelevantData(array $data, array $dependencies): array
{
if (empty($dependencies)) {
return $data;
}
$relevant = [];
foreach ($dependencies as $key) {
if (isset($data[$key])) {
$relevant[$key] = $data[$key];
}
}
return $relevant;
}
private function resolveTemplatePath(string $template): string
{
// Vereinfachte Template-Pfad-Auflösung
return "views/{$template}.php";
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace Archive\Async1;
use App\Framework\Async1\AsyncDiscovery;
use App\Framework\Async1\PcntlTaskProcessor;
use App\Framework\Async1\TaskProcessorFactory;
use App\Framework\Core\AttributeMapper;
use App\Framework\Core\Discovery;
final class DiscoveryFactory
{
/**
* Erstellt eine Discovery-Instanz mit optionaler asynchroner Verarbeitung
*
* @param bool $useAsync Ob asynchrone Verarbeitung genutzt werden soll
* @param string|null $srcDir Quellverzeichnis für automatisches Auffinden von Mappern
* @param AttributeMapper ...$mappers AttributeMapper-Instanzen
* @return Discovery
*/
public static function create(bool $useAsync = true, ?string $srcDir = null, AttributeMapper ...$mappers): Discovery
{
if ($useAsync && PcntlTaskProcessor::isAvailable()) {
return new AsyncDiscovery($srcDir, TaskProcessorFactory::create(), ...$mappers);
}
return new Discovery($srcDir, ...$mappers);
}
}

View File

@@ -0,0 +1,166 @@
<?php
declare(strict_types=1);
namespace Archive\Async1;
use App\Framework\Async1\TaskProcessor;
/**
* TaskProcessor-Implementierung für PCNTL mit Support für Closures
*/
final class ProcOpenTaskProcessor implements TaskProcessor
{
private int $maxProcesses;
private string $tempDir;
public function __construct(int $maxProcesses = 4, ?string $tempDir = null)
{
$this->maxProcesses = $maxProcesses;
$this->tempDir = $tempDir ?? sys_get_temp_dir();
}
/**
* {@inheritdoc}
*/
public function processTasks(array $tasks, mixed ...$sharedData): array
{
if (empty($tasks)) {
return [];
}
// Fasse gemeinsame Daten zusammen, die serialisierbar sind
$serializedSharedData = $this->safeSerialize($sharedData);
if ($serializedSharedData === false) {
throw new \RuntimeException("Gemeinsame Daten können nicht serialisiert werden");
}
// Teile die Aufgaben in Blöcke auf
$chunks = array_chunk($tasks, (int)ceil(count($tasks) / $this->maxProcesses));
$tempFiles = [];
$childPids = [];
// Starte für jeden Chunk einen separaten Prozess
foreach ($chunks as $index => $chunk) {
// Erstelle temporäre Datei für die Ergebnisse
$tempFile = $this->tempDir . '/async_results_' . uniqid() . '.tmp';
$tempFiles[$index] = $tempFile;
// Da wir Closures nicht serialisieren können, erstellen wir Stellvertreter-Aufgaben
$taskDescriptors = [];
foreach ($chunk as $taskIndex => $task) {
// Wir speichern nur den originalen Index für die spätere Zuordnung
$taskDescriptors[$taskIndex] = true;
}
// Starte einen Child-Prozess
$pid = pcntl_fork();
if ($pid == -1) {
throw new \RuntimeException('Konnte keinen neuen Prozess starten');
} elseif ($pid) {
// Elternprozess: speichere PID
$childPids[$pid] = ['index' => $index, 'chunk' => $chunk];
} else {
// Kindprozess: Verarbeite die Aufgaben
try {
$chunkResults = [];
// Deserialisiere gemeinsame Daten
$decodedSharedData = unserialize($serializedSharedData);
// Führe die Aufgaben in diesem Chunk aus
foreach ($chunk as $taskIndex => $task) {
try {
// Führe die Task direkt aus, ohne zu serialisieren
$chunkResults[$taskIndex] = $task(...$decodedSharedData);
} catch (\Throwable $e) {
// Fehler bei einer bestimmten Aufgabe
$chunkResults[$taskIndex] = ['__error__' => $e->getMessage()];
}
}
// Versuche, die Ergebnisse zu serialisieren
$serializedResults = $this->safeSerialize($chunkResults);
if ($serializedResults === false) {
throw new \RuntimeException("Ergebnisse können nicht serialisiert werden");
}
// Speichere die Ergebnisse
file_put_contents($tempFile, $serializedResults);
} catch (\Throwable $e) {
// Schwerwiegender Fehler im Kindprozess
file_put_contents($tempFile, serialize(['__process_error__' => $e->getMessage()]));
}
// Beende den Kindprozess
exit(0);
}
}
// Sammle die Ergebnisse aus allen Child-Prozessen
$allResults = [];
// Warte auf jeden Kindprozess
foreach ($childPids as $pid => $info) {
$index = $info['index'];
$chunk = $info['chunk'];
// Warte auf den Prozess
pcntl_waitpid($pid, $status);
$tempFile = $tempFiles[$index];
if (file_exists($tempFile)) {
$fileContent = file_get_contents($tempFile);
try {
$chunkResults = unserialize($fileContent);
if (is_array($chunkResults)) {
if (isset($chunkResults['__process_error__'])) {
error_log("Prozessfehler in Chunk $index: " . $chunkResults['__process_error__']);
} else {
// Füge die Ergebnisse zum Gesamtergebnis hinzu
foreach ($chunkResults as $taskIndex => $result) {
if (is_array($result) && isset($result['__error__'])) {
error_log("Fehler bei Task #$taskIndex: " . $result['__error__']);
$allResults[$taskIndex] = null;
} else {
$allResults[$taskIndex] = $result;
}
}
}
}
} catch (\Throwable $e) {
error_log("Fehler beim Deserialisieren der Ergebnisse aus Chunk $index: " . $e->getMessage());
}
// Lösche die temporäre Datei
unlink($tempFile);
}
}
return $allResults;
}
/**
* Sichere Serialisierung mit Fehlerbehandlung
*/
private function safeSerialize($data)
{
try {
return serialize($data);
} catch (\Throwable $e) {
return false;
}
}
/**
* {@inheritdoc}
*/
public static function isAvailable(): bool
{
return function_exists('pcntl_fork') && function_exists('pcntl_waitpid');
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace Archive\Async1;
use App\Framework\Async1\TaskProcessor;
final class SynchronousTaskProcessor implements TaskProcessor
{
/**
* {@inheritdoc}
*/
public function processTasks(array $tasks, mixed ...$sharedData): array
{
$results = [];
foreach ($tasks as $index => $task) {
try {
$results[$index] = $task(...$sharedData);
} catch (\Throwable $e) {
error_log("Fehler bei Task #{$index}: " . $e->getMessage());
$results[$index] = null;
}
}
return $results;
}
/**
* {@inheritdoc}
*/
public static function isAvailable(): bool
{
return true; // Immer verfügbar
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Archive\Config;
enum ApiConfig: string
{
// RapidMail Konfiguration
case RAPIDMAIL_USERNAME = '3f60a5c15c3d49c631d0e75b7c1090a3859423a7';
case RAPIDMAIL_PASSWORD = '572d25dc36e620f14c89e9c75c02c1f3794ba3c0';
case RAPIDMAIL_DEFAULT_LIST_ID = '776';
// Shopify API Konfiguration
case SHOPIFY_SHOP_DOMAIN = 'michaelschiemer.myshopify.com';
case SHOPIFY_ACCESS_TOKEN = 'shpat_123456789abcdef';
case SHOPIFY_API_VERSION = '2024-04';
/**
* Gibt die numerische RapidMail-Listen-ID zurück
*/
public static function getRapidmailListId(): int
{
return (int)self::RAPIDMAIL_DEFAULT_LIST_ID->value;
}
}

View File

@@ -0,0 +1,33 @@
<?php
return [
// Aktiviert oder deaktiviert das Analytics-Modul
'enabled' => true,
// Automatisches Speichern nach Erreichen der Batch-Größe
'auto_flush' => true,
// Anzahl der Events, die gesammelt werden, bevor sie gespeichert werden
'batch_size' => 50,
// Storage-Backend ('file')
'storage' => 'file',
// Speicherpfad für Analytics-Daten (relativ zum Datenverzeichnis)
'storage_path' => __DIR__ . '/../data/analytics',
// Anonymisierung von persönlichen Daten
'anonymize_ip' => true,
// Benutzerdefinierte Events, die getrackt werden sollen
'track_events' => [
'page_view',
'error',
'performance',
'database',
'http_request',
'http_response',
'user_login',
'user_logout',
],
];

View File

@@ -0,0 +1,82 @@
<?php
namespace Media\Controllers;
use App\Framework\Attributes\Route;
use App\Framework\Core\PathProvider;
use App\Framework\Http\Headers;
use App\Framework\Http\HttpResponse;
use App\Framework\Http\Method;
use App\Framework\Http\Status;
use App\Framework\Router\Result\FileResult;
use Media\Entities\Image;
use Media\Services\ImageService;
class MediaController
{
public function __construct(
private ImageService $imageService,
private PathProvider $pathProvider,
) {}
#[Route('/media/{path}-{filename}', method: Method::GET)]
public function serveMedia(string $path, string $filename): FileResult
{
$image = $this->imageService->resolveFromUrlId($path);
$imagePath = $this->pathProvider->resolvePath('storage' . $image->uploadPath . '/' . $filename);
#debug($imagePath);
// Prüfen ob Datei existiert
if (!file_exists($imagePath)) {
die('oh');
return $this->notFound();
}
// Lese Datei und sende sie zurück
$content = file_get_contents($imagePath);
$mimeType = $this->getMimeTypeFromFormat($image->mimeType);
$headers = new Headers([
'Content-Type' => $mimeType,
'Content-Length' => filesize($imagePath),
'Cache-Control' => 'public, max-age=31536000', // 1 Jahr cachen
'ETag' => '"' . md5_file($imagePath) . '"',
]);
return new FileResult($imagePath);
return new HttpResponse(
Status::OK,
$headers,
$content
);
}
private function constructImagePath(Image $image, string $variant, string $format): string
{
return $this->pathProvider->resolvePath('storage' . $image->uploadPath . '/' . $variant . '.' . $format);
}
private function getMimeTypeFromFormat(string $format): string
{
return match ($format) {
'jpg' => 'image/jpeg',
'png' => 'image/png',
'gif' => 'image/gif',
'webp' => 'image/webp',
'avif' => 'image/avif',
default => 'application/octet-stream'
};
}
private function notFound(): HttpResponse
{
return new HttpResponse(
Status::NOT_FOUND,
new Headers(['Content-Type' => 'text/plain']),
'Bild nicht gefunden'
);
}
}

View File

@@ -0,0 +1,57 @@
<?php
namespace Media\Entities;
use App\Framework\Database\Attributes\Entity;
#[Entity(tableName: 'images')]
class Image
{
public function __construct(
public ?int $id = null,
public string $filename = '',
public string $originalFilename = '',
public string $mimeType = '',
public int $fileSize = 0,
public int $width = 0,
public int $height = 0,
public string $hash = '',
public string $uploadPath = '',
public \DateTimeImmutable $createdAt = new \DateTimeImmutable(),
public ?\DateTimeImmutable $updatedAt = null,
) {}
public function getUploadDirectory(): string
{
return sprintf(
'/uploads/%s/%s/%s',
$this->createdAt->format('Y'),
$this->createdAt->format('m'),
$this->createdAt->format('d')
);
}
public function getFilePathPattern(): string
{
$idStr = str_pad((string)$this->id, 9, '0', STR_PAD_LEFT);
return sprintf(
'%s/%s/%s/%s',
substr($idStr, 0, 3),
substr($idStr, 3, 3),
substr($idStr, 6, 3),
$idStr
);
}
/**
* Generiert eine eindeutige URL-freundliche ID ohne Slashes
*/
public function getUrlId(): string
{
// Kombiniert Datum und ID zu einem eindeutigen String
$dateStr = $this->createdAt->format('Ymd');
$idStr = str_pad((string)$this->id, 8, '0', STR_PAD_LEFT);
return $dateStr . $idStr;
}
}

View File

@@ -0,0 +1,32 @@
<?php
namespace Media\Entities;
use App\Framework\Database\Attributes\Entity;
#[Entity(tableName: 'image_variants')]
class ImageVariant
{
public function __construct(
public ?int $id = null,
public int $imageId = 0,
public string $variant = '', // 'thumbnail', 'small', 'medium', 'large'
public string $format = '', // 'jpg', 'webp', 'avif'
public int $width = 0,
public int $height = 0,
public int $fileSize = 0,
public string $filename = '',
public \DateTimeImmutable $createdAt = new \DateTimeImmutable(),
) {}
public function getFullPath(Image $image): string
{
return sprintf(
'%s/%s/%s.%s',
$image->getUploadDirectory(),
$image->getFilePathPattern(),
$this->variant,
$this->format
);
}
}

View File

@@ -0,0 +1,106 @@
<?php
namespace Media\Repositories;
use App\Framework\Database\Connection;
use App\Framework\Database\ConnectionInterface;
use Media\Entities\Image;
class ImageRepository
{
public function __construct(
private ConnectionInterface $db
) {
}
public function save(Image $image): Image
{
$query = "INSERT INTO images "
. "(filename, original_filename, mime_type, file_size, width, height, hash, upload_path, created_at) "
. "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);";
$this->db->execute(
$query,
[
$image->filename,
$image->originalFilename,
$image->mimeType,
$image->fileSize,
$image->width,
$image->height,
$image->hash,
$image->uploadPath,
$image->createdAt->format('Y-m-d H:i:s'),
]
);
$image->id = (int)$this->db->lastInsertId();
return $image;
}
public function update(Image $image): void
{
$query = "UPDATE images SET "
. "filename = ?, original_filename = ?, mime_type = ?, file_size = ?, "
. "width = ?, height = ?, hash = ?, upload_path = ?, updated_at = CURRENT_TIMESTAMP "
. "WHERE id = ?;";
$this->db->execute(
$query,
[
$image->filename,
$image->originalFilename,
$image->mimeType,
$image->fileSize,
$image->width,
$image->height,
$image->hash,
$image->uploadPath,
$image->id,
]
);
}
public function findById(int $id): ?Image
{
$query = "SELECT * FROM images WHERE id = ?;";
$result = $this->db->query($query, [$id])->fetch();
if (null === $result) {
return null;
}
return $this->mapToEntity($result);
}
public function findByHash(string $hash): ?Image
{
$query = "SELECT * FROM images WHERE hash = ?;";
$result = $this->db->query($query, [$hash])->fetch();
if (!$result) {
return null;
}
return $this->mapToEntity($result);
}
private function mapToEntity(array $data): Image
{
return new Image(
id: (int)$data['id'],
filename: $data['filename'],
originalFilename: $data['original_filename'],
mimeType: $data['mime_type'],
fileSize: (int)$data['file_size'],
width: (int)$data['width'],
height: (int)$data['height'],
hash: $data['hash'],
uploadPath: $data['upload_path'],
createdAt: new \DateTimeImmutable($data['created_at']),
updatedAt: $data['updated_at'] ? new \DateTimeImmutable($data['updated_at']) : null,
);
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace Media\Repositories;
use App\Framework\Database\Connection;
use App\Framework\Database\ConnectionInterface;
use Media\Entities\ImageVariant;
class ImageVariantRepository
{
public function __construct(
private ConnectionInterface $db
) {}
public function save(ImageVariant $variant): ImageVariant
{
$query = "INSERT INTO image_variants "
. "(image_id, variant, format, width, height, file_size, filename, created_at) "
. "VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
$this->db->execute(
$query,
[
$variant->imageId,
$variant->variant,
$variant->format,
$variant->width,
$variant->height,
$variant->fileSize,
$variant->filename,
$variant->createdAt->format('Y-m-d H:i:s'),
]
);
$variant->id = (int)$this->db->lastInsertId();
return $variant;
}
public function findByImageId(int $imageId): array
{
$query = "SELECT * FROM image_variants WHERE image_id = ?;";
$results = $this->db->query($query, [$imageId]);
return array_map(fn($data) => $this->mapToEntity($data), $results);
}
public function findByImageIdAndVariant(int $imageId, string $variant, string $format): ?ImageVariant
{
$query = "SELECT * FROM image_variants WHERE image_id = ? AND variant = ? AND format = ?;";
$result = $this->db->query($query, [$imageId, $variant, $format]);
if (empty($result)) {
return null;
}
return $this->mapToEntity($result[0]);
}
private function mapToEntity(array $data): ImageVariant
{
return new ImageVariant(
id: (int)$data['id'],
imageId: (int)$data['image_id'],
variant: $data['variant'],
format: $data['format'],
width: (int)$data['width'],
height: (int)$data['height'],
fileSize: (int)$data['file_size'],
filename: $data['filename'],
createdAt: new \DateTimeImmutable($data['created_at']),
);
}
}

View File

@@ -0,0 +1,265 @@
<?php
namespace Media\Services;
use App\Framework\Core\PathProvider;
use App\Framework\Http\UploadedFile;
use Media\Entities\Image;
use Media\Entities\ImageVariant;
use Media\Repositories\ImageRepository;
use Media\Repositories\ImageVariantRepository;
use function filesize;
use function getimagesize;
use function imageavif;
use function imagecopyresampled;
use function imagecreatefromstring;
use function imagecreatetruecolor;
use function imagedestroy;
use function imagejpeg;
use function imagesx;
use function imagesy;
use function imagewebp;
class ImageService
{
private const array VARIANTS = [
'thumbnail' => ['width' => 150, 'height' => 150, 'crop' => true],
'small' => ['width' => 400, 'height' => 400, 'crop' => false],
'medium' => ['width' => 800, 'height' => 800, 'crop' => false],
'large' => ['width' => 1200, 'height' => 1200, 'crop' => false],
];
private const array FORMATS = ['jpg', 'webp', 'avif'];
public function __construct(
private PathProvider $pathProvider,
private ImageRepository $imageRepository,
private ImageVariantRepository $variantRepository,
) {}
public function uploadImage(UploadedFile $file): Image
{
// Validierung
if (!$file->isValid()) {
throw new \InvalidArgumentException('Ungültige Datei');
}
if (!$this->isValidImageType($file->getMimeType())) {
throw new \InvalidArgumentException('Ungültiger Bildtyp');
}
// Basis-Image-Informationen erfassen
$imageInfo = getimagesize($file->tmpName);
$hash = \hash_file('sha256', $file->tmpName);
// Prüfen ob ein Bild mit gleichem Hash bereits existiert
$existingImage = $this->imageRepository->findByHash($hash);
if ($existingImage) {
return $existingImage; // Duplikat gefunden, vorhandenes Bild zurückgeben
}
// Image Entity erstellen
$image = new Image(
filename: $this->generateSecureFilename($file->name),
originalFilename: $file->name,
mimeType: $file->getMimeType(),
fileSize: $file->size,
width: $imageInfo[0],
height: $imageInfo[1],
hash: $hash,
uploadPath: '',
);
// In Datenbank speichern um ID zu erhalten
$image = $this->imageRepository->save($image);
// Upload-Pfad setzen
$image->uploadPath = $image->getUploadDirectory() . '/' . $image->getFilePathPattern();
$this->imageRepository->update($image);
// Ordnerstruktur erstellen
$fullUploadPath = $this->pathProvider->resolvePath('storage' . $image->uploadPath);
$this->createDirectoryStructure($fullUploadPath);
// Original-Datei speichern
$originalPath = $fullUploadPath . '/original.' . $this->getFileExtension($file->getMimeType());
$file->moveTo($originalPath);
// Varianten erstellen
$this->createImageVariants($image, $originalPath);
// Image in Datenbank aktualisieren
#$this->imageRepository->update($image);
return $image;
}
private function createImageVariants(Image $image, string $originalPath): void
{
foreach (self::VARIANTS as $variantName => $config) {
foreach (self::FORMATS as $format) {
$variant = $this->createImageVariant(
$image,
$originalPath,
$variantName,
$format,
$config
);
// In Datenbank speichern
$this->variantRepository->save($variant);
}
}
}
private function createImageVariant(
Image $image,
string $originalPath,
string $variantName,
string $format,
array $config
): ImageVariant {
$outputPath = $this->pathProvider->resolvePath('storage' . $image->uploadPath . '/' . $variantName . '.' . $format);
// Bild verarbeiten
$resizedImage = $this->resizeImage($originalPath, $config['width'], $config['height'], $config['crop'] ?? false);
$this->saveImageInFormat($resizedImage, $outputPath, $format);
// Neue Dateigröße und Dimensionen ermitteln
$newImageInfo = getimagesize($outputPath);
$fileSize = filesize($outputPath);
return new ImageVariant(
imageId: $image->id,
variant: $variantName,
format: $format,
width: $newImageInfo[0],
height: $newImageInfo[1],
fileSize: $fileSize,
filename: $variantName . '.' . $format
);
}
private function resizeImage(string $sourcePath, int $maxWidth, int $maxHeight, bool $crop = false): \GdImage
{
$sourceImage = imagecreatefromstring(\file_get_contents($sourcePath));
$sourceWidth = imagesx($sourceImage);
$sourceHeight = imagesy($sourceImage);
if ($crop) {
// Crop-Logik für quadratische Thumbnails
$ratio = max($maxWidth / $sourceWidth, $maxHeight / $sourceHeight);
$newWidth = (int)round($sourceWidth * $ratio);
$newHeight = (int)round($sourceHeight * $ratio);
$tempImage = imagecreatetruecolor($newWidth, $newHeight);
imagecopyresampled($tempImage, $sourceImage, 0, 0, 0, 0, $newWidth, $newHeight, $sourceWidth, $sourceHeight);
$finalImage = imagecreatetruecolor($maxWidth, $maxHeight);
$cropX = (int)floor(($newWidth - $maxWidth) / 2);
$cropY = (int)floor(($newHeight - $maxHeight) / 2);
\imagecopy($finalImage, $tempImage, 0, 0, $cropX, $cropY, $maxWidth, $maxHeight);
imagedestroy($tempImage);
} else {
// Proportionales Skalieren
$ratio = min($maxWidth / $sourceWidth, $maxHeight / $sourceHeight);
$newWidth = (int)round($sourceWidth * $ratio);
$newHeight = (int)round($sourceHeight * $ratio);
$finalImage = imagecreatetruecolor($newWidth, $newHeight);
imagecopyresampled($finalImage, $sourceImage, 0, 0, 0, 0, $newWidth, $newHeight, $sourceWidth, $sourceHeight);
}
imagedestroy($sourceImage);
return $finalImage;
}
private function saveImageInFormat(\GdImage $image, string $path, string $format): void
{
switch ($format) {
case 'jpg':
imagejpeg($image, $path, 85);
break;
case 'webp':
imagewebp($image, $path, 85);
break;
case 'avif':
if (\function_exists('imageavif')) {
imageavif($image, $path, 85);
} else {
// Fallback auf WebP wenn AVIF nicht verfügbar
imagewebp($image, $path, 85);
}
break;
}
imagedestroy($image);
}
private function createDirectoryStructure(string $path): void
{
if (!is_dir($path)) {
mkdir($path, 0755, true);
}
}
private function isValidImageType(string $mimeType): bool
{
return in_array($mimeType, [
'image/jpeg',
'image/png',
'image/gif',
'image/webp'
]);
}
private function getFileExtension(string $mimeType): string
{
return match ($mimeType) {
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
default => 'jpg'
};
}
private function generateSecureFilename(string $originalName): string
{
$extension = pathinfo($originalName, PATHINFO_EXTENSION);
return uniqid('img_', true) . '.' . strtolower($extension);
}
public function getImageUrl(Image $image, string $variant = 'medium', string $format = 'jpg'): string
{
return '/media/' . $image->getUrlId() . '-' . $variant . '.' . $format;
}
public function findById(int $id): ?Image
{
return $this->imageRepository->findById($id);
}
public function getVariants(Image $image): array
{
return $this->variantRepository->findByImageId($image->id);
}
/**
* Löst eine URL-ID zu einem Image-Objekt auf
*/
public function resolveFromUrlId(string $urlId): ?Image
{
// URL-ID format: YYYYMMDDXXXXXXXX (Datum + 8-stellige ID)
if (strlen($urlId) !== 16) {
return null;
}
$idPart = substr($urlId, 8); // Die letzten 8 Zeichen
$id = (int)ltrim($idPart, '0'); // Führende Nullen entfernen
return $this->imageRepository->findById($id);
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace Archive\Optimized;
/**
* Cache-Analyse für Template-Rendering
*/
final readonly class CacheAnalysis
{
public function __construct(
public CacheStrategy $strategy,
public int $ttl,
public array $dependencies = [],
) {}
public function shouldCache(): bool
{
return $this->strategy !== CacheStrategy::DYNAMIC && $this->ttl > 0;
}
public function isUserSpecific(): bool
{
return isset($this->dependencies['user_id']) || isset($this->dependencies['session_id']);
}
public function getComplexity(): int
{
return count($this->dependencies) + ($this->isUserSpecific() ? 5 : 0);
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace Archive\Optimized;
/**
* Cache-Strategien für Templates
*/
enum CacheStrategy: string
{
case STATIC = 'static'; // Vollständig statischer Content
case PARTIAL = 'partial'; // Teilweise dynamischer Content
case FRAGMENT = 'fragment'; // Fragment-basiertes Caching
case DYNAMIC = 'dynamic'; // Kein Caching (vollständig dynamisch)
public function getTtl(): int
{
return match($this) {
self::STATIC => 3600, // 1 Stunde
self::PARTIAL => 900, // 15 Minuten
self::FRAGMENT => 600, // 10 Minuten
self::DYNAMIC => 0, // Kein Caching
};
}
public function getPriority(): int
{
return match($this) {
self::STATIC => 1, // Höchste Priorität
self::PARTIAL => 2,
self::FRAGMENT => 3,
self::DYNAMIC => 4, // Niedrigste Priorität
};
}
public function shouldCache(): bool
{
return $this !== self::DYNAMIC;
}
public function getDescription(): string
{
return match($this) {
self::STATIC => 'Fully cacheable static content',
self::PARTIAL => 'Partially cacheable with some dynamic elements',
self::FRAGMENT => 'Fragment-based caching for mixed content',
self::DYNAMIC => 'Fully dynamic content, no caching',
};
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace Archive\Optimized;
/**
* Template-Analyse-Ergebnisse
*/
final class TemplateAnalysis
{
public array $staticBlocks = [];
public array $dynamicBlocks = [];
public array $dependencies = [];
public array $optimizations = [];
public CacheStrategy $cacheStrategy = CacheStrategy::DYNAMIC;
public function getComplexity(): int
{
return count($this->staticBlocks) + count($this->dynamicBlocks) * 2;
}
public function isCacheable(): bool
{
return $this->cacheStrategy !== CacheStrategy::DYNAMIC;
}
public function hasFragments(): bool
{
return count($this->staticBlocks) > 0;
}
}

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace Archive\StreamWrapper\Context;
/**
* Wrapper für Stream-Context-Optionen
*/
final class StreamContext
{
private array $options = [];
private array $params = [];
public function __construct($resource = null)
{
if ($resource !== null) {
$this->options = stream_context_get_options($resource);
$this->params = stream_context_get_params($resource);
}
}
/**
* Gibt eine Option für das angegebene Protokoll zurück
*/
public function getOption(string $protocol, string $key, $default = null)
{
return $this->options[$protocol][$key] ?? $default;
}
/**
* Gibt alle Optionen für ein Protokoll zurück
*/
public function getProtocolOptions(string $protocol): array
{
return $this->options[$protocol] ?? [];
}
/**
* Setzt eine Option für das angegebene Protokoll
*/
public function setOption(string $protocol, string $key, $value): self
{
$this->options[$protocol][$key] = $value;
return $this;
}
/**
* Gibt einen Parameter zurück
*/
public function getParam(string $key, $default = null)
{
return $this->params[$key] ?? $default;
}
/**
* Setzt einen Parameter
*/
public function setParam(string $key, $value): self
{
$this->params[$key] = $value;
return $this;
}
/**
* Gibt alle Optionen zurück
*/
public function getAllOptions(): array
{
return $this->options;
}
/**
* Gibt alle Parameter zurück
*/
public function getAllParams(): array
{
return $this->params;
}
/**
* Erstellt einen neuen Stream-Context mit den aktuellen Optionen
*/
public function createResource()
{
return stream_context_create($this->options, $this->params);
}
}

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace Archive\StreamWrapper\Exception;
use App\Framework\StreamWrapper\Exception\StreamWrapperException;
/**
* Exception für nicht unterstützte Stream-Protokolle
*/
class UnsupportedProtocolException extends StreamWrapperException
{
public function __construct(string $protocol)
{
parent::__construct("Unsupported stream protocol: $protocol");
}
}

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace Archive\StreamWrapper\Filter\Filters;
use Archive\StreamWrapper\Filter\StreamFilterInterface;
/**
* Stream-Filter für AES-256-Entschlüsselung
*/
class AES256DecryptFilter extends \php_user_filter implements StreamFilterInterface
{
private string $filterName = 'decrypt.aes256';
private array $supportedModes = ['read'];
private array $params = [];
private string $key = '';
private string $method = 'AES-256-CBC';
public function onCreate(): bool
{
// Parameter aus dem Stream-Context extrahieren
if (isset($this->params)) {
$this->params = $this->params;
}
$this->key = $this->getParam('key', '');
$this->method = $this->getParam('method', 'AES-256-CBC');
return !empty($this->key);
}
public function onClose(): void
{
// Cleanup falls nötig
}
public function getFilterName(): string
{
return $this->filterName;
}
public function getSupportedModes(): array
{
return $this->supportedModes;
}
public function filter($in, $out, &$consumed, bool $closing): int
{
$data = $this->readFromBrigade($in);
$consumed = strlen($data);
if (!empty($data)) {
$ivLength = openssl_cipher_iv_length($this->method);
if (strlen($data) < $ivLength) {
return PSFS_ERR_FATAL;
}
$iv = substr($data, 0, $ivLength);
$encrypted = substr($data, $ivLength);
$decrypted = openssl_decrypt($encrypted, $this->method, $this->key, OPENSSL_RAW_DATA, $iv);
if ($decrypted === false) {
return PSFS_ERR_FATAL;
}
$this->writeToBrigade($out, $decrypted);
}
return PSFS_PASS_ON;
}
/**
* Hilfsmethode zum Lesen von Daten aus der Input-Brigade
*/
private function readFromBrigade($brigade): string
{
$data = '';
while ($bucket = stream_bucket_make_writeable($brigade)) {
$data .= $bucket->data;
}
return $data;
}
/**
* Hilfsmethode zum Schreiben von Daten in die Output-Brigade
*/
private function writeToBrigade($brigade, string $data): void
{
if (!empty($data)) {
$bucket = stream_bucket_new($this->stream, $data);
stream_bucket_append($brigade, $bucket);
}
}
/**
* Gibt einen Parameter zurück
*/
private function getParam(string $key, $default = null)
{
return $this->params[$key] ?? $default;
}
}

View File

@@ -0,0 +1,107 @@
<?php
declare(strict_types=1);
namespace Archive\StreamWrapper\Filter\Filters;
use Archive\StreamWrapper\Filter\StreamFilterInterface;
/**
* Stream-Filter für Access-Logging
*/
class AccessLogFilter extends \php_user_filter implements StreamFilterInterface
{
private string $filterName = 'log.access';
private array $supportedModes = ['read', 'write'];
private array $params = [];
private string $logFile = '';
private bool $logReadAccess = true;
private bool $logWriteAccess = true;
public function onCreate(): bool
{
// Parameter aus dem Stream-Context extrahieren
if (isset($this->params)) {
$this->params = $this->params;
}
$this->logFile = $this->getParam('log_file', sys_get_temp_dir() . '/stream_access.log');
$this->logReadAccess = $this->getParam('log_read', true);
$this->logWriteAccess = $this->getParam('log_write', true);
return true;
}
public function onClose(): void
{
// Cleanup falls nötig
}
public function getFilterName(): string
{
return $this->filterName;
}
public function getSupportedModes(): array
{
return $this->supportedModes;
}
public function filter($in, $out, &$consumed, bool $closing): int
{
$data = $this->readFromBrigade($in);
$consumed = strlen($data);
if (!empty($data)) {
// Access-Log schreiben
$this->logAccess($data);
// Daten unverändert durchreichen
$this->writeToBrigade($out, $data);
}
return PSFS_PASS_ON;
}
private function logAccess(string $data): void
{
$timestamp = date('Y-m-d H:i:s');
$size = strlen($data);
$mode = $this->filtername; // read oder write
$logEntry = "[{$timestamp}] {$mode} - {$size} bytes\n";
file_put_contents($this->logFile, $logEntry, FILE_APPEND | LOCK_EX);
}
/**
* Hilfsmethode zum Lesen von Daten aus der Input-Brigade
*/
private function readFromBrigade($brigade): string
{
$data = '';
while ($bucket = stream_bucket_make_writeable($brigade)) {
$data .= $bucket->data;
}
return $data;
}
/**
* Hilfsmethode zum Schreiben von Daten in die Output-Brigade
*/
private function writeToBrigade($brigade, string $data): void
{
if (!empty($data)) {
$bucket = stream_bucket_new($this->stream, $data);
stream_bucket_append($brigade, $bucket);
}
}
/**
* Gibt einen Parameter zurück
*/
private function getParam(string $key, $default = null)
{
return $this->params[$key] ?? $default;
}
}

View File

@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace Archive\StreamWrapper\Filter\Filters;
use Archive\StreamWrapper\Filter\StreamFilterInterface;
/**
* Stream-Filter für Base64-Kodierung
*/
class Base64EncodeFilter extends \php_user_filter implements StreamFilterInterface
{
private string $filterName = 'transform.base64encode';
private array $supportedModes = ['write'];
private array $params = [];
private bool $urlSafe = false;
public function onCreate(): bool
{
// Parameter aus dem Stream-Context extrahieren
if (isset($this->params)) {
$this->params = $this->params;
}
$this->urlSafe = $this->getParam('url_safe', false);
return true;
}
public function onClose(): void
{
// Cleanup falls nötig
}
public function getFilterName(): string
{
return $this->filterName;
}
public function getSupportedModes(): array
{
return $this->supportedModes;
}
public function filter($in, $out, &$consumed, bool $closing): int
{
$data = $this->readFromBrigade($in);
$consumed = strlen($data);
if (!empty($data)) {
$encoded = base64_encode($data);
if ($this->urlSafe) {
$encoded = strtr($encoded, '+/', '-_');
$encoded = rtrim($encoded, '=');
}
$this->writeToBrigade($out, $encoded);
}
return PSFS_PASS_ON;
}
/**
* Hilfsmethode zum Lesen von Daten aus der Input-Brigade
*/
private function readFromBrigade($brigade): string
{
$data = '';
while ($bucket = stream_bucket_make_writeable($brigade)) {
$data .= $bucket->data;
}
return $data;
}
/**
* Hilfsmethode zum Schreiben von Daten in die Output-Brigade
*/
private function writeToBrigade($brigade, string $data): void
{
if (!empty($data)) {
$bucket = stream_bucket_new($this->stream, $data);
stream_bucket_append($brigade, $bucket);
}
}
/**
* Gibt einen Parameter zurück
*/
private function getParam(string $key, $default = null)
{
return $this->params[$key] ?? $default;
}
}

View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace Archive\StreamWrapper\Filter\Filters;
use Archive\StreamWrapper\Filter\StreamFilterInterface;
/**
* Stream-Filter für Gzip-Dekomprimierung
*/
class GzipDecompressFilter extends \php_user_filter implements StreamFilterInterface
{
private string $filterName = 'gzip.decompress';
private array $supportedModes = ['read'];
private array $params = [];
public function onCreate(): bool
{
// Parameter aus dem Stream-Context extrahieren
if (isset($this->params)) {
$this->params = $this->params;
}
return true;
}
public function onClose(): void
{
// Cleanup falls nötig
}
public function getFilterName(): string
{
return $this->filterName;
}
public function getSupportedModes(): array
{
return $this->supportedModes;
}
public function filter($in, $out, &$consumed, bool $closing): int
{
$data = $this->readFromBrigade($in);
$consumed = strlen($data);
if (!empty($data)) {
$decompressed = gzuncompress($data);
if ($decompressed === false) {
return PSFS_ERR_FATAL;
}
$this->writeToBrigade($out, $decompressed);
}
return PSFS_PASS_ON;
}
/**
* Hilfsmethode zum Lesen von Daten aus der Input-Brigade
*/
private function readFromBrigade($brigade): string
{
$data = '';
while ($bucket = stream_bucket_make_writeable($brigade)) {
$data .= $bucket->data;
}
return $data;
}
/**
* Hilfsmethode zum Schreiben von Daten in die Output-Brigade
*/
private function writeToBrigade($brigade, string $data): void
{
if (!empty($data)) {
$bucket = stream_bucket_new($this->stream, $data);
stream_bucket_append($brigade, $bucket);
}
}
/**
* Gibt einen Parameter zurück
*/
private function getParam(string $key, $default = null)
{
return $this->params[$key] ?? $default;
}
}

View File

@@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
namespace Archive\StreamWrapper\Filter;
/**
* Factory für die einfache Erstellung und Nutzung von Stream-Filtern
*/
final class StreamFilterFactory
{
/**
* Initialisiert alle Stream-Filter
*/
public static function initialize(): void
{
StreamFilterRegistry::initializeDefaults();
}
/**
* Erstellt einen Kompressions-Filter
*/
public static function compression(int $level = 6): array
{
return [
'name' => 'gzip.compress',
'params' => ['level' => $level]
];
}
/**
* Erstellt einen Dekompressions-Filter
*/
public static function decompression(): array
{
return ['name' => 'gzip.decompress'];
}
/**
* Erstellt einen Verschlüsselungs-Filter
*/
public static function encryption(string $key, string $method = 'AES-256-CBC'): array
{
return [
'name' => 'encrypt.aes256',
'params' => [
'key' => $key,
'method' => $method
]
];
}
/**
* Erstellt einen Entschlüsselungs-Filter
*/
public static function decryption(string $key, string $method = 'AES-256-CBC'): array
{
return [
'name' => 'decrypt.aes256',
'params' => [
'key' => $key,
'method' => $method
]
];
}
/**
* Erstellt einen JSON-Validierungs-Filter
*/
public static function jsonValidation(bool $strict = true, bool $throwOnError = false): array
{
return [
'name' => 'validate.json',
'params' => [
'strict' => $strict,
'throw_on_error' => $throwOnError
]
];
}
/**
* Erstellt einen Base64-Encoding-Filter
*/
public static function base64Encode(bool $urlSafe = false): array
{
return [
'name' => 'transform.base64encode',
'params' => ['url_safe' => $urlSafe]
];
}
/**
* Erstellt einen Access-Log-Filter
*/
public static function accessLog(string $logFile = null): array
{
$params = [];
if ($logFile) {
$params['log_file'] = $logFile;
}
return [
'name' => 'log.access',
'params' => $params
];
}
/**
* Wendet Filter auf einen Stream an
*/
public static function applyFilters($stream, array $filters, int $mode = STREAM_FILTER_ALL): array
{
return StreamFilterRegistry::createFilterChain($stream, $filters, $mode);
}
/**
* Wendet einen einzelnen Filter an
*/
public static function applyFilter($stream, array $filterConfig, int $mode = STREAM_FILTER_ALL)
{
$filterName = $filterConfig['name'] ?? $filterConfig;
$params = $filterConfig['params'] ?? [];
return StreamFilterRegistry::appendFilter($stream, $filterName, $mode, $params);
}
/**
* Erstellt einen Stream mit vordefinierten Filtern
*/
public static function createFilteredStream(string $url, string $mode, array $filters = [], array $contextOptions = [])
{
$context = null;
if (!empty($contextOptions)) {
$context = stream_context_create($contextOptions);
}
$stream = fopen($url, $mode, false, $context);
if ($stream && !empty($filters)) {
self::applyFilters($stream, $filters);
}
return $stream;
}
/**
* Vordefinierte Filter-Kombinationen
*/
public static function secureDataFilter(string $encryptionKey): array
{
return [
self::jsonValidation(true, true),
self::compression(9),
self::encryption($encryptionKey),
self::accessLog()
];
}
public static function compressionFilter(int $level = 6): array
{
return [
self::compression($level),
self::accessLog()
];
}
public static function loggingFilter(string $logFile = null): array
{
return [
self::accessLog($logFile)
];
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Archive\StreamWrapper\Filter;
/**
* Interface für benutzerdefinierte Stream-Filter
*/
interface StreamFilterInterface
{
/**
* Filtert Daten beim Lesen/Schreiben
*
* @param resource $in Input-Bucket-Brigade
* @param resource $out Output-Bucket-Brigade
* @param int $consumed Anzahl der verbrauchten Bytes
* @param bool $closing Gibt an, ob der Stream geschlossen wird
* @return int PSFS_PASS_ON, PSFS_FEED_ME oder PSFS_ERR_FATAL
*/
public function filter($in, $out, &$consumed, bool $closing): int;
/**
* Wird beim Anhängen des Filters an einen Stream aufgerufen
*
* @return bool True bei Erfolg, false bei Fehler
*/
public function onCreate(): bool;
/**
* Wird beim Entfernen des Filters aufgerufen
*/
public function onClose(): void;
/**
* Gibt den Filter-Namen zurück
*/
public function getFilterName(): string;
/**
* Gibt die unterstützten Modi zurück (read, write, read|write)
*/
public function getSupportedModes(): array;
}

View File

@@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
namespace Archive\StreamWrapper\Filter;
use App\Framework\StreamWrapper\Exception\StreamWrapperException;
use App\Framework\StreamWrapper\Filter\Filters;
/**
* Registry zur Verwaltung und Registrierung von Stream-Filtern
*/
final class StreamFilterRegistry
{
private static array $registeredFilters = [];
private static bool $initialized = false;
/**
* Registriert einen Stream-Filter
*/
public static function register(string $filterName, string $filterClass): void
{
if (self::isRegistered($filterName)) {
stream_filter_remove($filterName);
}
if (!stream_filter_register($filterName, $filterClass)) {
throw new StreamWrapperException("Failed to register filter: $filterName");
}
self::$registeredFilters[$filterName] = $filterClass;
}
/**
* Entfernt die Registrierung eines Stream-Filters
*/
public static function unregister(string $filterName): void
{
if (self::isRegistered($filterName)) {
// Hinweis: stream_filter_unregister existiert nicht in PHP
// Filter werden automatisch beim Script-Ende entfernt
unset(self::$registeredFilters[$filterName]);
}
}
/**
* Prüft, ob ein Filter registriert ist
*/
public static function isRegistered(string $filterName): bool
{
return isset(self::$registeredFilters[$filterName]);
}
/**
* Gibt alle registrierten Filter zurück
*/
public static function getRegisteredFilters(): array
{
return array_keys(self::$registeredFilters);
}
/**
* Gibt die Filter-Klasse zurück
*/
public static function getFilterClass(string $filterName): ?string
{
return self::$registeredFilters[$filterName] ?? null;
}
/**
* Initialisiert alle Standard-Stream-Filter
*/
public static function initializeDefaults(): void
{
if (self::$initialized) {
return;
}
// Komprimierungs-Filter
self::register('gzip.compress', Filters\GzipCompressFilter::class);
self::register('gzip.decompress', \Archive\StreamWrapper\Filter\Filters\GzipDecompressFilter::class);
// Verschlüsselungs-Filter
self::register('encrypt.aes256', Filters\AES256EncryptFilter::class);
self::register('decrypt.aes256', \Archive\StreamWrapper\Filter\Filters\AES256DecryptFilter::class);
// Validierungs-Filter
self::register('validate.json', Filters\JsonValidationFilter::class);
self::register('validate.xml', Filters\XmlValidationFilter::class);
// Transform-Filter
self::register('transform.base64encode', \Archive\StreamWrapper\Filter\Filters\Base64EncodeFilter::class);
self::register('transform.base64decode', Filters\Base64DecodeFilter::class);
self::register('transform.uppercase', Filters\UppercaseFilter::class);
self::register('transform.lowercase', Filters\LowercaseFilter::class);
// Logging-Filter
self::register('log.access', \Archive\StreamWrapper\Filter\Filters\AccessLogFilter::class);
self::register('log.debug', Filters\DebugLogFilter::class);
// Caching-Filter
self::register('cache.write', Filters\CacheWriteFilter::class);
self::register('cache.read', Filters\CacheReadFilter::class);
self::$initialized = true;
}
/**
* Entfernt alle Framework-Stream-Filter
*/
public static function cleanup(): void
{
self::$registeredFilters = [];
self::$initialized = false;
}
/**
* Wendet einen Filter auf einen Stream an
*/
public static function appendFilter($stream, string $filterName, int $mode = STREAM_FILTER_ALL, array $params = [])
{
if (!self::isRegistered($filterName)) {
throw new StreamWrapperException("Filter not registered: $filterName");
}
return stream_filter_append($stream, $filterName, $mode, $params);
}
/**
* Wendet einen Filter am Anfang eines Streams an
*/
public static function prependFilter($stream, string $filterName, int $mode = STREAM_FILTER_ALL, array $params = [])
{
if (!self::isRegistered($filterName)) {
throw new StreamWrapperException("Filter not registered: $filterName");
}
return stream_filter_prepend($stream, $filterName, $mode, $params);
}
/**
* Entfernt einen Filter von einem Stream
*/
public static function removeFilter($filter): bool
{
return stream_filter_remove($filter);
}
/**
* Erstellt eine Filter-Kette für einen Stream
*/
public static function createFilterChain($stream, array $filters, int $mode = STREAM_FILTER_ALL): array
{
$appliedFilters = [];
foreach ($filters as $filterConfig) {
$filterName = $filterConfig['name'] ?? $filterConfig;
$params = $filterConfig['params'] ?? [];
$filter = self::appendFilter($stream, $filterName, $mode, $params);
if ($filter) {
$appliedFilters[] = $filter;
}
}
return $appliedFilters;
}
}

View File

@@ -0,0 +1,29 @@
# StreamWrapper Module
Das StreamWrapper-Modul ermöglicht den transparenten Zugriff auf verschiedene Framework-Services über einheitliche Stream-URLs. Dadurch können Sie native PHP-Funktionen wie `file_get_contents()`, `fopen()`, `copy()` etc. mit Framework-Services verwenden.
## Installation
```php
// Initialisierung
StreamWrapperFactory::initialize();
// Filter verwenden
$data = StreamWrapperFactory::readWithFilters(
'cache://session/user123',
[
StreamFilterFactory::decompression(),
StreamFilterFactory::decryption($key),
StreamFilterFactory::jsonValidation()
]
);
// Neue Filter sind verfügbar
$encoded = StreamWrapperFactory::writeWithFilters(
'cache://data',
$binaryData,
[
StreamFilterFactory::base64Encode(true), // URL-safe
StreamFilterFactory::compression(9)
]
);

View File

@@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
namespace Archive\StreamWrapper;
use App\Framework\StreamWrapper\StreamWrapperRegistry;
/**
* Factory für die einfache Erstellung und Nutzung von Stream-Wrappern
*/
final class StreamWrapperFactory
{
/**
* Initialisiert alle Stream-Wrapper und Filter
*/
public static function initialize(): void
{
StreamWrapperRegistry::initializeDefaults();
\Archive\StreamWrapper\Filter\StreamFilterRegistry::initializeDefaults();
}
/**
* Erstellt eine Cache-Stream-URL
*/
public static function cache(string $key, ?string $namespace = null): string
{
$host = $namespace ?? 'default';
return "cache://{$host}/{$key}";
}
/**
* Erstellt eine Config-Stream-URL
*/
public static function config(string $key, string $source = 'default'): string
{
return "config://{$source}/{$key}";
}
/**
* Erstellt eine Log-Stream-URL
*/
public static function log(string $filename, string $channel = 'default'): string
{
return "log://{$channel}/{$filename}";
}
/**
* Erstellt eine Database-Stream-URL
*/
public static function database(string $table, string $operation = 'select', array $params = [], string $connection = 'default'): string
{
$url = "db://{$connection}/{$table}/{$operation}";
if (!empty($params)) {
$url .= '?' . http_build_query($params);
}
return $url;
}
/**
* Erstellt eine HTTP-Client-Stream-URL
*/
public static function httpClient(string $host, string $path = ''): string
{
return "http-client://{$host}/{$path}";
}
/**
* Liest Daten von einer Stream-URL
*/
public static function read(string $url, array $contextOptions = []): string|false
{
if (!empty($contextOptions)) {
$context = stream_context_create($contextOptions);
return file_get_contents($url, false, $context);
}
return file_get_contents($url);
}
/**
* Schreibt Daten zu einer Stream-URL
*/
public static function write(string $url, string $data, array $contextOptions = []): int|false
{
if (!empty($contextOptions)) {
$context = stream_context_create($contextOptions);
return file_put_contents($url, $data, 0, $context);
}
return file_put_contents($url, $data);
}
/**
* Prüft, ob eine Stream-URL existiert
*/
public static function exists(string $url): bool
{
return file_exists($url);
}
/**
* Kopiert zwischen Stream-URLs
*/
public static function copy(string $from, string $to): bool
{
return copy($from, $to);
}
/**
* Löscht eine Stream-URL
*/
public static function delete(string $url): bool
{
return unlink($url);
}
/**
* Erstellt einen Stream mit Filtern
*/
public static function createFilteredStream(string $url, string $mode, array $filters = [], array $contextOptions = [])
{
return \Archive\StreamWrapper\Filter\StreamFilterFactory::createFilteredStream($url, $mode, $filters, $contextOptions);
}
/**
* Wendet Filter auf einen bestehenden Stream an
*/
public static function applyFilters($stream, array $filters, int $mode = STREAM_FILTER_ALL): array
{
return \Archive\StreamWrapper\Filter\StreamFilterFactory::applyFilters($stream, $filters, $mode);
}
/**
* Liest von einer URL mit angewendeten Filtern
*/
public static function readWithFilters(string $url, array $filters = [], array $contextOptions = []): string|false
{
$stream = self::createFilteredStream($url, 'r', $filters, $contextOptions);
if (!$stream) {
return false;
}
$content = stream_get_contents($stream);
fclose($stream);
return $content;
}
/**
* Schreibt zu einer URL mit angewendeten Filtern
*/
public static function writeWithFilters(string $url, string $data, array $filters = [], array $contextOptions = []): int|false
{
$stream = self::createFilteredStream($url, 'w', $filters, $contextOptions);
if (!$stream) {
return false;
}
$result = fwrite($stream, $data);
fclose($stream);
return $result;
}
}

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace Archive\StreamWrapper;
/**
* Interface für benutzerdefinierte Stream-Wrapper
*/
interface StreamWrapperInterface
{
/**
* Öffnet einen Stream
*/
public function stream_open(string $path, string $mode, int $options, ?string &$opened_path): bool;
/**
* Liest Daten aus dem Stream
*/
public function stream_read(int $count): string|false;
/**
* Schreibt Daten in den Stream
*/
public function stream_write(string $data): int|false;
/**
* Gibt die aktuelle Position im Stream zurück
*/
public function stream_tell(): int|false;
/**
* Prüft, ob das Ende des Streams erreicht ist
*/
public function stream_eof(): bool;
/**
* Setzt die Position im Stream
*/
public function stream_seek(int $offset, int $whence = SEEK_SET): bool;
/**
* Schließt den Stream
*/
public function stream_close(): void;
/**
* Gibt Statistiken über den Stream zurück
*/
public function stream_stat(): array|false;
/**
* Gibt Statistiken über eine URL zurück
*/
public function url_stat(string $path, int $flags): array|false;
/**
* Erstellt ein Verzeichnis
*/
public function mkdir(string $path, int $mode, int $options): bool;
/**
* Entfernt ein Verzeichnis
*/
public function rmdir(string $path, int $options): bool;
/**
* Öffnet ein Verzeichnis zum Lesen
*/
public function dir_opendir(string $path, int $options): bool;
/**
* Liest den nächsten Eintrag aus einem Verzeichnis
*/
public function dir_readdir(): string|false;
/**
* Setzt den Verzeichnis-Handle zurück
*/
public function dir_rewinddir(): bool;
/**
* Schließt ein Verzeichnis-Handle
*/
public function dir_closedir(): bool;
/**
* Benennt eine Datei um oder verschiebt sie
*/
public function rename(string $path_from, string $path_to): bool;
/**
* Löscht eine Datei
*/
public function unlink(string $path): bool;
}

View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace Archive\StreamWrapper\Wrappers;
use App\Framework\Config\ConfigManager;
use App\Framework\StreamWrapper\Helper\StreamWrapperHelper;
use Archive\StreamWrapper\Context\StreamContext;
use Archive\StreamWrapper\StreamWrapperInterface;
/**
* Stream-Wrapper für das Config-System
* Syntax: config://source/key.path
*/
class ConfigStreamWrapper implements StreamWrapperInterface
{
public $context;
private string $content = '';
private int $position = 0;
private string $mode = 'r';
private array $parsedUrl = [];
private ?StreamContext $streamContext = null;
private ConfigManager $config;
private string $configKey;
private string $source;
public function stream_open(string $path, string $mode, int $options, ?string &$opened_path): bool
{
$this->streamContext = StreamWrapperHelper::initializeContext($this->context);
$this->parsedUrl = StreamWrapperHelper::parseUrl($path);
$this->mode = $mode;
$this->source = $this->parsedUrl['host'] ?: 'default';
$this->configKey = $this->parsedUrl['path'];
// Config-Manager initialisieren
$this->config = new ConfigManager();
// Konfigurationswert laden
if (str_contains($mode, 'r') || str_contains($mode, '+')) {
$this->content = $this->loadContent($this->configKey);
}
return true;
}
public function stream_read(int $count): string|false
{
return StreamWrapperHelper::streamRead($this->content, $this->position, $count);
}
public function stream_write(string $data): int|false
{
return StreamWrapperHelper::streamWrite($this->content, $this->position, $data);
}
public function stream_tell(): int|false
{
return $this->position;
}
public function stream_eof(): bool
{
return $this->position >= strlen($this->content);
}
public function stream_seek(int $offset, int $whence = SEEK_SET): bool
{
return StreamWrapperHelper::streamSeek($this->content, $this->position, $offset, $whence);
}
public function stream_close(): void
{
$this->content = '';
$this->position = 0;
}
public function stream_stat(): array|false
{
return StreamWrapperHelper::streamStat($this->content);
}
public function url_stat(string $path, int $flags): array|false
{
$parsedUrl = StreamWrapperHelper::parseUrl($path);
$source = $parsedUrl['host'] ?: 'default';
$configKey = $parsedUrl['path'];
$config = new ConfigManager();
if (!$config->has($configKey)) {
return false;
}
$value = $config->get($configKey);
$content = is_string($value) ? $value : json_encode($value);
$size = strlen($content);
return StreamWrapperHelper::createDefaultStat($size);
}
// Nicht unterstützte Operationen
public function mkdir(string $path, int $mode, int $options): bool { return false; }
public function rmdir(string $path, int $options): bool { return false; }
public function dir_opendir(string $path, int $options): bool { return false; }
public function dir_readdir(): string|false { return false; }
public function dir_rewinddir(): bool { return false; }
public function dir_closedir(): bool { return false; }
public function rename(string $path_from, string $path_to): bool { return false; }
public function unlink(string $path): bool { return false; }
private function loadContent(string $path): string
{
$value = $this->config->get($path);
if ($value === null) {
return '';
}
return is_string($value) ? $value : json_encode($value);
}
}

View File

@@ -0,0 +1,205 @@
<?php
declare(strict_types=1);
namespace Archive\StreamWrapper\Wrappers;
use App\Framework\Database\DatabaseManager;
use App\Framework\StreamWrapper\Helper\StreamWrapperHelper;
use Archive\StreamWrapper\Context\StreamContext;
use Archive\StreamWrapper\StreamWrapperInterface;
/**
* Stream-Wrapper für Database-Operationen
* Syntax: db://connection/table/operation?params
*/
class DatabaseStreamWrapper implements StreamWrapperInterface
{
public $context;
private string $content = '';
private int $position = 0;
private string $mode = 'r';
private array $parsedUrl = [];
private ?StreamContext $streamContext = null;
private DatabaseManager $database;
private string $connection;
private string $table;
private string $operation;
private array $params = [];
public function stream_open(string $path, string $mode, int $options, ?string &$opened_path): bool
{
$this->streamContext = StreamWrapperHelper::initializeContext($this->context);
$this->parsedUrl = StreamWrapperHelper::parseUrl($path);
$this->mode = $mode;
$this->connection = $this->parsedUrl['host'] ?: 'default';
$pathParts = explode('/', $this->parsedUrl['path']);
$this->table = $pathParts[0] ?? '';
$this->operation = $pathParts[1] ?? 'select';
// Query-Parameter parsen
if ($this->parsedUrl['query']) {
parse_str($this->parsedUrl['query'], $this->params);
}
// Database-Manager initialisieren
$this->database = new DatabaseManager();
// Bei Lese-Operationen Query ausführen
if (str_contains($mode, 'r') || str_contains($mode, '+')) {
$this->content = $this->loadContent($this->operation);
}
return true;
}
public function stream_read(int $count): string|false
{
return StreamWrapperHelper::streamRead($this->content, $this->position, $count);
}
public function stream_write(string $data): int|false
{
return StreamWrapperHelper::streamWrite($this->content, $this->position, $data);
}
public function stream_tell(): int|false
{
return $this->position;
}
public function stream_eof(): bool
{
return $this->position >= strlen($this->content);
}
public function stream_seek(int $offset, int $whence = SEEK_SET): bool
{
return StreamWrapperHelper::streamSeek($this->content, $this->position, $offset, $whence);
}
public function stream_close(): void
{
// Bei Schreib-Modi Operation ausführen
if (str_contains($this->mode, 'w') || str_contains($this->mode, 'a') || str_contains($this->mode, '+')) {
$this->saveContent($this->operation, $this->content);
}
$this->content = '';
$this->position = 0;
}
public function stream_stat(): array|false
{
return StreamWrapperHelper::streamStat($this->content);
}
public function url_stat(string $path, int $flags): array|false
{
return StreamWrapperHelper::createDefaultStat(strlen($this->content));
}
// Nicht unterstützte Operationen
public function mkdir(string $path, int $mode, int $options): bool { return false; }
public function rmdir(string $path, int $options): bool { return false; }
public function dir_opendir(string $path, int $options): bool { return false; }
public function dir_readdir(): string|false { return false; }
public function dir_rewinddir(): bool { return false; }
public function dir_closedir(): bool { return false; }
public function rename(string $path_from, string $path_to): bool { return false; }
public function unlink(string $path): bool { return false; }
private function loadContent(string $operation): string
{
$connection = $this->streamContext?->getOption('db', 'connection', $this->connection);
$result = match($operation) {
'select', 'all' => $this->executeSelect(),
'count' => $this->executeCount(),
'exists' => $this->executeExists(),
default => []
};
return json_encode($result);
}
private function saveContent(string $operation, string $content): bool
{
$data = json_decode($content, true);
return match($operation) {
'insert' => $this->executeInsert($data),
'update' => $this->executeUpdate($data),
'delete' => $this->executeDelete($data),
'bulk-insert' => $this->executeBulkInsert($data),
default => false
};
}
private function executeSelect(): array
{
$query = $this->database->connection($this->connection)->table($this->table);
foreach ($this->params as $key => $value) {
if ($key === 'id') {
$query->where('id', $value);
} elseif (str_starts_with($key, 'where_')) {
$field = substr($key, 6);
$query->where($field, $value);
}
}
return $query->get()->toArray();
}
private function executeCount(): array
{
$count = $this->database->connection($this->connection)->table($this->table)->count();
return ['count' => $count];
}
private function executeExists(): array
{
$exists = $this->database->connection($this->connection)->table($this->table);
if (isset($this->params['id'])) {
$exists->where('id', $this->params['id']);
}
return ['exists' => $exists->exists()];
}
private function executeInsert(array $data): bool
{
return $this->database->connection($this->connection)->table($this->table)->insert($data);
}
private function executeUpdate(array $data): bool
{
$query = $this->database->connection($this->connection)->table($this->table);
if (isset($this->params['id'])) {
$query->where('id', $this->params['id']);
}
return $query->update($data) > 0;
}
private function executeDelete(array $data): bool
{
$query = $this->database->connection($this->connection)->table($this->table);
if (isset($this->params['id'])) {
$query->where('id', $this->params['id']);
}
return $query->delete() > 0;
}
private function executeBulkInsert(array $data): bool
{
return $this->database->connection($this->connection)->table($this->table)->insert($data);
}
}

View File

@@ -0,0 +1,164 @@
<?php
declare(strict_types=1);
namespace Archive\StreamWrapper\Wrappers;
use App\Framework\HttpClient\HttpClient;
use App\Framework\StreamWrapper\Helper\StreamWrapperHelper;
use Archive\StreamWrapper\Context\StreamContext;
use Archive\StreamWrapper\StreamWrapperInterface;
/**
* Stream-Wrapper für HTTP-Client-Operationen
* Syntax: http-client://host/path
*/
class HttpStreamWrapper implements StreamWrapperInterface
{
public $context;
private string $content = '';
private int $position = 0;
private string $mode = 'r';
private array $parsedUrl = [];
private ?StreamContext $streamContext = null;
private HttpClient $httpClient;
private string $host;
private string $path;
private array $headers = [];
private int $timeout = 30;
private ?string $auth = null;
public function stream_open(string $path, string $mode, int $options, ?string &$opened_path): bool
{
$this->streamContext = StreamWrapperHelper::initializeContext($this->context);
$this->parsedUrl = StreamWrapperHelper::parseUrl($path);
$this->mode = $mode;
$this->host = $this->parsedUrl['host'];
$this->path = '/' . ltrim($this->parsedUrl['path'], '/');
// Optionen aus Context lesen
if ($this->streamContext) {
$this->headers = $this->streamContext->getOption('http-client', 'headers', []);
$this->timeout = $this->streamContext->getOption('http-client', 'timeout', 30);
$this->auth = $this->streamContext->getOption('http-client', 'auth');
}
// HTTP-Client initialisieren
$this->httpClient = new HttpClient();
// Bei Lese-Modi HTTP-Request ausführen
if (str_contains($mode, 'r') || str_contains($mode, '+')) {
$this->content = $this->loadContent($this->buildUrl());
}
return true;
}
public function stream_read(int $count): string|false
{
return StreamWrapperHelper::streamRead($this->content, $this->position, $count);
}
public function stream_write(string $data): int|false
{
return StreamWrapperHelper::streamWrite($this->content, $this->position, $data);
}
public function stream_tell(): int|false
{
return $this->position;
}
public function stream_eof(): bool
{
return $this->position >= strlen($this->content);
}
public function stream_seek(int $offset, int $whence = SEEK_SET): bool
{
return StreamWrapperHelper::streamSeek($this->content, $this->position, $offset, $whence);
}
public function stream_close(): void
{
// Bei Schreib-Modi POST/PUT-Request senden
if (str_contains($this->mode, 'w') || str_contains($this->mode, 'a') || str_contains($this->mode, '+')) {
$this->saveContent($this->buildUrl(), $this->content);
}
$this->content = '';
$this->position = 0;
}
public function stream_stat(): array|false
{
return StreamWrapperHelper::streamStat($this->content);
}
public function url_stat(string $path, int $flags): array|false
{
return StreamWrapperHelper::createDefaultStat(strlen($this->content));
}
// Nicht unterstützte Operationen
public function mkdir(string $path, int $mode, int $options): bool { return false; }
public function rmdir(string $path, int $options): bool { return false; }
public function dir_opendir(string $path, int $options): bool { return false; }
public function dir_readdir(): string|false { return false; }
public function dir_rewinddir(): bool { return false; }
public function dir_closedir(): bool { return false; }
public function rename(string $path_from, string $path_to): bool { return false; }
public function unlink(string $path): bool { return false; }
private function loadContent(string $url): string
{
$request = $this->httpClient->get($url);
if ($this->auth) {
$request->withHeader('Authorization', $this->auth);
}
foreach ($this->headers as $name => $value) {
$request->withHeader($name, $value);
}
$response = $request->timeout($this->timeout)->send();
return $response->body();
}
private function saveContent(string $url, string $content): bool
{
$request = $this->httpClient->post($url, $content);
if ($this->auth) {
$request->withHeader('Authorization', $this->auth);
}
foreach ($this->headers as $name => $value) {
$request->withHeader($name, $value);
}
$response = $request->timeout($this->timeout)->send();
return $response->successful();
}
private function buildUrl(): string
{
$scheme = 'https'; // Default zu HTTPS
if ($this->streamContext) {
$scheme = $this->streamContext->getOption('http-client', 'scheme', 'https');
}
$url = "{$scheme}://{$this->host}{$this->path}";
if ($this->parsedUrl['query']) {
$url .= '?' . $this->parsedUrl['query'];
}
return $url;
}
}

158
.archive/docker-compose.yml Normal file
View File

@@ -0,0 +1,158 @@
x-docker-settings: &docker-settings
build:
context: .
args:
- BUILDKIT_INLINE_CACHE=1
services:
web:
container_name: web
build:
context: docker/nginx
dockerfile: Dockerfile
ports:
- "${APP_PORT:-8000}:80"
- "127.0.0.1:8080:80"
- "${APP_SSL_PORT:-443}:443/tcp"
- "443:443/udp"
environment:
- APP_ENV=${APP_ENV:-development}
volumes:
- ./:/var/www/html:cached
#- ./ssl:/etc/nginx/ssl:ro # SSL-Zertifikate mounten
- ./ssl:/var/www/ssl:ro
depends_on:
php:
condition: service_started
restart: unless-stopped
networks:
- frontend
- backend
env_file:
- .env
php:
container_name: php
build:
context: .
dockerfile: docker/php/Dockerfile
args:
- ENV=${APP_ENV:-dev}
- COMPOSER_INSTALL_FLAGS=${COMPOSER_INSTALL_FLAGS:---no-scripts --no-autoloader}
user: "1000:1000"
volumes:
# Shared Volume für Composer-Cache über Container-Neustarts hinweg
- composer-cache:/root/.composer/cache
# Bindet das Projektverzeichnis für Live-Änderungen ein
- ./:/var/www/html:cached
# Verhindert Überschreiben der Vendor-Verzeichnisse
#- /var/www/html/vendor
#- cache-volume:/var/www/html/cache:rw
environment:
PHP_IDE_CONFIG: "serverName=docker"
APP_ENV: ${APP_ENV:-development}
healthcheck:
test: [ "CMD", "php", "-v" ]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
networks:
- backend
- cache
env_file:
- .env
db:
container_name: db
image: mariadb:latest
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: qwee65132ertert # ändere das bitte!
MYSQL_DATABASE: database # optionale Initial-Datenbank
MYSQL_USER: mdb-user # optionaler zusätzlicher User
MYSQL_PASSWORD: dfghreh5465fghfgh # Passwort für den zusätzlichen User
ports:
- "33060:3306"
volumes:
- db_data:/var/lib/mysql
healthcheck:
test: [ "CMD", "mariadb-admin", "ping", "-h", "127.0.0.1", "-u", "root", "-pqwee65132ertert" ]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
networks:
- backend
redis:
image: redis:8-alpine
volumes:
- ./docker/redis/redis.conf:/usr/local/etc/redis/redis.conf
- redis_data:/data
command: ["redis-server", "/usr/local/etc/redis/redis.conf"]
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 5s
retries: 3
restart: unless-stopped
networks:
- cache
env_file:
- .env
queue-worker:
container_name: queue-worker
build:
context: .
dockerfile: docker/worker/Dockerfile
user: "1000:1000" # Same user ID as PHP container
depends_on:
php:
condition: service_healthy
redis:
condition: service_healthy
db:
condition: service_healthy
volumes:
- ./:/var/www/html:cached
- ./storage/logs:/var/www/html/storage/logs:rw
- ./src/Framework/CommandBus/storage:/var/www/html/src/Framework/CommandBus/storage:rw
environment:
- APP_ENV=${APP_ENV:-development}
- WORKER_DEBUG=${WORKER_DEBUG:-false}
- WORKER_SLEEP_TIME=${WORKER_SLEEP_TIME:-100000}
- WORKER_MAX_JOBS=${WORKER_MAX_JOBS:-1000}
restart: unless-stopped
networks:
- backend
- cache
env_file:
- .env
# Graceful shutdown timeout
stop_grace_period: 30s
# Resource limits for the worker
deploy:
resources:
limits:
memory: 512M
reservations:
memory: 256M
networks:
frontend:
driver: bridge
backend:
driver: bridge
cache:
driver: bridge
volumes:
redis_data:
composer-cache:
#cache-volume:
db_data:
worker-logs:
worker-queue:

View File

@@ -0,0 +1,48 @@
# Optimierungsvorschläge für die Docker-Compose Umgebung
## ToDo-Liste
- [ ] **Datenbank Passwörter & Secrets absichern**
- Datenbankpasswörter nicht im Klartext in der YAML speichern, sondern `secrets`-Mechanismus verwenden.
- `.env` Werte für Datenbanken statt statischer Angaben verwenden.
- Beispiel: `MYSQL_ROOT_PASSWORD_FILE` und `MYSQL_PASSWORD_FILE` setzen und Secrets einbinden.
- [ ] **Performance & Caching verbessern**
- `cache_from` und `cache_to` im Build-Prozess (BuildKit) einrichten.
- Für PHP einen dedizierten Volume für den Composer-Cache nutzen.
- Nginx-Cache als eigenes Volume deklarieren.
- Die Vendor-Ordner aus Mounts ausschließen oder gesondert berücksichtigen, damit lokale Änderungen keine Build-Optimierungen verhindern.
- [ ] **Netzwerk- und Bind-Mounts optimieren**
- Bei Nginx nur das Public-Verzeichnis (`public/`) einbinden, nicht das gesamte Projektverzeichnis.
- Nicht benötigte Verzeichnisse (wie z.B. `vendor/`) explizit ausschließen.
- Healthchecks und Startbedingungen konsistent definieren.
- [ ] **Image-Versionen festlegen**
- Keine `latest`-Images nutzen, sondern möglichst immer eine feste Version angeben (z.B. `mariadb:11.3` statt `mariadb:latest`).
- Gilt auch für Redis, PHP und weitere Services.
- [ ] **Ressourcenlimits setzen**
- `deploy.resources` für Speicher und CPU bei allen Services, nicht nur beim Worker.
- [ ] **Security-Best-Practices**
- Nicht produktive Ports (z.B. bei Entwicklung) durch `.env` variabel und gezielt auf localhost begrenzen.
- Feste Netzwerkbereiche und eigene Netzwerke für sensible Kommunikation (z.B. Backend, Cache).
- [ ] **Multi-Stage Builds in Dockerfiles nutzen**
- Die Images im PHP- und Worker-Bereich sollten über Multi-Stage-Builds möglichst klein gehalten werden (z.B. `FROM php:X-cli AS base`, dann Production-Image).
- [ ] **Environment-Konfiguration für Dev/Prod trennen**
- Eine `docker-compose.override.yml` für Entwicklung mit vollem Source-Mount und Debug-Konfiguration anlegen.
- Für Produktion keine Source-Mounts, keine Debug-Variablen, optimierte Settings.
- [ ] **Log-Rotation aktivieren**
- Logging-Driver auf `json-file` einstellen und Optionen für Größe/Rotation setzen.
- [ ] **Monitoring & Healthchecks**
- Für alle Services sinnvolle Healthchecks ergänzen.
- (Optional) Monitoring und/oder Alerting ergänzen.
---
**Tipp:** Die oben stehenden Punkte können Schritt für Schritt umgesetzt und pro optimiertem Bereich abgehakt werden.

View File

@@ -0,0 +1,58 @@
#!/bin/sh
set -e
#!/bin/sh
set -e
# Umgebungsvariablen-Substitution in Nginx-Konfiguration
if [ -n "$APP_ENV" ]; then
echo "Setting APP_ENV to: $APP_ENV"
sed -i "s/\${APP_ENV}/$APP_ENV/g" /etc/nginx/conf.d/default.conf
fi
# Warte auf PHP-FPM Container
echo "Waiting for PHP-FPM to be ready..."
while ! nc -z php 9000; do
sleep 1
done
echo "PHP-FPM is ready!"
# SSL-Zertifikate prüfen
if [ ! -f "/var/www/ssl/fullchain.pem" ] || [ ! -f "/var/www/ssl/privkey.pem" ]; then
echo "Warning: SSL certificates not found. HTTPS may not work properly."
fi
# Nginx-Konfiguration testen
echo "Testing Nginx configuration..."
nginx -t
# Nginx starten
echo "Starting Nginx..."
exec "$@"
until nc -z -w 2 php 9000; do
echo "Warte auf PHP-FPM..."
sleep 1
done
# Optional: eigene Umgebungsvariable mit Default setzen
export APP_ENV="${APP_ENV:-production}"
echo "Starte Nginx mit APP_ENV=$APP_ENV"
# Ersetze Platzhalter in temporäre Datei
envsubst '${APP_ENV}' < /etc/nginx/conf.d/default.conf > /tmp/default.conf
# Ersetzte Originalkonfiguration
cp /tmp/default.conf /etc/nginx/conf.d/default.conf
# WICHTIG: Rechte für stdout/stderr anpassen
chmod a+rw /dev/stdout /dev/stderr
# Nginx-Ordner Rechte anpassen
mkdir -p /var/cache/nginx /var/log/nginx
chown -R nginx:nginx /var/cache/nginx /var/log/nginx
# Stelle sicher, dass das SSL-Verzeichnis existiert
mkdir -p /var/www/ssl
# Jetzt kann nginx sicher starten
exec nginx -g 'daemon off;'

View File

@@ -0,0 +1,78 @@
# Standard Nginx User
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /var/run/nginx.pid;
# Worker-Prozess-Einstellungen
events {
worker_connections 1024;
use epoll;
multi_accept on;
}
http {
# MIME-Types
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Server-Tokens für Sicherheit ausblenden
server_tokens off;
# Rate-Limiting
limit_req_zone $binary_remote_addr zone=mylimit:10m rate=10r/s;
# Logging-Format
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for" '
'rt=$request_time uct="$upstream_connect_time" '
'uht="$upstream_header_time" urt="$upstream_response_time"';
# Container-optimierte Logs
access_log /dev/stdout main;
error_log /dev/stderr warn;
# Performance-Optimierungen
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
client_max_body_size 64M;
# Gzip-Kompression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied any;
gzip_comp_level 6;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/json
application/javascript
application/xml+rss
application/atom+xml
image/svg+xml
application/rss+xml
application/vnd.ms-fontobject
application/x-font-ttf
font/opentype;
# Basis-Sicherheits-Header
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
# SSL-Session-Cache
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
ssl_session_tickets off;
# Server-Konfigurationen einbinden
include /etc/nginx/conf.d/*.conf;
}

View File

@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDAzwS8FGSCDwDg
7QX8OpGkX1SbSwbUyzXNjEta319BvAH2OfcFFCj6u/iqfL7gKOM83t8u71VBFsCx
ZlxX2Ilyu2+r72sCdGBXcK6riTHrkjTs4uV6YV98eJuYhvAzSijpsRQjwnwQ587c
axtCXZhOzee3Tnbtzq4plqmOKR10D+cvrOZxuoKI914blXpGe8ds3vWEixewrex0
CYhzPj/zEF3yfCoSXeTmFBUbmmH/JwcCK8uO5t6XR1Dyo3M4GOMrmGtO7U4nuL6e
7JsbZfPaEW9wKtDjEwFDJSLy0ALEpiNWvbW4OaZWNkJk0jfKYwyBunNSs62B4307
oF8lqVo1AgMBAAECggEAbPlU0ryv5fZ256nvlRTBVmbvGep4zPKh0TA3MwBHBY8u
iK1QWVWAp95v+GQTOfzCGphZCl0JEYW7mUiibqAbZ3Za8pGaKMP/48vzXU5ooZ18
PlsrmlTItEAyqS2zOznyD8se9+snViK+f0QmHwdpWzjze15kx5nmQ+k8ofXJCNwq
q3dJIMI/WNuc0e/mMHYjZBsIwuoUi6YJHCE6RkWhGcnvlyXdKUV73/n8Loy6DUtW
VmshXag7+GfbVZIesMCjfnJ0gr9OG+XrFl6AcggzFA1ZHRoQliraVYGB2duQlIpW
o1wJMhFSGFPZxvl67hwXHJeo7ghHHfqNYXS1OuhV7QKBgQDBrvyzLtav51LzqOUY
2HPvaH86arbARc4Fy6ZJ0TaSlmKQ5GzRG0lG2CR03oZz+OcMV/BU8xUMM7CX0zUq
9RAmbE7rvXYOvqTe8pcdHeKKflzsr5p0HNROaeZdpMu8xoK1KLelAo6UCEBUGEny
oMtQWapuYvmdlHR2el2ICRGNzwKBgQD+1/iM1LcF9CYvEc8Sly9XuoRsdUCxavQa
sssv7eG5kkL8HroNs1pGZU8lNuZaT1V0ekWVOFk+X3+dGgCXg5/e/CluK9K7qOHX
3IkyUnZLEH5sDXGMGBzYA9AQTaB1PMTQYku6GNWYab6LFQTvpvvLcIILaFHokq8p
D/dGVJH8uwKBgQCBOxDBPe9hTye6DGdQPJyekUrS34EwqWLd2xQJDN8sz8rUgpVY
sKwj6PPqRs/PcbQ4ODTTeZ4BljuuEe7XyswL1xiRksjC7dF0MMlDVD1jywyVoFWe
Q94ks+RRdzO5sXplBdYC88HOY/MIKWytxzvhUPK21LNYwUU0CFGAAw0DYQKBgQD4
mT/qSdscoLXa9tl0fiz9vIJPtvXb3MSxgra5U6n9t9NGVMcUdGBdCZjyaaK+eGOZ
U2mrjiNouAop++KV6x26jWvxACj7TVy6kXT4tP6WbUmWKGsaya7hfp6qOL+NfjFU
Qn8y0+URYB4zWNbO3asFIwSJEkPMx8K9IMkMP5WF3wKBgCYiqAhPDF4WxA3fAqP7
95px8Clrety0mwOtE/rMQRf1nKJ78oA4pr+/VXRbyghAxtD4psbmBQofX3iwnn3B
o1DV3FLpNw004mvcKGScUcNwHQtWAtWX2nVDcxes5R2DgN+lpmWmf5Tq47p0r5ZP
nRb92drrnf8FoBv78CxLjIu+
-----END PRIVATE KEY-----

View File

@@ -0,0 +1,25 @@
-----BEGIN CERTIFICATE-----
MIIEHjCCAoagAwIBAgIQLqhFNHvvWJKUpuypArU2CjANBgkqhkiG9w0BAQsFADBb
MR4wHAYDVQQKExVta2NlcnQgZGV2ZWxvcG1lbnQgQ0ExGDAWBgNVBAsMD21pY2hh
ZWxATWlrZS1QQzEfMB0GA1UEAwwWbWtjZXJ0IG1pY2hhZWxATWlrZS1QQzAeFw0y
NTA1MTgxOTUyMDlaFw0yNzA4MTgxOTUyMDlaMEMxJzAlBgNVBAoTHm1rY2VydCBk
ZXZlbG9wbWVudCBjZXJ0aWZpY2F0ZTEYMBYGA1UECwwPbWljaGFlbEBNaWtlLVBD
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwM8EvBRkgg8A4O0F/DqR
pF9Um0sG1Ms1zYxLWt9fQbwB9jn3BRQo+rv4qny+4CjjPN7fLu9VQRbAsWZcV9iJ
crtvq+9rAnRgV3Cuq4kx65I07OLlemFffHibmIbwM0oo6bEUI8J8EOfO3GsbQl2Y
Ts3nt0527c6uKZapjikddA/nL6zmcbqCiPdeG5V6RnvHbN71hIsXsK3sdAmIcz4/
8xBd8nwqEl3k5hQVG5ph/ycHAivLjubel0dQ8qNzOBjjK5hrTu1OJ7i+nuybG2Xz
2hFvcCrQ4xMBQyUi8tACxKYjVr21uDmmVjZCZNI3ymMMgbpzUrOtgeN9O6BfJala
NQIDAQABo3YwdDAOBgNVHQ8BAf8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEw
HwYDVR0jBBgwFoAUhhzxUvThIGRX4MSoX91Vzm1zZ9AwLAYDVR0RBCUwI4IJbG9j
YWxob3N0hwR/AAABhxAAAAAAAAAAAAAAAAAAAAABMA0GCSqGSIb3DQEBCwUAA4IB
gQDUFLYZPo8RrfZh/vwT15LcIce8brdVegms6DvPK9lMZX6C4sGf4+rTJCwPuqHW
dqVZAhHdvcsyGI15xvVPT4qSh89RN1JB9uIHCk+weIzp+Rn06MMrB49m4abAvWp2
hB8bCo80hMVIsCb3Wr9sHg7CsJItsdGz8jHYCvHpvPLR7gWhYjm1g0meglT3tZqd
TsKDMb3Vj/vsivEueM6Oj/of8xbamVSSkqljWbRls7Ti7xqXMbmf7nl0WvG9IXg3
5Ucv1AWJIFEeLnMM5V0nEbO3sAhbNMLXieGPBWHXOgHuvVnQyu1mBESjgc5bjwfN
UjYBHluFkF9aYw3mGcFqAlb1FpGoMtHwTw0uGZzHzj5FY8oZix5edq/upriV6cU2
t0tidlfhvkJNSSO4zjAPjU1wd+/QRZwY2PcB5kBxs5MzSmiMlEjTkGgHWqMWMBf1
NPbyaxtjL69xBVonxpqD6BLJ2qLatgCs6fkZZF7AT38OFXr8Cv5vxt1rR5fs1P6X
mI0=
-----END CERTIFICATE-----

View File

@@ -0,0 +1,4 @@
location / {
try_files $uri $uri/ /index.php?$query_string;
autoindex off;
}

View File

@@ -0,0 +1,4 @@
location / {
try_files $uri $uri/ /index.php?$query_string;
autoindex off;
}

View File

@@ -0,0 +1,99 @@
# Dockerfile für PHP-FPM
FROM php:8.4-fpm AS base
# System-Abhängigkeiten: Werden selten geändert, daher ein eigener Layer
RUN apt-get update && apt-get install -y \
git \
unzip \
libzip-dev \
zip \
libpng-dev \
libjpeg-dev \
libfreetype6-dev \
libwebp-dev \
libavif-dev \
libxpm-dev \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
RUN docker-php-ext-configure gd \
--with-freetype \
--with-jpeg \
--with-webp \
--with-avif \
--with-xpm \
&& docker-php-ext-install -j$(nproc) gd
RUN docker-php-ext-install -j$(nproc) \
zip \
pdo \
pdo_mysql \
opcache \
pcntl \
posix \
shmop
# Composer installieren
RUN curl -sS https://getcomposer.org/installer | php \
&& mv composer.phar /usr/local/bin/composer
# Installiere Xdebug nur im Entwicklungsmodus
ARG ENV=prod
RUN if [ "$ENV" = "dev" ]; then \
pecl install xdebug \
&& docker-php-ext-enable xdebug; \
fi
WORKDIR /var/www/html
# Kopiere composer.json
COPY composer.json ./
# Kopiere composer.lock falls vorhanden (robuste Lösung)
COPY composer.loc[k] ./
# Falls keine composer.lock existiert, erstelle eine leere um Layer-Caching zu ermöglichen
RUN [ ! -f composer.lock ] && touch composer.lock || true
# Remove potentially corrupted composer.lock and install dependencies
RUN rm -f composer.lock && \
if [ "$ENV" = "prod" ]; then \
composer install --no-dev --no-scripts --no-autoloader --optimize-autoloader; \
else \
composer install --no-scripts --no-autoloader; \
fi
# Kopiere PHP-Konfigurationen
COPY docker/php/php.common.ini /usr/local/etc/php/php.common.ini
COPY docker/php/php.${ENV}.ini /usr/local/etc/php/php.ini
# Wenn dev, kopiere auch xdebug-Konfiguration
RUN if [ "$ENV" = "dev" ]; then \
mkdir -p /usr/local/etc/php/conf.d/; \
fi
COPY docker/php/xdebug.ini /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
# Kopiere den Rest des Projekts
COPY . .
# Optimiere Autoloader
RUN composer dump-autoload --optimize
# <<--- ALLE zusätzlichen System-Dateien und chmod noch als root!
COPY docker/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Danach erst den Nutzer wechseln!
RUN groupadd -g 1000 appuser && useradd -u 1000 -g appuser -m appuser
RUN chown -R appuser:appuser /var/www/html
USER appuser
RUN mkdir -p /var/www/html/cache && \
chown -R 1000:1000 /var/www/html/cache && \
chmod -R 775 /var/www/html/cache
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["php-fpm"]

View File

@@ -0,0 +1,4 @@
#!/bin/bash
chown -R www-data:www-data /var/www/html/cache
chmod -R 775 /var/www/html/cache
exec "$@"

View File

@@ -0,0 +1,10 @@
expose_php = Off
session.cookie_secure = 1
session.cookie_httponly = 1
session.cookie_samesite = Lax
date.timezone = Europe/Berlin
opcache.preload=/var/www/michaelschiemer/src/preload.php

View File

@@ -0,0 +1,29 @@
; php.ini für Entwicklung
include = php.common.ini
[opcache]
opcache.enable=0
opcache.enable_cli=0
opcache.memory_consumption=128
opcache.max_accelerated_files=10000
; Häufigere Validierung im Dev-Modus
opcache.revalidate_freq=0
; Timestamps-Validierung einschalten für Entwicklung
opcache.validate_timestamps=1
opcache.file_cache=
realpath_cache_ttl=0
opcache.interned_strings_buffer=16
display_errors = On
display_startup_errors = On
error_reporting = E_ALL
memory_limit = 512M
upload_max_filesize = 20M
post_max_size = 25M
max_execution_time = 60
; Xdebug-Einstellungen können auch hier hinzugefügt werden, falls gewünscht

View File

@@ -0,0 +1,31 @@
; php.ini für Produktion
include = php.common.ini
[opcache]
; Aktiviere OPcache
opcache.enable=1
; Aktiviere OPcache für CLI-Anwendungen (optional)
opcache.enable_cli=0
; Maximale Speichernutzung für Cache in MB
opcache.memory_consumption=128
; Maximale Anzahl an gecachten Dateien
opcache.max_accelerated_files=10000
; Wie oft wird der Cache validiert (0 = bei jedem Request, empfohlen für Entwicklung)
; In Produktion höher setzen für bessere Performance
opcache.revalidate_freq=60
; Cache-Zeitstempel prüfen (0 für Produktionsumgebungen)
opcache.validate_timestamps=0
; Performance-Optimierungen
opcache.interned_strings_buffer=16
; JIT (Just-In-Time Compilation) - Optional für PHP 8.0+
opcache.jit_buffer_size=100M
opcache.jit=1255
display_errors = Off
display_startup_errors = Off
error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT
memory_limit = 256M
upload_max_filesize = 10M
post_max_size = 12M
max_execution_time = 30

View File

@@ -0,0 +1,7 @@
; Xdebug 3 Konfiguration
xdebug.mode=${XDEBUG_MODE:-off}
xdebug.client_host=host.docker.internal
xdebug.client_port=9003
xdebug.start_with_request=yes
xdebug.log=/var/log/xdebug.log
xdebug.idekey=PHPSTORM

View File

@@ -0,0 +1,7 @@
bind 0.0.0.0
#protected-mode yes
dir /data
save 900 1
save 300 10
save 60 10000
appendonly yes

View File

@@ -0,0 +1,115 @@
FROM php:8.4.8-cli
# Install system dependencies including libraries for GD and other extensions
RUN apt-get update && apt-get install -y \
git \
curl \
libpng-dev \
libjpeg62-turbo-dev \
libfreetype6-dev \
libonig-dev \
libxml2-dev \
libzip-dev \
libicu-dev \
zip \
unzip \
procps \
&& rm -rf /var/lib/apt/lists/* \
&& apt-get clean
# Configure GD extension with JPEG and FreeType support
RUN docker-php-ext-configure gd --with-freetype --with-jpeg
# Install PHP extensions for worker functionality and web features
RUN docker-php-ext-install -j$(nproc) \
pdo_mysql \
mbstring \
exif \
pcntl \
posix \
sockets \
gd \
zip \
intl \
opcache
# Install Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Set working directory
WORKDIR /var/www/html
# Copy application files
COPY . .
# Install dependencies (composer.lock wird automatisch erstellt falls nicht vorhanden)
# Check if composer.json exists, if not create a minimal one
RUN if [ ! -f composer.json ]; then \
echo "Creating minimal composer.json..."; \
echo '{\
"name": "worker/app",\
"description": "Worker application",\
"type": "project",\
"require": {\
"php": ">=8.4"\
},\
"autoload": {\
"psr-4": {\
"App\\\\": "src/"\
}\
},\
"minimum-stability": "stable",\
"prefer-stable": true\
}' > composer.json; \
fi && \
composer install \
--no-dev \
--optimize-autoloader \
--no-interaction || echo "Composer install skipped or failed - continuing without dependencies"
# Create startup script for permission fixing
RUN echo '#!/bin/bash\n\
set -e\n\
\n\
echo "🔧 Fixing permissions..."\n\
\n\
# Create directories if they do not exist\n\
mkdir -p /var/www/html/src/Framework/CommandBus/storage/queue\n\
mkdir -p /var/www/html/storage/logs\n\
mkdir -p /var/www/html/storage/cache\n\
\n\
# Fix permissions on mounted volumes\n\
chown -R www-data:www-data /var/www/html/storage || true\n\
chown -R www-data:www-data /var/www/html/src/Framework/CommandBus/storage || true\n\
chmod -R 775 /var/www/html/storage || true\n\
chmod -R 775 /var/www/html/src/Framework/CommandBus/storage || true\n\
\n\
echo "✅ Permissions fixed"\n\
echo "🚀 Starting worker..."\n\
\n\
# Switch to www-data user and run the worker\n\
exec gosu www-data php /var/www/html/worker.php\n' > /usr/local/bin/start-worker.sh \
&& chmod +x /usr/local/bin/start-worker.sh
# Install gosu for better user switching (alternative to su-exec for Debian)
RUN apt-get update && apt-get install -y gosu && rm -rf /var/lib/apt/lists/*
# Create necessary directories and set permissions
RUN mkdir -p \
/var/www/html/src/Framework/CommandBus/storage/queue \
/var/www/html/storage/logs \
/var/www/html/storage/cache \
&& chown -R www-data:www-data /var/www/html/storage \
&& chmod -R 775 /var/www/html/storage
# Create queue storage directory with proper permissions
RUN mkdir -p /var/www/html/src/Framework/CommandBus/storage \
&& chown -R www-data:www-data /var/www/html/src/Framework/CommandBus/storage \
&& chmod -R 775 /var/www/html/src/Framework/CommandBus/storage
# Health check for the worker
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD ps aux | grep -v grep | grep "worker.php" || exit 1
# Use startup script instead of direct PHP command
CMD ["/usr/local/bin/start-worker.sh"]

View File

@@ -0,0 +1,73 @@
FROM macbre/nginx-http3
# Zurück zu root wechseln
USER root
# Entferne Default-Site
RUN rm -f /etc/nginx/conf.d/default.conf || true
# Verzeichnisse erstellen
RUN mkdir -p /var/cache/nginx /var/log/nginx /var/www/ssl && \
chmod 755 /var/cache/nginx /var/log/nginx /var/www/ssl
# Konfigurationen kopieren
COPY ./nginx.conf /etc/nginx/nginx.conf
COPY ./default.conf /etc/nginx/conf.d/default.conf
COPY ./ssl/ /var/www/ssl/
# Entry-Script kopieren
COPY ./docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# su-exec und netcat installieren
RUN apk add --no-cache su-exec netcat-openbsd
# Berechtigungen für stdout/stderr anpassen
RUN chmod a+rw /dev/stdout /dev/stderr
# Ordner-Berechtigungen für den nginx-User setzen
RUN chown -R nginx:nginx /var/cache/nginx /var/log/nginx /var/www/ssl
EXPOSE 80 443
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
#CMD ["nginx", "-g", "daemon off;"]
## Standard-Konfiguration entfernen
#RUN rm -f /etc/nginx/conf.d/default.conf
#
## Verzeichnisse erstellen mit korrekten Berechtigungen
#RUN mkdir -p /var/cache/nginx /var/log/nginx /etc/nginx/template && \
# chmod -R 777 /var/cache/nginx /var/log/nginx
#
## Kopiere die Template-Konfiguration
#COPY ./nginx.conf /etc/nginx/nginx.conf
#COPY ./default.conf /etc/nginx/conf.d/default.conf
#
## Kopiert config Include
#COPY ./vite-proxy.inc.dev /etc/nginx/vite-proxy.inc
#
## Kopiere die SSL-Zertifikate
#COPY ./ssl/ /etc/nginx/ssl/
#
## Startup-Skript zum Ersetzen der Variablen
#COPY ./docker-entrypoint.sh /
#RUN chmod +x /docker-entrypoint.sh
#
##Install Netcat
#RUN apk add --no-cache netcat-openbsd
#
#
## Als user www-data laufen lassen
#RUN addgroup -g 1000 www && adduser -D -G www -u 1000 www-data \
# && chown -R www-data:www /var/cache/nginx /var/log/nginx /etc/nginx
#USER www-data
#
#EXPOSE 80 443
#
#ENTRYPOINT ["/docker-entrypoint.sh"]
#CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,252 @@
# FastCGI-Cache-Einstellungen
fastcgi_cache_path /var/cache/nginx levels=1:2 keys_zone=PHPCACHE:100m inactive=60m;
fastcgi_cache_key "$scheme$request_method$host$request_uri";
fastcgi_cache_use_stale error timeout invalid_header http_500;
fastcgi_ignore_headers Cache-Control Expires Set-Cookie;
# Hardcoded Umgebungsmodus basierend auf Template-Ersetzung
map $http_host $env_mode {
default "${APP_ENV}";
}
# Dynamische Cache-Kontrolle basierend auf Umgebungsvariable
map $env_mode $should_skip_cache {
default 0; # Standard (Produktion): Cache aktivieren
development 1; # Entwicklung: Cache deaktivieren
testing 1; # Testing: Cache deaktivieren
}
# Skip-Cache für Sessions und basierend auf Umgebung
map $http_cookie$should_skip_cache $skip_cache {
"~ms_context" 1; # Sessions nie cachen
"~1$" 1; # Cache überspringen, wenn should_skip_cache = 1
default 0; # Ansonsten cachen
}
map $host $block_health {
default 1; # Blockiere alles
localhost 0; # Erlaube nur Host "localhost"
}
upstream php-upstream {
server php:9000; # „php“ ist durch Network-Alias immer erreichbar
}
server {
listen 80;
server_name localhost;
return 301 https://$host$request_uri;
}
server {
# Korrigierte HTTP/2 Syntax
listen 443 ssl;
listen 443 quic reuseport; # QUIC für HTTP/3
http2 on; # Neue Syntax für HTTP/2
server_name localhost;
#ssl_certificate /etc/nginx/ssl/localhost+2.pem;
#ssl_certificate_key /etc/nginx/ssl/localhost+2-key.pem;
#ssl_certificate /etc/nginx/ssl/fullchain.pem;
#ssl_certificate_key /etc/nginx/ssl/privkey.pem;
ssl_certificate /var/www/ssl/fullchain.pem;
ssl_certificate_key /var/www/ssl/privkey.pem;
add_header Alt-Svc 'h3=":443"'; # Für HTTP/3 Unterstützung
#add_header QUIC-Status $quic;
ssl_protocols TLSv1.3 TLSv1.2;
ssl_prefer_server_ciphers on;
ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384;
# Verbesserte SSL-Konfiguration
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
# OCSP Stapling (auskommentiert, wenn Zertifikate fehlen)
# ssl_stapling on;
# ssl_stapling_verify on;
resolver 1.1.1.1 1.0.0.1 valid=300s;
resolver_timeout 5s;
root /var/www/html/public;
index index.php index.html;
location / {
try_files $uri $uri/ /index.php?$query_string;
autoindex off;
}
# Debug-Header für die Entwicklung
add_header X-Environment $env_mode always;
# Sicherheits-Header
add_header X-Content-Type-Options nosniff;
add_header X-Frame-Options DENY always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Permissions-Policy "geolocation=(), microphone=()" always;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
# CSP Header
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; object-src 'none'" always;
# Buffer-Größen anpassen
client_body_buffer_size 10K;
client_header_buffer_size 1k;
client_max_body_size 10m;
large_client_header_buffers 2 1k;
# Verbesserte Gzip-Kompression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.1;
gzip_min_length 256;
gzip_types
application/atom+xml
application/javascript
application/json
application/ld+json
application/manifest+json
application/rss+xml
application/vnd.geo+json
application/vnd.ms-fontobject
application/x-font-ttf
application/x-web-app-manifest+json
application/xhtml+xml
application/xml
font/opentype
image/bmp
image/svg+xml
image/x-icon
text/cache-manifest
text/css
text/plain
text/vcard
text/vnd.rim.location.xloc
text/vtt
text/x-component
text/x-cross-domain-policy;
# Logs
#access_log /var/log/nginx/access.log combined;
#error_log /var/log/nginx/error.log error;
access_log /dev/stdout;
error_log /dev/stderr warn;
# läuft aktuell oben über dynamischen include!
#location / {
# try_files $uri $uri/ /index.php?$query_string;
# autoindex off;
#}
# Service-Worker explizit erlauben (auch im Production-Server ungefährlich!)
location = /sw.js {
# je nach Build-Ordner anpassen!
alias /var/www/html/public/sw.js;
add_header Cache-Control "no-cache, must-revalidate";
}
# Caching Header für statische Dateien
#location ~* \.(jpg|jpeg|png|gif|ico|svg|woff|woff2|ttf|eot)$ {
# expires 1y;
# add_header Cache-Control "public, immutable, max-age=31536000";
#}
location ~* \.(css|js)$ {
expires 1w;
add_header Cache-Control "public, max-age=604800";
}
# location ~* \.(json|xml)$ {
# expires 1d;
# add_header Cache-Control "public, max-age=86400";
# }
location ~ \.php$ {
try_files $uri =404;
include fastcgi_params;
fastcgi_pass php-upstream;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_split_path_info ^(.+\.php)(/.+)$;
# Wichtig: APP_ENV an PHP weitergeben
fastcgi_param APP_ENV $env_mode;
# Timeout-Einstellungen
fastcgi_read_timeout 60s;
fastcgi_connect_timeout 60s;
fastcgi_send_timeout 60s;
# Caching-Einstellungen
fastcgi_buffer_size 128k;
fastcgi_buffers 4 256k;
fastcgi_busy_buffers_size 256k;
# Cache FastCGI-Antworten
fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache $skip_cache;
fastcgi_cache PHPCACHE;
fastcgi_cache_valid 200 60m;
# Debug-Header hinzufügen
add_header X-Cache-Status $upstream_cache_status;
add_header X-Cache-Environment $env_mode;
add_header X-Cache-Skip $skip_cache;
# Für bessere Performance
fastcgi_keep_conn on;
}
# Sicherheitseinstellungen
location ~ /\.(?!well-known).* {
deny all;
}
server_tokens off;
limit_req zone=mylimit burst=20 nodelay;
location ~* /(?:uploads|files)/.*\.php$ {
deny all;
}
# Healthcheck-Endpunkt
location = /ping {
access_log off;
add_header Content-Type text/plain;
return 200 'pong';
}
location = /health {
if ($block_health) {
return 404;
}
try_files /health.php =404;
allow 127.0.0.1; # Lokal erlaubt (Ansible, Docker, Monitoring intern)
allow ::1;
allow 192.168.0.0/16; # Optional: internes Netz (z.B. für internen Loadbalancer)
deny all;
error_page 403 =404;
}
error_page 404 /errors/404.html;
error_page 403 /errors/403.html;
error_page 500 502 503 504 /errors/50x.html;
location /errors/ {
internal; # Verhindert direkten Zugriff
}
}

View File

@@ -0,0 +1,31 @@
#!/bin/sh
set -e
until nc -z -w 2 php 9000; do
echo "Warte auf PHP-FPM..."
sleep 1
done
# Optional: eigene Umgebungsvariable mit Default setzen
export APP_ENV="${APP_ENV:-production}"
echo "Starte Nginx mit APP_ENV=$APP_ENV"
# Ersetze Platzhalter in temporäre Datei
envsubst '${APP_ENV}' < /etc/nginx/conf.d/default.conf > /tmp/default.conf
# Ersetzte Originalkonfiguration
cp /tmp/default.conf /etc/nginx/conf.d/default.conf
# WICHTIG: Rechte für stdout/stderr anpassen
chmod a+rw /dev/stdout /dev/stderr
# Nginx-Ordner Rechte anpassen
mkdir -p /var/cache/nginx /var/log/nginx
chown -R nginx:nginx /var/cache/nginx /var/log/nginx
# Stelle sicher, dass das SSL-Verzeichnis existiert
mkdir -p /var/www/ssl
# Jetzt kann nginx sicher starten
exec nginx -g 'daemon off;'

View File

@@ -0,0 +1,39 @@
worker_processes auto;
pid /tmp/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
server_tokens off;
# Rate-Limiting für besseren DDoS-Schutz
limit_req_zone $binary_remote_addr zone=mylimit:10m rate=10r/s;
# Logging-Einstellungen
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
#access_log /var/log/nginx/access.log main;
#error_log /var/log/nginx/error.log warn;
access_log /dev/stdout;
error_log /dev/stderr warn;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
# TLS-Einstellungen
#ssl_session_cache shared:SSL:10m;
#ssl_session_timeout 10m;
# Include server configs
include /etc/nginx/conf.d/*.conf;
}

View File

@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDAzwS8FGSCDwDg
7QX8OpGkX1SbSwbUyzXNjEta319BvAH2OfcFFCj6u/iqfL7gKOM83t8u71VBFsCx
ZlxX2Ilyu2+r72sCdGBXcK6riTHrkjTs4uV6YV98eJuYhvAzSijpsRQjwnwQ587c
axtCXZhOzee3Tnbtzq4plqmOKR10D+cvrOZxuoKI914blXpGe8ds3vWEixewrex0
CYhzPj/zEF3yfCoSXeTmFBUbmmH/JwcCK8uO5t6XR1Dyo3M4GOMrmGtO7U4nuL6e
7JsbZfPaEW9wKtDjEwFDJSLy0ALEpiNWvbW4OaZWNkJk0jfKYwyBunNSs62B4307
oF8lqVo1AgMBAAECggEAbPlU0ryv5fZ256nvlRTBVmbvGep4zPKh0TA3MwBHBY8u
iK1QWVWAp95v+GQTOfzCGphZCl0JEYW7mUiibqAbZ3Za8pGaKMP/48vzXU5ooZ18
PlsrmlTItEAyqS2zOznyD8se9+snViK+f0QmHwdpWzjze15kx5nmQ+k8ofXJCNwq
q3dJIMI/WNuc0e/mMHYjZBsIwuoUi6YJHCE6RkWhGcnvlyXdKUV73/n8Loy6DUtW
VmshXag7+GfbVZIesMCjfnJ0gr9OG+XrFl6AcggzFA1ZHRoQliraVYGB2duQlIpW
o1wJMhFSGFPZxvl67hwXHJeo7ghHHfqNYXS1OuhV7QKBgQDBrvyzLtav51LzqOUY
2HPvaH86arbARc4Fy6ZJ0TaSlmKQ5GzRG0lG2CR03oZz+OcMV/BU8xUMM7CX0zUq
9RAmbE7rvXYOvqTe8pcdHeKKflzsr5p0HNROaeZdpMu8xoK1KLelAo6UCEBUGEny
oMtQWapuYvmdlHR2el2ICRGNzwKBgQD+1/iM1LcF9CYvEc8Sly9XuoRsdUCxavQa
sssv7eG5kkL8HroNs1pGZU8lNuZaT1V0ekWVOFk+X3+dGgCXg5/e/CluK9K7qOHX
3IkyUnZLEH5sDXGMGBzYA9AQTaB1PMTQYku6GNWYab6LFQTvpvvLcIILaFHokq8p
D/dGVJH8uwKBgQCBOxDBPe9hTye6DGdQPJyekUrS34EwqWLd2xQJDN8sz8rUgpVY
sKwj6PPqRs/PcbQ4ODTTeZ4BljuuEe7XyswL1xiRksjC7dF0MMlDVD1jywyVoFWe
Q94ks+RRdzO5sXplBdYC88HOY/MIKWytxzvhUPK21LNYwUU0CFGAAw0DYQKBgQD4
mT/qSdscoLXa9tl0fiz9vIJPtvXb3MSxgra5U6n9t9NGVMcUdGBdCZjyaaK+eGOZ
U2mrjiNouAop++KV6x26jWvxACj7TVy6kXT4tP6WbUmWKGsaya7hfp6qOL+NfjFU
Qn8y0+URYB4zWNbO3asFIwSJEkPMx8K9IMkMP5WF3wKBgCYiqAhPDF4WxA3fAqP7
95px8Clrety0mwOtE/rMQRf1nKJ78oA4pr+/VXRbyghAxtD4psbmBQofX3iwnn3B
o1DV3FLpNw004mvcKGScUcNwHQtWAtWX2nVDcxes5R2DgN+lpmWmf5Tq47p0r5ZP
nRb92drrnf8FoBv78CxLjIu+
-----END PRIVATE KEY-----

View File

@@ -0,0 +1,25 @@
-----BEGIN CERTIFICATE-----
MIIEHjCCAoagAwIBAgIQLqhFNHvvWJKUpuypArU2CjANBgkqhkiG9w0BAQsFADBb
MR4wHAYDVQQKExVta2NlcnQgZGV2ZWxvcG1lbnQgQ0ExGDAWBgNVBAsMD21pY2hh
ZWxATWlrZS1QQzEfMB0GA1UEAwwWbWtjZXJ0IG1pY2hhZWxATWlrZS1QQzAeFw0y
NTA1MTgxOTUyMDlaFw0yNzA4MTgxOTUyMDlaMEMxJzAlBgNVBAoTHm1rY2VydCBk
ZXZlbG9wbWVudCBjZXJ0aWZpY2F0ZTEYMBYGA1UECwwPbWljaGFlbEBNaWtlLVBD
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwM8EvBRkgg8A4O0F/DqR
pF9Um0sG1Ms1zYxLWt9fQbwB9jn3BRQo+rv4qny+4CjjPN7fLu9VQRbAsWZcV9iJ
crtvq+9rAnRgV3Cuq4kx65I07OLlemFffHibmIbwM0oo6bEUI8J8EOfO3GsbQl2Y
Ts3nt0527c6uKZapjikddA/nL6zmcbqCiPdeG5V6RnvHbN71hIsXsK3sdAmIcz4/
8xBd8nwqEl3k5hQVG5ph/ycHAivLjubel0dQ8qNzOBjjK5hrTu1OJ7i+nuybG2Xz
2hFvcCrQ4xMBQyUi8tACxKYjVr21uDmmVjZCZNI3ymMMgbpzUrOtgeN9O6BfJala
NQIDAQABo3YwdDAOBgNVHQ8BAf8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEw
HwYDVR0jBBgwFoAUhhzxUvThIGRX4MSoX91Vzm1zZ9AwLAYDVR0RBCUwI4IJbG9j
YWxob3N0hwR/AAABhxAAAAAAAAAAAAAAAAAAAAABMA0GCSqGSIb3DQEBCwUAA4IB
gQDUFLYZPo8RrfZh/vwT15LcIce8brdVegms6DvPK9lMZX6C4sGf4+rTJCwPuqHW
dqVZAhHdvcsyGI15xvVPT4qSh89RN1JB9uIHCk+weIzp+Rn06MMrB49m4abAvWp2
hB8bCo80hMVIsCb3Wr9sHg7CsJItsdGz8jHYCvHpvPLR7gWhYjm1g0meglT3tZqd
TsKDMb3Vj/vsivEueM6Oj/of8xbamVSSkqljWbRls7Ti7xqXMbmf7nl0WvG9IXg3
5Ucv1AWJIFEeLnMM5V0nEbO3sAhbNMLXieGPBWHXOgHuvVnQyu1mBESjgc5bjwfN
UjYBHluFkF9aYw3mGcFqAlb1FpGoMtHwTw0uGZzHzj5FY8oZix5edq/upriV6cU2
t0tidlfhvkJNSSO4zjAPjU1wd+/QRZwY2PcB5kBxs5MzSmiMlEjTkGgHWqMWMBf1
NPbyaxtjL69xBVonxpqD6BLJ2qLatgCs6fkZZF7AT38OFXr8Cv5vxt1rR5fs1P6X
mI0=
-----END CERTIFICATE-----

View File

@@ -0,0 +1,4 @@
location / {
try_files $uri $uri/ /index.php?$query_string;
autoindex off;
}

View File

@@ -0,0 +1,4 @@
location / {
try_files $uri $uri/ /index.php?$query_string;
autoindex off;
}

View File

@@ -0,0 +1,98 @@
# Dockerfile für PHP-FPM
FROM php:8.4-fpm AS base
# System-Abhängigkeiten: Werden selten geändert, daher ein eigener Layer
RUN apt-get update && apt-get install -y \
git \
unzip \
libzip-dev \
zip \
libpng-dev \
libjpeg-dev \
libfreetype6-dev \
libwebp-dev \
libavif-dev \
libxpm-dev \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
RUN docker-php-ext-configure gd \
--with-freetype \
--with-jpeg \
--with-webp \
--with-avif \
--with-xpm \
&& docker-php-ext-install -j$(nproc) gd
RUN docker-php-ext-install -j$(nproc) \
zip \
pdo \
pdo_mysql \
opcache \
pcntl \
posix \
shmop
# Composer installieren
RUN curl -sS https://getcomposer.org/installer | php \
&& mv composer.phar /usr/local/bin/composer
# Installiere Xdebug nur im Entwicklungsmodus
ARG ENV=prod
RUN if [ "$ENV" = "dev" ]; then \
pecl install xdebug \
&& docker-php-ext-enable xdebug; \
fi
WORKDIR /var/www/html
# Kopiere composer.json
COPY composer.json ./
# Kopiere composer.lock falls vorhanden (robuste Lösung)
COPY composer.loc[k] ./
# Falls keine composer.lock existiert, erstelle eine leere um Layer-Caching zu ermöglichen
RUN [ ! -f composer.lock ] && touch composer.lock || true
# Installiere Abhängigkeiten - variiert je nach Umgebung
RUN if [ "$ENV" = "prod" ]; then \
composer install --no-dev --no-scripts --no-autoloader --optimize-autoloader; \
else \
composer install --no-scripts --no-autoloader; \
fi
# Kopiere PHP-Konfigurationen
COPY docker/php/php.common.ini /usr/local/etc/php/php.common.ini
COPY docker/php/php.${ENV}.ini /usr/local/etc/php/php.ini
# Wenn dev, kopiere auch xdebug-Konfiguration
RUN if [ "$ENV" = "dev" ]; then \
mkdir -p /usr/local/etc/php/conf.d/; \
fi
COPY docker/php/xdebug.ini /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
# Kopiere den Rest des Projekts
COPY . .
# Optimiere Autoloader
RUN composer dump-autoload --optimize
# <<--- ALLE zusätzlichen System-Dateien und chmod noch als root!
COPY docker/php/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Danach erst den Nutzer wechseln!
RUN groupadd -g 1000 appuser && useradd -u 1000 -g appuser -m appuser
RUN chown -R appuser:appuser /var/www/html
USER appuser
RUN mkdir -p /var/www/html/cache && \
chown -R 1000:1000 /var/www/html/cache && \
chmod -R 775 /var/www/html/cache
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["php-fpm"]

View File

@@ -0,0 +1,4 @@
#!/bin/bash
chown -R www-data:www-data /var/www/html/cache
chmod -R 775 /var/www/html/cache
exec "$@"

View File

@@ -0,0 +1,10 @@
expose_php = Off
session.cookie_secure = 1
session.cookie_httponly = 1
session.cookie_samesite = Lax
date.timezone = Europe/Berlin
opcache.preload=/var/www/michaelschiemer/src/preload.php

View File

@@ -0,0 +1,29 @@
; php.ini für Entwicklung
include = php.common.ini
[opcache]
opcache.enable=0
opcache.enable_cli=0
opcache.memory_consumption=128
opcache.max_accelerated_files=10000
; Häufigere Validierung im Dev-Modus
opcache.revalidate_freq=0
; Timestamps-Validierung einschalten für Entwicklung
opcache.validate_timestamps=1
opcache.file_cache=
realpath_cache_ttl=0
opcache.interned_strings_buffer=16
display_errors = On
display_startup_errors = On
error_reporting = E_ALL
memory_limit = 512M
upload_max_filesize = 20M
post_max_size = 25M
max_execution_time = 60
; Xdebug-Einstellungen können auch hier hinzugefügt werden, falls gewünscht

View File

@@ -0,0 +1,31 @@
; php.ini für Produktion
include = php.common.ini
[opcache]
; Aktiviere OPcache
opcache.enable=1
; Aktiviere OPcache für CLI-Anwendungen (optional)
opcache.enable_cli=0
; Maximale Speichernutzung für Cache in MB
opcache.memory_consumption=128
; Maximale Anzahl an gecachten Dateien
opcache.max_accelerated_files=10000
; Wie oft wird der Cache validiert (0 = bei jedem Request, empfohlen für Entwicklung)
; In Produktion höher setzen für bessere Performance
opcache.revalidate_freq=60
; Cache-Zeitstempel prüfen (0 für Produktionsumgebungen)
opcache.validate_timestamps=0
; Performance-Optimierungen
opcache.interned_strings_buffer=16
; JIT (Just-In-Time Compilation) - Optional für PHP 8.0+
opcache.jit_buffer_size=100M
opcache.jit=1255
display_errors = Off
display_startup_errors = Off
error_reporting = E_ALL & ~E_DEPRECATED & ~E_STRICT
memory_limit = 256M
upload_max_filesize = 10M
post_max_size = 12M
max_execution_time = 30

View File

@@ -0,0 +1,7 @@
; Xdebug 3 Konfiguration
xdebug.mode=${XDEBUG_MODE:-off}
xdebug.client_host=host.docker.internal
xdebug.client_port=9003
xdebug.start_with_request=yes
xdebug.log=/var/log/xdebug.log
xdebug.idekey=PHPSTORM

1
.bashrc Normal file
View File

@@ -0,0 +1 @@
alias appconsole='docker exec -it php php console.php'

View File

@@ -1,8 +1,115 @@
# Exclude everything first
*
# Then include only what we need
!composer.json
!src/
!public/index.php
!public/.htaccess
!config/
!routes/
!resources/views/
!resources/lang/
!app.php
!index.php
!*.php
# Include docker configuration files
!docker/php/
# Exclude everything in src except PHP files
src/**/*
!src/**/*.php
# Exclude everything in public except essential files
public/*
!public/index.php
!public/.htaccess
# Exclude binary files even if they match the whitelist
*.jpg
*.jpeg
*.png
*.gif
*.bmp
*.tiff
*.ico
*.webp
*.pdf
*.zip
*.tar.gz
*.rar
*.7z
*.exe
*.dll
*.so
*.dylib
*.bin
*.dat
# Git und Entwicklungstools
.git
.gitignore
.editorconfig
# Node.js
node_modules
npm-debug.log
package-lock.json
# PHP
vendor
storage/logs/*
storage/app/*
storage/framework/cache/*
storage/framework/sessions/*
storage/framework/views/*
composer.lock
.php-cs-fixer.cache
# Umgebungsdateien
.env*
!.env.example
# Cache und temporäre Dateien
storage/logs
storage/cache
storage/sessions
*.log
*.cache
*.tmp
# Datenbank
*.sqlite
*.db
# Build-Dateien
*.md
Makefile
build.sh
deploy.sh
# IDE
.vscode
.idea
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Spezifische Dateien dieses Projekts
m
ms
qodana.yaml
phpunit.xml
# Exclude directories that might contain problematic files
.git/
node_modules/
vendor/
storage/
cache/
tmp/
temp/
logs/
.vscode/
.idea/
*.log
.env*
.DS_Store
Thumbs.db

View File

@@ -1,8 +1,44 @@
# Projektname für Docker Compose
COMPOSE_PROJECT_NAME=michaelschiemer
# Anwendungseinstellungen
APP_ENV=development
APP_DEBUG=true
# API-Konfiguration
RAPIDMAIL_USERNAME=3f60a5c15c3d49c631d0e75b7c1090a3859423a7
RAPIDMAIL_PASSWORD=572d25dc36e620f14c89e9c75c02c1f3794ba3c0
RAPIDMAIL_DEFAULT_LIST_ID=776
# Shopify API-Konfiguration
SHOPIFY_SHOP_DOMAIN=michaelschiemer.myshopify.com
SHOPIFY_ACCESS_TOKEN=shpat_123456789abcdef
SHOPIFY_API_VERSION=2024-04
# Datenbank-Konfiguration (falls benötigt)
# DB_HOST=localhost
# DB_PORT=3306
# DB_DATABASE=meine_datenbank
# DB_USERNAME=root
# DB_PASSWORD=secret
# Redis-Konfiguration (falls benötigt)
# REDIS_HOST=redis
# REDIS_PORT=6379
# REDIS_PASSWORD=null
# Externer Port für Web-Zugriff
APP_PORT=8000
# PHP-Version (für Build)
PHP_VERSION=8.4
# Redis Konfiguration
REDIS_SCHEME=tcp
REDIS_HOST=redis
REDIS_PORT=6379
# Rate Limiting Konfiguration
RATE_LIMIT_DEFAULT=60
RATE_LIMIT_WINDOW=60
RATE_LIMIT_AUTH=10
RATE_LIMIT_AUTH_WINDOW=300
RATE_LIMIT_API=30
RATE_LIMIT_API_WINDOW=60

43
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,43 @@
# Pull Request
## Beschreibung
Bitte beschreiben Sie kurz die Änderungen, die in diesem Pull Request enthalten sind.
## Art der Änderung
Bitte markieren Sie die zutreffenden Optionen:
- [ ] Bugfix (nicht-kritische Änderung, die ein Problem behebt)
- [ ] Neue Funktion (nicht-kritische Änderung, die Funktionalität hinzufügt)
- [ ] Breaking Change (Änderung, die zu Inkompatibilitäten führt)
- [ ] Dokumentationsänderung
- [ ] Performance-Verbesserung
## Wie wurde getestet?
Bitte beschreiben Sie, wie Sie Ihre Änderungen getestet haben.
## Dokumentation
**Sind Dokumentationsänderungen notwendig?**
- [ ] Ja (wenn ja, bitte unten Details angeben)
- [ ] Nein
**Welche Dokumentation wurde aktualisiert?**
- [ ] Code-Kommentare
- [ ] README/Markdown-Dateien
- [ ] PHPDoc
- [ ] Andere: _bitte angeben_
## Checkliste:
- [ ] Mein Code folgt den Coding-Guidelines des Projekts
- [ ] Ich habe Tests für meinen Code geschrieben
- [ ] Alle Tests bestehen
- [ ] Ich habe die Dokumentation aktualisiert (wenn nötig)
- [ ] Ich habe die Änderungen mit dem KI-Assistenten überprüft
- [ ] Ich habe die Leistung bei großen Datenmengen getestet (wenn relevant)
- [ ] Ich habe sichergestellt, dass keine externen Abhängigkeiten hinzugefügt wurden

13
.gitignore vendored
View File

@@ -10,7 +10,7 @@ vendor/
.env
*.log
*.retry
ansible/.vault_pass
x_ansible/.vault_pass
*.Zone.Identifier
# Backup Dateien
@@ -20,4 +20,13 @@ ansible/.vault_pass
.php-cs-fixer.php
#ssl/*.pem
ssl/*.pem
node_modules
storage/uploads/*
storage/logs/.gitkeep
dist/
.archive/
!/storage/uploads/videos/

12
.junie/guidelines.md Normal file
View File

@@ -0,0 +1,12 @@
# Project Guidelines
This is a placeholder of the project guidelines for Junie.
Replace this text with any project-level instructions for Junie, e.g.:
* What is the project structure
* Whether Junie should run tests to check the correctness of the proposed solution
* How does Junie run tests (once it requires any non-standard approach)
* Whether Junie should build the project before submitting the result
* Any code-style related instructions
As an option you can ask Junie to create these guidelines for you.

View File

@@ -9,10 +9,10 @@ ENV ?= dev
# Standart Docker Compose Befehle
up: ## Startet alle Docker-Container
./bin/up
docker compose up -d
down: ## Stoppt alle Container
./bin/down
docker compose down
build:
docker compose build
@@ -51,6 +51,10 @@ clean: ## Entfernt temporäre Dateien
find . -type f -name "*Zone.Identifier" -delete
find . -type f -name "*.retry" -delete
static: ## Generate Static Files
./bin/generate-static.php
# Projektstatus
status: ## Zeigt Container-Status
@echo "Aktuelles Projekt: $(PROJECT_NAME)"
@@ -70,6 +74,11 @@ help: ## Zeigt diese Hilfe an
@echo ""
console:
docker exec -it php php console.php $(Args)
composer: ## Use Composer
docker compose exec php composer $(ARGS)
@@ -85,23 +94,46 @@ cs-fix-file: ## Fix code style for a specific file
cs-fix: ## Fix code style for all PHP files
docker compose exec -e PHP_CS_FIXER_IGNORE_ENV=1 php ./vendor/bin/php-cs-fixer fix
setup-ssh: ## SSH-Schlüssel korrekt einrichten
mkdir -p ~/.ssh
cp /mnt/c/Users/Mike/.ssh/test.michaelschiemer.de ~/.ssh/staging
chmod 600 ~/.ssh/staging
@echo "SSH-Schlüssel für Staging korrekt eingerichtet"
fix-ssh-perms: ## Korrigiert SSH-Schlüsselberechtigungen (veraltet)
chmod 600 /mnt/c/Users/Mike/.ssh/test.michaelschiemer.de
@echo "SSH-Schlüsselberechtigungen korrigiert"
health:
ansible-playbook ansible/check.yml
# Konfiguration
# Ansible Konfiguration
ANSIBLE_INVENTORY=ansible/inventory/hosts.ini
PLAYBOOK_DIR=ansible/playbooks/deploy
TAGS=
.PHONY: dev staging production
.PHONY: dev staging production setup-server check
dev:
ansible-playbook -i $(ANSIBLE_INVENTORY) $(PLAYBOOK_DIR)/dev.yml #--ask-become-pass
# Deployment-Ziele
dev: ## Lokales Deployment (Development)
ansible-playbook -i $(ANSIBLE_INVENTORY) $(PLAYBOOK_DIR)/dev.yml --ask-become-pass $(if $(TAGS),--tags="$(TAGS)",)
staging:
ansible-playbook -i $(ANSIBLE_INVENTORY) $(PLAYBOOK_DIR)/staging.yml
staging: ## Staging-Deployment
ansible-playbook -i $(ANSIBLE_INVENTORY) $(PLAYBOOK_DIR)/staging.yml $(if $(TAGS),--tags="$(TAGS)",)
production:
ansible-playbook -i $(ANSIBLE_INVENTORY) $(PLAYBOOK_DIR)/production.yml
production: ## Produktions-Deployment
ansible-playbook -i $(ANSIBLE_INVENTORY) $(PLAYBOOK_DIR)/production.yml $(if $(TAGS),--tags="$(TAGS)",)
.PHONY: up down build restart logs ps phpinfo deploy setup clean status
setup-server: ## Server-Grundkonfiguration
ansible-playbook -i $(ANSIBLE_INVENTORY) ansible/setup.yml $(if $(LIMIT),--limit="$(LIMIT)",) $(if $(TAGS),--tags="$(TAGS)",)
check: ## Serververbindung prüfen
ansible -i $(ANSIBLE_INVENTORY) all -m ping $(if $(LIMIT),--limit="$(LIMIT)",)
# Beispielaufrufe:
# make staging TAGS="deploy,check"
# make setup-server LIMIT="staging" TAGS="docker"
.PHONY: up down build restart logs ps phpinfo deploy setup clean status fix-ssh-perms setup-ssh

128
README-image-upload.md Normal file
View File

@@ -0,0 +1,128 @@
# Bild-Upload-System
Dieses Dokument beschreibt das Bild-Upload-System des Frameworks.
## Funktionen
- Strukturierte Ordnerhierarchie basierend auf Datum und ID
- Automatische Erstellung mehrerer Bildvarianten (Größen)
- Unterstützung für mehrere Dateiformate (JPG, WebP, AVIF)
- Duplikat-Erkennung über SHA256-Hash
- Sicherheitsvalidierung von Dateitypen und -größen
- Performance-Optimierung durch moderne Bildformate
## Ordnerstruktur
Die Bilder werden in folgendem Muster gespeichert:
```
/uploads
└─ /YYYY (Jahr)
└─ /MM (Monat)
└─ /DD (Tag)
└─ /XXX (ID Teil 1)
└─ /XXX (ID Teil 2)
└─ /XXX (ID Teil 3)
└─ original.jpg
└─ thumbnail.jpg
└─ thumbnail.webp
└─ thumbnail.avif
└─ small.jpg
└─ small.webp
└─ small.avif
└─ medium.jpg
└─ medium.webp
└─ medium.avif
└─ large.jpg
└─ large.webp
└─ large.avif
```
## Bildvarianten
Folgende Bildvarianten werden automatisch erstellt:
- **thumbnail**: 150x150px, quadratisch zugeschnitten
- **small**: 400x400px, proportional skaliert
- **medium**: 800x800px, proportional skaliert
- **large**: 1200x1200px, proportional skaliert
## Dateiformate
Jede Variante wird in folgenden Formaten gespeichert:
- **JPG**: Für universelle Kompatibilität
- **WebP**: Für moderne Browser mit besserer Kompression
- **AVIF**: Für neueste Browser mit bester Kompression (falls PHP-Unterstützung vorhanden)
## Datenbank-Schema
Das System verwendet zwei Tabellen:
### `images`
- `id`: Primärschlüssel
- `filename`: Generierter Dateiname
- `original_filename`: Originaler Upload-Dateiname
- `mime_type`: MIME-Typ des Bildes
- `file_size`: Dateigröße in Bytes
- `width`: Bildbreite in Pixeln
- `height`: Bildhöhe in Pixeln
- `hash`: SHA256-Hash für Duplikat-Erkennung
- `upload_path`: Relativer Pfad zum Bild
- `created_at`: Erstellungszeitpunkt
- `updated_at`: Letzter Aktualisierungszeitpunkt
### `image_variants`
- `id`: Primärschlüssel
- `image_id`: Fremdschlüssel zur `images`-Tabelle
- `variant`: Name der Variante (thumbnail, small, medium, large)
- `format`: Dateiformat (jpg, webp, avif)
- `width`: Bildbreite in Pixeln
- `height`: Bildhöhe in Pixeln
- `file_size`: Dateigröße in Bytes
- `filename`: Name der Variantendatei
- `created_at`: Erstellungszeitpunkt
## Verwendung
### Bild hochladen
```php
// In einem Controller
public function upload(Request $request)
{
$file = $request->files->get('image');
$image = $this->imageService->uploadImage($file);
// URL zu einer Bildvariante erhalten
$thumbnailUrl = $this->imageService->getImageUrl($image, 'thumbnail', 'webp');
$mediumUrl = $this->imageService->getImageUrl($image, 'medium', 'jpg');
}
```
### Bild anzeigen
```html
<!-- Optimale Bildauswahl mit picture-Element -->
<picture>
<source srcset="/media/uploads/2025/06/26/001/234/567/1234567/medium.avif" type="image/avif">
<source srcset="/media/uploads/2025/06/26/001/234/567/1234567/medium.webp" type="image/webp">
<img src="/media/uploads/2025/06/26/001/234/567/1234567/medium.jpg" alt="Beschreibung">
</picture>
```
## Technische Details
### Bildverarbeitung
Die Bildverarbeitung erfolgt mit der GD-Bibliothek. Für die Verwendung von AVIF ist PHP 8.1+ mit AVIF-Unterstützung erforderlich.
### Performance
Bilder werden mit Cache-Headers ausgeliefert, die eine lange Browser-Cache-Dauer ermöglichen, um Bandbreite zu sparen und die Ladezeit zu verbessern.
### Sicherheit
Alle Uploads werden validiert, um sicherzustellen, dass nur erlaubte Bildtypen verarbeitet werden. Es werden sichere, eindeutige Dateinamen generiert, um Konflikte zu vermeiden.

118
README-static-site.md Normal file
View File

@@ -0,0 +1,118 @@
# Static Site Generator
Diese Komponente ermöglicht die Generierung statischer HTML-Seiten aus den dynamischen Routen der Anwendung, die mit dem `#[StaticPage]` Attribut markiert sind.
## Funktionsweise
Der Static Site Generator identifiziert alle Controller-Methoden, die mit dem `#[StaticPage]` Attribut markiert sind, und erzeugt für jede entsprechende Route eine statische HTML-Datei im `public/static` Verzeichnis. Die Verzeichnisstruktur wird entsprechend der URL-Struktur erstellt.
## Markieren von Routen als statische Seiten
Um eine Route als statische Seite zu kennzeichnen, füge das `#[StaticPage]` Attribut zur Controller-Methode hinzu:
```php
use App\Framework\Attributes\StaticPage;
use App\Framework\Attributes\Route;
class HomeController
{
#[Route('/')]
#[StaticPage]
public function index()
{
// Diese Seite wird als statische Seite generiert
return new ViewResult(new HomeViewModel());
}
#[Route('/about')]
#[StaticPage(outputPath: 'ueber-uns')]
public function about()
{
// Diese Seite wird mit benutzerdefiniertem Pfad generiert
return new ViewResult(new AboutViewModel());
}
#[Route('/dynamic-content')]
public function dynamic()
{
// Diese Seite wird NICHT als statische Seite generiert
return new ViewResult(new DynamicViewModel());
}
}
```
## Verwendung
### Über die Kommandozeile
```bash
# Nur mit #[StaticPage] markierte Routen generieren (Standard)
php bin/generate-static.php
# Oder mit Composer
composer generate-static
# Alle GET-Routen generieren (ignoriert #[StaticPage] Attribut)
php bin/generate-static.php --all
# Mit benutzerdefinierten Optionen
php bin/generate-static.php --manual-routes=config/static-routes.json --output=public/static --exclude="\/api\/.*,\/admin\/.*"
```
### Verfügbare Optionen
- `--manual-routes=<datei>`: Pfad zu einer JSON-Datei mit zu generierenden Routen (überschreibt Attributerkennung)
- `--output=<ordner>`: Ausgabeverzeichnis (Standard: public/static)
- `--exclude=<muster>`: Kommagetrennte Liste von Regex-Mustern für auszuschließende Routen
- `--all`: Alle GET-Routen generieren (ignoriert #[StaticPage] Attribut)
- `--help`: Hilfe anzeigen
### Programmatisch verwenden
```php
use App\Framework\StaticSite\StaticSiteGenerator;
use App\Framework\StaticSite\StaticPageCollector;
// Anwendung initialisieren
$app = /* ... */;
// Routen mit StaticPage-Attribut sammeln
$router = $app->getContainer()->get(HttpRouter::class);
$staticPageCollector = new StaticPageCollector($router);
$routes = $staticPageCollector->collectStaticPages();
// Oder alle GET-Routen sammeln
// $routes = $staticPageCollector->collectAllGetRoutes();
// Generator initialisieren
$generator = new StaticSiteGenerator($app, $routes);
// Optional: Ausgabeverzeichnis anpassen
$generator->setOutputDirectory(__DIR__ . '/path/to/output');
// Statische Seiten generieren
$generator->generate();
```
## Ausgabestruktur
Für jede markierte Route wird eine entsprechende Datei oder Verzeichnisstruktur erstellt:
- `/``/public/static/index.html`
- `/about``/public/static/about/index.html` (oder benutzerdefinierter Pfad, wenn im Attribut angegeben)
- `/blog/post-1``/public/static/blog/post-1/index.html`
## Erweitertes StaticPage-Attribut
Das `#[StaticPage]` Attribut unterstützt folgende Parameter:
- `outputPath`: Optionaler benutzerdefinierter Ausgabepfad für die statische Seite
- `prerender`: Ob die Seite beim Deployment vorgerendert werden soll (Standard: true)
## Vorteile statischer Seiten
- Verbesserte Performance durch Wegfall der dynamischen Verarbeitung
- Geringere Serverlast
- Einfacheres Hosting (statische Dateien können überall gehostet werden)
- Verbesserte Sicherheit durch Reduzierung der Angriffsfläche
- Bessere SEO durch schnellere Ladezeiten

254
README.md
View File

@@ -1,5 +1,259 @@
## 🚀 Quick Start
# PHP Framework
Ein modulares und erweiterbares PHP-Framework mit API-Client-Integration und Attribut-basiertem Routing.
## Installation
```bash
# Repository klonen
git clone https://github.com/username/framework.git
cd framework
# Abhängigkeiten installieren
composer install
# Umgebungsvariablen konfigurieren
cp .env.example .env
# Bearbeiten Sie .env mit Ihren Einstellungen
```
## Anwendung starten
Das Framework kann entweder mit dem eingebauten PHP-Webserver oder mit einem Webserver wie Apache oder Nginx ausgeführt werden.
### Eingebauter PHP-Webserver
```bash
php -S localhost:8000 -t public/
```
### Nginx-Konfiguration
```nginx
server {
listen 80;
server_name yourdomain.com;
root /path/to/framework/public;
index index.php;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass unix:/var/run/php/php8.1-fpm.sock;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
}
}
```
## Architektur
Das Framework folgt einer modularen Architektur mit den folgenden Hauptkomponenten:
### Core
- **Application**: Steuert den Anwendungslebenszyklus
- **Container**: Dependency Injection Container
- **Router**: Attribute-basiertes Routing
- **Middleware**: HTTP-Middleware-Chain
### HTTP
# DateTime-Modul für das Framework
Dieses Modul bietet eine umfassende Unterstützung für DateTime-Operationen im Framework mit einem Fokus auf Testbarkeit, Immutabilität und Zeitzonen-Unterstützung.
## Hauptkomponenten
### Clock-Interface
Das `Clock`-Interface abstrahiert den Zugriff auf die aktuelle Zeit und stellt sicher, dass Anwendungscode testbar bleibt:
```php
// Produktionscode verwendet SystemClock
$clock = new SystemClock();
$now = $clock->now();
// Testcode kann FrozenClock verwenden
$clock = new FrozenClock('2021-01-01 00:00:00');
$frozenTime = $clock->now(); // Gibt immer das gleiche Datum zurück
```
### FrozenClock für Tests
Die `FrozenClock`-Klasse ist besonders nützlich für Tests, bei denen Sie die Zeit kontrollieren müssen:
```php
$clock = new FrozenClock('2021-01-01 00:00:00');
// Zeit vorstellen
$clock->moveForward('PT1H'); // Eine Stunde vorstellen
// Zeit zurückstellen
$clock->moveBackward('P1D'); // Einen Tag zurückstellen
// Zeit direkt setzen
$clock->setTo('2022-01-01 00:00:00');
```
### DateTimeFormatter
Der `DateTimeFormatter` bietet bequeme Methoden zum konsistenten Formatieren von Datums- und Zeitwerten:
```php
$formatter = new DateTimeFormatter();
// ISO8601-Format (für APIs und JSON)
$iso = $formatter->formatIso8601($date); // 2021-01-01T12:34:56+00:00
// SQL-Format
$sql = $formatter->formatSql($date); // 2021-01-01 12:34:56
// Nur Datum
$dateOnly = $formatter->formatDate($date); // 2021-01-01
// Benutzerdefiniertes Format
$custom = $formatter->format($date, 'd.m.Y H:i'); // 01.01.2021 12:34
```
### DateRange
Die `DateRange`-Klasse ermöglicht die einfache Arbeit mit Zeiträumen:
```php
// Zeitraum erstellen
$range = DateRange::fromStrings('2021-01-01', '2021-01-31');
// Prüfen, ob ein Datum im Bereich liegt
$isInRange = $range->contains($someDate);
// Prüfen, ob sich Bereiche überschneiden
$doOverlap = $range->overlaps($otherRange);
// Dauer berechnen
$seconds = $range->getDurationInSeconds();
```
## DI-Container Integration
Das Modul lässt sich nahtlos in den DI-Container des Frameworks integrieren:
```php
// In Ihrer Bootstrap-Datei oder Service-Provider
$container->singleton(Clock::class, SystemClock::class);
$container->singleton(DateTimeFormatter::class, DateTimeFormatter::class);
// Oder den bereitgestellten ServiceProvider verwenden
$dateTimeServiceProvider = new DateTimeServiceProvider();
$dateTimeServiceProvider->register($container);
```
## Testunterstützung
Für Tests kann die FrozenClock einfach registriert werden:
```php
// In Ihrem Test-Setup
$frozenClock = new FrozenClock('2021-01-01 00:00:00');
$container->instance(Clock::class, $frozenClock);
// Zeit während des Tests manipulieren
$frozenClock->moveForward('PT1H');
```
- **Request/Response**: HTTP-Nachrichten
- **HttpClient**: HTTP-Client für API-Anfragen
### API
- **ApiRequestTrait**: Hilfsklasse für API-Clients
- **Integrierte Clients**: RapidMail, Shopify
## Features
- **Attribut-basiertes Routing**: Deklaratives Routing mit PHP 8 Attributen
- **Dependency Injection**: Automatische Auflösung von Abhängigkeiten
- **Middleware-System**: Erweiterbare HTTP-Middleware-Chain
- **API-Clients**: Integrierte Clients für gängige APIs
- **Konfigurationsmanagement**: Flexible Konfiguration mit Umgebungsvariablen
## Beispiele
### Controller mit Routing
```php
<?php
namespace App\Application\Example;
use App\Framework\Attributes\Route;
use App\Framework\Router\Result\JsonResult;
final class ExampleController
{
#[Route(path: '/api/example', method: 'GET')]
public function getExample(): JsonResult
{
return new JsonResult([
'success' => true,
'message' => 'Hello World!'
]);
}
}
```
### API-Client verwenden
```php
<?php
namespace App\Application\Newsletter;
use App\Framework\Attributes\Route;use App\Framework\Http\Status;use App\Framework\Router\Result\JsonResult;use App\Infrastructure\Api\RapidMailClient;use Archive\Config\ApiConfig;
final class NewsletterController
{
private RapidMailClient $client;
public function __construct()
{
$this->client = new RapidMailClient(
ApiConfig::RAPIDMAIL_USERNAME->value,
ApiConfig::RAPIDMAIL_PASSWORD->value
);
}
#[Route(path: '/newsletter/register', method: 'POST')]
public function register(NewsletterRequest $request): JsonResult
{
$result = $this->client->addRecipient(
$request->name,
$request->email,
ApiConfig::getRapidmailListId()
);
return new JsonResult(
['success' => true, 'data' => $result],
Status::CREATED
);
}
}
```
## Erweiterung
Das Framework kann durch eigene Komponenten und Module erweitert werden:
- **Eigene Middleware**: Erstellen Sie benutzerdefinierte Middleware-Klassen
- **Attribute**: Definieren Sie eigene Attribute und Mapper
- **API-Clients**: Integrieren Sie zusätzliche APIs mit dem ApiRequestTrait
## Lizenz
MIT
```bash
# Starten
make up

View File

@@ -1,39 +0,0 @@
# Basis-Konfiguration
app_name: michaelschiemer
app_domain: test.michaelschiemer.de
app_email: kontakt@michaelschiemer.de
# Verzeichnisse
project_root: "{{ playbook_dir | dirname }}"
app_root: /var/www/{{ app_name }}
app_public: "{{ app_root }}/public"
# Docker
docker_version: "20.10"
docker_compose_version: "2.24.5"
# Benutzer
deploy_user: deploy
# Let's Encrypt
letsencrypt_enabled: true
letsencrypt_certbot_method: webroot # oder standalone oder nginx
#netcup_customer_id: "218722"
#netcup_api_key: "dmJINUMyNjRmOG1aNDViajZHN2JkOTFRUjU3ckE5ZjJ1Zm1vUz"
#netcup_api_password: "iGWL8Hl4m93DgESsP/MPXmtDd0hEVkZ3480Na0psTlXRALnopl"
#netcup_vserver_id: "v2202309206672239295"
# fallback_ip:
wg_all_clients_private_keys:
michael: "PITbFZ3UfY5vD5dYUCELO37Qo2W8I4R8+r6D9CeMrm4="
wireguard_clients:
- name: michael
address: 10.8.0.2
public_key: DEIN_PUBLIC_KEY

View File

@@ -1,8 +0,0 @@
#[web]
#localhost ansible_connection=local
[web]
94.16.110.151 ansible_user=deploy ansible_ssh_private_key_file=/mnt/c/Users/Mike/.ssh/test.michaelschiemer.de
[vpn]
94.16.110.151 ansible_user=deploy

View File

@@ -1,12 +0,0 @@
[localhost]
127.0.0.1 ansible_connection=local
[staging]
94.16.110.151 ansible_user=deploy ansible_ssh_private_key_file=/mnt/c/Users/Mike/.ssh/test.michaelschiemer.de
[production]
[vpn]
94.16.110.151 ansible_user=deploy

View File

@@ -1,24 +0,0 @@
- name: Deployment in jeweilige Umgebung
hosts: all
become: true
gather_facts: false
vars:
docker_compose_project_path: "/var/www/michaelschiemer/"
env_file_path: "/var/www/michaelschiemer/.env"
deploy_root: /var/www/michaelschiemer
deploy_public: "{{ deploy_root }}/public"
deploy_user: deploy
app_domain: "example.com" # Passe ggf. an
project_root: "{{ playbook_dir }}/../.."
roles:
- app
- nginx
- php
- redis

View File

@@ -1,26 +0,0 @@
- name: Deployment für DEV (localhost)
hosts: localhost
become: true
gather_facts: false
vars:
docker_compose_project_path: "/home/michael/dev/michaelschiemer"
env_file_path: "/var/www/michaelschiemer/.env"
deploy_root: /var/www/michaelschiemer
deploy_public: "{{ deploy_root }}/public"
deploy_user: deploy
app_domain: "localhost" # Passe ggf. an
project_root: "/home/michael/dev/michaelschiemer"
roles:
#- app
- nginx
- php
- redis
tasks:
- name: Common Deployment Tasks
import_tasks: ../deploy/includes/deploy_common.yml

View File

@@ -1,29 +0,0 @@
- name: Docker Compose Files & Konfigurationen synchronisieren
ansible.builtin.copy:
src: "{{ item.src }}"
dest: "{{ docker_compose_project_path }}/{{ item.dest }}"
owner: root
group: root
mode: 0644
loop:
- { src: '{{ project_root }}/docker-compose.yml', dest: 'docker-compose.yml' }
- { src: '{{ project_root }}/.env', dest: '.env' }
# Weitere Konfigdateien nach Bedarf (z.B. nginx.conf, redis.conf, ...)
- { src: '{{ project_root }}/docker/nginx/nginx.conf', dest: 'nginx.conf' }
- name: "Docker Compose: Container hochfahren (Build & Start)"
ansible.builtin.command: |
docker-compose -f {{ docker_compose_project_path }}/docker-compose.yml up -d --build
args:
chdir: "{{ docker_compose_project_path }}"
- name: Status prüfen
ansible.builtin.command: |
docker-compose -f {{ docker_compose_project_path }}/docker-compose.yml ps
args:
chdir: "{{ docker_compose_project_path }}"
register: compose_ps
- name: Ergebnis anzeigen
ansible.builtin.debug:
var: compose_ps.stdout_lines

View File

@@ -1,23 +0,0 @@
- name: Deployment für PRODUCTION
hosts: production
become: true
gather_facts: false
vars:
docker_compose_project_path: "/var/www/www.michaelschiemer.de/"
env_file_path: "/var/www/www.michaelschiemer.de/.env"
deploy_root: /var/www/www.michaelschiemer.de
deploy_public: "{{ deploy_root }}/public"
deploy_user: deploy
app_domain: "michaelschiemer.de"
project_root: "{{ playbook_dir }}/../.."
roles:
- app
- nginx
- php
- redis
tasks:
- name: Common Deployment Tasks
import_tasks: ../deploy/includes/deploy_common.yml

View File

@@ -1,23 +0,0 @@
- name: Deployment für STAGING
hosts: staging
become: true
gather_facts: false
vars:
docker_compose_project_path: "/var/www/stage.michaelschiemer/"
env_file_path: "/var/www/stage.michaelschiemer/.env"
deploy_root: /var/www/stage.michaelschiemer
deploy_public: "{{ deploy_root }}/public"
deploy_user: deploy
app_domain: "stage.example.com"
project_root: "{{ playbook_dir }}/../.."
roles:
- app
- nginx
- php
- redis
tasks:
- name: Common Deployment Tasks
import_tasks: ../deploy/includes/deploy_common.yml

View File

@@ -1,11 +0,0 @@
---
- name: Basis Setup für alle Zielsysteme
hosts: all
become: true
#gather_facts: true
roles:
#- common
- docker
#- webserver
#- app

View File

@@ -1,134 +0,0 @@
- name: Zielverzeichnis erstellen
file:
path: "{{ deploy_root }}"
state: directory
owner: "{{ deploy_user }}"
group: "{{ deploy_user }}"
mode: '0755'
- name: SSL-Verzeichnis sicherstellen
file:
path: "{{ deploy_root }}/ssl"
state: directory
owner: "{{ deploy_user }}"
group: "{{ deploy_user }}"
mode: '0755'
- name: SSL-Zertifikate prüfen
stat:
path: "/etc/letsencrypt/live/{{ app_domain }}/fullchain.pem"
register: ssl_certs
- name: SSL-Zertifikate kopieren (falls vorhanden)
copy:
src: "{{ item.src }}"
dest: "{{ item.dest }}"
remote_src: yes
owner: "{{ deploy_user }}"
group: "{{ deploy_user }}"
mode: '0644'
loop:
- { src: "/etc/letsencrypt/live/{{ app_domain }}/fullchain.pem", dest: "{{ deploy_root }}/ssl/fullchain.pem" }
- { src: "/etc/letsencrypt/live/{{ app_domain }}/privkey.pem", dest: "{{ deploy_root }}/ssl/privkey.pem" }
when: ssl_certs.stat.exists
- name: public-Verzeichnis synchronisieren
synchronize:
src: "{{ playbook_dir }}/../../public/"
dest: "{{ deploy_public }}/"
delete: yes
recursive: yes
- name: Projekt-Stammdaten kopieren
copy:
src: "{{ playbook_dir }}/../../docker-compose.yml"
dest: "{{ deploy_root }}/docker-compose.yml"
owner: "{{ deploy_user }}"
group: "{{ deploy_user }}"
mode: '0644'
- name: .env-Datei prüfen
stat:
path: "{{ project_root }}/.env"
register: env_file
- name: .env kopieren (falls vorhanden)
copy:
src: "{{ project_root }}/.env"
dest: "{{ deploy_root }}/.env"
mode: '0644'
when: env_file.stat.exists
- name: Quellcode synchronisieren
synchronize:
src: "{{ playbook_dir }}/../../src/"
dest: "{{ deploy_root }}/src/"
delete: yes
recursive: yes
- name: Docker-Verzeichnis prüfen
stat:
path: "{{ project_root }}/docker"
register: docker_dir
delegate_to: localhost
become: false
- name: Docker-Configs synchronisieren (falls vorhanden)
synchronize:
src: "{{ project_root }}/docker/"
dest: "{{ deploy_root }}/docker/"
delete: yes
recursive: yes
when: docker_dir.stat.exists
- name: Rechte im Zielverzeichnis korrigieren
file:
path: "{{ deploy_root }}"
state: directory
owner: "{{ deploy_user }}"
group: "{{ deploy_user }}"
mode: '0755'
recurse: yes
# Cache-Verzeichnis für UID/GID 1000 (z.B. appuser im Container)
- name: Stelle Schreibrechte für Cache-Verzeichnis her
file:
path: "{{ deploy_root }}/cache"
state: directory
owner: 1000
group: 1000
mode: '0775'
recurse: yes
- name: Docker Compose neu bauen und starten
shell: |
docker compose down
docker compose up -d --build
args:
chdir: "{{ deploy_root }}"
- name: PHP-Container für Composer starten
shell: docker compose up -d php
args:
chdir: "{{ deploy_root }}"
- name: Kurze Wartezeit bis PHP-Container bereit
wait_for:
timeout: 5
- name: Composer Abhängigkeiten installieren
shell: docker compose exec -T php composer install --no-interaction
args:
chdir: "{{ deploy_root }}"
register: composer_result
ignore_errors: yes
- name: Composer-Ergebnis anzeigen
debug:
var: composer_result.stdout_lines
when: composer_result.stdout is defined
- name: Composer-Fehler anzeigen
debug:
var: composer_result.stderr_lines
when: composer_result.stderr is defined

View File

@@ -1,26 +0,0 @@
# Grundlegende Systemkonfiguration
- name: Basis-Pakete aktualisieren und installieren
apt:
name:
- sudo
- vim
- htop
- git
- zip
- unzip
- curl
- wget
state: present
update_cache: yes
become: true
# Passwordless sudo für den deploy-Benutzer einrichten
- name: Konfiguriere passwordless sudo für deploy-Benutzer
lineinfile:
path: "/etc/sudoers.d/{{ deploy_user }}"
line: "{{ deploy_user }} ALL=(ALL) NOPASSWD: ALL"
state: present
create: yes
mode: '0440'
validate: 'visudo -cf %s'
become: true

View File

@@ -1,3 +0,0 @@
docker_compose_version: "v2.29.2"
docker_install_compose: true
docker_user: "{{ ansible_user || default('michael' }}"

View File

@@ -1,58 +0,0 @@
- name: Docker-Abhängigkeiten installieren
apt:
name:
- apt-transport-https
- ca-certificates
- curl
- gnupg
- lsb-release
state: present
update_cache: yes
- name: Docker GPG-Schlüssel hinzufügen
apt_key:
url: https://download.docker.com/linux/{{ ansible_distribution | lower }}/gpg
state: present
- name: Docker Repository hinzufügen
apt_repository:
repo: "deb [arch=amd64] https://download.docker.com/linux/{{ ansible_distribution | lower }} {{ ansible_distribution_release }} stable"
state: present
- name: Docker Engine installieren
apt:
name:
- docker-ce
- docker-ce-cli
- containerd.io
- docker-compose-plugin
state: present
update_cache: yes
- name: Docker Compose installieren (V1 als Fallback)
get_url:
url: "https://github.com/docker/compose/releases/download/v{{ docker_compose_version }}/docker-compose-linux-x86_64"
dest: /usr/local/bin/docker-compose
mode: '0755'
- name: Benutzer zur Docker-Gruppe hinzufügen
user:
name: "{{ ansible_user }}"
groups: docker
append: yes
- name: Docker-Service starten und aktivieren
service:
name: docker
state: started
enabled: yes
notify: restart docker
- name: Starte Docker-Container via Compose
community.docker.docker_compose_v2:
#project_src: "{{ playbook_dir | dirname }}/../" # ggf. anpassen auf deinen Compose-Pfad!
project_src: "{{ app_root }}"
build: always
recreate: always

View File

@@ -1,5 +0,0 @@
nginx_conf_template: nginx.conf.j2
nginx_default_site_template: default.conf.j2
nginx_ssl_src_dir: "{{ app_root }}/ssl"
nginx_ssl_dest_dir: "/var/www/michaelschiemer/ssl"
nginx_target_dir: "/var/www/michaelschiemer/docker/nginx"

View File

@@ -1,2 +0,0 @@
- name: reload nginx
ansible.builtin.command: docker exec <nginx_container_name> nginx -s reload

View File

@@ -1,37 +0,0 @@
- name: Stelle das nginx-Verzeichnis sicher
ansible.builtin.file:
path: "{{ nginx_target_dir }}"
state: directory
recurse: yes
mode: '0755'
- name: Kopiere nginx-Konfiguration (nginx.conf)
ansible.builtin.template:
src: "{{ nginx_conf_template }}"
dest: "{{ nginx_target_dir }}/nginx.conf"
mode: '0644'
- name: Kopiere default site conf
ansible.builtin.template:
src: "{{ nginx_default_site_template }}"
dest: "{{ nginx_target_dir }}/default.conf"
mode: '0644'
- name: Kopiere docker-entrypoint Skript
ansible.builtin.copy:
src: docker-entrypoint.sh
dest: "{{ nginx_target_dir }}/docker-entrypoint.sh"
mode: '0755'
- name: Baue und starte Nginx-Container (optional, wenn Compose separat genutzt wird, dann hier nicht nötig)
ansible.builtin.command: docker-compose up -d --build web
args:
chdir: "{{ docker_compose_project_path }}"
when: nginx_target_dir is defined
register: nginx_compose_result
ignore_errors: true
- name: Zeige Compose-Resultat
ansible.builtin.debug:
var: nginx_compose_result.stdout_lines
when: nginx_compose_result is defined

View File

@@ -1,133 +0,0 @@
# --------------------------------------------------------
# WireGuard installieren
# --------------------------------------------------------
- name: Stelle sicher, dass WireGuard installiert ist
apt:
name: wireguard
state: present
update_cache: yes
become: true
when: ansible_connection != "local"
# --------------------------------------------------------
# Server-Schlüssel erzeugen und speichern
# --------------------------------------------------------
- name: Prüfe ob privater Server-Schlüssel existiert
stat:
path: /etc/wireguard/privatekey
register: privkey_file
become: true
when: ansible_connection != "local"
- name: Erstelle Schlüsselpaar für Server (wenn nicht vorhanden)
command: wg genkey
register: server_private_key
when: ansible_connection != "local" and (not privkey_file.stat.exists | default(true))
- name: Speichere privaten Schlüssel
copy:
content: "{{ server_private_key.stdout }}"
dest: /etc/wireguard/privatekey
mode: "0600"
when: server_private_key.stdout is defined and server_private_key.stdout is defined
- name: Lies privaten Schlüssel ein
slurp:
src: /etc/wireguard/privatekey
become: true
when: ansible_connection != "local"
- name: Erzeuge öffentlichen Server-Schlüssel
command: "echo '{{ wg_privkey }}' | wg pubkey"
register: wg_pubkey
when: ansible_connection != "local"
- name: Privaten Server-Schlüssel anzeigen
debug:
msg: "{{ server_private_key }}"
when: ansible_connection != "local"
# --------------------------------------------------------
# Client-Key-Erzeugung lokal (einmalig pro Client)
# --------------------------------------------------------
- name: Generiere privaten Schlüssel für Clients (auf dem Server)
command: wg genkey
args:
creates: "/etc/wireguard/client-{{ item.name }}.key"
loop: "{{ wireguard_clients }}"
loop_control:
label: "{{ item.name }}"
register: client_private_keys
when: ansible_connection != "local"
- name: Erzeuge öffentlichen Schlüssel für Clients
command: "echo '{{ client_privkey_result.stdout }}' | wg pubkey"
register: client_pubkey_result
when:
- ansible_connection != "local"
- client_privkey_result is defined
- client_privkey_result.stdout is defined
- name: wireguard_clients mit public_key anreichern
set_fact:
wireguard_clients: "{{ wireguard_clients_with_pubkey | default([]) + [ item.0 | combine({'public_key': item.1.stdout|trim }) ] }}"
loop: "{{ wireguard_clients | zip(client_public_keys.results) | list }}"
when: client_public_keys is defined
- name: Aktuelles wireguard_clients-Set überschreiben
set_fact:
wireguard_clients: "{{ wireguard_clients_with_pubkey }}"
when: wireguard_clients_with_pubkey is defined
# --------------------------------------------------------
# Konfigurationsdatei erzeugen
# --------------------------------------------------------
#- debug:
# var: wireguard_clients
- name: Render wg0.conf
template:
src: wg0.conf.j2
dest: /etc/wireguard/wg0.conf
when: wg_privkey is defined and wg_privkey != ""
# --------------------------------------------------------
# IP Forwarding & WireGuard aktivieren
# --------------------------------------------------------
- name: Aktiviere IP-Forwarding
sysctl:
name: net.ipv4.ip_forward
value: 1
state: present
sysctl_set: yes
reload: yes
become: true
when: ansible_connection != "local"
- name: Starte und aktiviere WireGuard
systemd:
name: wg-quick@wg0
enabled: true
state: started
daemon_reload: yes
become: true
when: ansible_connection != "local"
- name: Verteilt für jeden Client die Client-Config
template:
src: client.conf.j2
dest: "/etc/wireguard/clients/{{ item.name }}.conf"
owner: root
group: root
mode: 0600
loop: "{{ wireguard_clients }}"
#delegate_to: localhost
run_once: true
become: true
when: ansible_connection != "local"

View File

@@ -1,54 +0,0 @@
---
# roles/wireguard/tasks/failsafe.yml
# Sicherstellt, dass SSH über VPN funktioniert und ein Fallback vorhanden ist
- name: Stelle sicher, dass wireguard_network gesetzt ist
assert:
that:
- wireguard_network is defined
fail_msg: "wireguard_network muss gesetzt sein (z.B. 10.8.0.0/24)"
- name: Automatisch externe IP als fallback_ip setzen (nur wenn nicht gesetzt)
shell: curl -s ifconfig.me
register: detected_fallback_ip
when: fallback_ip is not defined
changed_when: false
- name: Setze fallback_ip dynamisch als Ansible-Fact (wenn nicht gesetzt)
set_fact:
fallback_ip: "{{ detected_fallback_ip.stdout }}"
when: fallback_ip is not defined
- name: (Optional) Erlaube temporär Fallback-SSH von aktueller IP
ufw:
rule: allow
port: 22
proto: tcp
from_ip: "{{ fallback_ip }}"
- name: Erlaube SSH-Zugriff über VPN
ufw:
rule: allow
port: 22
proto: tcp
from_ip: "{{ wireguard_network }}"
- name: (Warnung) Prüfe ob VPN-Interface aktiv ist
shell: ip a show dev wg0
register: vpn_interface_check
failed_when: false
- name: Hinweis, wenn VPN-Interface nicht aktiv ist
debug:
msg: "⚠️ VPN-Interface wg0 scheint nicht aktiv zu sein. SSH über VPN wird nicht funktionieren."
when: vpn_interface_check.rc != 0
- name: (Optional) SSH von überall blockieren nur wenn VPN aktiv
when:
- ssh_lockdown | default(false)
- vpn_interface_check.rc == 0
ufw:
rule: deny
port: 22
proto: tcp
from_ip: 0.0.0.0/0

View File

@@ -1,83 +0,0 @@
# Beispiel: Passe jeden Task in dieser Datei so an:
- name: Aktiviere Firewall-Regeln für WireGuard
ufw:
rule: allow
port: "{{ wireguard_port }}"
proto: udp
become: true
when: ansible_connection != "local"
- name: Prüfe, ob UFW installiert ist
command: which ufw
register: ufw_installed
ignore_errors: true
changed_when: false
- name: Installiere UFW (falls nicht vorhanden)
apt:
name: ufw
state: present
update_cache: yes
when: ufw_installed.rc != 0
# Setze Standardrichtlinien (erst Konfiguration, dann am Ende aktivieren)
- name: Setze Policy für eingehenden Traffic auf "deny"
ufw:
direction: incoming
policy: deny
- name: Setze Policy für ausgehenden Traffic auf "allow"
ufw:
direction: outgoing
policy: allow
# WireGuard-Port freigeben (UDP)
- name: WireGuard-Port erlauben
ufw:
rule: allow
port: "{{ wireguard_port | default(51820) }}"
proto: udp
# SSH von bestimmter IP erlauben
- name: SSH von deiner IP erlauben (empfohlen)
ufw:
rule: allow
port: 22
proto: tcp
from_ip: "{{ fallback_ip }}"
when: fallback_ip is defined and fallback_ip | length > 0
# Temporär für Tests: SSH für alle erlauben (nur bei Bedarf!)
- name: SSH von überall erlauben (fail-safe, NUR während Setup/Test)
ufw:
rule: allow
port: 22
proto: tcp
when: (not (fallback_ip is defined and fallback_ip | length > 0)) or (enable_ssh_from_anywhere | default(false))
# Masquerading für WireGuard
- name: NAT für WireGuard aktivieren
iptables:
table: nat
chain: POSTROUTING
out_interface: "{{ wireguard_exit_interface | default('eth0') }}"
source: "{{ wireguard_network }}"
jump: MASQUERADE
- name: WireGuard Kernel-Modul laden
modprobe:
name: wireguard
state: present
# UFW ganz am Schluss aktivieren
- name: UFW aktivieren
ufw:
state: enabled
- name: Aktive UFW-Regeln anzeigen (zum Debuggen)
command: ufw status verbose
register: ufw_status
changed_when: false
- name: Zeige UFW-Regeln im Ansible-Output
debug:
var: ufw_status.stdout

View File

@@ -1,60 +0,0 @@
- name: Key-Verzeichnis für Client anlegen
file:
path: "{{ role_path }}/client-keys/{{ client.name }}"
state: directory
mode: "0700"
become: true
- name: Existenz des privaten Schlüssels prüfen
stat:
path: "{{ role_path }}/client-keys/{{ client.name }}/private.key"
register: client_private_key_stat
- name: Privaten Schlüssel generieren (nur falls nicht vorhanden)
command: wg genkey
register: genpriv
args:
chdir: "{{ role_path }}/client-keys/{{ client.name }}"
when: not client_private_key_stat.stat.exists
- name: Privaten Schlüssel speichern (nur falls nicht vorhanden)
copy:
content: "{{ genpriv.stdout }}"
dest: "{{ role_path }}/client-keys/{{ client.name }}/private.key"
mode: "0600"
when: not client_private_key_stat.stat.exists
- name: Public Key aus privaten Schlüssel generieren (bei Neuerstellung)
command: wg pubkey
args:
stdin: "{{ genpriv.stdout }}"
chdir: "{{ role_path }}/client-keys/{{ client.name }}"
register: genpub
when: not client_private_key_stat.stat.exists
- name: Bestehenden privaten Schlüssel laden (falls vorhanden)
slurp:
src: "{{ role_path }}/client-keys/{{ client.name }}/private.key"
register: loaded_private
when: client_private_key_stat.stat.exists
- name: Public Key aus gespeichertem Private Key erzeugen (falls vorhanden)
command: wg pubkey
args:
stdin: "{{ loaded_private.content | b64decode }}"
chdir: "{{ role_path }}/client-keys/{{ client.name }}"
register: genpub_existing
when: client_private_key_stat.stat.exists
- name: Public Key für Client in Datei schreiben
copy:
content: >
{{ (genpub.stdout if not client_private_key_stat.stat.exists else genpub_existing.stdout) }}
dest: "{{ role_path }}/client-keys/{{ client.name }}/public.key"
mode: "0644"
- name: Variablen für Client setzen (private/public key, Adresse)
set_fact:
"wg_{{ client.name }}_private_key": "{{ (genpriv.stdout if not client_private_key_stat.stat.exists else loaded_private.content | b64decode) }}"
"wg_{{ client.name }}_public_key": "{{ (genpub.stdout if not client_private_key_stat.stat.exists else genpub_existing.stdout) }}"
"wg_{{ client.name }}_address": "{{ client.address }}"

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