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