1<?php 2 3/** 4 +-----------------------------------------------------------------------+ 5 | This file is part of the Roundcube Webmail client | 6 | | 7 | Copyright (C) The Roundcube Dev Team | 8 | | 9 | Licensed under the GNU General Public License version 3 or | 10 | any later version with exceptions for skins & plugins. | 11 | See the README file for a full license statement. | 12 | | 13 | PURPOSE: | 14 | Class to handle HTML page output using a skin template. | 15 +-----------------------------------------------------------------------+ 16 | Author: Thomas Bruederli <roundcube@gmail.com> | 17 +-----------------------------------------------------------------------+ 18*/ 19 20/** 21 * Class to create HTML page output using a skin template 22 * 23 * @package Webmail 24 * @subpackage View 25 */ 26class rcmail_output_html extends rcmail_output 27{ 28 public $type = 'html'; 29 30 protected $message; 31 protected $template_name; 32 protected $objects = []; 33 protected $js_env = []; 34 protected $js_labels = []; 35 protected $js_commands = []; 36 protected $skin_paths = []; 37 protected $skin_name = ''; 38 protected $scripts_path = ''; 39 protected $script_files = []; 40 protected $css_files = []; 41 protected $scripts = []; 42 protected $meta_tags = []; 43 protected $link_tags = ['shortcut icon' => '']; 44 protected $header = ''; 45 protected $footer = ''; 46 protected $body = ''; 47 protected $base_path = ''; 48 protected $assets_path; 49 protected $assets_dir = RCUBE_INSTALL_PATH; 50 protected $devel_mode = false; 51 protected $default_template = "<html>\n<head><meta name='generator' content='Roundcube'></head>\n<body></body>\n</html>"; 52 53 // deprecated names of templates used before 0.5 54 protected $deprecated_templates = [ 55 'contact' => 'showcontact', 56 'contactadd' => 'addcontact', 57 'contactedit' => 'editcontact', 58 'identityedit' => 'editidentity', 59 'messageprint' => 'printmessage', 60 ]; 61 62 // deprecated names of template objects used before 1.4 63 protected $deprecated_template_objects = [ 64 'addressframe' => 'contentframe', 65 'messagecontentframe' => 'contentframe', 66 'prefsframe' => 'contentframe', 67 'folderframe' => 'contentframe', 68 'identityframe' => 'contentframe', 69 'responseframe' => 'contentframe', 70 'keyframe' => 'contentframe', 71 'filterframe' => 'contentframe', 72 ]; 73 74 /** 75 * Constructor 76 */ 77 public function __construct($task = null, $framed = false) 78 { 79 parent::__construct(); 80 81 $this->devel_mode = $this->config->get('devel_mode'); 82 83 $this->set_env('task', $task); 84 $this->set_env('standard_windows', (bool) $this->config->get('standard_windows')); 85 $this->set_env('locale', !empty($_SESSION['language']) ? $_SESSION['language'] : 'en_US'); 86 $this->set_env('devel_mode', $this->devel_mode); 87 88 // Version number e.g. 1.4.2 will be 10402 89 $version = explode('.', preg_replace('/[^0-9.].*/', '', RCMAIL_VERSION)); 90 $this->set_env('rcversion', $version[0] * 10000 + $version[1] * 100 + (isset($version[2]) ? $version[2] : 0)); 91 92 // add cookie info 93 $this->set_env('cookie_domain', ini_get('session.cookie_domain')); 94 $this->set_env('cookie_path', ini_get('session.cookie_path')); 95 $this->set_env('cookie_secure', filter_var(ini_get('session.cookie_secure'), FILTER_VALIDATE_BOOLEAN)); 96 97 // Easy way to change skin via GET argument, for developers 98 if ($this->devel_mode && !empty($_GET['skin']) && preg_match('/^[a-z0-9-_]+$/i', $_GET['skin'])) { 99 if ($this->check_skin($_GET['skin'])) { 100 $this->set_skin($_GET['skin']); 101 $this->app->user->save_prefs(['skin' => $_GET['skin']]); 102 } 103 } 104 105 // load and setup the skin 106 $this->set_skin($this->config->get('skin')); 107 $this->set_assets_path($this->config->get('assets_path'), $this->config->get('assets_dir')); 108 109 if (!empty($_REQUEST['_extwin'])) { 110 $this->set_env('extwin', 1); 111 } 112 113 if ($this->framed || $framed) { 114 $this->set_env('framed', 1); 115 } 116 117 $lic = <<<EOF 118/* 119 @licstart The following is the entire license notice for the 120 JavaScript code in this page. 121 122 Copyright (C) The Roundcube Dev Team 123 124 The JavaScript code in this page is free software: you can redistribute 125 it and/or modify it under the terms of the GNU General Public License 126 as published by the Free Software Foundation, either version 3 of 127 the License, or (at your option) any later version. 128 129 The code is distributed WITHOUT ANY WARRANTY; without even the implied 130 warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. 131 See the GNU GPL for more details. 132 133 @licend The above is the entire license notice 134 for the JavaScript code in this page. 135*/ 136EOF; 137 // add common javascripts 138 $this->add_script($lic, 'head_top'); 139 $this->add_script('var '.self::JS_OBJECT_NAME.' = new rcube_webmail();', 'head_top'); 140 141 // don't wait for page onload. Call init at the bottom of the page (delayed) 142 $this->add_script(self::JS_OBJECT_NAME.'.init();', 'docready'); 143 144 $this->scripts_path = 'program/js/'; 145 $this->include_script('jquery.min.js'); 146 $this->include_script('common.js'); 147 $this->include_script('app.js'); 148 149 // register common UI objects 150 $this->add_handlers([ 151 'loginform' => [$this, 'login_form'], 152 'preloader' => [$this, 'preloader'], 153 'username' => [$this, 'current_username'], 154 'message' => [$this, 'message_container'], 155 'charsetselector' => [$this, 'charset_selector'], 156 'aboutcontent' => [$this, 'about_content'], 157 ]); 158 159 // set blankpage (watermark) url 160 $blankpage = $this->config->get('blankpage_url', '/watermark.html'); 161 $this->set_env('blankpage', $blankpage); 162 } 163 164 /** 165 * Set environment variable 166 * 167 * @param string $name Property name 168 * @param mixed $value Property value 169 * @param bool $addtojs True if this property should be added 170 * to client environment 171 */ 172 public function set_env($name, $value, $addtojs = true) 173 { 174 $this->env[$name] = $value; 175 176 if ($addtojs || isset($this->js_env[$name])) { 177 $this->js_env[$name] = $value; 178 } 179 } 180 181 /** 182 * Parse and set assets path 183 * 184 * @param string $path Assets path URL (relative or absolute) 185 * @param string $fs_dif Assets path in filesystem 186 */ 187 public function set_assets_path($path, $fs_dir = null) 188 { 189 // set absolute path for assets if /index.php/foo/bar url is used 190 if (empty($path) && !empty($_SERVER['PATH_INFO'])) { 191 $path = preg_replace('/\/?\?_task=[a-z]+/', '', $this->app->url([], true)); 192 } 193 194 if (empty($path)) { 195 return; 196 } 197 198 $path = rtrim($path, '/') . '/'; 199 200 // handle relative assets path 201 if (!preg_match('|^https?://|', $path) && $path[0] != '/') { 202 // save the path to search for asset files later 203 $this->assets_dir = $path; 204 205 $base = preg_replace('/[?#&].*$/', '', $_SERVER['REQUEST_URI']); 206 $base = rtrim($base, '/'); 207 208 // remove url token if exists 209 if ($len = intval($this->config->get('use_secure_urls'))) { 210 $_base = explode('/', $base); 211 $last = count($_base) - 1; 212 $length = $len > 1 ? $len : 16; // as in rcube::get_secure_url_token() 213 214 // we can't use real token here because it 215 // does not exists in unauthenticated state, 216 // hope this will not produce false-positive matches 217 if ($last > -1 && preg_match('/^[a-f0-9]{' . $length . '}$/', $_base[$last])) { 218 $path = '../' . $path; 219 } 220 } 221 } 222 223 // set filesystem path for assets 224 if ($fs_dir) { 225 if ($fs_dir[0] != '/') { 226 $fs_dir = realpath(RCUBE_INSTALL_PATH . $fs_dir); 227 } 228 // ensure the path ends with a slash 229 $this->assets_dir = rtrim($fs_dir, '/') . '/'; 230 } 231 232 $this->assets_path = $path; 233 $this->set_env('assets_path', $path); 234 } 235 236 /** 237 * Getter for the current page title 238 * 239 * @param bool $full Prepend title with product/user name 240 * 241 * @return string The page title 242 */ 243 protected function get_pagetitle($full = true) 244 { 245 if (!empty($this->pagetitle)) { 246 $title = $this->pagetitle; 247 } 248 else if (isset($this->env['task'])) { 249 if ($this->env['task'] == 'login') { 250 $title = $this->app->gettext([ 251 'name' => 'welcome', 252 'vars' => ['product' => $this->config->get('product_name')] 253 ]); 254 } 255 else { 256 $title = ucfirst($this->env['task']); 257 } 258 } 259 else { 260 $title = ''; 261 } 262 263 if ($full && $title) { 264 if ($this->devel_mode && !empty($_SESSION['username'])) { 265 $title = $_SESSION['username'] . ' :: ' . $title; 266 } 267 else if ($prod_name = $this->config->get('product_name')) { 268 $title = $prod_name . ' :: ' . $title; 269 } 270 } 271 272 return $title; 273 } 274 275 /** 276 * Getter for the current skin path property 277 */ 278 public function get_skin_path() 279 { 280 return $this->skin_paths[0]; 281 } 282 283 /** 284 * Set skin 285 * 286 * @param string $skin Skin name 287 */ 288 public function set_skin($skin) 289 { 290 if (!$this->check_skin($skin)) { 291 // If the skin does not exist (could be removed or invalid), 292 // fallback to the skin set in the system configuration (#7271) 293 $skin = $this->config->system_skin; 294 } 295 296 $skin_path = 'skins/' . $skin; 297 298 $this->config->set('skin_path', $skin_path); 299 $this->base_path = $skin_path; 300 301 // register skin path(s) 302 $this->skin_paths = []; 303 $this->skins = []; 304 $this->load_skin($skin_path); 305 306 $this->skin_name = $skin; 307 $this->set_env('skin', $skin); 308 } 309 310 /** 311 * Check skin validity/existence 312 * 313 * @param string $skin Skin name 314 * 315 * @return bool True if the skin exist and is readable, False otherwise 316 */ 317 public function check_skin($skin) 318 { 319 // Sanity check to prevent from path traversal vulnerability (#1490620) 320 if (strpos($skin, '/') !== false || strpos($skin, "\\") !== false) { 321 rcube::raise_error([ 322 'file' => __FILE__, 323 'line' => __LINE__, 324 'message' => 'Invalid skin name' 325 ], true, false); 326 327 return false; 328 } 329 330 $skins_allowed = $this->config->get('skins_allowed'); 331 332 if (!empty($skins_allowed) && !in_array($skin, (array) $skins_allowed)) { 333 return false; 334 } 335 336 $path = RCUBE_INSTALL_PATH . 'skins/'; 337 338 return !empty($skin) && is_dir($path . $skin) && is_readable($path . $skin); 339 } 340 341 /** 342 * Helper method to recursively read skin meta files and register search paths 343 */ 344 private function load_skin($skin_path) 345 { 346 $this->skin_paths[] = $skin_path; 347 348 // read meta file and check for dependencies 349 $meta = @file_get_contents(RCUBE_INSTALL_PATH . $skin_path . '/meta.json'); 350 $meta = @json_decode($meta, true); 351 352 $meta['path'] = $skin_path; 353 $path_elements = explode('/', $skin_path); 354 $skin_id = end($path_elements); 355 356 if (empty($meta['name'])) { 357 $meta['name'] = $skin_id; 358 } 359 360 $this->skins[$skin_id] = $meta; 361 362 // Keep skin config for ajax requests (#6613) 363 $_SESSION['skin_config'] = []; 364 365 if (!empty($meta['extends'])) { 366 $path = RCUBE_INSTALL_PATH . 'skins/'; 367 if (is_dir($path . $meta['extends']) && is_readable($path . $meta['extends'])) { 368 $_SESSION['skin_config'] = $this->load_skin('skins/' . $meta['extends']); 369 } 370 } 371 372 if (!empty($meta['config'])) { 373 foreach ($meta['config'] as $key => $value) { 374 $this->config->set($key, $value, true); 375 $_SESSION['skin_config'][$key] = $value; 376 } 377 378 $value = array_merge((array) $this->config->get('dont_override'), array_keys($meta['config'])); 379 $this->config->set('dont_override', $value, true); 380 } 381 382 if (!empty($meta['localization'])) { 383 $locdir = $meta['localization'] === true ? 'localization' : $meta['localization']; 384 if ($texts = $this->app->read_localization(RCUBE_INSTALL_PATH . $skin_path . '/' . $locdir)) { 385 $this->app->load_language($_SESSION['language'], $texts); 386 } 387 } 388 389 // Use array_merge() here to allow for global default and extended skins 390 if (!empty($meta['meta'])) { 391 $this->meta_tags = array_merge($this->meta_tags, (array) $meta['meta']); 392 } 393 if (!empty($meta['links'])) { 394 $this->link_tags = array_merge($this->link_tags, (array) $meta['links']); 395 } 396 397 $this->set_env('dark_mode_support', (bool) $this->config->get('dark_mode_support')); 398 399 return $_SESSION['skin_config']; 400 } 401 402 /** 403 * Check if a specific template exists 404 * 405 * @param string $name Template name 406 * 407 * @return bool True if template exists, False otherwise 408 */ 409 public function template_exists($name) 410 { 411 foreach ($this->skin_paths as $skin_path) { 412 $filename = RCUBE_INSTALL_PATH . $skin_path . '/templates/' . $name . '.html'; 413 if ( 414 (is_file($filename) && is_readable($filename)) 415 || (!empty($this->deprecated_templates[$name]) && $this->template_exists($this->deprecated_templates[$name])) 416 ) { 417 return true; 418 } 419 } 420 421 return false; 422 } 423 424 /** 425 * Find the given file in the current skin path stack 426 * 427 * @param string $file File name/path to resolve (starting with /) 428 * @param string &$skin_path Reference to the base path of the matching skin 429 * @param string $add_path Additional path to search in 430 * @param bool $minified Fallback to a minified version of the file 431 * 432 * @return string|false Relative path to the requested file or False if not found 433 */ 434 public function get_skin_file($file, &$skin_path = null, $add_path = null, $minified = false) 435 { 436 $skin_paths = $this->skin_paths; 437 438 if ($add_path) { 439 array_unshift($skin_paths, $add_path); 440 $skin_paths = array_unique($skin_paths); 441 } 442 443 if ($file[0] != '/') { 444 $file = '/' . $file; 445 } 446 447 if ($skin_path = $this->find_file_path($file, $skin_paths)) { 448 return $skin_path . $file; 449 } 450 451 if ($minified && preg_match('/(?<!\.min)\.(js|css)$/', $file)) { 452 $file = preg_replace('/\.(js|css)$/', '.min.\\1', $file); 453 454 if ($skin_path = $this->find_file_path($file, $skin_paths)) { 455 return $skin_path . $file; 456 } 457 } 458 459 return false; 460 } 461 462 /** 463 * Find path of the asset file 464 */ 465 protected function find_file_path($file, $skin_paths) 466 { 467 foreach ($skin_paths as $skin_path) { 468 if ($this->assets_dir != RCUBE_INSTALL_PATH) { 469 if (realpath($this->assets_dir . $skin_path . $file)) { 470 return $skin_path; 471 } 472 } 473 474 if (realpath(RCUBE_INSTALL_PATH . $skin_path . $file)) { 475 return $skin_path; 476 } 477 } 478 } 479 480 /** 481 * Register a GUI object to the client script 482 * 483 * @param string $obj Object name 484 * @param string $id Object ID 485 */ 486 public function add_gui_object($obj, $id) 487 { 488 $this->add_script(self::JS_OBJECT_NAME.".gui_object('$obj', '$id');"); 489 } 490 491 /** 492 * Call a client method 493 * 494 * @param string Method to call 495 * @param ... Additional arguments 496 */ 497 public function command() 498 { 499 $cmd = func_get_args(); 500 501 if (strpos($cmd[0], 'plugin.') !== false) { 502 $this->js_commands[] = ['triggerEvent', $cmd[0], $cmd[1]]; 503 } 504 else { 505 $this->js_commands[] = $cmd; 506 } 507 } 508 509 /** 510 * Add a localized label to the client environment 511 */ 512 public function add_label() 513 { 514 $args = func_get_args(); 515 516 if (count($args) == 1 && is_array($args[0])) { 517 $args = $args[0]; 518 } 519 520 foreach ($args as $name) { 521 $this->js_labels[$name] = $this->app->gettext($name); 522 } 523 } 524 525 /** 526 * Invoke display_message command 527 * 528 * @param string $message Message to display 529 * @param string $type Message type [notice|confirm|error] 530 * @param array $vars Key-value pairs to be replaced in localized text 531 * @param bool $override Override last set message 532 * @param int $timeout Message display time in seconds 533 * 534 * @uses self::command() 535 */ 536 public function show_message($message, $type = 'notice', $vars = null, $override = true, $timeout = 0) 537 { 538 if ($override || !$this->message) { 539 if ($this->app->text_exists($message)) { 540 if (!empty($vars)) { 541 $vars = array_map(['rcube','Q'], $vars); 542 } 543 544 $msgtext = $this->app->gettext(['name' => $message, 'vars' => $vars]); 545 } 546 else { 547 $msgtext = $message; 548 } 549 550 $this->message = $message; 551 $this->command('display_message', $msgtext, $type, $timeout * 1000); 552 } 553 } 554 555 /** 556 * Delete all stored env variables and commands 557 * 558 * @param bool $all Reset all env variables (including internal) 559 */ 560 public function reset($all = false) 561 { 562 $framed = $this->framed; 563 $task = isset($this->env['task']) ? $this->env['task'] : ''; 564 $env = $all ? null : array_intersect_key($this->env, ['extwin' => 1, 'framed' => 1]); 565 566 // keep jQuery-UI files 567 $css_files = $script_files = []; 568 569 foreach ($this->css_files as $file) { 570 if (strpos($file, 'plugins/jqueryui') === 0) { 571 $css_files[] = $file; 572 } 573 } 574 575 foreach ($this->script_files as $position => $files) { 576 foreach ($files as $file) { 577 if (strpos($file, 'plugins/jqueryui') === 0) { 578 $script_files[$position][] = $file; 579 } 580 } 581 } 582 583 parent::reset(); 584 585 // let some env variables survive 586 $this->env = $this->js_env = $env; 587 $this->framed = $framed || !empty($this->env['framed']); 588 $this->js_labels = []; 589 $this->js_commands = []; 590 $this->scripts = []; 591 $this->header = ''; 592 $this->footer = ''; 593 $this->body = ''; 594 $this->css_files = []; 595 $this->script_files = []; 596 597 // load defaults 598 if (!$all) { 599 $this->__construct(); 600 } 601 602 // Note: we merge jQuery-UI scripts after jQuery... 603 $this->css_files = array_merge($this->css_files, $css_files); 604 $this->script_files = array_merge_recursive($this->script_files, $script_files); 605 606 $this->set_env('orig_task', $task); 607 } 608 609 /** 610 * Redirect to a certain url 611 * 612 * @param mixed $p Either a string with the action or url parameters as key-value pairs 613 * @param int $delay Delay in seconds 614 * @param bool $secure Redirect to secure location (see rcmail::url()) 615 */ 616 public function redirect($p = [], $delay = 1, $secure = false) 617 { 618 if (!empty($this->env['extwin']) && !(is_string($p) && preg_match('#^https?://#', $p))) { 619 if (!is_array($p)) { 620 $p = ['_action' => $p]; 621 } 622 623 $p['_extwin'] = 1; 624 } 625 626 $location = $this->app->url($p, false, false, $secure); 627 $this->header('Location: ' . $location); 628 exit; 629 } 630 631 /** 632 * Send the request output to the client. 633 * This will either parse a skin template. 634 * 635 * @param string $templ Template name 636 * @param bool $exit True if script should terminate (default) 637 */ 638 public function send($templ = null, $exit = true) 639 { 640 if ($templ != 'iframe') { 641 // prevent from endless loops 642 if ($exit != 'recur' && $this->app->plugins->is_processing('render_page')) { 643 rcube::raise_error([ 644 'code' => 505, 645 'file' => __FILE__, 646 'line' => __LINE__, 647 'message' => 'Recursion alert: ignoring output->send()' 648 ], true, false 649 ); 650 651 return; 652 } 653 654 $this->parse($templ, false); 655 } 656 else { 657 $this->framed = true; 658 $this->write(); 659 } 660 661 // set output asap 662 ob_flush(); 663 flush(); 664 665 if ($exit) { 666 exit; 667 } 668 } 669 670 /** 671 * Process template and write to stdOut 672 * 673 * @param string $template HTML template content 674 */ 675 public function write($template = '') 676 { 677 if (!empty($this->script_files)) { 678 $this->set_env('request_token', $this->app->get_request_token()); 679 } 680 681 // Fix assets path on blankpage 682 if (!empty($this->js_env['blankpage'])) { 683 $this->js_env['blankpage'] = $this->asset_url($this->js_env['blankpage'], true); 684 } 685 686 $commands = $this->get_js_commands($framed); 687 688 // if all js commands go to parent window we can ignore all 689 // script files and skip rcube_webmail initialization (#1489792) 690 // but not on error pages where skins may need jQuery, etc. 691 if ($framed && empty($this->js_env['server_error'])) { 692 $this->scripts = []; 693 $this->script_files = []; 694 $this->header = ''; 695 $this->footer = ''; 696 } 697 698 // write all javascript commands 699 if (!empty($commands)) { 700 $this->add_script($commands, 'head_top'); 701 } 702 703 $this->page_headers(); 704 705 // call super method 706 $this->_write($template); 707 } 708 709 /** 710 * Send common page headers 711 * For now it only (re)sets X-Frame-Options when needed 712 */ 713 public function page_headers() 714 { 715 if (headers_sent()) { 716 return; 717 } 718 719 // allow (legal) iframe content to be loaded 720 $framed = $this->framed || !empty($this->env['framed']); 721 if ($framed && ($xopt = $this->app->config->get('x_frame_options', 'sameorigin'))) { 722 if (strtolower($xopt) === 'deny') { 723 $this->header('X-Frame-Options: sameorigin', true); 724 } 725 } 726 } 727 728 /** 729 * Parse a specific skin template and deliver to stdout (or return) 730 * 731 * @param string $name Template name 732 * @param bool $exit Exit script 733 * @param bool $write Don't write to stdout, return parsed content instead 734 * 735 * @link http://php.net/manual/en/function.exit.php 736 */ 737 function parse($name = 'main', $exit = true, $write = true) 738 { 739 $plugin = false; 740 $realname = $name; 741 $skin_dir = ''; 742 $plugin_skin_paths = []; 743 744 $this->template_name = $realname; 745 746 $temp = explode('.', $name, 2); 747 if (count($temp) > 1) { 748 $plugin = $temp[0]; 749 $name = $temp[1]; 750 $skin_dir = $plugin . '/skins/' . $this->config->get('skin'); 751 752 // apply skin search escalation list to plugin directory 753 foreach ($this->skin_paths as $skin_path) { 754 // skin folder in plugin dir 755 $plugin_skin_paths[] = $this->app->plugins->url . $plugin . '/' . $skin_path; 756 // plugin folder in skin dir 757 $plugin_skin_paths[] = $skin_path . '/plugins/' . $plugin; 758 } 759 760 // prepend plugin skin paths to search list 761 $this->skin_paths = array_merge($plugin_skin_paths, $this->skin_paths); 762 } 763 764 // find skin template 765 $path = false; 766 foreach ($this->skin_paths as $skin_path) { 767 // when requesting a plugin template ignore global skin path(s) 768 if ($plugin && strpos($skin_path, $this->app->plugins->url) === false) { 769 continue; 770 } 771 772 $path = RCUBE_INSTALL_PATH . "$skin_path/templates/$name.html"; 773 774 // fallback to deprecated template names 775 if (!is_readable($path) && !empty($this->deprecated_templates[$realname])) { 776 $dname = $this->deprecated_templates[$realname]; 777 $path = RCUBE_INSTALL_PATH . "$skin_path/templates/$dname.html"; 778 779 if (is_readable($path)) { 780 rcube::raise_error([ 781 'code' => 502, 'file' => __FILE__, 'line' => __LINE__, 782 'message' => "Using deprecated template '$dname' in $skin_path/templates. Please rename to '$realname'" 783 ], true, false 784 ); 785 } 786 } 787 788 if (is_readable($path)) { 789 $this->config->set('skin_path', $skin_path); 790 // set base_path to core skin directory (not plugin's skin) 791 $this->base_path = preg_replace('!plugins/\w+/!', '', $skin_path); 792 $skin_dir = preg_replace('!^plugins/!', '', $skin_path); 793 break; 794 } 795 else { 796 $path = false; 797 } 798 } 799 800 // read template file 801 if (!$path || ($templ = @file_get_contents($path)) === false) { 802 rcube::raise_error([ 803 'code' => 404, 804 'line' => __LINE__, 805 'file' => __FILE__, 806 'message' => 'Error loading template for '.$realname 807 ], true, $write); 808 809 $this->skin_paths = array_slice($this->skin_paths, count($plugin_skin_paths)); 810 return false; 811 } 812 813 // replace all path references to plugins/... with the configured plugins dir 814 // and /this/ to the current plugin skin directory 815 if ($plugin) { 816 $templ = preg_replace( 817 ['/\bplugins\//', '/(["\']?)\/this\//'], 818 [$this->app->plugins->url, '\\1' . $this->app->plugins->url . $skin_dir . '/'], 819 $templ 820 ); 821 } 822 823 // parse for special tags 824 $output = $this->parse_conditions($templ); 825 $output = $this->parse_xml($output); 826 827 // trigger generic hook where plugins can put additional content to the page 828 $hook = $this->app->plugins->exec_hook("render_page", [ 829 'template' => $realname, 830 'content' => $output, 831 'write' => $write 832 ]); 833 834 // save some memory 835 $output = $hook['content']; 836 unset($hook['content']); 837 838 // remove plugin skin paths from current context 839 $this->skin_paths = array_slice($this->skin_paths, count($plugin_skin_paths)); 840 841 if (!$write) { 842 return $this->postrender($output); 843 } 844 845 $this->write(trim($output)); 846 847 if ($exit) { 848 exit; 849 } 850 } 851 852 /** 853 * Return executable javascript code for all registered commands 854 */ 855 protected function get_js_commands(&$framed = null) 856 { 857 $out = ''; 858 $parent_commands = 0; 859 $parent_prefix = ''; 860 $top_commands = []; 861 862 // these should be always on top, 863 // e.g. hide_message() below depends on env.framed 864 if (!$this->framed && !empty($this->js_env)) { 865 $top_commands[] = ['set_env', $this->js_env]; 866 } 867 if (!empty($this->js_labels)) { 868 $top_commands[] = ['add_label', $this->js_labels]; 869 } 870 871 // unlock interface after iframe load 872 $unlock = isset($_REQUEST['_unlock']) ? preg_replace('/[^a-z0-9]/i', '', $_REQUEST['_unlock']) : 0; 873 if ($this->framed) { 874 $top_commands[] = ['iframe_loaded', $unlock]; 875 } 876 else if ($unlock) { 877 $top_commands[] = ['hide_message', $unlock]; 878 } 879 880 $commands = array_merge($top_commands, $this->js_commands); 881 882 foreach ($commands as $i => $args) { 883 $method = array_shift($args); 884 $parent = $this->framed || preg_match('/^parent\./', $method); 885 886 foreach ($args as $i => $arg) { 887 $args[$i] = self::json_serialize($arg, $this->devel_mode); 888 } 889 890 if ($parent) { 891 $parent_commands++; 892 $method = preg_replace('/^parent\./', '', $method); 893 $parent_prefix = 'if (window.parent && parent.' . self::JS_OBJECT_NAME . ') parent.'; 894 $method = $parent_prefix . self::JS_OBJECT_NAME . '.' . $method; 895 } 896 else { 897 $method = self::JS_OBJECT_NAME . '.' . $method; 898 } 899 900 $out .= sprintf("%s(%s);\n", $method, implode(',', $args)); 901 } 902 903 $framed = $parent_prefix && $parent_commands == count($commands); 904 905 // make the output more compact if all commands go to parent window 906 if ($framed) { 907 $out = "if (window.parent && parent." . self::JS_OBJECT_NAME . ") {\n" 908 . str_replace($parent_prefix, "\tparent.", $out) 909 . "}\n"; 910 } 911 912 return $out; 913 } 914 915 /** 916 * Make URLs starting with a slash point to skin directory 917 * 918 * @param string $str Input string 919 * @param bool $search_path True if URL should be resolved using the current skin path stack 920 * 921 * @return string URL 922 */ 923 public function abs_url($str, $search_path = false) 924 { 925 if (isset($str[0]) && $str[0] == '/') { 926 if ($search_path && ($file_url = $this->get_skin_file($str))) { 927 return $file_url; 928 } 929 930 return $this->base_path . $str; 931 } 932 933 return $str; 934 } 935 936 /** 937 * Show error page and terminate script execution 938 * 939 * @param int $code Error code 940 * @param string $message Error message 941 */ 942 public function raise_error($code, $message) 943 { 944 $args = [ 945 'code' => $code, 946 'message' => $message, 947 ]; 948 949 $page = new rcmail_action_utils_error; 950 $page->run($args); 951 } 952 953 /** 954 * Modify path by adding URL prefix if configured 955 * 956 * @param string $path Asset path 957 * @param bool $abs_url Pass to self::abs_url() first 958 * 959 * @return string Asset path 960 */ 961 public function asset_url($path, $abs_url = false) 962 { 963 // iframe content can't be in a different domain 964 // @TODO: check if assets are on a different domain 965 966 if ($abs_url) { 967 $path = $this->abs_url($path, true); 968 } 969 970 if (!$this->assets_path || in_array($path[0], ['?', '/', '.']) || strpos($path, '://')) { 971 return $path; 972 } 973 974 return $this->assets_path . $path; 975 } 976 977 978 /***** Template parsing methods *****/ 979 980 /** 981 * Replace all strings ($varname) 982 * with the content of the according global variable. 983 */ 984 protected function parse_with_globals($input) 985 { 986 $GLOBALS['__version'] = html::quote(RCMAIL_VERSION); 987 $GLOBALS['__comm_path'] = html::quote($this->app->comm_path); 988 $GLOBALS['__skin_path'] = html::quote($this->base_path); 989 990 return preg_replace_callback('/\$(__[a-z0-9_\-]+)/', [$this, 'globals_callback'], $input); 991 } 992 993 /** 994 * Callback function for preg_replace_callback() in parse_with_globals() 995 */ 996 protected function globals_callback($matches) 997 { 998 return $GLOBALS[$matches[1]]; 999 } 1000 1001 /** 1002 * Correct absolute paths in images and other tags (add cache busters) 1003 */ 1004 protected function fix_paths($output) 1005 { 1006 $regexp = '!(src|href|background|data-src-[a-z]+)=(["\']?)([a-z0-9/_.-]+)(["\'\s>])!i'; 1007 1008 return preg_replace_callback($regexp, [$this, 'file_callback'], $output); 1009 } 1010 1011 /** 1012 * Callback function for preg_replace_callback in fix_paths() 1013 * 1014 * @return string Parsed string 1015 */ 1016 protected function file_callback($matches) 1017 { 1018 $file = $matches[3]; 1019 $file = preg_replace('!^/this/!', '/', $file); 1020 1021 // correct absolute paths 1022 if ($file[0] == '/') { 1023 $this->get_skin_file($file, $skin_path, $this->base_path); 1024 $file = ($skin_path ?: $this->base_path) . $file; 1025 } 1026 1027 // add file modification timestamp 1028 if (preg_match('/\.(js|css|less|ico|png|svg|jpeg)$/', $file)) { 1029 $file = $this->file_mod($file); 1030 } 1031 1032 return $matches[1] . '=' . $matches[2] . $file . $matches[4]; 1033 } 1034 1035 /** 1036 * Correct paths of asset files according to assets_path 1037 */ 1038 protected function fix_assets_paths($output) 1039 { 1040 $regexp = '!(src|href|background)=(["\']?)([a-z0-9/_.?=-]+)(["\'\s>])!i'; 1041 1042 return preg_replace_callback($regexp, [$this, 'assets_callback'], $output); 1043 } 1044 1045 /** 1046 * Callback function for preg_replace_callback in fix_assets_paths() 1047 * 1048 * @return string Parsed string 1049 */ 1050 protected function assets_callback($matches) 1051 { 1052 $file = $this->asset_url($matches[3]); 1053 1054 return $matches[1] . '=' . $matches[2] . $file . $matches[4]; 1055 } 1056 1057 /** 1058 * Modify file by adding mtime indicator 1059 */ 1060 protected function file_mod($file) 1061 { 1062 $fs = false; 1063 $ext = substr($file, strrpos($file, '.') + 1); 1064 1065 // use minified file if exists (not in development mode) 1066 if (!$this->devel_mode && !preg_match('/\.min\.' . $ext . '$/', $file)) { 1067 $minified_file = substr($file, 0, strlen($ext) * -1) . 'min.' . $ext; 1068 if ($fs = @filemtime($this->assets_dir . $minified_file)) { 1069 return $minified_file . '?s=' . $fs; 1070 } 1071 } 1072 1073 if ($fs = @filemtime($this->assets_dir . $file)) { 1074 $file .= '?s=' . $fs; 1075 } 1076 1077 return $file; 1078 } 1079 1080 /** 1081 * Public wrapper to dip into template parsing. 1082 * 1083 * @param string $input Template content 1084 * 1085 * @return string 1086 * @uses rcmail_output_html::parse_xml() 1087 * @since 0.1-rc1 1088 */ 1089 public function just_parse($input) 1090 { 1091 $input = $this->parse_conditions($input); 1092 $input = $this->parse_xml($input); 1093 $input = $this->postrender($input); 1094 1095 return $input; 1096 } 1097 1098 /** 1099 * Parse for conditional tags 1100 */ 1101 protected function parse_conditions($input) 1102 { 1103 $regexp1 = '/<roundcube:if\s+([^>]+)>/is'; 1104 $regexp2 = '/<roundcube:(if|elseif|else|endif)\s*([^>]*)>/is'; 1105 1106 $pos = 0; 1107 1108 // Find IF tags and process them 1109 while ($pos < strlen($input) && preg_match($regexp1, $input, $conditions, PREG_OFFSET_CAPTURE, $pos)) { 1110 $pos = $start = $conditions[0][1]; 1111 1112 // Process the 'condition' attribute 1113 $attrib = html::parse_attrib_string($conditions[1][0]); 1114 $condmet = isset($attrib['condition']) && $this->check_condition($attrib['condition']); 1115 1116 // Define start/end position of the content to pass into the output 1117 $content_start = $condmet ? $pos + strlen($conditions[0][0]) : null; 1118 $content_end = null; 1119 1120 $level = 0; 1121 $endif = null; 1122 $n = $pos + 1; 1123 1124 // Process the code until the closing tag (for the processed IF tag) 1125 while (preg_match($regexp2, $input, $matches, PREG_OFFSET_CAPTURE, $n)) { 1126 $tag_start = $matches[0][1]; 1127 $tag_end = $tag_start + strlen($matches[0][0]); 1128 $tag_name = strtolower($matches[1][0]); 1129 1130 switch ($tag_name) { 1131 case 'if': 1132 $level++; 1133 break; 1134 1135 case 'endif': 1136 if (!$level--) { 1137 $endif = $tag_end; 1138 if ($content_end === null) { 1139 $content_end = $tag_start; 1140 } 1141 break 2; 1142 } 1143 break; 1144 1145 case 'elseif': 1146 if (!$level) { 1147 if ($condmet) { 1148 if ($content_end === null) { 1149 $content_end = $tag_start; 1150 } 1151 } 1152 else { 1153 // Process the 'condition' attribute 1154 $attrib = html::parse_attrib_string($matches[2][0]); 1155 $condmet = isset($attrib['condition']) && $this->check_condition($attrib['condition']); 1156 1157 if ($condmet) { 1158 $content_start = $tag_end; 1159 } 1160 } 1161 } 1162 break; 1163 1164 case 'else': 1165 if (!$level) { 1166 if ($condmet) { 1167 if ($content_end === null) { 1168 $content_end = $tag_start; 1169 } 1170 } 1171 else { 1172 $content_start = $tag_end; 1173 } 1174 } 1175 break; 1176 } 1177 1178 $n = $tag_end; 1179 } 1180 1181 // No ending tag found 1182 if ($endif === null) { 1183 $pos = strlen($input); 1184 if ($content_end === null) { 1185 $content_end = $pos; 1186 } 1187 } 1188 1189 if ($content_start === null) { 1190 $content = ''; 1191 } 1192 else { 1193 $content = substr($input, $content_start, $content_end - $content_start); 1194 } 1195 1196 // Replace the whole IF statement with the output content 1197 $input = substr_replace($input, $content, $start, max($endif, $content_end, $pos) - $start); 1198 $pos = $start; 1199 } 1200 1201 return $input; 1202 } 1203 1204 /** 1205 * Determines if a given condition is met 1206 * 1207 * @param string $condition Condition statement 1208 * 1209 * @return bool True if condition is met, False if not 1210 * @todo Extend this to allow real conditions, not just "set" 1211 */ 1212 protected function check_condition($condition) 1213 { 1214 return $this->eval_expression($condition); 1215 } 1216 1217 /** 1218 * Inserts hidden field with CSRF-prevention-token into POST forms 1219 */ 1220 protected function alter_form_tag($matches) 1221 { 1222 $out = $matches[0]; 1223 $attrib = html::parse_attrib_string($matches[1]); 1224 1225 if (!empty($attrib['method']) && strtolower($attrib['method']) == 'post') { 1226 $hidden = new html_hiddenfield(['name' => '_token', 'value' => $this->app->get_request_token()]); 1227 $out .= "\n" . $hidden->show(); 1228 } 1229 1230 return $out; 1231 } 1232 1233 /** 1234 * Parse & evaluate a given expression and return its result. 1235 * 1236 * @param string $expression Expression statement 1237 * 1238 * @return mixed Expression result 1239 */ 1240 protected function eval_expression($expression) 1241 { 1242 $expression = preg_replace( 1243 [ 1244 '/session:([a-z0-9_]+)/i', 1245 '/config:([a-z0-9_]+)(:([a-z0-9_]+))?/i', 1246 '/env:([a-z0-9_]+)/i', 1247 '/request:([a-z0-9_]+)/i', 1248 '/cookie:([a-z0-9_]+)/i', 1249 '/browser:([a-z0-9_]+)/i', 1250 '/template:name/i', 1251 ], 1252 [ 1253 "(isset(\$_SESSION['\\1']) ? \$_SESSION['\\1'] : null)", 1254 "\$this->app->config->get('\\1',rcube_utils::get_boolean('\\3'))", 1255 "(isset(\$this->env['\\1']) ? \$this->env['\\1'] : null)", 1256 "rcube_utils::get_input_value('\\1', rcube_utils::INPUT_GPC)", 1257 "(isset(\$_COOKIE['\\1']) ? \$_COOKIE['\\1'] : null)", 1258 "(isset(\$this->browser->{'\\1'}) ? \$this->browser->{'\\1'} : null)", 1259 "'{$this->template_name}'", 1260 ], 1261 $expression 1262 ); 1263 1264 // Note: We used create_function() before but it's deprecated in PHP 7.2 1265 // and really it was just a wrapper on eval(). 1266 return eval("return ($expression);"); 1267 } 1268 1269 /** 1270 * Parse variable strings 1271 * 1272 * @param string $type Variable type (env, config etc) 1273 * @param string $name Variable name 1274 * 1275 * @return mixed Variable value 1276 */ 1277 protected function parse_variable($type, $name) 1278 { 1279 $value = ''; 1280 1281 switch ($type) { 1282 case 'env': 1283 $value = isset($this->env[$name]) ? $this->env[$name] : null; 1284 break; 1285 case 'config': 1286 $value = $this->config->get($name); 1287 if (is_array($value) && !empty($value[$_SESSION['storage_host']])) { 1288 $value = $value[$_SESSION['storage_host']]; 1289 } 1290 break; 1291 case 'request': 1292 $value = rcube_utils::get_input_value($name, rcube_utils::INPUT_GPC); 1293 break; 1294 case 'session': 1295 $value = isset($_SESSION[$name]) ? $_SESSION[$name] : ''; 1296 break; 1297 case 'cookie': 1298 $value = htmlspecialchars($_COOKIE[$name], ENT_COMPAT | ENT_HTML401, RCUBE_CHARSET); 1299 break; 1300 case 'browser': 1301 $value = isset($this->browser->{$name}) ? $this->browser->{$name} : ''; 1302 break; 1303 } 1304 1305 return $value; 1306 } 1307 1308 /** 1309 * Search for special tags in input and replace them 1310 * with the appropriate content 1311 * 1312 * @param string $input Input string to parse 1313 * 1314 * @return string Altered input string 1315 * @todo Use DOM-parser to traverse template HTML 1316 * @todo Maybe a cache. 1317 */ 1318 protected function parse_xml($input) 1319 { 1320 $regexp = '/<roundcube:([-_a-z]+)\s+((?:[^>]|\\\\>)+)(?<!\\\\)>/Ui'; 1321 1322 return preg_replace_callback($regexp, [$this, 'xml_command'], $input); 1323 } 1324 1325 /** 1326 * Callback function for parsing an xml command tag 1327 * and turn it into real html content 1328 * 1329 * @param array $matches Matches array of preg_replace_callback 1330 * 1331 * @return string Tag/Object content 1332 */ 1333 protected function xml_command($matches) 1334 { 1335 $command = strtolower($matches[1]); 1336 $attrib = html::parse_attrib_string($matches[2]); 1337 1338 // empty output if required condition is not met 1339 if (!empty($attrib['condition']) && !$this->check_condition($attrib['condition'])) { 1340 return ''; 1341 } 1342 1343 // localize title and summary attributes 1344 if ($command != 'button' && !empty($attrib['title']) && $this->app->text_exists($attrib['title'])) { 1345 $attrib['title'] = $this->app->gettext($attrib['title']); 1346 } 1347 if ($command != 'button' && !empty($attrib['summary']) && $this->app->text_exists($attrib['summary'])) { 1348 $attrib['summary'] = $this->app->gettext($attrib['summary']); 1349 } 1350 1351 // execute command 1352 switch ($command) { 1353 // return a button 1354 case 'button': 1355 if (!empty($attrib['name']) || !empty($attrib['command'])) { 1356 return $this->button($attrib); 1357 } 1358 break; 1359 1360 // frame 1361 case 'frame': 1362 return $this->frame($attrib); 1363 break; 1364 1365 // show a label 1366 case 'label': 1367 if (!empty($attrib['expression'])) { 1368 $attrib['name'] = $this->eval_expression($attrib['expression']); 1369 } 1370 1371 if (!empty($attrib['name']) || !empty($attrib['command'])) { 1372 $vars = $attrib + ['product' => $this->config->get('product_name')]; 1373 unset($vars['name'], $vars['command']); 1374 1375 $label = $this->app->gettext($attrib + ['vars' => $vars]); 1376 $quoting = null; 1377 1378 if (!empty($attrib['quoting'])) { 1379 $quoting = strtolower($attrib['quoting']); 1380 } 1381 else if (isset($attrib['html'])) { 1382 $quoting = rcube_utils::get_boolean((string) $attrib['html']) ? 'no' : ''; 1383 } 1384 1385 // 'noshow' can be used in skins to define new labels 1386 if (!empty($attrib['noshow'])) { 1387 return ''; 1388 } 1389 1390 switch ($quoting) { 1391 case 'no': 1392 case 'raw': 1393 break; 1394 case 'javascript': 1395 case 'js': 1396 $label = rcube::JQ($label); 1397 break; 1398 default: 1399 $label = html::quote($label); 1400 break; 1401 } 1402 1403 return $label; 1404 } 1405 break; 1406 1407 case 'add_label': 1408 $this->add_label($attrib['name']); 1409 break; 1410 1411 // include a file 1412 case 'include': 1413 if (!empty($attrib['condition']) && !$this->check_condition($attrib['condition'])) { 1414 break; 1415 } 1416 1417 if ($attrib['file'][0] != '/') { 1418 $attrib['file'] = '/templates/' . $attrib['file']; 1419 } 1420 1421 $old_base_path = $this->base_path; 1422 $include = ''; 1423 $attr_skin_path = !empty($attrib['skinpath']) ? $attrib['skinpath'] : null; 1424 1425 if (!empty($attrib['skin_path'])) { 1426 $attr_skin_path = $attrib['skin_path']; 1427 } 1428 1429 if ($path = $this->get_skin_file($attrib['file'], $skin_path, $attr_skin_path)) { 1430 // set base_path to core skin directory (not plugin's skin) 1431 $this->base_path = preg_replace('!plugins/\w+/!', '', $skin_path); 1432 $path = realpath(RCUBE_INSTALL_PATH . $path); 1433 } 1434 1435 if (is_readable($path)) { 1436 $allow_php = $this->config->get('skin_include_php'); 1437 $include = $allow_php ? $this->include_php($path) : file_get_contents($path); 1438 $include = $this->parse_conditions($include); 1439 $include = $this->parse_xml($include); 1440 $include = $this->fix_paths($include); 1441 } 1442 1443 $this->base_path = $old_base_path; 1444 1445 return $include; 1446 1447 case 'plugin.include': 1448 $hook = $this->app->plugins->exec_hook("template_plugin_include", $attrib + ['content' => '']); 1449 return $hook['content']; 1450 1451 // define a container block 1452 case 'container': 1453 if (!empty($attrib['name']) && !empty($attrib['id'])) { 1454 $this->command('gui_container', $attrib['name'], $attrib['id']); 1455 // let plugins insert some content here 1456 $hook = $this->app->plugins->exec_hook("template_container", $attrib + ['content' => '']); 1457 return $hook['content']; 1458 } 1459 break; 1460 1461 // return code for a specific application object 1462 case 'object': 1463 $object = strtolower($attrib['name']); 1464 $content = ''; 1465 $handler = null; 1466 1467 // correct deprecated object names 1468 if (!empty($this->deprecated_template_objects[$object])) { 1469 $object = $this->deprecated_template_objects[$object]; 1470 } 1471 1472 if (!empty($this->object_handlers[$object])) { 1473 $handler = $this->object_handlers[$object]; 1474 } 1475 1476 // execute object handler function 1477 if (is_callable($handler)) { 1478 $this->prepare_object_attribs($attrib); 1479 1480 // We assume that objects with src attribute are internal (in most 1481 // cases this is a watermark frame). We need this to make sure assets_path 1482 // is added to the internal assets paths 1483 $external = empty($attrib['src']); 1484 $content = call_user_func($handler, $attrib); 1485 } 1486 else if ($object == 'doctype') { 1487 $content = html::doctype($attrib['value']); 1488 } 1489 else if ($object == 'logo') { 1490 $attrib += ['alt' => $this->xml_command(['', 'object', 'name="productname"'])]; 1491 1492 // 'type' attribute added in 1.4 was renamed 'logo-type' in 1.5 1493 // check both for backwards compatibility 1494 $logo_type = !empty($attrib['logo-type']) ? $attrib['logo-type'] : null; 1495 $logo_match = !empty($attrib['logo-match']) ? $attrib['logo-match'] : null; 1496 if (!empty($attrib['type']) && empty($logo_type)) { 1497 $logo_type = $attrib['type']; 1498 } 1499 1500 if (($template_logo = $this->get_template_logo($logo_type, $logo_match)) !== null) { 1501 $attrib['src'] = $template_logo; 1502 } 1503 1504 $additional_logos = []; 1505 $logo_types = (array) $this->config->get('additional_logo_types'); 1506 1507 foreach ($logo_types as $type) { 1508 if (($template_logo = $this->get_template_logo($type)) !== null) { 1509 $additional_logos[$type] = $this->abs_url($template_logo); 1510 } 1511 else if (!empty($attrib['data-src-' . $type])) { 1512 $additional_logos[$type] = $this->abs_url($attrib['data-src-' . $type]); 1513 } 1514 } 1515 1516 if (!empty($additional_logos)) { 1517 $this->set_env('additional_logos', $additional_logos); 1518 } 1519 1520 if (!empty($attrib['src'])) { 1521 $content = html::img($attrib); 1522 } 1523 } 1524 else if ($object == 'productname') { 1525 $name = $this->config->get('product_name', 'Roundcube Webmail'); 1526 $content = html::quote($name); 1527 } 1528 else if ($object == 'version') { 1529 $ver = (string) RCMAIL_VERSION; 1530 if (is_file(RCUBE_INSTALL_PATH . '.svn/entries')) { 1531 if (preg_match('/Revision:\s(\d+)/', @shell_exec('svn info'), $regs)) 1532 $ver .= ' [SVN r'.$regs[1].']'; 1533 } 1534 else if (is_file(RCUBE_INSTALL_PATH . '.git/index')) { 1535 if (preg_match('/Date:\s+([^\n]+)/', @shell_exec('git log -1'), $regs)) { 1536 if ($date = date('Ymd.Hi', strtotime($regs[1]))) { 1537 $ver .= ' [GIT '.$date.']'; 1538 } 1539 } 1540 } 1541 $content = html::quote($ver); 1542 } 1543 else if ($object == 'steptitle') { 1544 $content = html::quote($this->get_pagetitle(false)); 1545 } 1546 else if ($object == 'pagetitle') { 1547 // Deprecated, <title> will be added automatically 1548 $content = html::quote($this->get_pagetitle()); 1549 } 1550 else if ($object == 'contentframe') { 1551 if (empty($attrib['id'])) { 1552 $attrib['id'] = 'rcm' . $this->env['task'] . 'frame'; 1553 } 1554 1555 // parse variables 1556 if (preg_match('/^(config|env):([a-z0-9_]+)$/i', $attrib['src'], $matches)) { 1557 $attrib['src'] = $this->parse_variable($matches[1], $matches[2]); 1558 } 1559 1560 $content = $this->frame($attrib, true); 1561 } 1562 else if ($object == 'meta' || $object == 'links') { 1563 if ($object == 'meta') { 1564 $source = 'meta_tags'; 1565 $tag = 'meta'; 1566 $key = 'name'; 1567 $param = 'content'; 1568 } 1569 else { 1570 $source = 'link_tags'; 1571 $tag = 'link'; 1572 $key = 'rel'; 1573 $param = 'href'; 1574 } 1575 1576 foreach ($this->$source as $name => $vars) { 1577 // $vars can be in many forms: 1578 // - string 1579 // - array('key' => 'val') 1580 // - array(string, string) 1581 // - array(array(), string) 1582 // - array(array('key' => 'val'), array('key' => 'val')) 1583 // normalise this for processing by checking for string array keys 1584 $vars = is_array($vars) ? (count(array_filter(array_keys($vars), 'is_string')) > 0 ? [$vars] : $vars) : [$vars]; 1585 1586 foreach ($vars as $args) { 1587 // skip unset headers e.g. when extending a skin and removing a header defined in the parent 1588 if ($args === false) { 1589 continue; 1590 } 1591 1592 $args = is_array($args) ? $args : [$param => $args]; 1593 1594 // special handling for favicon 1595 if ($object == 'links' && $name == 'shortcut icon' && empty($args[$param])) { 1596 if ($href = $this->get_template_logo('favicon')) { 1597 $args[$param] = $href; 1598 } 1599 else if ($href = $this->config->get('favicon', '/images/favicon.ico')) { 1600 $args[$param] = $href; 1601 } 1602 } 1603 1604 $content .= html::tag($tag, [$key => $name, 'nl' => true] + $args); 1605 } 1606 } 1607 } 1608 1609 // exec plugin hooks for this template object 1610 $hook = $this->app->plugins->exec_hook("template_object_$object", $attrib + ['content' => $content]); 1611 1612 if (strlen($hook['content']) && !empty($external)) { 1613 $object_id = uniqid('TEMPLOBJECT:', true); 1614 $this->objects[$object_id] = $hook['content']; 1615 $hook['content'] = $object_id; 1616 } 1617 1618 return $hook['content']; 1619 1620 // return <link> element 1621 case 'link': 1622 if ($attrib['condition'] && !$this->check_condition($attrib['condition'])) { 1623 break; 1624 } 1625 1626 unset($attrib['condition']); 1627 1628 return html::tag('link', $attrib); 1629 1630 1631 // return code for a specified eval expression 1632 case 'exp': 1633 return html::quote($this->eval_expression($attrib['expression'])); 1634 1635 // return variable 1636 case 'var': 1637 $var = explode(':', $attrib['name']); 1638 $value = $this->parse_variable($var[0], $var[1]); 1639 1640 if (is_array($value)) { 1641 $value = implode(', ', $value); 1642 } 1643 1644 return html::quote($value); 1645 1646 case 'form': 1647 return $this->form_tag($attrib); 1648 } 1649 1650 return ''; 1651 } 1652 1653 /** 1654 * Prepares template object attributes 1655 * 1656 * @param array &$attribs Attributes 1657 */ 1658 protected function prepare_object_attribs(&$attribs) 1659 { 1660 foreach ($attribs as $key => &$value) { 1661 if (strpos($key, 'data-label-') === 0) { 1662 // Localize data-label-* attributes 1663 $value = $this->app->gettext($value); 1664 } 1665 elseif ($key[0] == ':') { 1666 // Evaluate attributes with expressions and remove special character from attribute name 1667 $attribs[substr($key, 1)] = $this->eval_expression($value); 1668 unset($attribs[$key]); 1669 } 1670 } 1671 } 1672 1673 /** 1674 * Include a specific file and return it's contents 1675 * 1676 * @param string $file File path 1677 * 1678 * @return string Contents of the processed file 1679 */ 1680 protected function include_php($file) 1681 { 1682 ob_start(); 1683 include $file; 1684 $out = ob_get_contents(); 1685 ob_end_clean(); 1686 1687 return $out; 1688 } 1689 1690 /** 1691 * Put objects' content back into template output 1692 */ 1693 protected function postrender($output) 1694 { 1695 // insert objects' contents 1696 foreach ($this->objects as $key => $val) { 1697 $output = str_replace($key, $val, $output, $count); 1698 if ($count) { 1699 $this->objects[$key] = null; 1700 } 1701 } 1702 1703 // make sure all <form> tags have a valid request token 1704 $output = preg_replace_callback('/<form\s+([^>]+)>/Ui', [$this, 'alter_form_tag'], $output); 1705 1706 return $output; 1707 } 1708 1709 /** 1710 * Create and register a button 1711 * 1712 * @param array $attrib Named button attributes 1713 * 1714 * @return string HTML button 1715 * @todo Remove all inline JS calls and use jQuery instead. 1716 * @todo Remove all sprintf()'s - they are pretty, but also slow. 1717 */ 1718 public function button($attrib) 1719 { 1720 static $s_button_count = 100; 1721 1722 // these commands can be called directly via url 1723 $a_static_commands = ['compose', 'list', 'preferences', 'folders', 'identities']; 1724 1725 if (empty($attrib['command']) && empty($attrib['name']) && empty($attrib['href'])) { 1726 return ''; 1727 } 1728 1729 $command = !empty($attrib['command']) ? $attrib['command'] : null; 1730 $action = $command ?: (!empty($attrib['name']) ? $attrib['name'] : null); 1731 1732 if (!empty($attrib['task'])) { 1733 $command = $attrib['task'] . '.' . $command; 1734 $element = $attrib['task'] . '.' . $action; 1735 } 1736 else { 1737 $element = (!empty($this->env['task']) ? $this->env['task'] . '.' : '') . $action; 1738 } 1739 1740 $disabled_actions = (array) $this->config->get('disabled_actions'); 1741 1742 // remove buttons for disabled actions 1743 if (in_array($element, $disabled_actions) || in_array($action, $disabled_actions)) { 1744 return ''; 1745 } 1746 1747 // try to find out the button type 1748 if (!empty($attrib['type'])) { 1749 $attrib['type'] = strtolower($attrib['type']); 1750 if (strpos($attrib['type'], '-menuitem')) { 1751 $attrib['type'] = substr($attrib['type'], 0, -9); 1752 $menuitem = true; 1753 } 1754 } 1755 else if (!empty($attrib['image']) || !empty($attrib['imagepas']) || !empty($attrib['imageact'])) { 1756 $attrib['type'] = 'image'; 1757 } 1758 else { 1759 $attrib['type'] = 'button'; 1760 } 1761 1762 if (empty($attrib['image'])) { 1763 if (!empty($attrib['imagepas'])) { 1764 $attrib['image'] = $attrib['imagepas']; 1765 } 1766 else if (!empty($attrib['imageact'])) { 1767 $attrib['image'] = $attrib['imageact']; 1768 } 1769 } 1770 1771 if (empty($attrib['id'])) { 1772 // ensure auto generated IDs are unique between main window and content frame 1773 // Elastic skin duplicates buttons between the two on smaller screens (#7618) 1774 $prefix = ($this->framed || !empty($this->env['framed'])) ? 'frm' : ''; 1775 $attrib['id'] = sprintf('rcmbtn%s%d', $prefix, $s_button_count++); 1776 } 1777 1778 // get localized text for labels and titles 1779 $domain = !empty($attrib['domain']) ? $attrib['domain'] : null; 1780 if (!empty($attrib['title'])) { 1781 $attrib['title'] = html::quote($this->app->gettext($attrib['title'], $domain)); 1782 } 1783 if (!empty($attrib['label'])) { 1784 $attrib['label'] = html::quote($this->app->gettext($attrib['label'], $domain)); 1785 } 1786 if (!empty($attrib['alt'])) { 1787 $attrib['alt'] = html::quote($this->app->gettext($attrib['alt'], $domain)); 1788 } 1789 1790 // set accessibility attributes 1791 if (empty($attrib['role'])) { 1792 $attrib['role'] = 'button'; 1793 } 1794 1795 if (!empty($attrib['class']) && !empty($attrib['classact']) || !empty($attrib['imagepas']) && !empty($attrib['imageact'])) { 1796 if (array_key_exists('tabindex', $attrib)) { 1797 $attrib['data-tabindex'] = $attrib['tabindex']; 1798 } 1799 $attrib['tabindex'] = '-1'; // disable button by default 1800 $attrib['aria-disabled'] = 'true'; 1801 } 1802 1803 // set title to alt attribute for IE browsers 1804 if ($this->browser->ie && empty($attrib['title']) && !empty($attrib['alt'])) { 1805 $attrib['title'] = $attrib['alt']; 1806 } 1807 1808 // add empty alt attribute for XHTML compatibility 1809 if (!isset($attrib['alt'])) { 1810 $attrib['alt'] = ''; 1811 } 1812 1813 // register button in the system 1814 if (!empty($attrib['command'])) { 1815 $this->add_script(sprintf( 1816 "%s.register_button('%s', '%s', '%s', '%s', '%s', '%s');", 1817 self::JS_OBJECT_NAME, 1818 $command, 1819 $attrib['id'], 1820 $attrib['type'], 1821 !empty($attrib['imageact']) ? $this->abs_url($attrib['imageact']) : (!empty($attrib['classact']) ? $attrib['classact'] : ''), 1822 !empty($attrib['imagesel']) ? $this->abs_url($attrib['imagesel']) : (!empty($attrib['classsel']) ? $attrib['classsel'] : ''), 1823 !empty($attrib['imageover']) ? $this->abs_url($attrib['imageover']) : '' 1824 )); 1825 1826 // make valid href to specific buttons 1827 if (in_array($attrib['command'], rcmail::$main_tasks)) { 1828 $attrib['href'] = $this->app->url(['task' => $attrib['command']]); 1829 $attrib['onclick'] = sprintf("return %s.command('switch-task','%s',this,event)", self::JS_OBJECT_NAME, $attrib['command']); 1830 } 1831 else if (!empty($attrib['task']) && in_array($attrib['task'], rcmail::$main_tasks)) { 1832 $attrib['href'] = $this->app->url(['action' => $attrib['command'], 'task' => $attrib['task']]); 1833 } 1834 else if (in_array($attrib['command'], $a_static_commands)) { 1835 $attrib['href'] = $this->app->url(['action' => $attrib['command']]); 1836 } 1837 else if (($attrib['command'] == 'permaurl' || $attrib['command'] == 'extwin') && !empty($this->env['permaurl'])) { 1838 $attrib['href'] = $this->env['permaurl']; 1839 } 1840 } 1841 1842 // overwrite attributes 1843 if (empty($attrib['href'])) { 1844 $attrib['href'] = '#'; 1845 } 1846 1847 if (!empty($attrib['task'])) { 1848 if (!empty($attrib['classact'])) { 1849 $attrib['class'] = $attrib['classact']; 1850 } 1851 } 1852 else if ($command && empty($attrib['onclick'])) { 1853 $attrib['onclick'] = sprintf( 1854 "return %s.command('%s','%s',this,event)", 1855 self::JS_OBJECT_NAME, 1856 $command, 1857 !empty($attrib['prop']) ? $attrib['prop'] : '' 1858 ); 1859 } 1860 1861 $out = ''; 1862 $btn_content = null; 1863 $link_attrib = []; 1864 1865 // generate image tag 1866 if ($attrib['type'] == 'image') { 1867 $attrib_str = html::attrib_string( 1868 $attrib, 1869 [ 1870 'style', 'class', 'id', 'width', 'height', 'border', 'hspace', 1871 'vspace', 'align', 'alt', 'tabindex', 'title' 1872 ] 1873 ); 1874 $btn_content = sprintf('<img src="%s"%s />', $this->abs_url($attrib['image']), $attrib_str); 1875 if (!empty($attrib['label'])) { 1876 $btn_content .= ' '.$attrib['label']; 1877 } 1878 $link_attrib = ['href', 'onclick', 'onmouseover', 'onmouseout', 'onmousedown', 'onmouseup', 'target']; 1879 } 1880 else if ($attrib['type'] == 'link') { 1881 $btn_content = isset($attrib['content']) ? $attrib['content'] : (!empty($attrib['label']) ? $attrib['label'] : $attrib['command']); 1882 $link_attrib = array_merge(html::$common_attrib, ['href', 'onclick', 'tabindex', 'target', 'rel']); 1883 if (!empty($attrib['innerclass'])) { 1884 $btn_content = html::span($attrib['innerclass'], $btn_content); 1885 } 1886 } 1887 else if ($attrib['type'] == 'input') { 1888 $attrib['type'] = 'button'; 1889 1890 if (!empty($attrib['label'])) { 1891 $attrib['value'] = $attrib['label']; 1892 } 1893 if (!empty($attrib['command'])) { 1894 $attrib['disabled'] = 'disabled'; 1895 } 1896 1897 $out = html::tag('input', $attrib, null, ['type', 'value', 'onclick', 'id', 'class', 'style', 'tabindex', 'disabled']); 1898 } 1899 else { 1900 if (!empty($attrib['label'])) { 1901 $attrib['value'] = $attrib['label']; 1902 } 1903 if (!empty($attrib['command'])) { 1904 $attrib['disabled'] = 'disabled'; 1905 } 1906 1907 $content = isset($attrib['content']) ? $attrib['content'] : $attrib['label']; 1908 $out = html::tag('button', $attrib, $content, ['type', 'value', 'onclick', 'id', 'class', 'style', 'tabindex', 'disabled']); 1909 } 1910 1911 // generate html code for button 1912 if ($btn_content) { 1913 $attrib_str = html::attrib_string($attrib, $link_attrib); 1914 $out = sprintf('<a%s>%s</a>', $attrib_str, $btn_content); 1915 } 1916 1917 if (!empty($attrib['wrapper'])) { 1918 $out = html::tag($attrib['wrapper'], null, $out); 1919 } 1920 1921 if (!empty($menuitem)) { 1922 $class = !empty($attrib['menuitem-class']) ? ' class="' . $attrib['menuitem-class'] . '"' : ''; 1923 $out = '<li role="menuitem"' . $class . '>' . $out . '</li>'; 1924 } 1925 1926 return $out; 1927 } 1928 1929 /** 1930 * Link an external script file 1931 * 1932 * @param string $file File URL 1933 * @param string $position Target position [head|head_bottom|foot] 1934 */ 1935 public function include_script($file, $position = 'head', $add_path = true) 1936 { 1937 if ($add_path && !preg_match('|^https?://|i', $file) && $file[0] != '/') { 1938 $file = $this->file_mod($this->scripts_path . $file); 1939 } 1940 1941 if (!isset($this->script_files[$position]) || !is_array($this->script_files[$position])) { 1942 $this->script_files[$position] = []; 1943 } 1944 1945 if (!in_array($file, $this->script_files[$position])) { 1946 $this->script_files[$position][] = $file; 1947 } 1948 } 1949 1950 /** 1951 * Add inline javascript code 1952 * 1953 * @param string $script JS code snippet 1954 * @param string $position Target position [head|head_top|foot|docready] 1955 */ 1956 public function add_script($script, $position = 'head') 1957 { 1958 if (!isset($this->scripts[$position])) { 1959 $this->scripts[$position] = rtrim($script); 1960 } 1961 else { 1962 $this->scripts[$position] .= "\n" . rtrim($script); 1963 } 1964 } 1965 1966 /** 1967 * Link an external css file 1968 * 1969 * @param string $file File URL 1970 */ 1971 public function include_css($file) 1972 { 1973 $this->css_files[] = $file; 1974 } 1975 1976 /** 1977 * Add HTML code to the page header 1978 * 1979 * @param string $str HTML code 1980 */ 1981 public function add_header($str) 1982 { 1983 $this->header .= "\n" . $str; 1984 } 1985 1986 /** 1987 * Add HTML code to the page footer 1988 * To be added right before </body> 1989 * 1990 * @param string $str HTML code 1991 */ 1992 public function add_footer($str) 1993 { 1994 $this->footer .= "\n" . $str; 1995 } 1996 1997 /** 1998 * Process template and write to stdOut 1999 * 2000 * @param string $output HTML output 2001 */ 2002 protected function _write($output = '') 2003 { 2004 $output = trim($output); 2005 2006 if (empty($output)) { 2007 $output = html::doctype('html5') . "\n" . $this->default_template; 2008 $is_empty = true; 2009 } 2010 2011 $merge_script_files = function($output, $script) { 2012 return $output . html::script($script); 2013 }; 2014 2015 $merge_scripts = function($output, $script) { 2016 return $output . html::script([], $script); 2017 }; 2018 2019 // put docready commands into page footer 2020 if (!empty($this->scripts['docready'])) { 2021 $this->add_script("\$(function() {\n" . $this->scripts['docready'] . "\n});", 'foot'); 2022 } 2023 2024 $page_header = ''; 2025 $page_footer = ''; 2026 $meta = ''; 2027 2028 // declare page language 2029 if (!empty($_SESSION['language'])) { 2030 $lang = substr($_SESSION['language'], 0, 2); 2031 $output = preg_replace('/<html/', '<html lang="' . html::quote($lang) . '"', $output, 1); 2032 2033 if (!headers_sent()) { 2034 $this->header('Content-Language: ' . $lang); 2035 } 2036 } 2037 2038 // include meta tag with charset 2039 if (!empty($this->charset)) { 2040 if (!headers_sent()) { 2041 $this->header('Content-Type: text/html; charset=' . $this->charset); 2042 } 2043 2044 $meta .= html::tag('meta', [ 2045 'http-equiv' => 'content-type', 2046 'content' => "text/html; charset={$this->charset}", 2047 'nl' => true 2048 ]); 2049 } 2050 2051 // include page title (after charset specification) 2052 $meta .= '<title>' . html::quote($this->get_pagetitle()) . "</title>\n"; 2053 2054 $output = preg_replace('/(<head[^>]*>)\n*/i', "\\1\n{$meta}", $output, 1, $count); 2055 if (!$count) { 2056 $page_header .= $meta; 2057 } 2058 2059 // include scripts into header/footer 2060 if (!empty($this->script_files['head'])) { 2061 $page_header .= array_reduce((array) $this->script_files['head'], $merge_script_files); 2062 } 2063 2064 $head = isset($this->scripts['head_top']) ? $this->scripts['head_top'] : ''; 2065 $head .= isset($this->scripts['head']) ? $this->scripts['head'] : ''; 2066 2067 $page_header .= array_reduce((array) $head, $merge_scripts); 2068 $page_header .= $this->header . "\n"; 2069 2070 if (!empty($this->script_files['head_bottom'])) { 2071 $page_header .= array_reduce((array) $this->script_files['head_bottom'], $merge_script_files); 2072 } 2073 2074 if (!empty($this->script_files['foot'])) { 2075 $page_footer .= array_reduce((array) $this->script_files['foot'], $merge_script_files); 2076 } 2077 2078 $page_footer .= $this->footer . "\n"; 2079 2080 if (!empty($this->scripts['foot'])) { 2081 $page_footer .= array_reduce((array) $this->scripts['foot'], $merge_scripts); 2082 } 2083 2084 // find page header 2085 if ($hpos = stripos($output, '</head>')) { 2086 $page_header .= "\n"; 2087 } 2088 else { 2089 if (!is_numeric($hpos)) { 2090 $hpos = stripos($output, '<body'); 2091 } 2092 if (!is_numeric($hpos) && ($hpos = stripos($output, '<html'))) { 2093 while ($output[$hpos] != '>') { 2094 $hpos++; 2095 } 2096 $hpos++; 2097 } 2098 $page_header = "<head>\n$page_header\n</head>\n"; 2099 } 2100 2101 // add page header 2102 if ($hpos) { 2103 $output = substr_replace($output, $page_header, $hpos, 0); 2104 } 2105 else { 2106 $output = $page_header . $output; 2107 } 2108 2109 // add page footer 2110 if (($fpos = strripos($output, '</body>')) || ($fpos = strripos($output, '</html>'))) { 2111 // for Elastic: put footer content before "footer scripts" 2112 while (($npos = strripos($output, "\n", -strlen($output) + $fpos - 1)) 2113 && $npos != $fpos 2114 && ($chunk = substr($output, $npos, $fpos - $npos)) !== '' 2115 && (trim($chunk) === '' || preg_match('/\s*<script[^>]+><\/script>\s*/', $chunk)) 2116 ) { 2117 $fpos = $npos; 2118 } 2119 2120 $output = substr_replace($output, $page_footer."\n", $fpos, 0); 2121 } 2122 else { 2123 $output .= "\n".$page_footer; 2124 } 2125 2126 // add css files in head, before scripts, for speed up with parallel downloads 2127 if (!empty($this->css_files) && empty($is_empty) 2128 && (($pos = stripos($output, '<script ')) || ($pos = stripos($output, '</head>'))) 2129 ) { 2130 $css = ''; 2131 foreach ($this->css_files as $file) { 2132 $is_less = substr_compare($file, '.less', -5, 5, true) === 0; 2133 $css .= html::tag('link', [ 2134 'rel' => $is_less ? 'stylesheet/less' : 'stylesheet', 2135 'type' => 'text/css', 2136 'href' => $file, 2137 'nl' => true, 2138 ]); 2139 } 2140 $output = substr_replace($output, $css, $pos, 0); 2141 } 2142 2143 $output = $this->parse_with_globals($this->fix_paths($output)); 2144 2145 if ($this->assets_path) { 2146 $output = $this->fix_assets_paths($output); 2147 } 2148 2149 $output = $this->postrender($output); 2150 2151 // trigger hook with final HTML content to be sent 2152 $hook = $this->app->plugins->exec_hook("send_page", ['content' => $output]); 2153 if (!$hook['abort']) { 2154 if ($this->charset != RCUBE_CHARSET) { 2155 echo rcube_charset::convert($hook['content'], RCUBE_CHARSET, $this->charset); 2156 } 2157 else { 2158 echo $hook['content']; 2159 } 2160 } 2161 } 2162 2163 /** 2164 * Returns iframe object, registers some related env variables 2165 * 2166 * @param array $attrib HTML attributes 2167 * @param bool $is_contentframe Register this iframe as the 'contentframe' gui object 2168 * 2169 * @return string IFRAME element 2170 */ 2171 public function frame($attrib, $is_contentframe = false) 2172 { 2173 static $idcount = 0; 2174 2175 if (empty($attrib['id'])) { 2176 $attrib['id'] = 'rcmframe' . ++$idcount; 2177 } 2178 2179 $attrib['name'] = $attrib['id']; 2180 $attrib['src'] = !empty($attrib['src']) ? $this->abs_url($attrib['src'], true) : 'about:blank'; 2181 2182 // register as 'contentframe' object 2183 if ($is_contentframe || !empty($attrib['contentframe'])) { 2184 $this->set_env('contentframe', !empty($attrib['contentframe']) ? $attrib['contentframe'] : $attrib['name']); 2185 } 2186 2187 return html::iframe($attrib); 2188 } 2189 2190 2191 /* ************* common functions delivering gui objects ************** */ 2192 2193 /** 2194 * Create a form tag with the necessary hidden fields 2195 * 2196 * @param array $attrib Named tag parameters 2197 * @param string $content HTML content of the form 2198 * 2199 * @return string HTML code for the form 2200 */ 2201 public function form_tag($attrib, $content = null) 2202 { 2203 $hidden = ''; 2204 2205 if (!empty($this->env['extwin'])) { 2206 $hiddenfield = new html_hiddenfield(['name' => '_extwin', 'value' => '1']); 2207 $hidden = $hiddenfield->show(); 2208 } 2209 else if ($this->framed || !empty($this->env['framed'])) { 2210 $hiddenfield = new html_hiddenfield(['name' => '_framed', 'value' => '1']); 2211 $hidden = $hiddenfield->show(); 2212 } 2213 2214 if (!$content) { 2215 $attrib['noclose'] = true; 2216 } 2217 2218 return html::tag('form', 2219 $attrib + ['action' => $this->app->comm_path, 'method' => 'get'], 2220 $hidden . $content, 2221 ['id', 'class', 'style', 'name', 'method', 'action', 'enctype', 'onsubmit'] 2222 ); 2223 } 2224 2225 /** 2226 * Build a form tag with a unique request token 2227 * 2228 * @param array $attrib Named tag parameters including 'action' and 'task' values 2229 * which will be put into hidden fields 2230 * @param string $content Form content 2231 * 2232 * @return string HTML code for the form 2233 */ 2234 public function request_form($attrib, $content = '') 2235 { 2236 $hidden = new html_hiddenfield(); 2237 2238 if (!empty($attrib['task'])) { 2239 $hidden->add(['name' => '_task', 'value' => $attrib['task']]); 2240 } 2241 2242 if (!empty($attrib['action'])) { 2243 $hidden->add(['name' => '_action', 'value' => $attrib['action']]); 2244 } 2245 2246 // we already have a <form> tag 2247 if (!empty($attrib['form'])) { 2248 if ($this->framed || !empty($this->env['framed'])) { 2249 $hidden->add(['name' => '_framed', 'value' => '1']); 2250 } 2251 2252 return $hidden->show() . $content; 2253 } 2254 2255 unset($attrib['task'], $attrib['request']); 2256 $attrib['action'] = './'; 2257 2258 return $this->form_tag($attrib, $hidden->show() . $content); 2259 } 2260 2261 /** 2262 * GUI object 'username' 2263 * Showing IMAP username of the current session 2264 * 2265 * @param array $attrib Named tag parameters (currently not used) 2266 * 2267 * @return string HTML code for the gui object 2268 */ 2269 public function current_username($attrib) 2270 { 2271 static $username; 2272 2273 // already fetched 2274 if (!empty($username)) { 2275 return $username; 2276 } 2277 2278 // Current username is an e-mail address 2279 if (isset($_SESSION['username']) && strpos($_SESSION['username'], '@')) { 2280 $username = $_SESSION['username']; 2281 } 2282 // get e-mail address from default identity 2283 else if ($sql_arr = $this->app->user->get_identity()) { 2284 $username = $sql_arr['email']; 2285 } 2286 else { 2287 $username = $this->app->user->get_username(); 2288 } 2289 2290 $username = rcube_utils::idn_to_utf8($username); 2291 2292 return html::quote($username); 2293 } 2294 2295 /** 2296 * GUI object 'loginform' 2297 * Returns code for the webmail login form 2298 * 2299 * @param array $attrib Named parameters 2300 * 2301 * @return string HTML code for the gui object 2302 */ 2303 protected function login_form($attrib) 2304 { 2305 $default_host = $this->config->get('default_host'); 2306 $autocomplete = (int) $this->config->get('login_autocomplete'); 2307 $username_filter = $this->config->get('login_username_filter'); 2308 $_SESSION['temp'] = true; 2309 2310 // save original url 2311 $url = rcube_utils::get_input_value('_url', rcube_utils::INPUT_POST); 2312 if ( 2313 empty($url) 2314 && !empty($_SERVER['QUERY_STRING']) 2315 && !preg_match('/_(task|action)=logout/', $_SERVER['QUERY_STRING']) 2316 ) { 2317 $url = $_SERVER['QUERY_STRING']; 2318 } 2319 2320 // Disable autocapitalization on iPad/iPhone (#1488609) 2321 $attrib['autocapitalize'] = 'off'; 2322 2323 $form_name = !empty($attrib['form']) ? $attrib['form'] : 'form'; 2324 2325 // set autocomplete attribute 2326 $user_attrib = $autocomplete > 0 ? [] : ['autocomplete' => 'off']; 2327 $host_attrib = $autocomplete > 0 ? [] : ['autocomplete' => 'off']; 2328 $pass_attrib = $autocomplete > 1 ? [] : ['autocomplete' => 'off']; 2329 2330 if ($username_filter && strtolower($username_filter) == 'email') { 2331 $user_attrib['type'] = 'email'; 2332 } 2333 2334 $input_task = new html_hiddenfield(['name' => '_task', 'value' => 'login']); 2335 $input_action = new html_hiddenfield(['name' => '_action', 'value' => 'login']); 2336 $input_tzone = new html_hiddenfield(['name' => '_timezone', 'id' => 'rcmlogintz', 'value' => '_default_']); 2337 $input_url = new html_hiddenfield(['name' => '_url', 'id' => 'rcmloginurl', 'value' => $url]); 2338 $input_user = new html_inputfield(['name' => '_user', 'id' => 'rcmloginuser', 'required' => 'required'] 2339 + $attrib + $user_attrib); 2340 $input_pass = new html_passwordfield(['name' => '_pass', 'id' => 'rcmloginpwd', 'required' => 'required'] 2341 + $attrib + $pass_attrib); 2342 $input_host = null; 2343 $hide_host = false; 2344 2345 if (is_array($default_host) && count($default_host) > 1) { 2346 $input_host = new html_select(['name' => '_host', 'id' => 'rcmloginhost', 'class' => 'custom-select']); 2347 2348 foreach ($default_host as $key => $value) { 2349 if (!is_array($value)) { 2350 $input_host->add($value, (is_numeric($key) ? $value : $key)); 2351 } 2352 else { 2353 $input_host = null; 2354 break; 2355 } 2356 } 2357 } 2358 else if (is_array($default_host) && ($host = key($default_host)) !== null) { 2359 $hide_host = true; 2360 $input_host = new html_hiddenfield([ 2361 'name' => '_host', 'id' => 'rcmloginhost', 'value' => is_numeric($host) ? $default_host[$host] : $host] + $attrib); 2362 } 2363 else if (empty($default_host)) { 2364 $input_host = new html_inputfield(['name' => '_host', 'id' => 'rcmloginhost', 'class' => 'form-control'] 2365 + $attrib + $host_attrib); 2366 } 2367 2368 $this->add_gui_object('loginform', $form_name); 2369 2370 // create HTML table with two cols 2371 $table = new html_table(['cols' => 2]); 2372 2373 $table->add('title', html::label('rcmloginuser', html::quote($this->app->gettext('username')))); 2374 $table->add('input', $input_user->show(rcube_utils::get_input_value('_user', rcube_utils::INPUT_GPC))); 2375 2376 $table->add('title', html::label('rcmloginpwd', html::quote($this->app->gettext('password')))); 2377 $table->add('input', $input_pass->show()); 2378 2379 // add host selection row 2380 if (is_object($input_host) && !$hide_host) { 2381 $table->add('title', html::label('rcmloginhost', html::quote($this->app->gettext('server')))); 2382 $table->add('input', $input_host->show(rcube_utils::get_input_value('_host', rcube_utils::INPUT_GPC))); 2383 } 2384 2385 $out = $input_task->show(); 2386 $out .= $input_action->show(); 2387 $out .= $input_tzone->show(); 2388 $out .= $input_url->show(); 2389 $out .= $table->show(); 2390 2391 if ($hide_host) { 2392 $out .= $input_host->show(); 2393 } 2394 2395 if (rcube_utils::get_boolean($attrib['submit'])) { 2396 $button_attr = ['type' => 'submit', 'id' => 'rcmloginsubmit', 'class' => 'button mainaction submit']; 2397 $out .= html::p('formbuttons', html::tag('button', $button_attr, $this->app->gettext('login'))); 2398 } 2399 2400 // add oauth login button 2401 if ($this->config->get('oauth_auth_uri') && $this->config->get('oauth_provider')) { 2402 // hide login form fields when `oauth_login_redirect` is configured 2403 if ($this->config->get('oauth_login_redirect')) { 2404 $out = ''; 2405 } 2406 2407 $link_attr = ['href' => $this->app->url(['action' => 'oauth']), 'id' => 'rcmloginoauth', 'class' => 'button oauth ' . $this->config->get('oauth_provider')]; 2408 $out .= html::p('oauthlogin', html::a($link_attr, $this->app->gettext(['name' => 'oauthlogin', 'vars' => ['provider' => $this->config->get('oauth_provider_name', 'OAuth')]]))); 2409 } 2410 2411 // surround html output with a form tag 2412 if (empty($attrib['form'])) { 2413 $out = $this->form_tag(['name' => $form_name, 'method' => 'post'], $out); 2414 } 2415 2416 // include script for timezone detection 2417 $this->include_script('jstz.min.js'); 2418 2419 return $out; 2420 } 2421 2422 /** 2423 * GUI object 'preloader' 2424 * Loads javascript code for images preloading 2425 * 2426 * @param array $attrib Named parameters 2427 * @return void 2428 */ 2429 protected function preloader($attrib) 2430 { 2431 $images = preg_split('/[\s\t\n,]+/', $attrib['images'], -1, PREG_SPLIT_NO_EMPTY); 2432 $images = array_map([$this, 'abs_url'], $images); 2433 $images = array_map([$this, 'asset_url'], $images); 2434 2435 if (empty($images) || $_REQUEST['_task'] == 'logout') { 2436 return; 2437 } 2438 2439 $this->add_script('var images = ' . self::json_serialize($images, $this->devel_mode) .'; 2440 for (var i=0; i<images.length; i++) { 2441 img = new Image(); 2442 img.src = images[i]; 2443 }', 'docready'); 2444 } 2445 2446 /** 2447 * GUI object 'searchform' 2448 * Returns code for search function 2449 * 2450 * @param array $attrib Named parameters 2451 * 2452 * @return string HTML code for the gui object 2453 */ 2454 public function search_form($attrib) 2455 { 2456 // add some labels to client 2457 $this->add_label('searching'); 2458 2459 $attrib['name'] = '_q'; 2460 $attrib['class'] = trim((!empty($attrib['class']) ? $attrib['class'] : '') . ' no-bs'); 2461 2462 if (empty($attrib['id'])) { 2463 $attrib['id'] = 'rcmqsearchbox'; 2464 } 2465 if (isset($attrib['type']) && $attrib['type'] == 'search' && !$this->browser->khtml) { 2466 unset($attrib['type'], $attrib['results']); 2467 } 2468 if (empty($attrib['placeholder'])) { 2469 $attrib['placeholder'] = $this->app->gettext('searchplaceholder'); 2470 } 2471 2472 $label = html::label(['for' => $attrib['id'], 'class' => 'voice'], rcube::Q($this->app->gettext('arialabelsearchterms'))); 2473 $input_q = new html_inputfield($attrib); 2474 $out = $label . $input_q->show(); 2475 $name = 'qsearchbox'; 2476 2477 // Support for multiple searchforms on the same page 2478 if (isset($attrib['gui-object']) && $attrib['gui-object'] !== false && $attrib['gui-object'] !== 'false') { 2479 $name = $attrib['gui-object']; 2480 } 2481 2482 $this->add_gui_object($name, $attrib['id']); 2483 2484 // add form tag around text field 2485 if (empty($attrib['form']) && empty($attrib['no-form'])) { 2486 $out = $this->form_tag([ 2487 'name' => !empty($attrib['form-name']) ? $attrib['form-name'] : 'rcmqsearchform', 2488 'onsubmit' => sprintf( 2489 "%s.command('%s'); return false", 2490 self::JS_OBJECT_NAME, 2491 !empty($attrib['command']) ? $attrib['command'] : 'search' 2492 ), 2493 // 'style' => "display:inline" 2494 ], $out); 2495 } 2496 2497 if (!empty($attrib['wrapper'])) { 2498 $options_button = ''; 2499 2500 $ariatag = !empty($attrib['ariatag']) ? $attrib['ariatag'] : 'h2'; 2501 $domain = !empty($attrib['label-domain']) ? $attrib['label-domain'] : null; 2502 $options = !empty($attrib['options']) ? $attrib['options'] : null; 2503 2504 $header_label = $this->app->gettext('arialabel' . $attrib['label'], $domain); 2505 $header_attrs = [ 2506 'id' => 'aria-label-' . $attrib['label'], 2507 'class' => 'voice' 2508 ]; 2509 2510 $header = html::tag($ariatag, $header_attrs, rcube::Q($header_label)); 2511 2512 if (!empty($attrib['options'])) { 2513 $options_button = $this->button([ 2514 'type' => 'link', 2515 'href' => '#search-filter', 2516 'class' => 'button options', 2517 'label' => 'options', 2518 'title' => 'options', 2519 'tabindex' => '0', 2520 'innerclass' => 'inner', 2521 'data-target' => $options 2522 ]); 2523 } 2524 2525 $search_button = $this->button([ 2526 'type' => 'link', 2527 'href' => '#search', 2528 'class' => 'button search', 2529 'label' => $attrib['buttontitle'], 2530 'title' => $attrib['buttontitle'], 2531 'tabindex' => '0', 2532 'innerclass' => 'inner', 2533 ]); 2534 2535 $reset_button = $this->button([ 2536 'type' => 'link', 2537 'command' => !empty($attrib['reset-command']) ? $attrib['reset-command'] : 'reset-search', 2538 'class' => 'button reset', 2539 'label' => 'resetsearch', 2540 'title' => 'resetsearch', 2541 'tabindex' => '0', 2542 'innerclass' => 'inner', 2543 ]); 2544 2545 $out = html::div([ 2546 'role' => 'search', 2547 'aria-labelledby' => !empty($attrib['label']) ? 'aria-label-' . $attrib['label'] : null, 2548 'class' => $attrib['wrapper'], 2549 ], 2550 "$header$out\n$reset_button\n$options_button\n$search_button" 2551 ); 2552 } 2553 2554 return $out; 2555 } 2556 2557 /** 2558 * Builder for GUI object 'message' 2559 * 2560 * @param array Named tag parameters 2561 * @return string HTML code for the gui object 2562 */ 2563 protected function message_container($attrib) 2564 { 2565 if (isset($attrib['id']) === false) { 2566 $attrib['id'] = 'rcmMessageContainer'; 2567 } 2568 2569 $this->add_gui_object('message', $attrib['id']); 2570 2571 return html::div($attrib, ''); 2572 } 2573 2574 /** 2575 * GUI object 'charsetselector' 2576 * 2577 * @param array $attrib Named parameters for the select tag 2578 * 2579 * @return string HTML code for the gui object 2580 */ 2581 public function charset_selector($attrib) 2582 { 2583 // pass the following attributes to the form class 2584 $field_attrib = ['name' => '_charset']; 2585 foreach ($attrib as $attr => $value) { 2586 if (in_array($attr, ['id', 'name', 'class', 'style', 'size', 'tabindex'])) { 2587 $field_attrib[$attr] = $value; 2588 } 2589 } 2590 2591 $charsets = [ 2592 'UTF-8' => 'UTF-8 ('.$this->app->gettext('unicode').')', 2593 'US-ASCII' => 'ASCII ('.$this->app->gettext('english').')', 2594 'ISO-8859-1' => 'ISO-8859-1 ('.$this->app->gettext('westerneuropean').')', 2595 'ISO-8859-2' => 'ISO-8859-2 ('.$this->app->gettext('easterneuropean').')', 2596 'ISO-8859-4' => 'ISO-8859-4 ('.$this->app->gettext('baltic').')', 2597 'ISO-8859-5' => 'ISO-8859-5 ('.$this->app->gettext('cyrillic').')', 2598 'ISO-8859-6' => 'ISO-8859-6 ('.$this->app->gettext('arabic').')', 2599 'ISO-8859-7' => 'ISO-8859-7 ('.$this->app->gettext('greek').')', 2600 'ISO-8859-8' => 'ISO-8859-8 ('.$this->app->gettext('hebrew').')', 2601 'ISO-8859-9' => 'ISO-8859-9 ('.$this->app->gettext('turkish').')', 2602 'ISO-8859-10' => 'ISO-8859-10 ('.$this->app->gettext('nordic').')', 2603 'ISO-8859-11' => 'ISO-8859-11 ('.$this->app->gettext('thai').')', 2604 'ISO-8859-13' => 'ISO-8859-13 ('.$this->app->gettext('baltic').')', 2605 'ISO-8859-14' => 'ISO-8859-14 ('.$this->app->gettext('celtic').')', 2606 'ISO-8859-15' => 'ISO-8859-15 ('.$this->app->gettext('westerneuropean').')', 2607 'ISO-8859-16' => 'ISO-8859-16 ('.$this->app->gettext('southeasterneuropean').')', 2608 'WINDOWS-1250' => 'Windows-1250 ('.$this->app->gettext('easterneuropean').')', 2609 'WINDOWS-1251' => 'Windows-1251 ('.$this->app->gettext('cyrillic').')', 2610 'WINDOWS-1252' => 'Windows-1252 ('.$this->app->gettext('westerneuropean').')', 2611 'WINDOWS-1253' => 'Windows-1253 ('.$this->app->gettext('greek').')', 2612 'WINDOWS-1254' => 'Windows-1254 ('.$this->app->gettext('turkish').')', 2613 'WINDOWS-1255' => 'Windows-1255 ('.$this->app->gettext('hebrew').')', 2614 'WINDOWS-1256' => 'Windows-1256 ('.$this->app->gettext('arabic').')', 2615 'WINDOWS-1257' => 'Windows-1257 ('.$this->app->gettext('baltic').')', 2616 'WINDOWS-1258' => 'Windows-1258 ('.$this->app->gettext('vietnamese').')', 2617 'ISO-2022-JP' => 'ISO-2022-JP ('.$this->app->gettext('japanese').')', 2618 'ISO-2022-KR' => 'ISO-2022-KR ('.$this->app->gettext('korean').')', 2619 'ISO-2022-CN' => 'ISO-2022-CN ('.$this->app->gettext('chinese').')', 2620 'EUC-JP' => 'EUC-JP ('.$this->app->gettext('japanese').')', 2621 'EUC-KR' => 'EUC-KR ('.$this->app->gettext('korean').')', 2622 'EUC-CN' => 'EUC-CN ('.$this->app->gettext('chinese').')', 2623 'BIG5' => 'BIG5 ('.$this->app->gettext('chinese').')', 2624 'GB2312' => 'GB2312 ('.$this->app->gettext('chinese').')', 2625 'KOI8-R' => 'KOI8-R ('.$this->app->gettext('cyrillic').')', 2626 ]; 2627 2628 if ($post = rcube_utils::get_input_value('_charset', rcube_utils::INPUT_POST)) { 2629 $set = $post; 2630 } 2631 else if (!empty($attrib['selected'])) { 2632 $set = $attrib['selected']; 2633 } 2634 else { 2635 $set = $this->get_charset(); 2636 } 2637 2638 $set = strtoupper($set); 2639 if (!isset($charsets[$set]) && preg_match('/^[A-Z0-9-]+$/', $set)) { 2640 $charsets[$set] = $set; 2641 } 2642 2643 $select = new html_select($field_attrib); 2644 $select->add(array_values($charsets), array_keys($charsets)); 2645 2646 return $select->show($set); 2647 } 2648 2649 /** 2650 * Include content from config/about.<LANG>.html if available 2651 */ 2652 protected function about_content($attrib) 2653 { 2654 $content = ''; 2655 $filenames = [ 2656 'about.' . $_SESSION['language'] . '.html', 2657 'about.' . substr($_SESSION['language'], 0, 2) . '.html', 2658 'about.html', 2659 ]; 2660 2661 foreach ($filenames as $file) { 2662 $fn = RCUBE_CONFIG_DIR . $file; 2663 if (is_readable($fn)) { 2664 $content = file_get_contents($fn); 2665 $content = $this->parse_conditions($content); 2666 $content = $this->parse_xml($content); 2667 break; 2668 } 2669 } 2670 2671 return $content; 2672 } 2673 2674 /** 2675 * Get logo URL for current template based on skin_logo config option 2676 * 2677 * @param string $type Type of the logo to check for (e.g. 'print' or 'small') 2678 * default is null (no special type) 2679 * @param string $match (optional) 'all' = type, template or wildcard, 'template' = type or template 2680 * Note: when type is specified matches are limited to type only unless $match is defined 2681 * 2682 * @return string image URL 2683 */ 2684 protected function get_template_logo($type = null, $match = null) 2685 { 2686 $template_logo = null; 2687 2688 if ($logo = $this->config->get('skin_logo')) { 2689 if (is_array($logo)) { 2690 $template_names = [ 2691 $this->skin_name . ':' . $this->template_name . '[' . $type . ']', 2692 $this->skin_name . ':' . $this->template_name, 2693 $this->skin_name . ':*[' . $type . ']', 2694 $this->skin_name . ':[' . $type . ']', 2695 $this->skin_name . ':*', 2696 '*:' . $this->template_name . '[' . $type . ']', 2697 '*:' . $this->template_name, 2698 '*:*[' . $type . ']', 2699 '*:[' . $type . ']', 2700 $this->template_name . '[' . $type . ']', 2701 $this->template_name, 2702 '*[' . $type . ']', 2703 '[' . $type . ']', 2704 '*', 2705 ]; 2706 2707 if (empty($type)) { 2708 // If no type provided then remove those options from the list 2709 $template_names = preg_grep("/\]$/", $template_names, PREG_GREP_INVERT); 2710 } 2711 elseif ($match === null) { 2712 // Type specified with no special matching requirements so remove all none type specific options from the list 2713 $template_names = preg_grep("/\]$/", $template_names); 2714 } 2715 2716 if ($match == 'template') { 2717 // Match only specific type or template name 2718 $template_names = preg_grep("/\*$/", $template_names, PREG_GREP_INVERT); 2719 } 2720 2721 foreach ($template_names as $key) { 2722 if (isset($logo[$key])) { 2723 $template_logo = $logo[$key]; 2724 break; 2725 } 2726 } 2727 } 2728 else { 2729 $template_logo = $logo; 2730 } 2731 } 2732 2733 return $template_logo; 2734 } 2735} 2736