Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 39 additions & 11 deletions src/Core/View/Cache/FileCacheAdapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,53 +3,81 @@
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}");
}
}

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';
}
}
35 changes: 28 additions & 7 deletions src/Core/View/Compiler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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 => ''
};
}
Expand All @@ -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";
}

/**
Expand All @@ -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";
}

/**
Expand All @@ -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
)) . ')';
Expand All @@ -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";
}

/**
Expand All @@ -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" : '';
}
}
144 changes: 139 additions & 5 deletions src/Core/View/Engine.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class Engine
private CacheManager $cacheManager;
private ComponentRegistry $componentRegistry;
private FilterRegistry $filterRegistry;
private int $cacheTtl;

/**
* Construtor do Engine
Expand All @@ -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)) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}

/**
Expand All @@ -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)));
}

/**
Expand Down Expand Up @@ -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
*
Expand Down
7 changes: 3 additions & 4 deletions src/Core/View/Filters/FilterRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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
Expand Down
Loading