eesyphp/src/Tpl.php
2024-01-23 19:23:10 +01:00

908 lines
28 KiB
PHP

<?php
namespace EesyPHP;
use Exception;
use Smarty;
use Smarty_Security;
use League\MimeTypeDetection\ExtensionMimeTypeDetector;
class Tpl {
/**
* Smarty object
* @var \Smarty
*/
public static $smarty;
/**
* Smarty_Security object
* @var \Smarty_Security|null
*/
public static $smarty_security_policy = null;
/**
* Core Smarty templates directory
* @var string
*/
public static $core_templates_directory;
/**
* Smarty templates directories path with their priority
* @var array<string,int>
*/
public static $templates_directories = array();
/**
* Enable/disable AJAX returned data debugging in logs
* @var bool
*/
public static $_debug_ajax;
/**
* Core static directory
* @var string|null
*/
public static $core_static_directory = null;
/**
* Static directories path with their priority
* @var array<string,array>
*/
private static $static_directories = array();
/**
* CSS files to load in next displayed page
* @var array<string>
*/
private static $css_files = array();
/**
* JavaScript files to load in next displayed page
* @var array<string>
*/
private static $js_files = array();
/**
* MIME type detector object
* @var null|string
*/
private static $static_root_url;
/**
* MIME type detector object
* @var ExtensionMimeTypeDetector|null
*/
private static $mime_type_detector = null;
/**
* Keep trace of Smarty start displaying page time
* @see display()
* @see smarty_computing_time()
* @var int
*/
private static $start_time = null;
/**
* Initialization
* @return void
*/
public static function init() {
// In phpstan context, do not initialize
// @phpstan-ignore-next-line
if (defined('__PHPSTAN_RUNNING__') && constant('__PHPSTAN_RUNNING__'))
return;
// Set config default values
App :: set_default(
'templates',
array(
// Main Smarty templates directory path
'directory' => null,
// Smarty cache templates directory path
// Default: see below, compute only if not set
'cache_directory' => null,
'main_pagetitle' => null,
'static_root_url' => 'static/',
'static_directories' => array(),
'included_css_files' => array(),
'included_js_files' => array(),
'webstats_js_code' => null,
'upload_max_filesize' => null,
'debug_ajax' => App::get('debug_ajax', false, 'bool'),
'logo_url' => 'images/logo.svg',
'favicon_url' => 'images/favicon.png',
)
);
// Handle templates directories
self :: $core_templates_directory = realpath(__DIR__."/../templates");
self :: register_templates_directory(self :: $core_templates_directory);
$templates_dir = App::get('templates.directory', null, 'string');
if ($templates_dir) {
if (!is_dir($templates_dir))
Log :: fatal("Template directory not found (%s)", $templates_dir);
self :: register_templates_directory($templates_dir);
}
// Handle and check templates_c directories
$templates_c_dir = App::get('templates.cache_directory', null, 'string');
if ($templates_c_dir) {
if (!is_dir($templates_c_dir) || !is_writable($templates_c_dir))
Log :: fatal(
"Template cache directory not found or not writable (%s)",
$templates_c_dir);
}
else {
$public_root_url = Url :: public_root_url();
if ($public_root_url != '/') {
$unique_name = preg_replace('#^https?://#', '', $public_root_url);
}
else {
$root_directory_path = App::root_directory_path();
if ($root_directory_path == '.')
Log :: fatal(
'Fail to compute a unique templates cache directory for this application. An public '.
'root URL or an application root directory must be set if you do not provide it at '.
'initialization (or via config parameter).');
$unique_name = $root_directory_path;
if (substr($unique_name, 0, 1) == '/')
$unique_name = substr($unique_name, 1);
if (substr($unique_name, -1) == '/')
$unique_name = substr($unique_name, 0, -1);
}
$templates_c_dir = sys_get_temp_dir().'/'.str_replace(
'/', '_', "eesyphp_templates_cache_$unique_name"
);
if (!is_dir($templates_c_dir) && !mkdir($templates_c_dir))
Log :: fatal(
'Fail to create application templates cache directory (%s)',
$templates_c_dir);
App :: set_default('templates.cache_directory', $templates_c_dir);
}
self :: $smarty = new Smarty();
self :: $smarty->setTemplateDir(self :: $core_templates_directory);
self :: $smarty->setCompileDir($templates_c_dir);
self :: $smarty->registerResource('Tpl', new TplSmartyResource());
self :: $smarty->registerResource('TplCore', new TplSmartyResource(true));
$debug_ajax = App::get('templates.debug_ajax', null, 'bool');
self :: $_debug_ajax = boolval($debug_ajax);
Log :: register_fatal_error_handler(array('\\EesyPHP\\Tpl', 'fatal_error'));
$static_root_url = App::get('templates.static_root_url', null, 'string');
if ($static_root_url) {
if (substr($static_root_url, 0, 1) == '/')
$static_root_url = substr($static_root_url, 1);
if (substr($static_root_url, -1) != '/')
$static_root_url = "$static_root_url/";
self :: $static_root_url = $static_root_url;
self :: $core_static_directory = realpath(__DIR__."/../static");
self :: register_static_directory(self :: $core_static_directory, 100);
self :: register_function('static_url', array('EesyPHP\\Tpl', 'smarty_static_url'));
foreach(App :: get('templates.static_directories', null, 'array') as $path)
self :: register_static_directory($path);
}
self :: register_function('var_dump', array('EesyPHP\\Tpl', 'smarty_var_dump'));
self :: register_function(
'smarty_computing_time', array('EesyPHP\\Tpl', 'smarty_computing_time'));
self :: register_function(
'smarty_total_computing_time', array('EesyPHP\\Tpl', 'smarty_total_computing_time'));
self :: register_class('App', '\\EesyPHP\\App');
}
/**
* Enable security in mode to limit functions (in IF clauses) and modifiers usable from
* template files
* @param array<string>|null $functions List of function names granted in IF clauses
* @param array<string>|null $modifiers List of modifier names granted
* @return void
*/
public static function enable_security_mode($functions=null, $modifiers=null) {
// Define security policy
self :: $smarty_security_policy = new Smarty_Security(self :: $smarty);
// Allow functions in IF clauses
if (is_array($functions))
foreach($functions as $function)
self :: $smarty_security_policy->php_functions[] = $function;
// Allow modifier functions
if (is_array($modifiers))
foreach($modifiers as $modifier)
self :: $smarty_security_policy->php_modifiers[] = $modifier;
// Enable security
self :: $smarty -> enableSecurity(self :: $smarty_security_policy);
// Initialize errors & messages session variables
if (!isset($_SESSION['errors']))
$_SESSION['errors'] = array();
if (!isset($_SESSION['messages']))
$_SESSION['messages'] = array();
}
/**
* Register a function usable from template files
* @param string $name The function name
* @param callable $callable The function
* @return void
*/
public static function register_function($name, $callable) {
self :: $smarty -> registerPlugin("function", $name, $callable);
}
/**
* Register a class usable from template files
* @param string $class_name The class name
* @param string $class_ref The class reference (eg: \EesyPHP\App)
* @return void
*/
public static function register_class($class_name, $class_ref) {
self :: $smarty -> registerClass($class_name, $class_ref);
}
/**
* Assign template variable
* @param string $name The variable name
* @param mixed $value The variable value
* @return void
*/
public static function assign($name, $value) {
self :: $smarty -> assign($name, $value);
}
/**
* Add error message
* @param string $error The message
* @param array $extra_args Extra arguments to use to compute error message using sprintf
* @return void
*/
public static function add_error($error, ...$extra_args) {
// If extra arguments passed, format error message using sprintf
if ($extra_args) {
$error = call_user_func_array(
'sprintf',
array_merge(array($error), $extra_args)
);
}
$_SESSION['errors'][] = $error;
}
/**
* Add informational message
* @param string $message The message
* @param array $extra_args Extra arguments to use to compute message using sprintf
* @return void
*/
public static function add_message($message, ...$extra_args) {
// If extra arguments passed, format message using sprintf
if ($extra_args) {
$message = call_user_func_array(
'sprintf',
array_merge(array($message), $extra_args)
);
}
$_SESSION['messages'][] = $message;
}
/**
* Get errors
* @return array<string>
*/
public static function get_errors() {
if(isset($_SESSION['errors']) && is_array($_SESSION['errors']))
return $_SESSION['errors'];
return array();
}
/**
* Get messages
* @return array<string>
*/
public static function get_messages() {
if(isset($_SESSION['messages']) && is_array($_SESSION['messages']))
return $_SESSION['messages'];
return array();
}
/**
* Purge messages
* @return void
*/
public static function purge_errors() {
if(isset($_SESSION['errors']))
unset($_SESSION['errors']);
}
/**
* Purge messages
* @return void
*/
public static function purge_messages() {
if(isset($_SESSION['messages']))
unset($_SESSION['messages']);
}
/**
* Register CSS file(s) to load on next displayed page
* @param string|array<string> $args CSS files to load
* @return void
*/
public static function add_css_file(...$args) {
// Check if the first argument is a custom static root URL
$root_url = self :: $static_root_url;
if (
$args && is_string($args[0]) && array_key_exists(
self :: clean_static_root_url($args[0]),
self :: $static_directories
)
)
$root_url = self :: clean_static_root_url(array_shift($args));
foreach ($args as $files) {
if (!is_array($files)) $files = array($files);
foreach ($files as $file) {
$path = $root_url.$file;
if (!in_array($path, self :: $css_files))
self :: $css_files[] = $path;
}
}
}
/**
* Register JS file(s) to load on next displayed page
* @param string|array<string> $args JS files to load
* @return void
*/
public static function add_js_file(...$args) {
// Check if the first argument is a custom static root URL
$root_url = self :: $static_root_url;
if (
$args && is_string($args[0]) && array_key_exists(
self :: clean_static_root_url($args[0]),
self :: $static_directories
)
)
$root_url = self :: clean_static_root_url(array_shift($args));
foreach ($args as $files) {
if (!is_array($files)) $files = array($files);
foreach ($files as $file) {
$path = $root_url.$file;
if (!in_array($path, self :: $js_files))
self :: $js_files[] = $path;
}
}
}
/**
* Define common variables
* @param string|null $pagetitle The page title
* @return void
*/
protected static function define_common_variables($pagetitle=null) {
self :: assign('public_root_url', Url :: public_root_url());
self :: assign('pagetitle', $pagetitle);
self :: assign('main_pagetitle', App::get('main_pagetitle', null, 'string'));
self :: assign('session_key', isset($_SESSION['session_key'])?$_SESSION['session_key']:null);
$logo_url = App::get('templates.logo_url', null, 'string');
self :: assign(
'logo_url',
Url::is_absolute_url($logo_url)?$logo_url:self::static_url($logo_url)
);
$favicon_url = App::get('templates.favicon_url', null, 'string');
self :: assign(
'favicon_url',
Url::is_absolute_url($favicon_url)?$favicon_url:self::static_url($favicon_url)
);
// Handle CSS & JS files included
self :: add_css_file(App::get('templates.included_css_files', null, 'array'));
self :: add_js_file(App::get('templates.included_js_files', null, 'array'));
// Messages
self :: assign('errors', self :: get_errors());
self :: assign('messages', self :: get_messages());
// Files inclusions
self :: assign('css', self :: $css_files);
self :: assign('js', self :: $js_files);
// I18n text domains
self :: assign('CORE_TEXT_DOMAIN', I18n :: CORE_TEXT_DOMAIN);
self :: assign('TEXT_DOMAIN', I18n :: TEXT_DOMAIN);
// Authenticated user info
if (Auth::user())
self :: assign('auth_user', Auth::user());
// Webstats JS code
Tpl :: assign(
'webstats_js_code',
App::get('webstats_js_code', null, 'string'));
// Upload max size
if (App::get('upload_max_filesize', null, 'int'))
Tpl :: assign(
'upload_max_filesize',
App::get('upload_max_filesize', null, 'int'));
$init_time = App::get('init_time');
Tpl :: assign('compute_time', $init_time?format_duration(hrtime(true)-$init_time, 'ns', 'ms'):null);
Tpl :: assign('db_time', Db :: $total_query_time?format_duration(Db :: $total_query_time, 'ns', 'ms'):null);
}
/**
* Display the template
* @param string $template The template to display
* @param string|null $pagetitle The page title (optional)
* @param array $extra_args Extra arguments to use to compute the page title using sprintf
* @return void
*/
public static function display($template, $pagetitle=null, ...$extra_args) {
if (!$template)
Log :: fatal(I18n::_("No template specified."));
// If refresh parameter is present, remove it and redirect
if (isset($_GET['refresh'])) {
unset($_GET['refresh']);
$url = Url :: get_current_url();
if (!empty($_GET))
$url .= '?'.http_build_query($_GET);
Url :: redirect($url);
}
$sentry_span = new SentrySpan('smarty.display_template', "Display Smarty template");
// If extra arguments passed, format pagetitle using sprintf
if ($pagetitle && $extra_args) {
$pagetitle = call_user_func_array(
'sprintf',
array_merge(array($pagetitle), $extra_args)
);
}
try {
Hook :: trigger('before_displaying_template');
self :: define_common_variables($pagetitle);
self :: $start_time = hrtime(true);
self :: $smarty->display("Tpl:$template");
}
catch (Exception $e) {
Log :: exception($e, "Smarty - An exception occurred displaying template '$template'");
Hook :: trigger('after_displaying_template', array('success' => false));
$sentry_span->finish();
if ($template != 'fatal_error.tpl')
Log :: fatal(I18n::_("An error occurred while displaying this page."));
return;
}
self :: purge_errors();
self :: purge_messages();
Hook :: trigger('after_displaying_template', array('success' => true));
$sentry_span->finish();
}
/**
* Fetch a template
* @param string $template The template to fetch
* @param string|null $pagetitle The page title (optional)
* @param array $extra_args Extra arguments to use to compute the page title using sprintf
* @return string|false
*/
public static function fetch($template, $pagetitle=null, ...$extra_args) {
if (!$template) {
Log :: warning("Tpl::fetch(): No template specified.");
return false;
}
$sentry_span = new SentrySpan('smarty.fetch_template', "Fetch Smarty template");
// If extra arguments passed, format pagetitle using sprintf
if ($pagetitle && $extra_args) {
$pagetitle = call_user_func_array(
'sprintf',
array_merge(array($pagetitle), $extra_args)
);
}
$result = false;
$success = false;
try {
Hook :: trigger('before_fetching_template');
self :: define_common_variables($pagetitle);
$result = self :: $smarty->fetch("Tpl:$template");
$success = true;
}
catch (Exception $e) {
Log :: exception($e, "Smarty - An exception occurred fetching template '$template'");
}
Hook :: trigger('after_fetching_template', array('success' => $success));
$sentry_span->finish();
return $success?$result:false;
}
/**
* Display AJAX return
* @param array|null $data AJAX returned data (optional)
* @param int|null $error_code HTTP error code (optional, default: 400 if not $data['success'])
* @param bool $pretty AJAX returned data
* (optional, default: true if $_REQUEST['pretty'] is set, False otherwise)
* @return never
*/
public static function display_ajax_return($data=null, $error_code=null, $pretty=false) {
if (!is_array($data))
$data = array();
// Adjust HTTP error code on unsuccessful request (or if custom error code is provided)
if (
$error_code
|| (isset($data['success']) && !$data['success'] && http_response_code() == 200)
)
http_response_code($error_code ? $error_code : 400);
$data['messages'] = self :: get_messages();
if (!$data['messages']) unset($data['messages']);
self :: purge_messages();
$data['errors'] = self :: get_errors();
if (!$data['errors']) unset($data['errors']);
self :: purge_errors();
if (self :: $_debug_ajax)
Log :: debug("AJAX Response : ".vardump($data));
header('Content-Type: application/json');
echo json_encode($data, (($pretty||isset($_REQUEST['pretty']))?JSON_PRETTY_PRINT:0));
exit();
}
/**
* Display AJAX error
* @param string|null $error Error message (optional)
* @param int|null $error_code HTTP error code (optional, default: 400)
* @param bool $pretty AJAX returned data
* (optional, default: true if $_REQUEST['pretty'] is set, False otherwise)
* @return never
*/
public static function display_ajax_error($error=null, $error_code=null, $pretty=false) {
self :: display_ajax_return(
array(
'success' => false,
'error' => (
$error?
$error:
I18n::_("Unexpected error occurred. If problem persist, please contact support.")
),
),
$error_code,
$pretty
);
}
/**
* Handle a fatal error
* @param string $error The error message
* @param array $extra_args Extra arguments to use to compute the error message using sprintf
* @return never
*/
public static function fatal_error($error, ...$extra_args) {
// If extra arguments passed, format error message using sprintf
if ($extra_args) {
$error = call_user_func_array(
'sprintf',
array_merge(array($error), $extra_args)
);
}
if (php_sapi_name() == "cli") {
if (App :: get('cli.enabled', null, 'bool'))
Cli :: fatal_error($error);
die("FATAL ERROR: $error\n");
}
// Set HTTP response code to 500
http_response_code(500);
// Handle API mode
if (Url :: api_mode())
self :: display_ajax_error($error);
self :: assign('fatal_error', $error);
self :: display('fatal_error.tpl');
exit();
}
/**
* Get/set AJAX debug mode
* @param bool|null $value If boolean, the current API mode will be changed
* @return bool Current API mode
*/
public static function debug_ajax($value=null) {
if (is_bool($value)) self :: $_debug_ajax = $value;
return self :: $_debug_ajax;
}
/**
* Check if initialized
* @return bool
*/
public static function initialized() {
return self :: $smarty instanceof Smarty;
}
/**
* Get the templates directories path
* @return array<string>
*/
public static function templates_directories() {
return array_keys(self :: $templates_directories);
}
/**
* Register a templates directory
* @param string $path The templates directory path
* @param int|null $priority The priority of this templates directory
* (optional, default: prior than all other registered directories)
* @return void
*/
public static function register_templates_directory($path, $priority=null) {
if (!is_dir($path))
Log :: fatal(
'register_templates_directory(%s): this templates directory does not exists',
$path);
if (substr($path, -1) == '/')
$path = substr($path, 0, -1);
if (is_null($priority)) {
$priority = (
!empty(self :: $templates_directories)?
max(self :: $templates_directories) + 1:
100
);
}
Log :: trace(
'Register templates directory "%s" with priority %d',
$path, $priority);
self :: $templates_directories[$path] = $priority;
arsort(self :: $templates_directories);
}
/**
* Resolve templates path against registered templates directories
* @param string $path
* @param bool $core_only Core only mode (optional, default: false)
* @return string|false
*/
public static function resolve_templates_path($path, $core_only=false) {
foreach(array_keys(self :: $templates_directories) as $dir) {
if ($core_only && $dir != self :: $core_templates_directory)
continue;
$fullpath = "$dir/$path";
if (file_exists($fullpath)) {
Log::trace('Templates file "%s" resolved as "%s"', $path, $fullpath);
return $fullpath;
}
}
Log::trace('Templates file "%s" not found', $path);
return false;
}
/**
* Return the content of a Smarty template file.
*
* @param string $template The template name (eg: base.tpl)
* @param bool $core_only Core only mode (optional, default: false)
*
* @return string The content of the Smarty template file
**/
public static function get_template_source($template, $core_only=false) {
$path = self :: resolve_templates_path($template, $core_only);
if (!is_readable($path)) {
// No error return with Smarty3 and higher because it's call
// template name in lower first systematically
return '';
}
return file_get_contents($path);
}
/**
* Return the timestamp of the last change of a Smarty
* template file.
*
* @param string $template The template name (eg: empty.tpl)
* @param bool $core_only Core only mode (optional, default: false)
*
* @return int|null The timestamp of the last change of the Smarty template file
**/
public static function get_template_timestamp($template, $core_only=false) {
$path = self :: resolve_templates_path($template, $core_only);
if (is_file($path)) {
$time = filemtime($path);
if ($time)
return $time;
}
return null;
}
/**
* Get static root URL
* @return string|null
*/
public static function static_root_url() {
return self :: $static_root_url;
}
/**
* Clean static root URL helper
* @param string $value
* @return string
*/
public static function clean_static_root_url($value) {
if (substr($value, 0, 1) == '/')
$value = substr($value, 1);
if (substr($value, -1) != '/')
$value = "$value/";
return $value;
}
/**
* Get the static directories path
* @return array<string>
*/
public static function static_directories() {
$result = array();
foreach(self :: $static_directories as $root_url => $dirs)
foreach(array_keys($dirs) as $dir)
if (!in_array($dir, $result))
$result[] = $dir;
return $result;
}
/**
* Register a static directory
* @param string $path The static directory path
* @param int|null $priority The priority of this static directory
* (optional, default: prior than all other registered directories)
* @return void
*/
public static function register_static_directory($path, $priority=null, $root_url=null) {
if (is_null($root_url)) {
if (!self :: $static_root_url)
Log :: fatal(
'register_static_directory(%s): no root URL provided and no default value configured',
$path);
$root_url = self :: $static_root_url;
}
if (!array_key_exists($root_url, self :: $static_directories)) {
self :: $static_directories[$root_url] = array();
if (is_null($priority)) $priority = 100;
$pattern = "#^(?P<root_url>$root_url)(?P<path>.*)#";
Log :: trace(
'Register static file URL handler for root URL "%s" with pattern "%s" and directory '.
'"%s" (priority: %d)', $root_url, $pattern, $path, $priority);
Url :: add_url_handler(
$pattern,
array('EesyPHP\\Tpl', 'handle_static_file'),
null, // additional info
false, // authenticated
false, // override
false, // API mode
array('GET') // methods
);
if (is_null(self :: $mime_type_detector))
self :: $mime_type_detector = new ExtensionMimeTypeDetector();
}
else {
if (is_null($priority)) {
$priority = max(self :: $static_directories[$root_url]);
$priority++;
}
Log :: trace(
'Register additional static directory "%s" for root URL "%s" (priority: %d)',
$path, $root_url, $priority);
}
if (substr($path, -1) == '/')
$path = substr($path, 0, -1);
self :: $static_directories[$root_url][$path] = $priority;
arsort(self :: $static_directories[$root_url]);
}
/**
* Resolve static path against registered static directories
* @param string $path
* @return string|false
*/
public static function resolve_static_path($root_url, $path) {
if (!array_key_exists($root_url, self :: $static_directories)) {
Log::error(
'No static directory registered for root URL "%s". Can no resolve static file "%s" path.',
$root_url, $path);
return false;
}
foreach(array_keys(self :: $static_directories[$root_url]) as $dir) {
$fullpath = "$dir/$path";
if (file_exists($fullpath))
return $fullpath;
}
Log::trace('Static file "%s%s" not found', $root_url, $path);
return false;
}
/**
* Handle URL request for static file
* Note: this URL handler is registered in EesyPHP\Url by self::init().
* @see self::init()
* @param UrlRequest $request
* @return never
*/
public static function handle_static_file($request) {
$path = self :: resolve_static_path($request->root_url, $request->path);
Log::trace('Resolved static file path for "%s": "%s"', $request->path, $path);
if (!$path)
Url :: trigger_error_404($request);
$mime_type = self :: $mime_type_detector->detectMimeTypeFromFile($path);
Log::trace('MIME type detected for "%s" is "%s"', $path, $mime_type);
dump_file($path, $mime_type);
}
/**
* Function to retrieve static file URL
* @param string $path The file path
* @return string|false
*/
public static function static_url($path) {
if (self :: $static_root_url)
return self :: $static_root_url.$path;
return false;
}
/**
* Smarty function to print static file URL
* @param array<string,mixed> $params Parameters from template file
* @param Smarty $smarty The smarty object
* @return void
*/
public static function smarty_static_url($params, $smarty) {
if (!isset($params['path'])) return;
$url = self :: static_url($params['path']);
if ($url) echo $url;
}
/**
* Smarty function to dump variable using var_dump()
* @param array<string,mixed> $params Parameters from template file
* @param Smarty $smarty The smarty object
* @return void
*/
public static function smarty_var_dump($params, $smarty) {
if (!isset($params['data'])) return;
var_dump($params['data']);
}
/**
* Compute Smarty computing time
* @param array<string,mixed> $params Parameters from template file
* @param Smarty $smarty The smarty object
* @return void
*/
public static function smarty_computing_time($params, $smarty) {
echo format_duration(hrtime(true) - self :: $start_time, 'ns', 'ms');
}
/**
* Compute total page computing time
* @param array<string,mixed> $params Parameters from template file
* @param Smarty $smarty The smarty object
* @return void
*/
public static function smarty_total_computing_time($params, $smarty) {
$init_time = App::get('init_time');
if ($init_time) {
echo format_duration(hrtime(true) - $init_time, 'ns', 'ms');
}
else {
echo _('Unknown');
}
}
}