diff --git a/src/Core/View/Cache/FileCacheAdapter.php b/src/Core/View/Cache/FileCacheAdapter.php index ddcbdb7..b3e81ad 100644 --- a/src/Core/View/Cache/FileCacheAdapter.php +++ b/src/Core/View/Cache/FileCacheAdapter.php @@ -3,15 +3,15 @@ namespace Beobles\Core\View\Cache; /** - * Adaptador de cache em arquivo + * Adaptador de cache em arquivo com TTL, escrita atômica e permissões seguras. */ class FileCacheAdapter implements CacheInterface { public function __construct( private string $cacheDir ) { - if (!is_dir($this->cacheDir)) { - mkdir($this->cacheDir, 0755, true); + if (!is_dir($this->cacheDir) && !mkdir($this->cacheDir, 0755, true) && !is_dir($this->cacheDir)) { + throw new \RuntimeException("Unable to create cache directory: {$this->cacheDir}"); } } @@ -19,37 +19,65 @@ public function get(string $key) { $file = $this->getFilePath($key); - if (!file_exists($file)) { + if (!is_file($file)) { return null; } - return file_get_contents($file); + $payload = file_get_contents($file); + if ($payload === false) { + return null; + } + + $entry = unserialize($payload, ['allowed_classes' => false]); + if (!is_array($entry) || !array_key_exists('value', $entry) || !isset($entry['expires_at'])) { + $this->delete($key); + return null; + } + + if ($entry['expires_at'] !== 0 && $entry['expires_at'] < time()) { + $this->delete($key); + return null; + } + + return $entry['value']; } public function set(string $key, $value, int $ttl = 3600): void { $file = $this->getFilePath($key); - file_put_contents($file, $value); + $tmp = $file . '.' . bin2hex(random_bytes(6)) . '.tmp'; + $entry = [ + 'expires_at' => $ttl > 0 ? time() + $ttl : 0, + 'value' => $value, + ]; + + if (file_put_contents($tmp, serialize($entry), LOCK_EX) === false) { + throw new \RuntimeException("Unable to write cache file: {$file}"); + } + + chmod($tmp, 0644); + rename($tmp, $file); } public function delete(string $key): void { $file = $this->getFilePath($key); - if (file_exists($file)) { + if (is_file($file)) { unlink($file); } } public function clear(): void { - $files = glob($this->cacheDir . '/*'); - if ($files) { - array_map('unlink', $files); + foreach (glob($this->cacheDir . '/*.cache') ?: [] as $file) { + if (is_file($file)) { + unlink($file); + } } } private function getFilePath(string $key): string { - return $this->cacheDir . '/' . md5($key) . '.cache'; + return rtrim($this->cacheDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . hash('sha256', $key) . '.cache'; } } diff --git a/src/Core/View/Compiler.php b/src/Core/View/Compiler.php index 5a8e31d..cf3b8a2 100644 --- a/src/Core/View/Compiler.php +++ b/src/Core/View/Compiler.php @@ -33,7 +33,7 @@ public function compile(array $nodes): string */ private function compileNode(object $node): string { - $class = class_basename($node); + $class = substr(strrchr('\\' . get_class($node), '\\'), 1); return match ($class) { 'TextNode' => $this->compileText($node), @@ -43,6 +43,9 @@ private function compileNode(object $node): string 'IfNode' => $this->compileIf($node), 'BlockNode' => $this->compileBlock($node), 'ForeachNode' => $this->compileForeach($node), + 'ElseNode' => '} else {' . "\n", + 'ElseIfNode' => $this->compileElseIf($node), + 'EndNode' => $this->compileEnd($node), default => '' }; } @@ -66,8 +69,7 @@ private function compileText(object $node): string */ private function compileExpression(object $node): string { - $escaped = 'htmlspecialchars(' . $node->value . ', ENT_QUOTES, "UTF-8")'; - return 'echo ' . $escaped . ";\n"; + return 'echo htmlspecialchars((string) $__engine->evaluateExpression(' . var_export($node->value, true) . ', $__data), ENT_QUOTES | ENT_SUBSTITUTE, "UTF-8");' . "\n"; } /** @@ -78,7 +80,7 @@ private function compileExpression(object $node): string */ private function compileRaw(object $node): string { - return 'echo ' . $node->value . ";\n"; + return 'echo (string) $__engine->evaluateExpression(' . var_export($node->value, true) . ', $__data);' . "\n"; } /** @@ -90,7 +92,7 @@ private function compileRaw(object $node): string private function compileComponent(object $node): string { $props = 'array(' . implode(', ', array_map( - fn($k, $v) => "'" . $k . "' => " . $v, + fn($k, $v) => var_export((string) $k, true) . ' => $__engine->evaluateExpression(' . var_export((string) $v, true) . ', $__data)', array_keys($node->attributes), $node->attributes )) . ')'; @@ -106,7 +108,7 @@ private function compileComponent(object $node): string */ private function compileIf(object $node): string { - return 'if (' . $node->condition . ") {\n"; + return 'if ($__engine->isTruthy(' . var_export($node->condition, true) . ', $__data)) {' . "\n"; } /** @@ -128,6 +130,25 @@ private function compileBlock(object $node): string */ private function compileForeach(object $node): string { - return 'foreach (' . $node->items . ' as ' . $node->as . ") {\n"; + [$valueName, $keyName] = array_map('trim', explode(',', $node->as . ',')); + foreach (array_filter([$valueName, $keyName]) as $name) { + if (!preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $name)) { + throw new \InvalidArgumentException('Invalid foreach variable name: ' . $name); + } + } + $iterable = '$__engine->evaluateExpression(' . var_export($node->items, true) . ', $__data)'; + if ($keyName !== '') { + return 'foreach ((array) ' . $iterable . ' as $' . $keyName . ' => $' . $valueName . ') { $__data[' . var_export($keyName, true) . '] = $' . $keyName . '; $__data[' . var_export($valueName, true) . '] = $' . $valueName . ';' . "\n"; + } + return 'foreach ((array) ' . $iterable . ' as $' . $valueName . ') { $__data[' . var_export($valueName, true) . '] = $' . $valueName . ';' . "\n"; + } + private function compileElseIf(object $node): string + { + return '} elseif ($__engine->isTruthy(' . var_export($node->condition, true) . ', $__data)) {' . "\n"; + } + + private function compileEnd(object $node): string + { + return in_array($node->name, ['If', 'Unless', 'Foreach'], true) ? '} ' . "\n" : ''; } } diff --git a/src/Core/View/Engine.php b/src/Core/View/Engine.php index f8f74b8..e9b4534 100644 --- a/src/Core/View/Engine.php +++ b/src/Core/View/Engine.php @@ -27,6 +27,7 @@ class Engine private CacheManager $cacheManager; private ComponentRegistry $componentRegistry; private FilterRegistry $filterRegistry; + private int $cacheTtl; /** * Construtor do Engine @@ -40,10 +41,11 @@ class Engine */ public function __construct(array $config = []) { - $this->templatesDir = $config['templates_dir'] ?? __DIR__ . '/../../../templates'; - $this->cacheDir = $config['cache_dir'] ?? __DIR__ . '/../../../cache'; + $this->templatesDir = $this->normalizeDirectory($config['templates_dir'] ?? __DIR__ . '/../../../templates'); + $this->cacheDir = $this->normalizeDirectory($config['cache_dir'] ?? __DIR__ . '/../../../cache', false); $this->autoEscape = $config['auto_escape'] ?? true; $this->cacheEnabled = $config['cache_enabled'] ?? true; + $this->cacheTtl = (int) ($config['cache_ttl'] ?? 0); // Validar diretórios if (!is_dir($this->templatesDir)) { @@ -107,7 +109,7 @@ public function render(string $templatePath, array $data = []): string // Cachear se habilitado if ($this->cacheEnabled) { - $this->cacheManager->set($cacheKey, $compiledCode); + $this->cacheManager->set($cacheKey, $compiledCode, $this->cacheTtl); } // Renderizar @@ -159,7 +161,15 @@ public function resolveTemplatePath(string $path): string $path .= '.html'; } - return $this->templatesDir . '/' . $path; + $candidate = $this->templatesDir . DIRECTORY_SEPARATOR . ltrim($path, DIRECTORY_SEPARATOR); + $resolved = realpath($candidate); + $base = realpath($this->templatesDir); + + if ($resolved === false || $base === false || !str_starts_with($resolved, $base . DIRECTORY_SEPARATOR)) { + throw new ViewException("Template path is outside templates directory: {$path}"); + } + + return $resolved; } /** @@ -170,7 +180,7 @@ public function resolveTemplatePath(string $path): string */ private function generateCacheKey(string $path): string { - return 'template_' . md5($path); + return 'template_' . hash('sha256', $path . '|' . filemtime($this->resolveTemplatePath($path)) . '|' . filesize($this->resolveTemplatePath($path))); } /** @@ -209,6 +219,130 @@ public function getEnvironment(): Environment return $this->environment; } + + public function evaluateExpression(string $expression, array $data) + { + $parts = array_map('trim', explode('|', $expression)); + $value = $this->evaluateValue(array_shift($parts), $data); + + foreach ($parts as $filterExpression) { + if ($filterExpression === '') { + continue; + } + $segments = array_map('trim', explode(':', $filterExpression, 2)); + $args = []; + if (isset($segments[1])) { + $args = array_map(fn($arg) => $this->evaluateValue(trim($arg), $data), explode(',', $segments[1])); + } + $value = $this->applyFilter($value, $segments[0], $args); + } + + return $value; + } + + public function isTruthy(string $expression, array $data): bool + { + $expression = trim($expression); + + foreach (['||', '&&'] as $operator) { + $parts = $this->splitExpression($expression, $operator); + if (count($parts) > 1) { + $results = array_map(fn($part) => $this->isTruthy($part, $data), $parts); + return $operator === '||' ? in_array(true, $results, true) : !in_array(false, $results, true); + } + } + + if (str_starts_with($expression, '!')) { + return !$this->isTruthy(substr($expression, 1), $data); + } + + foreach (['===', '!==', '>=', '<=', '==', '!=', '>', '<'] as $operator) { + $parts = $this->splitExpression($expression, $operator); + if (count($parts) === 2) { + [$left, $right] = $parts; + $leftValue = $this->evaluateExpression($left, $data); + $rightValue = $this->evaluateExpression($right, $data); + + return match ($operator) { + '===' => $leftValue === $rightValue, + '!==' => $leftValue !== $rightValue, + '==' => $leftValue == $rightValue, + '!=' => $leftValue != $rightValue, + '>=' => $leftValue >= $rightValue, + '<=' => $leftValue <= $rightValue, + '>' => $leftValue > $rightValue, + '<' => $leftValue < $rightValue, + }; + } + } + + return (bool) $this->evaluateExpression($expression, $data); + } + + private function splitExpression(string $expression, string $operator): array + { + $parts = preg_split('/\s*' . preg_quote($operator, '/') . '\s*/', $expression, 2); + if ($parts === false || count($parts) < 2 || trim($parts[0]) === '' || trim($parts[1]) === '') { + return [trim($expression)]; + } + + return array_map('trim', $parts); + } + + private function evaluateValue(string $expression, array $data) + { + $expression = trim($expression); + if (preg_match('/^(.+?)\s*\?\?\s*(.+)$/', $expression, $matches)) { + $value = $this->evaluateValue($matches[1], $data); + return $value ?? $this->evaluateValue($matches[2], $data); + } + if ((str_starts_with($expression, '"') && str_ends_with($expression, '"')) || (str_starts_with($expression, "'") && str_ends_with($expression, "'"))) { + return stripcslashes(substr($expression, 1, -1)); + } + if (is_numeric($expression)) { + return $expression + 0; + } + return match (strtolower($expression)) { + 'true' => true, + 'false' => false, + 'null' => null, + default => $this->resolveDataPath($expression, $data), + }; + } + + private function resolveDataPath(string $path, array $data) + { + $value = $data; + foreach (explode('.', $path) as $segment) { + $segment = trim($segment); + if ($segment === '') { + return null; + } + if (is_array($value) && array_key_exists($segment, $value)) { + $value = $value[$segment]; + continue; + } + if (is_object($value) && isset($value->{$segment})) { + $value = $value->{$segment}; + continue; + } + return null; + } + return $value; + } + + private function normalizeDirectory(string $directory, bool $mustExist = true): string + { + $resolved = realpath($directory); + if ($resolved === false) { + if ($mustExist) { + throw new ViewException("Directory not found: {$directory}"); + } + return rtrim($directory, DIRECTORY_SEPARATOR); + } + return $resolved; + } + /** * Limpa o cache * diff --git a/src/Core/View/Filters/FilterRegistry.php b/src/Core/View/Filters/FilterRegistry.php index 0de4e19..4dd57f4 100644 --- a/src/Core/View/Filters/FilterRegistry.php +++ b/src/Core/View/Filters/FilterRegistry.php @@ -54,10 +54,10 @@ public function apply($value, string $filter, array $args = []) private function registerDefaultFilters(): void { // String filters - $this->register('uppercase', fn($v) => strtoupper($v)); - $this->register('lowercase', fn($v) => strtolower($v)); + $this->register('uppercase', fn($v) => function_exists('mb_strtoupper') ? mb_strtoupper((string) $v, 'UTF-8') : strtoupper((string) $v)); + $this->register('lowercase', fn($v) => function_exists('mb_strtolower') ? mb_strtolower((string) $v, 'UTF-8') : strtolower((string) $v)); $this->register('ucfirst', fn($v) => ucfirst($v)); - $this->register('reverse', fn($v) => strrev($v)); + $this->register('reverse', fn($v) => is_array($v) ? array_reverse($v) : strrev((string) $v)); $this->register('trim', fn($v) => trim($v)); $this->register('ltrim', fn($v) => ltrim($v)); $this->register('rtrim', fn($v) => rtrim($v)); @@ -83,7 +83,6 @@ private function registerDefaultFilters(): void $this->register('count', fn($v) => count($v)); $this->register('first', fn($v) => $v[0] ?? null); $this->register('last', fn($v) => end($v)); - $this->register('reverse', fn($v) => array_reverse($v)); $this->register('join', fn($v, $sep = ',') => implode($sep, $v)); // JSON diff --git a/src/Core/View/Lexer.php b/src/Core/View/Lexer.php index fd73a0e..fee5069 100644 --- a/src/Core/View/Lexer.php +++ b/src/Core/View/Lexer.php @@ -30,21 +30,16 @@ public function tokenize(string $content): array $pos = 0; while ($pos < $length) { - // Detectar keywords - if (strpos($content, 'extends', $pos) === $pos) { - $tokens[] = ['type' => 'KEYWORD', 'value' => 'extends']; - $pos += 7; - continue; - } - - if (strpos($content, 'import', $pos) === $pos) { - $tokens[] = ['type' => 'KEYWORD', 'value' => 'import']; - $pos += 6; + // Detectar keywords de template e consumir a instrução completa. + if (preg_match('/^(extends|import)\b/i', substr($content, $pos))) { + $token = $this->extractKeyword($content, $pos); + $tokens[] = $token; + $pos += $token['length']; continue; } // Detectar tag de abertura - if ($content[$pos] === '<' && preg_match('/^<([A-Z][a-zA-Z0-9]*)/', substr($content, $pos), $matches)) { + if ($content[$pos] === '<' && preg_match('/^<\/?([A-Z][a-zA-Z0-9]*)/', substr($content, $pos), $matches)) { // Isso é uma tag customizada $token = $this->extractTag($content, $pos); $tokens[] = $token; @@ -71,9 +66,16 @@ public function tokenize(string $content): array // Texto normal $textLength = 0; while ($pos + $textLength < $length) { + if (preg_match('/^(extends|import)\b/i', substr($content, $pos + $textLength))) { + $previous = $pos + $textLength === 0 ? "\n" : $content[$pos + $textLength - 1]; + if ($previous === "\n" || $previous === "\r") { + break; + } + } + if (in_array($content[$pos + $textLength], ['<', '{'])) { // Verifica se é realmente um token - if (preg_match('/^<[A-Z]/', substr($content, $pos + $textLength))) { + if (preg_match('/^<\/?[A-Z]/', substr($content, $pos + $textLength))) { break; } if (strpos($content, '{{', $pos + $textLength) === $pos + $textLength || @@ -97,6 +99,30 @@ public function tokenize(string $content): array return $tokens; } + /** + * Extrai uma keyword de template (extends/import) sem deixar resíduos no HTML. + * + * @param string $content Conteúdo + * @param int $pos Posição atual + * @return array Token da keyword + */ + private function extractKeyword(string $content, int $pos): array + { + if (!preg_match('/^(extends|import)\b([^;\r\n]*)(;?)/i', substr($content, $pos), $matches)) { + throw new SyntaxException("Invalid keyword at position $pos"); + } + + $fullMatch = $matches[0]; + + return [ + 'type' => 'KEYWORD', + 'name' => strtolower($matches[1]), + 'value' => trim($matches[2]), + 'length' => strlen($fullMatch), + 'statement' => trim($fullMatch), + ]; + } + /** * Extrai uma tag customizada * @@ -106,19 +132,20 @@ public function tokenize(string $content): array */ private function extractTag(string $content, int $pos): array { - preg_match('/^<([A-Z][a-zA-Z0-9]*)([^>]*)\s*\/?>/s', substr($content, $pos), $matches); + preg_match('/^<(\/?)([A-Z][a-zA-Z0-9]*)([^>]*)\s*\/?>/s', substr($content, $pos), $matches); if (empty($matches)) { throw new SyntaxException("Invalid tag at position $pos"); } - $tagName = $matches[1]; - $attributes = trim($matches[2]); + $closing = $matches[1] === '/'; + $tagName = $matches[2]; + $attributes = trim($matches[3]); $fullMatch = $matches[0]; $selfClosing = str_ends_with($fullMatch, '/>'); return [ - 'type' => 'TAG', + 'type' => $closing ? 'TAG_CLOSE' : 'TAG', 'name' => $tagName, 'attributes' => $attributes, 'self_closing' => $selfClosing, diff --git a/src/Core/View/Parser.php b/src/Core/View/Parser.php index 384cb83..4e65f0a 100644 --- a/src/Core/View/Parser.php +++ b/src/Core/View/Parser.php @@ -42,6 +42,9 @@ public function parse(array $tokens): array } elseif ($token['type'] === 'RAW') { $nodes[] = new RawNode($token['value']); $this->advance(); + } elseif ($token['type'] === 'TAG_CLOSE') { + $nodes[] = new EndNode($token['name']); + $this->advance(); } elseif ($token['type'] === 'TAG') { $node = $this->parseTag(); if ($node) { @@ -75,10 +78,18 @@ private function parseTag(): ?object switch ($tagName) { case 'If': return $this->parseIfTag($token); + case 'Unless': + $unless = $this->parseIfTag($token); + $unless->condition = '!' . $unless->condition; + return $unless; + case 'ElseIf': + return new ElseIfNode($this->parseIfTag($token)->condition); case 'Block': return $this->parseBlockTag($token); case 'Foreach': return $this->parseForEachTag($token); + case 'Else': + return new ElseNode(); case 'Component': case preg_match('/^[A-Z]/', $tagName) ? $tagName : null: return $this->parseComponentTag($token); @@ -219,3 +230,22 @@ public function __construct( public string $as ) {} } + + +class ElseNode +{ +} + +class ElseIfNode +{ + public function __construct( + public string $condition + ) {} +} + +class EndNode +{ + public function __construct( + public string $name + ) {} +} diff --git a/src/Core/View/Renderer.php b/src/Core/View/Renderer.php index 9546f1d..d7c1e9c 100644 --- a/src/Core/View/Renderer.php +++ b/src/Core/View/Renderer.php @@ -3,30 +3,23 @@ namespace Beobles\Core\View; /** - * Renderizador de templates compilados + * Renderizador de templates compilados. */ class Renderer { - /** - * Renderiza código PHP compilado - * - * @param string $compiledCode Código PHP compilado - * @param array $data Dados para o template - * @param Engine $engine Instância do engine - * @return string Output renderizado - */ public function render(string $compiledCode, array $data = [], Engine $engine = null): string { - // Criar escopo de variáveis - extract($data, EXTR_SKIP); + $__data = $data; $__engine = $engine; - // Capturar output ob_start(); try { - eval('?>' . $compiledCode); - return ob_get_clean(); - } catch (\Exception $e) { + (static function () use ($compiledCode, $__data, $__engine): void { + extract($__data, EXTR_SKIP); + eval('?>' . $compiledCode); + })(); + return (string) ob_get_clean(); + } catch (\Throwable $e) { ob_end_clean(); throw $e; }