Merge pull request #22271 from civicrm/5.45
[civicrm-core.git] / CRM / Core / Smarty.php
1 <?php
2 /*
3 +--------------------------------------------------------------------+
4 | Copyright CiviCRM LLC. All rights reserved. |
5 | |
6 | This work is published under the GNU AGPLv3 license with some |
7 | permitted exceptions and without any warranty. For full license |
8 | and copyright information, see https://civicrm.org/licensing |
9 +--------------------------------------------------------------------+
10 */
11
12 /**
13 *
14 * @package CRM
15 * @copyright CiviCRM LLC https://civicrm.org/licensing
16 */
17
18 /**
19 * Fix for bug CRM-392. Not sure if this is the best fix or it will impact
20 * other similar PEAR packages. doubt it
21 */
22 if (!class_exists('Smarty')) {
23 require_once 'Smarty/Smarty.class.php';
24 }
25
26 /**
27 *
28 */
29 class CRM_Core_Smarty extends Smarty {
30 const
31 // use print.tpl and bypass the CMS. Civi prints a valid html file
32 PRINT_PAGE = 1,
33 // this and all the below bypasses the CMS html surrounding it and assumes we will embed this within other pages
34 PRINT_SNIPPET = 2,
35 // sends the generated html to the chosen pdf engine
36 PRINT_PDF = 3,
37 // this options also skips the enclosing form html and does not
38 // generate any of the hidden fields, most notably qfKey
39 // this is typically used in ajax scripts to embed form snippets based on user choices
40 PRINT_NOFORM = 4,
41 // this prints a complete form and also generates a qfKey, can we replace this with
42 // snippet = 2?? Does the constant _NOFFORM do anything?
43 PRINT_QFKEY = 5,
44 // Note: added in v 4.3 with the value '6'
45 // Value changed in 4.5 to 'json' for better readability
46 // @see CRM_Core_Page_AJAX::returnJsonResponse
47 PRINT_JSON = 'json';
48
49 /**
50 * We only need one instance of this object. So we use the singleton
51 * pattern and cache the instance in this variable
52 *
53 * @var object
54 */
55 static private $_singleton = NULL;
56
57 /**
58 * Backup frames.
59 *
60 * A list of variables ot save temporarily in format (string $name => mixed $value).
61 *
62 * @var array
63 */
64 private $backupFrames = [];
65
66 /**
67 * Class constructor.
68 *
69 * @return CRM_Core_Smarty
70 */
71 public function __construct() {
72 parent::__construct();
73 }
74
75 private function initialize() {
76 $config = CRM_Core_Config::singleton();
77
78 if (isset($config->customTemplateDir) && $config->customTemplateDir) {
79 $this->template_dir = array_merge([$config->customTemplateDir],
80 $config->templateDir
81 );
82 }
83 else {
84 $this->template_dir = $config->templateDir;
85 }
86 $this->compile_dir = CRM_Utils_File::addTrailingSlash(CRM_Utils_File::addTrailingSlash($config->templateCompileDir) . $this->getLocale());
87 CRM_Utils_File::createDir($this->compile_dir);
88 CRM_Utils_File::restrictAccess($this->compile_dir);
89
90 // check and ensure it is writable
91 // else we sometime suppress errors quietly and this results
92 // in blank emails etc
93 if (!is_writable($this->compile_dir)) {
94 echo "CiviCRM does not have permission to write temp files in {$this->compile_dir}, Exiting";
95 exit();
96 }
97
98 $this->use_sub_dirs = TRUE;
99
100 $customPluginsDir = NULL;
101 if (!empty($config->customPHPPathDir) || $config->customPHPPathDir === '0') {
102 $customPluginsDir
103 = $config->customPHPPathDir . DIRECTORY_SEPARATOR .
104 'CRM' . DIRECTORY_SEPARATOR .
105 'Core' . DIRECTORY_SEPARATOR .
106 'Smarty' . DIRECTORY_SEPARATOR .
107 'plugins' . DIRECTORY_SEPARATOR;
108 if (!file_exists($customPluginsDir)) {
109 $customPluginsDir = NULL;
110 }
111 }
112
113 $pkgsDir = Civi::paths()->getVariable('civicrm.packages', 'path');
114 $smartyDir = $pkgsDir . DIRECTORY_SEPARATOR . 'Smarty' . DIRECTORY_SEPARATOR;
115 $pluginsDir = __DIR__ . DIRECTORY_SEPARATOR . 'Smarty' . DIRECTORY_SEPARATOR . 'plugins' . DIRECTORY_SEPARATOR;
116
117 if ($customPluginsDir) {
118 $this->plugins_dir = [$customPluginsDir, $smartyDir . 'plugins', $pluginsDir];
119 }
120 else {
121 $this->plugins_dir = [$smartyDir . 'plugins', $pluginsDir];
122 }
123
124 $this->compile_check = $this->isCheckSmartyIsCompiled();
125
126 // add the session and the config here
127 $session = CRM_Core_Session::singleton();
128
129 $this->assign_by_ref('config', $config);
130 $this->assign_by_ref('session', $session);
131
132 $tsLocale = CRM_Core_I18n::getLocale();
133 $this->assign('tsLocale', $tsLocale);
134
135 // CRM-7163 hack: we don’t display langSwitch on upgrades anyway
136 if (!CRM_Core_Config::isUpgradeMode()) {
137 $this->assign('langSwitch', CRM_Core_I18n::uiLanguages());
138 }
139
140 $this->register_function('crmURL', ['CRM_Utils_System', 'crmURL']);
141 if (CRM_Utils_Constant::value('CIVICRM_SMARTY_DEFAULT_ESCAPE')) {
142 // When default escape is enabled if the core escape is called before
143 // any custom escaping is done the modifier_escape function is not
144 // found, so require_once straight away. Note this was hit on the basic
145 // contribution dashboard from RecentlyViewed.tpl
146 require_once 'Smarty/plugins/modifier.escape.php';
147 if (!isset($this->_plugins['modifier']['escape'])) {
148 $this->register_modifier('escape', ['CRM_Core_Smarty', 'escape']);
149 }
150 $this->default_modifiers[] = 'escape:"htmlall"';
151 }
152 $this->load_filter('pre', 'resetExtScope');
153
154 $this->assign('crmPermissions', new CRM_Core_Smarty_Permissions());
155
156 if ($config->debug) {
157 $this->error_reporting = E_ALL;
158 }
159 }
160
161 /**
162 * Static instance provider.
163 *
164 * Method providing static instance of SmartTemplate, as
165 * in Singleton pattern.
166 *
167 * @return \CRM_Core_Smarty
168 */
169 public static function &singleton() {
170 if (!isset(self::$_singleton)) {
171 self::$_singleton = new CRM_Core_Smarty();
172 self::$_singleton->initialize();
173
174 self::registerStringResource();
175 }
176 return self::$_singleton;
177 }
178
179 /**
180 * Executes & returns or displays the template results
181 *
182 * @param string $resource_name
183 * @param string $cache_id
184 * @param string $compile_id
185 * @param bool $display
186 *
187 * @return bool|mixed|string
188 */
189 public function fetch($resource_name, $cache_id = NULL, $compile_id = NULL, $display = FALSE) {
190 if (preg_match('/^(\s+)?string:/', $resource_name)) {
191 $old_security = $this->security;
192 $this->security = TRUE;
193 }
194 $output = parent::fetch($resource_name, $cache_id, $compile_id, $display);
195 if (isset($old_security)) {
196 $this->security = $old_security;
197 }
198 return $output;
199 }
200
201 /**
202 * Ensure these variables are set to make it easier to access them without e-notice.
203 *
204 * @param array $variables
205 */
206 public function ensureVariablesAreAssigned(array $variables): void {
207 foreach ($variables as $variable) {
208 if (!isset($this->get_template_vars()[$variable])) {
209 $this->assign($variable);
210 }
211 }
212 }
213
214 /**
215 * Fetch a template (while using certain variables)
216 *
217 * @param string $resource_name
218 * @param array $vars
219 * (string $name => mixed $value) variables to export to Smarty.
220 * @throws Exception
221 * @return bool|mixed|string
222 */
223 public function fetchWith($resource_name, $vars) {
224 $this->pushScope($vars);
225 try {
226 $result = $this->fetch($resource_name);
227 }
228 catch (Exception $e) {
229 // simulate try { ... } finally { ... }
230 $this->popScope();
231 throw $e;
232 }
233 $this->popScope();
234 return $result;
235 }
236
237 /**
238 * @param string $name
239 * @param $value
240 */
241 public function appendValue($name, $value) {
242 $currentValue = $this->get_template_vars($name);
243 if (!$currentValue) {
244 $this->assign($name, $value);
245 }
246 else {
247 if (strpos($currentValue, $value) === FALSE) {
248 $this->assign($name, $currentValue . $value);
249 }
250 }
251 }
252
253 public function clearTemplateVars() {
254 foreach (array_keys($this->_tpl_vars) as $key) {
255 if ($key == 'config' || $key == 'session') {
256 continue;
257 }
258 unset($this->_tpl_vars[$key]);
259 }
260 }
261
262 public static function registerStringResource() {
263 require_once 'CRM/Core/Smarty/resources/String.php';
264 civicrm_smarty_register_string_resource();
265 }
266
267 /**
268 * @param $path
269 */
270 public function addTemplateDir($path) {
271 if (is_array($this->template_dir)) {
272 array_unshift($this->template_dir, $path);
273 }
274 else {
275 $this->template_dir = [$path, $this->template_dir];
276 }
277
278 }
279
280 /**
281 * Temporarily assign a list of variables.
282 *
283 * ```
284 * $smarty->pushScope(array(
285 * 'first_name' => 'Alice',
286 * 'last_name' => 'roberts',
287 * ));
288 * $html = $smarty->fetch('view-contact.tpl');
289 * $smarty->popScope();
290 * ```
291 *
292 * @param array $vars
293 * (string $name => mixed $value).
294 * @return CRM_Core_Smarty
295 * @see popScope
296 */
297 public function pushScope($vars) {
298 $oldVars = $this->get_template_vars();
299 $backupFrame = [];
300 foreach ($vars as $key => $value) {
301 $backupFrame[$key] = $oldVars[$key] ?? NULL;
302 }
303 $this->backupFrames[] = $backupFrame;
304
305 $this->assignAll($vars);
306
307 return $this;
308 }
309
310 /**
311 * Remove any values that were previously pushed.
312 *
313 * @return CRM_Core_Smarty
314 * @see pushScope
315 */
316 public function popScope() {
317 $this->assignAll(array_pop($this->backupFrames));
318 return $this;
319 }
320
321 /**
322 * @param array $vars
323 * (string $name => mixed $value).
324 * @return CRM_Core_Smarty
325 */
326 public function assignAll($vars) {
327 foreach ($vars as $key => $value) {
328 $this->assign($key, $value);
329 }
330 return $this;
331 }
332
333 /**
334 * Get the locale for translation.
335 *
336 * @return string
337 */
338 private function getLocale() {
339 $tsLocale = CRM_Core_I18n::getLocale();
340 if (!empty($tsLocale)) {
341 return $tsLocale;
342 }
343
344 $config = CRM_Core_Config::singleton();
345 if (!empty($config->lcMessages)) {
346 return $config->lcMessages;
347 }
348
349 return 'en_US';
350 }
351
352 /**
353 * Get the compile_check value.
354 *
355 * @return bool
356 */
357 private function isCheckSmartyIsCompiled() {
358 // check for define in civicrm.settings.php as FALSE, otherwise returns TRUE
359 return CRM_Utils_Constant::value('CIVICRM_TEMPLATE_COMPILE_CHECK', TRUE);
360 }
361
362 /**
363 * Smarty escape modifier plugin.
364 *
365 * This replaces the core smarty modifier and basically does a lot of
366 * early-returning before calling the core function.
367 *
368 * It early returns on patterns that are common 'no-escape' patterns
369 * in CiviCRM - this list can be honed over time.
370 *
371 * It also logs anything that is actually escaped. Since this only kicks
372 * in when CIVICRM_SMARTY_DEFAULT_ESCAPE is defined it is ok to be aggressive
373 * about logging as we mostly care about developers using it at this stage.
374 *
375 * Note we don't actually use 'htmlall' anywhere in our tpl layer yet so
376 * anything coming in with this be happening because of the default modifier.
377 *
378 * Also note the right way to opt a field OUT of escaping is
379 * ``{$fieldName|smarty:nodefaults}``
380 * This should be used for fields with known html AND for fields where
381 * we are doing empty or isset checks - as otherwise the value is passed for
382 * escaping first so you still get an enotice for 'empty' or a fatal for 'isset'
383 *
384 * Type: modifier<br>
385 * Name: escape<br>
386 * Purpose: Escape the string according to escapement type
387 *
388 * @link http://smarty.php.net/manual/en/language.modifier.escape.php
389 * escape (Smarty online manual)
390 * @author Monte Ohrt <monte at ohrt dot com>
391 *
392 * @param string $string
393 * @param string $esc_type
394 * @param string $char_set
395 *
396 * @return string
397 */
398 public static function escape($string, $esc_type = 'html', $char_set = 'ISO-8859-1') {
399 // CiviCRM variables are often arrays - just handle them.
400 // The early return on booleans & numbers is mostly to prevent them being
401 // logged as 'changed' when they are cast to a string.
402 if (!is_scalar($string) || empty($string) || is_bool($string) || is_numeric($string) || $esc_type === 'none') {
403 return $string;
404 }
405 if ($esc_type === 'htmlall') {
406 // 'htmlall' is the nothing-specified default.
407 // Don't escape things we think quickform added.
408 if (strpos($string, '<input') === 0
409 || strpos($string, '<select') === 0
410 // Not handling as yet but these ones really should get some love.
411 || strpos($string, '<label') === 0
412 || strpos($string, '<button') === 0
413 || strpos($string, '<span class="crm-frozen-field">') === 0
414 || strpos($string, '<textarea') === 0
415
416 // The ones below this point are hopefully here short term.
417 || strpos($string, '<a') === 0
418 // Message templates screen
419 || strpos($string, '<span><a href') === 0
420 // Not sure how big a pattern this is - used in Pledge view tab
421 // not sure if it needs escaping
422 || strpos($string, ' action="/civicrm/') === 0
423 // eg. Tag edit page, civicrm/admin/financial/financialType/accounts?action=add&reset=1&aid=1
424 || strpos($string, ' action="" method="post"') === 0
425 // This seems to be urls...
426 || strpos($string, '/civicrm/') === 0
427 // Validation error message - eg. <span class="crm-error">Tournament Fees is a required field.</span>
428 || strpos($string, '
429 <span class="crm-error">') === 0
430 // e.g from participant tab class="action-item" href=/civicrm/contact/view/participant?reset=1&amp;action=add&amp;cid=142&amp;context=participant
431 || strpos($string, 'class="action-item" href=/civicrm/"') === 0
432 ) {
433 // Do not escape the above common patterns.
434 return $string;
435 }
436 }
437
438 $value = smarty_modifier_escape($string, $esc_type, $char_set);
439 if ($value !== $string) {
440 Civi::log()->debug('smarty escaping original {original}, escaped {escaped} type {type} charset {charset}', [
441 'original' => $string,
442 'escaped' => $value,
443 'type' => $esc_type,
444 'charset' => $char_set,
445 ]);
446 }
447 return $value;
448 }
449
450 }