- Add initializer count logging in DiscoveryServiceBootstrapper - Add route structure analysis in RouterSetup - Add request parameter logging in HttpRouter - Update PHP production config for better OPcache handling - Fix various config and error handling improvements
500 lines
17 KiB
PHP
500 lines
17 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Framework\View\Processors;
|
|
|
|
use App\Framework\Config\AppConfig;
|
|
use App\Framework\Config\Environment;
|
|
use App\Framework\DI\Container;
|
|
use App\Framework\LiveComponents\ComponentRegistry;
|
|
use App\Framework\LiveComponents\Contracts\LiveComponentContract;
|
|
use App\Framework\Template\Processing\StringProcessor;
|
|
use App\Framework\View\Functions\ImageSlotFunction;
|
|
use App\Framework\View\Functions\LazyComponentFunction;
|
|
use App\Framework\View\Functions\UrlFunction;
|
|
use App\Framework\View\RawHtml;
|
|
use App\Framework\View\RenderContext;
|
|
use App\Framework\View\TemplateFunctions;
|
|
use DateTime;
|
|
use DateTimeZone;
|
|
|
|
final class PlaceholderReplacer implements StringProcessor
|
|
{
|
|
public function __construct(
|
|
private readonly Container $container,
|
|
private readonly ComponentRegistry $componentRegistry
|
|
) {
|
|
}
|
|
|
|
// Erlaubte Template-Funktionen für zusätzliche Sicherheit
|
|
|
|
/**
|
|
* @var string[]
|
|
*/
|
|
private array $allowedTemplateFunctions = [
|
|
'date', 'format_date', 'format_currency', 'format_filesize',
|
|
'strtoupper', 'strtolower', 'ucfirst', 'trim', 'count', /*'imageslot'*/
|
|
];
|
|
|
|
public function process(string $html, RenderContext $context): string
|
|
{
|
|
// Template-Funktionen: {{ date('Y-m-d') }}, {{ format_currency(100) }}
|
|
$html = $this->replaceTemplateFunctions($html, $context);
|
|
|
|
// Triple curly braces for raw/unescaped HTML: {{{ $content }}} or {{{ content }}}
|
|
// Supports both old and new syntax for backwards compatibility
|
|
$html = preg_replace_callback(
|
|
'/{{{\\s*\\$?([\\w.]+)\\s*}}}/',
|
|
function ($matches) use ($context) {
|
|
$expression = $matches[1];
|
|
|
|
return $this->resolveRaw($context->data, $expression);
|
|
},
|
|
$html
|
|
);
|
|
|
|
// Standard Variablen und Methoden: {{ $item.getRelativeFile() }} or {{ item.getRelativeFile() }}
|
|
// Supports both old and new syntax for backwards compatibility
|
|
// Also supports array bracket syntax: {{ $model['key'] }} or {{ $model["key"] }}
|
|
return preg_replace_callback(
|
|
'/{{\\s*\\$?([\\w.\\[\\]\'\"]+)(?:\\(\\s*([^)]*)\\s*\\))?\\s*}}/',
|
|
function ($matches) use ($context) {
|
|
$expression = $matches[1];
|
|
$params = isset($matches[2]) ? trim($matches[2]) : null;
|
|
|
|
if ($params !== null) {
|
|
return $this->resolveMethodCall($context->data, $expression, $params);
|
|
} else {
|
|
return $this->resolveEscaped($context->data, $expression, ENT_QUOTES | ENT_HTML5);
|
|
}
|
|
},
|
|
$html
|
|
);
|
|
}
|
|
|
|
private function replaceTemplateFunctions(string $html, RenderContext $context): string
|
|
{
|
|
return preg_replace_callback(
|
|
'/{{\\s*([a-zA-Z_][a-zA-Z0-9_]*)\\(([^)]*)\\)\\s*}}/',
|
|
function ($matches) use ($context) {
|
|
$functionName = $matches[1];
|
|
$params = trim($matches[2]);
|
|
|
|
$functions = new TemplateFunctions($this->container, ImageSlotFunction::class, LazyComponentFunction::class, UrlFunction::class);
|
|
if ($functions->has($functionName)) {
|
|
$function = $functions->get($functionName);
|
|
$args = $this->parseParams($params, $context->data);
|
|
if (is_callable($function)) {
|
|
return $function(...$args);
|
|
}
|
|
#return $function(...$args);
|
|
}
|
|
|
|
// Nur erlaubte Funktionen
|
|
if (! in_array($functionName, $this->allowedTemplateFunctions)) {
|
|
return $matches[0];
|
|
}
|
|
|
|
try {
|
|
$args = $this->parseParams($params, $context->data);
|
|
|
|
// Custom Template-Funktionen
|
|
if (method_exists($this, 'function_' . $functionName)) {
|
|
$result = $this->{'function_' . $functionName}(...$args);
|
|
|
|
return $result;
|
|
#return htmlspecialchars((string)$result, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
}
|
|
|
|
// Standard PHP-Funktionen (begrenzt)
|
|
if (function_exists($functionName)) {
|
|
$result = $functionName(...$args);
|
|
|
|
return htmlspecialchars((string)$result, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
}
|
|
|
|
} catch (\Throwable $e) {
|
|
return $matches[0];
|
|
}
|
|
|
|
return $matches[0];
|
|
},
|
|
$html
|
|
);
|
|
}
|
|
|
|
private function function_imageslot(string $slotName): string
|
|
{
|
|
$function = $this->container->get(ImageSlotFunction::class);
|
|
|
|
return ($function('slot1'));
|
|
}
|
|
|
|
// Custom Template-Funktionen
|
|
private function function_format_date(string|DateTime $date, string $format = 'Y-m-d H:i:s'): string
|
|
{
|
|
if (is_string($date)) {
|
|
$date = new \DateTimeImmutable($date, new DateTimeZone('Europe/Berlin'));
|
|
}
|
|
|
|
return $date->format($format);
|
|
}
|
|
|
|
private function function_format_currency(float $amount, string $currency = 'EUR'): string
|
|
{
|
|
return number_format($amount, 2, ',', '.') . ' ' . $currency;
|
|
}
|
|
|
|
private function function_format_filesize(int $bytes): string
|
|
{
|
|
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
$factor = floor((strlen((string)$bytes) - 1) / 3);
|
|
|
|
return sprintf("%.1f %s", $bytes / pow(1024, $factor), $units[$factor]);
|
|
}
|
|
|
|
private function resolveMethodCall(array $data, string $expression, string $params): string
|
|
{
|
|
$parts = explode('.', $expression);
|
|
$methodName = array_pop($parts); // Letzter Teil ist der Methodenname
|
|
$objectPath = implode('.', $parts); // Pfad zum Objekt
|
|
|
|
// Objekt auflösen
|
|
$object = $this->resolveValue($data, $objectPath);
|
|
|
|
if (! is_object($object)) {
|
|
return '{{ ' . $expression . '() }}'; // Platzhalter beibehalten
|
|
}
|
|
|
|
if (! method_exists($object, $methodName)) {
|
|
return '{{ ' . $expression . '() }}'; // Platzhalter beibehalten
|
|
}
|
|
|
|
try {
|
|
// Parameter parsen (falls vorhanden)
|
|
$parsedParams = empty($params) ? [] : $this->parseParams($params, $data);
|
|
|
|
// Methode aufrufen
|
|
$result = $object->$methodName(...$parsedParams);
|
|
|
|
return htmlspecialchars((string)$result, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
} catch (\Throwable $e) {
|
|
// Bei Fehlern Platzhalter beibehalten
|
|
return '{{ ' . $expression . '() }}';
|
|
}
|
|
}
|
|
|
|
private function resolveRaw(array $data, string $expr): string
|
|
{
|
|
$value = $this->resolveValue($data, $expr);
|
|
if ($value === null) {
|
|
// Bleibt als Platzhalter stehen
|
|
return '{{{ ' . $expr . ' }}}';
|
|
}
|
|
|
|
// LiveComponentContract - automatisch rendern mit Wrapper
|
|
if ($value instanceof LiveComponentContract) {
|
|
return $this->componentRegistry->renderWithWrapper($value);
|
|
}
|
|
|
|
// RawHtml-Objekte - direkt ausgeben
|
|
if ($value instanceof RawHtml) {
|
|
return $value->content;
|
|
}
|
|
|
|
// HtmlElement-Objekte - direkt ausgeben
|
|
if ($value instanceof \App\Framework\View\ValueObjects\HtmlElement) {
|
|
return (string) $value;
|
|
}
|
|
|
|
// Strings direkt ausgeben (KEIN escaping!)
|
|
if (is_string($value)) {
|
|
return $value;
|
|
}
|
|
|
|
// Arrays und komplexe Objekte können nicht direkt als String dargestellt werden
|
|
if (is_array($value)) {
|
|
return $this->handleArrayValue($expr, $value);
|
|
}
|
|
|
|
if (is_object($value) && ! method_exists($value, '__toString')) {
|
|
return $this->handleObjectValue($expr, $value);
|
|
}
|
|
|
|
return (string)$value;
|
|
}
|
|
|
|
private function resolveEscaped(array $data, string $expr, int $flags): string
|
|
{
|
|
$value = $this->resolveValue($data, $expr);
|
|
if ($value === null) {
|
|
// Bleibt als Platzhalter stehen
|
|
return '{{ ' . $expr . ' }}';
|
|
}
|
|
|
|
// LiveComponentContract - automatisch rendern mit Wrapper
|
|
// WICHTIG: Wrapper-HTML ist bereits escaped, also NICHT nochmal escapen!
|
|
if ($value instanceof LiveComponentContract) {
|
|
$wrapperHtml = $this->componentRegistry->renderWithWrapper($value);
|
|
// Wrap in RawHtml to prevent double-escaping by code below (line 271)
|
|
return $wrapperHtml;
|
|
}
|
|
|
|
// RawHtml-Objekte nicht escapen
|
|
if ($value instanceof RawHtml) {
|
|
return $value->content;
|
|
}
|
|
|
|
// HtmlElement-Objekte nicht escapen (Framework Components)
|
|
if ($value instanceof \App\Framework\View\ValueObjects\HtmlElement) {
|
|
return (string) $value;
|
|
}
|
|
|
|
// Arrays und komplexe Objekte können nicht direkt als String dargestellt werden
|
|
if (is_array($value)) {
|
|
return $this->handleArrayValue($expr, $value);
|
|
}
|
|
|
|
if (is_object($value) && ! method_exists($value, '__toString')) {
|
|
return $this->handleObjectValue($expr, $value);
|
|
}
|
|
|
|
// Zusätzlicher Schutz gegen Array-to-String Conversion
|
|
if (is_array($value) || (is_object($value) && ! method_exists($value, '__toString'))) {
|
|
// In Debug: Exception mit Details werfen
|
|
if ($this->isDebugMode()) {
|
|
$type = is_array($value) ? 'array[' . count($value) . ']' : 'object(' . get_class($value) . ')';
|
|
|
|
throw new \InvalidArgumentException("Cannot convert {$type} to string in placeholder: {{ {$expr} }}");
|
|
}
|
|
|
|
// In Production: leerer String
|
|
return '';
|
|
}
|
|
|
|
return htmlspecialchars((string)$value, $flags, 'UTF-8');
|
|
}
|
|
|
|
private function resolveValue(array $data, string $expr): mixed
|
|
{
|
|
// Handle array bracket syntax: $var['key'] or $var["key"]
|
|
// Can be chained: $var['key1']['key2'] or mixed: $var.prop['key']
|
|
$originalExpr = $expr;
|
|
$value = $data;
|
|
|
|
// Split expression into parts, handling both dot notation and bracket notation
|
|
$pattern = '/([\\w]+)|\\[([\'"])([^\\2]+?)\\2\\]/';
|
|
preg_match_all($pattern, $expr, $matches, PREG_SET_ORDER);
|
|
|
|
foreach ($matches as $match) {
|
|
if (!empty($match[1])) {
|
|
// Dot notation: variable.property
|
|
$key = $match[1];
|
|
if (is_array($value) && array_key_exists($key, $value)) {
|
|
$value = $value[$key];
|
|
} elseif (is_object($value) && isset($value->$key)) {
|
|
$value = $value->$key;
|
|
} else {
|
|
return null;
|
|
}
|
|
} elseif (!empty($match[3])) {
|
|
// Bracket notation: variable['key'] or variable["key"]
|
|
$key = $match[3];
|
|
if (is_array($value) && array_key_exists($key, $value)) {
|
|
$value = $value[$key];
|
|
} else {
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
return $value;
|
|
}
|
|
|
|
/**
|
|
* Parst Parameter aus einem Parameter-String
|
|
*/
|
|
private function parseParams(string $paramsString, array $data): array
|
|
{
|
|
if (empty(trim($paramsString))) {
|
|
return [];
|
|
}
|
|
|
|
$params = [];
|
|
$parts = $this->splitParams($paramsString);
|
|
|
|
foreach ($parts as $part) {
|
|
$part = trim($part);
|
|
|
|
// String-Literale: 'text' oder "text"
|
|
if (preg_match('/^[\'\"](.*)[\'\"]/s', $part, $matches)) {
|
|
$params[] = $matches[1];
|
|
|
|
continue;
|
|
}
|
|
|
|
// Zahlen
|
|
if (is_numeric($part)) {
|
|
$params[] = str_contains($part, '.') ? (float)$part : (int)$part;
|
|
|
|
continue;
|
|
}
|
|
|
|
// Boolean
|
|
if ($part === 'true') {
|
|
$params[] = true;
|
|
|
|
continue;
|
|
}
|
|
if ($part === 'false') {
|
|
$params[] = false;
|
|
|
|
continue;
|
|
}
|
|
|
|
// Null
|
|
if ($part === 'null') {
|
|
$params[] = null;
|
|
|
|
continue;
|
|
}
|
|
|
|
// Variablen-Referenzen: $variable oder Pfade: objekt.eigenschaft
|
|
if (str_starts_with($part, '$')) {
|
|
$varName = substr($part, 1);
|
|
if (array_key_exists($varName, $data)) {
|
|
$params[] = $data[$varName];
|
|
|
|
continue;
|
|
}
|
|
} elseif (str_contains($part, '.')) {
|
|
$value = $this->resolveValue($data, $part);
|
|
if ($value !== null) {
|
|
$params[] = $value;
|
|
|
|
continue;
|
|
}
|
|
} else {
|
|
// Einfache Variablennamen (ohne $ oder .) aus Template-Daten auflösen
|
|
$value = $this->resolveValue($data, $part);
|
|
if ($value !== null) {
|
|
$params[] = $value;
|
|
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Fallback: Als String behandeln
|
|
$params[] = $part;
|
|
}
|
|
|
|
return $params;
|
|
}
|
|
|
|
/**
|
|
* Behandelt Array-Werte in Templates - Debug vs Production Verhalten
|
|
*/
|
|
private function handleArrayValue(string $expr, array $value): string
|
|
{
|
|
// In Debug-Mode: Exception werfen um Entwickler auf das Problem hinzuweisen
|
|
if ($this->isDebugMode()) {
|
|
throw new \InvalidArgumentException(
|
|
"Template placeholder '{{ {$expr} }}' resolved to an array, which cannot be displayed as string. " .
|
|
"Use array access like '{{ {$expr}.0 }}' or iterate with <for> loop."
|
|
);
|
|
}
|
|
|
|
// In Production: Sinnvolle Fallback-Werte für häufige Arrays
|
|
if (empty($value)) {
|
|
return ''; // Leere Arrays werden zu leerem String
|
|
}
|
|
|
|
// Für Arrays mit nur einem Element: das Element zurückgeben
|
|
if (count($value) === 1 && ! is_array(reset($value)) && ! is_object(reset($value))) {
|
|
return htmlspecialchars((string)reset($value), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
}
|
|
|
|
// Für einfache Arrays: Anzahl anzeigen
|
|
return '[' . count($value) . ' items]';
|
|
}
|
|
|
|
/**
|
|
* Behandelt Object-Werte in Templates - Debug vs Production Verhalten
|
|
*/
|
|
private function handleObjectValue(string $expr, object $value): string
|
|
{
|
|
// In Debug-Mode: Exception werfen
|
|
if ($this->isDebugMode()) {
|
|
throw new \InvalidArgumentException(
|
|
"Template placeholder '{{ {$expr} }}' resolved to object of type '" . get_class($value) . "', " .
|
|
"which cannot be displayed as string. Implement __toString() method or access specific properties."
|
|
);
|
|
}
|
|
|
|
// In Production: Klassenname anzeigen
|
|
$className = get_class($value);
|
|
$shortName = substr($className, strrpos($className, '\\') + 1);
|
|
|
|
return '[' . $shortName . ']';
|
|
}
|
|
|
|
/**
|
|
* Erkennt ob wir im Debug-Modus sind über AppConfig
|
|
*/
|
|
private function isDebugMode(): bool
|
|
{
|
|
try {
|
|
/** @var AppConfig $appConfig */
|
|
$appConfig = $this->container->get(AppConfig::class);
|
|
|
|
return $appConfig->isDebug();
|
|
} catch (\Throwable $e) {
|
|
// Fallback zu Environment falls AppConfig nicht verfügbar
|
|
try {
|
|
$env = $this->container->get(Environment::class);
|
|
return $env->getString('APP_ENV', 'production') === 'development';
|
|
} catch (\Throwable) {
|
|
return false; // Safe fallback: production mode
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Teilt einen Parameter-String in einzelne Parameter auf
|
|
*/
|
|
private function splitParams(string $paramsString): array
|
|
{
|
|
$params = [];
|
|
$current = '';
|
|
$inQuotes = false;
|
|
$quoteChar = null;
|
|
$length = strlen($paramsString);
|
|
|
|
for ($i = 0; $i < $length; $i++) {
|
|
$char = $paramsString[$i];
|
|
|
|
if (! $inQuotes && ($char === '"' || $char === "'")) {
|
|
$inQuotes = true;
|
|
$quoteChar = $char;
|
|
$current .= $char;
|
|
} elseif ($inQuotes && $char === $quoteChar) {
|
|
$inQuotes = false;
|
|
$quoteChar = null;
|
|
$current .= $char;
|
|
} elseif (! $inQuotes && $char === ',') {
|
|
$params[] = trim($current);
|
|
$current = '';
|
|
} else {
|
|
$current .= $char;
|
|
}
|
|
}
|
|
|
|
if ($current !== '') {
|
|
$params[] = trim($current);
|
|
}
|
|
|
|
return $params;
|
|
}
|
|
}
|