Switch PHP views to Twig and add 2FA/UI enhancements
Migrate many view templates from raw PHP to Twig and modernize UI/UX for 2FA and settings. Controllers updated to provide avatar data and two-factor info (ProfileController, UserController) and SettingsController now includes timezone lists, notification preset selection, cron path, cached update state and rollback availability. ErrorHandler now attempts to render error pages via a new Core\TwigService with a safe fallback to raw PHP views. TwoFactorService generation silences deprecated warnings during QR code creation. Numerous .php view files were removed and replaced with .twig equivalents (2fa setup/verify/backup-codes and many auth, dashboard, domains, errors, layout, users, tags, tld-registry, etc.), and core/TwigService was added. These changes move the app toward a Twig-based templating system, improve 2FA flows, surface avatar images in lists/profiles, and make error rendering more robust.
2026-03-03 18:21:32 +02:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace Core;
|
|
|
|
|
|
|
|
|
|
use Twig\Environment;
|
|
|
|
|
use Twig\Loader\FilesystemLoader;
|
|
|
|
|
use Twig\Extension\DebugExtension;
|
|
|
|
|
use Twig\TwigFunction;
|
|
|
|
|
use Twig\TwigFilter;
|
|
|
|
|
|
|
|
|
|
class TwigService
|
|
|
|
|
{
|
|
|
|
|
private static ?self $instance = null;
|
|
|
|
|
private Environment $twig;
|
|
|
|
|
|
|
|
|
|
private function __construct()
|
|
|
|
|
{
|
|
|
|
|
$viewsPath = PATH_ROOT . 'app/Views';
|
|
|
|
|
$loader = new FilesystemLoader($viewsPath);
|
|
|
|
|
|
|
|
|
|
$isDev = ($_ENV['APP_ENV'] ?? 'development') === 'development';
|
|
|
|
|
$cachePath = $isDev ? false : PATH_ROOT . 'cache/twig';
|
|
|
|
|
|
|
|
|
|
$this->twig = new Environment($loader, [
|
|
|
|
|
'cache' => $cachePath,
|
|
|
|
|
'debug' => $isDev,
|
|
|
|
|
'auto_reload' => $isDev,
|
|
|
|
|
'strict_variables' => false,
|
|
|
|
|
'autoescape' => 'html',
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
if ($isDev) {
|
|
|
|
|
$this->twig->addExtension(new DebugExtension());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$this->registerFunctions();
|
|
|
|
|
$this->registerFilters();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public static function getInstance(): self
|
|
|
|
|
{
|
|
|
|
|
if (self::$instance === null) {
|
|
|
|
|
self::$instance = new self();
|
|
|
|
|
}
|
|
|
|
|
return self::$instance;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function getEnvironment(): Environment
|
|
|
|
|
{
|
|
|
|
|
return $this->twig;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Render a Twig template with data + automatically injected globals.
|
|
|
|
|
*/
|
|
|
|
|
public function render(string $template, array $data = []): string
|
|
|
|
|
{
|
|
|
|
|
$globals = $this->getGlobals();
|
|
|
|
|
$context = array_merge($globals, $data);
|
|
|
|
|
|
|
|
|
|
return $this->twig->render($template, $context);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Collect layout-level data that every template may need.
|
|
|
|
|
* Computed on each render so values are always fresh.
|
|
|
|
|
*/
|
|
|
|
|
private function getGlobals(): array
|
|
|
|
|
{
|
|
|
|
|
// Session flash messages (read & clear) — always safe
|
|
|
|
|
$flash = [];
|
|
|
|
|
foreach (['success', 'error', 'warning', 'info'] as $type) {
|
|
|
|
|
if (isset($_SESSION[$type])) {
|
|
|
|
|
$flash[$type] = $_SESSION[$type];
|
|
|
|
|
unset($_SESSION[$type]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Database-dependent globals are wrapped in try/catch so standalone
|
|
|
|
|
// pages (installer, error pages) still render when the DB is absent.
|
|
|
|
|
try {
|
|
|
|
|
$userId = Auth::id();
|
|
|
|
|
|
|
|
|
|
if ($userId) {
|
|
|
|
|
$notificationData = \App\Helpers\LayoutHelper::getNotifications($userId);
|
|
|
|
|
$recentNotifications = $notificationData['items'];
|
|
|
|
|
$unreadNotifications = $notificationData['unread_count'];
|
|
|
|
|
$updateBadge = Auth::isAdmin()
|
|
|
|
|
? \App\Helpers\LayoutHelper::getUpdateBadgeInfo()
|
|
|
|
|
: ['show' => false, 'available' => false, 'label' => ''];
|
|
|
|
|
} else {
|
|
|
|
|
$recentNotifications = [];
|
|
|
|
|
$unreadNotifications = 0;
|
|
|
|
|
$updateBadge = ['show' => false, 'available' => false, 'label' => ''];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$domainStats = \App\Helpers\LayoutHelper::getDomainStats();
|
|
|
|
|
$appSettings = \App\Helpers\LayoutHelper::getAppSettings();
|
|
|
|
|
|
|
|
|
|
$avatar = null;
|
|
|
|
|
if ($userId) {
|
|
|
|
|
$userModel = new \App\Models\User();
|
|
|
|
|
$user = $userModel->find($userId);
|
|
|
|
|
if ($user) {
|
|
|
|
|
$avatar = \App\Helpers\AvatarHelper::getAvatar($user, 36);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
'auth' => [
|
|
|
|
|
'check' => Auth::check(),
|
|
|
|
|
'id' => $userId,
|
|
|
|
|
'username' => Auth::username(),
|
|
|
|
|
'fullName' => Auth::fullName(),
|
|
|
|
|
'role' => Auth::role(),
|
|
|
|
|
'isAdmin' => Auth::isAdmin(),
|
|
|
|
|
],
|
|
|
|
|
'session' => $_SESSION ?? [],
|
|
|
|
|
'flash' => $flash,
|
|
|
|
|
'recentNotifications' => $recentNotifications,
|
|
|
|
|
'unreadNotifications' => $unreadNotifications,
|
|
|
|
|
'updateBadge' => $updateBadge,
|
|
|
|
|
'domainStats' => $domainStats,
|
|
|
|
|
'appName' => $appSettings['app_name'],
|
|
|
|
|
'appTimezone' => $appSettings['app_timezone'],
|
|
|
|
|
'appVersion' => $appSettings['app_version'],
|
|
|
|
|
'avatar' => $avatar,
|
|
|
|
|
'currentUrl' => $_SERVER['REQUEST_URI'] ?? '/',
|
|
|
|
|
'appEnv' => $_ENV['APP_ENV'] ?? 'development',
|
|
|
|
|
];
|
|
|
|
|
} catch (\Throwable $e) {
|
|
|
|
|
return [
|
|
|
|
|
'auth' => ['check' => false, 'id' => null, 'username' => '', 'fullName' => '', 'role' => '', 'isAdmin' => false],
|
|
|
|
|
'session' => $_SESSION ?? [],
|
|
|
|
|
'flash' => $flash,
|
|
|
|
|
'recentNotifications' => [],
|
|
|
|
|
'unreadNotifications' => 0,
|
|
|
|
|
'updateBadge' => ['show' => false, 'available' => false, 'label' => ''],
|
|
|
|
|
'domainStats' => [],
|
|
|
|
|
'appName' => 'Domain Monitor',
|
|
|
|
|
'appTimezone' => 'UTC',
|
|
|
|
|
'appVersion' => '',
|
|
|
|
|
'avatar' => null,
|
|
|
|
|
'currentUrl' => $_SERVER['REQUEST_URI'] ?? '/',
|
|
|
|
|
'appEnv' => $_ENV['APP_ENV'] ?? 'development',
|
|
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function registerFunctions(): void
|
|
|
|
|
{
|
|
|
|
|
$this->twig->addFunction(new TwigFunction('csrf_field', function (): string {
|
|
|
|
|
return \Core\Csrf::field();
|
|
|
|
|
}, ['is_safe' => ['html']]));
|
|
|
|
|
|
|
|
|
|
$this->twig->addFunction(new TwigFunction('csrf_token', function (): string {
|
|
|
|
|
return \Core\Csrf::getToken();
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
$this->twig->addFunction(new TwigFunction('old', function (string $key, string $default = ''): string {
|
|
|
|
|
return htmlspecialchars($_POST[$key] ?? $default, ENT_QUOTES, 'UTF-8');
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
$this->twig->addFunction(new TwigFunction('asset', function (string $path): string {
|
|
|
|
|
return '/' . ltrim($path, '/');
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
$this->twig->addFunction(new TwigFunction('is_active', function (string $path): bool {
|
|
|
|
|
$current = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
|
|
|
|
|
return $current === $path;
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
$this->twig->addFunction(new TwigFunction('is_active_prefix', function (string $prefix): bool {
|
|
|
|
|
$current = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
|
|
|
|
|
return str_starts_with($current, $prefix);
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
$this->twig->addFunction(new TwigFunction('sort_url', function (string $column, string $currentSort, string $currentOrder, array $filters = []): string {
|
|
|
|
|
return \App\Helpers\ViewHelper::sortUrl($column, $currentSort, $currentOrder, $filters);
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
$this->twig->addFunction(new TwigFunction('sort_icon', function (string $column, string $currentSort, string $currentOrder): string {
|
|
|
|
|
return \App\Helpers\ViewHelper::sortIcon($column, $currentSort, $currentOrder);
|
|
|
|
|
}, ['is_safe' => ['html']]));
|
|
|
|
|
|
|
|
|
|
$this->twig->addFunction(new TwigFunction('pagination_url', function (int $page, array $filters, int $perPage): string {
|
|
|
|
|
return \App\Helpers\ViewHelper::paginationUrl($page, $filters, $perPage);
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
$this->twig->addFunction(new TwigFunction('status_badge', function (string $status): string {
|
|
|
|
|
return \App\Helpers\ViewHelper::statusBadge($status);
|
|
|
|
|
}, ['is_safe' => ['html']]));
|
|
|
|
|
|
|
|
|
|
$this->twig->addFunction(new TwigFunction('alert', function (string $type, string $message): string {
|
|
|
|
|
return \App\Helpers\ViewHelper::alert($type, $message);
|
|
|
|
|
}, ['is_safe' => ['html']]));
|
|
|
|
|
|
|
|
|
|
$this->twig->addFunction(new TwigFunction('breadcrumbs', function (array $items): string {
|
|
|
|
|
return \App\Helpers\ViewHelper::breadcrumbs($items);
|
|
|
|
|
}, ['is_safe' => ['html']]));
|
|
|
|
|
|
|
|
|
|
$this->twig->addFunction(new TwigFunction('format_login_dropdown', function (array $loginData): string {
|
|
|
|
|
return \App\Helpers\LayoutHelper::formatLoginDropdown($loginData);
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
$this->twig->addFunction(new TwigFunction('max_upload_size', function (): string {
|
|
|
|
|
return \App\Helpers\ViewHelper::getMaxUploadSize();
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
$this->twig->addFunction(new TwigFunction('role_badge', function (string $role, string $size = 'sm'): string {
|
|
|
|
|
$isAdmin = $role === 'admin';
|
|
|
|
|
$color = $isAdmin ? 'amber' : 'blue';
|
|
|
|
|
$icon = $isAdmin ? 'crown' : 'user';
|
|
|
|
|
$label = ucfirst($role);
|
|
|
|
|
|
|
|
|
|
if ($size === 'xs') {
|
|
|
|
|
$padding = 'px-2 py-0.5';
|
|
|
|
|
} else {
|
|
|
|
|
$padding = 'px-2.5 py-1';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return '<span class="inline-flex items-center ' . $padding . ' rounded-full text-xs font-semibold '
|
|
|
|
|
. 'bg-' . $color . '-100 dark:bg-' . $color . '-500/10 '
|
|
|
|
|
. 'text-' . $color . '-700 dark:text-' . $color . '-400 '
|
|
|
|
|
. 'border border-' . $color . '-200 dark:border-' . $color . '-500/20">'
|
|
|
|
|
. '<i class="fas fa-' . $icon . ' mr-1"></i>'
|
|
|
|
|
. htmlspecialchars($label)
|
|
|
|
|
. '</span>';
|
|
|
|
|
}, ['is_safe' => ['html']]));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function registerFilters(): void
|
|
|
|
|
{
|
|
|
|
|
$this->twig->addFilter(new TwigFilter('truncate', function (string $text, int $length = 50, string $suffix = '...'): string {
|
|
|
|
|
return \App\Helpers\ViewHelper::truncate($text, $length, $suffix);
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
$this->twig->addFilter(new TwigFilter('format_bytes', function (int $bytes, int $precision = 2): string {
|
|
|
|
|
return \App\Helpers\ViewHelper::formatBytes($bytes, $precision);
|
|
|
|
|
}));
|
|
|
|
|
|
Improve security, validation, and isolation checks
Add multiple security and validation improvements across the app:
- Prevent session fixation: regenerate session ID on login and after successful 2FA; tighten session cookie params (Secure, HttpOnly, SameSite=Lax).
- Harden installer: add CSRF checks for install/update flows and use PDO::quote when injecting admin credentials into SQL migration to avoid injection; add csrf_field() to installer templates.
- Template hardening: add safe_url and safe_mailto Twig filters, escape tag names for JS, and add rel="noopener noreferrer" to external links to mitigate XSS/opener risks.
- Domain controller: validate referrer to avoid open redirects, enforce user isolation mode when finding/deleting/updating domains and when assigning notification groups (ensures users only affect their own resources).
- Notification groups: verify channel belongs to group before deleting or toggling to prevent unauthorized access.
- ErrorLog: whitelist allowed sort columns to avoid arbitrary column injection in ORDER BY.
- Routes: move the debug whois route to protected/admin area.
These changes collectively reduce attack surface (XSS, open redirect, session fixation, SQL injection) and enforce proper resource isolation and input validation.
2026-03-11 00:03:54 +02:00
|
|
|
$this->twig->addFilter(new TwigFilter('safe_url', function (?string $url): string {
|
|
|
|
|
if ($url === null || $url === '') {
|
|
|
|
|
return '#';
|
|
|
|
|
}
|
|
|
|
|
if (preg_match('#^https?://#i', $url)) {
|
|
|
|
|
return $url;
|
|
|
|
|
}
|
|
|
|
|
return '#';
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
$this->twig->addFilter(new TwigFilter('safe_mailto', function (?string $email): string {
|
|
|
|
|
if ($email === null || $email === '') {
|
|
|
|
|
return '#';
|
|
|
|
|
}
|
|
|
|
|
if (filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
|
|
|
|
return 'mailto:' . $email;
|
|
|
|
|
}
|
|
|
|
|
return '#';
|
|
|
|
|
}));
|
|
|
|
|
|
Switch PHP views to Twig and add 2FA/UI enhancements
Migrate many view templates from raw PHP to Twig and modernize UI/UX for 2FA and settings. Controllers updated to provide avatar data and two-factor info (ProfileController, UserController) and SettingsController now includes timezone lists, notification preset selection, cron path, cached update state and rollback availability. ErrorHandler now attempts to render error pages via a new Core\TwigService with a safe fallback to raw PHP views. TwoFactorService generation silences deprecated warnings during QR code creation. Numerous .php view files were removed and replaced with .twig equivalents (2fa setup/verify/backup-codes and many auth, dashboard, domains, errors, layout, users, tags, tld-registry, etc.), and core/TwigService was added. These changes move the app toward a Twig-based templating system, improve 2FA flows, surface avatar images in lists/profiles, and make error rendering more robust.
2026-03-03 18:21:32 +02:00
|
|
|
$this->twig->addFilter(new TwigFilter('from_json', function ($value) {
|
|
|
|
|
if ($value === null || $value === '') {
|
|
|
|
|
return [];
|
|
|
|
|
}
|
|
|
|
|
if (is_array($value) || is_object($value)) {
|
|
|
|
|
return $value;
|
|
|
|
|
}
|
|
|
|
|
$decoded = json_decode((string) $value, true);
|
|
|
|
|
return $decoded ?? [];
|
|
|
|
|
}));
|
|
|
|
|
}
|
|
|
|
|
}
|