1<?php
2/**
3 * Copyright 2012-2017 Horde LLC (http://www.horde.org/)
4 *
5 * See the enclosed file COPYING for license information (LGPL). If you
6 * did not receive this file, see http://www.horde.org/licenses/lgpl21.
7 *
8 * @category  Horde
9 * @copyright 2012-2017 Horde LLC
10 * @license   http://www.horde.org/licenses/lgpl21 LGPL 2.1
11 * @package   Core
12 */
13
14/**
15 * This object consolidates the elements needed to output a page to the
16 * browser.
17 *
18 * @author    Michael Slusarz <slusarz@horde.org>
19 * @category  Horde
20 * @copyright 2012-2017 Horde LLC
21 * @license   http://www.horde.org/licenses/lgpl21 LGPL 2.1
22 * @package   Core
23 */
24class Horde_PageOutput
25{
26    /**
27     * Output code necessary to perform AJAX operations?
28     *
29     * @var boolean
30     */
31    public $ajax = false;
32
33    /**
34     * Stylesheet object.
35     *
36     * @var Horde_Themes_Css
37     */
38    public $css;
39
40    /**
41     * Activate debugging output.
42     *
43     * @internal
44     *
45     * @var boolean
46     */
47    public $debug = false;
48
49    /**
50     * Defer loading of scripts until end of page?
51     *
52     * @var boolean
53     */
54    public $deferScripts = true;
55
56    /**
57     * Output code necessary to display growler notifications?
58     *
59     * @var boolean
60     */
61    public $growler = false;
62
63    /**
64     * Script list.
65     *
66     * @var Horde_Script_List
67     */
68    public $hsl;
69
70    /**
71     * List of inline scripts.
72     *
73     * @var array
74     */
75    public $inlineScript = array();
76
77    /**
78     * List of LINK tags to output.
79     *
80     * @var array
81     */
82    public $linkTags = array();
83
84    /**
85     * List of META tags to output.
86     *
87     * @var array
88     */
89    public $metaTags = array();
90
91    /**
92     * Load the sidebar in this page?
93     *
94     * @var boolean
95     */
96    public $sidebar = true;
97
98    /**
99     * Smartmobile init code that needs to be output before jquery.mobile.js
100     * is loaded.
101     *
102     * @since 2.12.0
103     *
104     * @var array
105     */
106    public $smartmobileInit = array();
107
108    /**
109     * Load the topbar in this page?
110     *
111     * @var boolean
112     */
113    public $topbar = true;
114
115    /**
116     * Has PHP userspace page compression been started?
117     *
118     * @var boolean
119     */
120    protected $_compress = false;
121
122    /**
123     * View mode.
124     *
125     * @var integer
126     */
127    protected $_view = 0;
128
129    /**
130     * Constructor.
131     */
132    public function __construct()
133    {
134        $this->css = new Horde_Themes_Css();
135        $this->hsl = new Horde_Script_List();
136    }
137
138    /**
139     * Adds a single javascript script to the output (if output has already
140     * started), or to the list of script files to include in the output.
141     *
142     * @param mixed $file  Either a Horde_Script_File object, or the full
143     *                     javascript file name.
144     * @param string $app  If $file is a file name, this is the application
145     *                     where the file is located. Defaults to the current
146     *                     registry application.
147     *
148     * @return Horde_Script_File  Script file object.
149     */
150    public function addScriptFile($file, $app = null)
151    {
152        $ob = is_object($file)
153            ? $file
154            : new Horde_Script_File_JsDir($file, $app);
155
156        return $this->hsl->add($ob);
157    }
158
159    /**
160     * Adds a javascript package to the browser output.
161     *
162     * @param mixed $package  Either a classname, basename of a
163     *                        Horde_Core_Script_Package class, or a
164     *                        Horde_Script_Package object.
165     *
166     * @return Horde_Script_Package  Package object.
167     * @throws Horde_Exception
168     */
169    public function addScriptPackage($package)
170    {
171        if (!is_object($package)) {
172            if (!class_exists($package)) {
173                $package = 'Horde_Core_Script_Package_' . $package;
174                if (!class_exists($package)) {
175                    throw new Horde_Exception('Invalid package name provided.');
176                }
177            }
178            $package = new $package();
179        }
180
181        foreach ($package as $ob) {
182            $this->hsl->add($ob);
183        }
184
185        return $package;
186    }
187
188    /**
189     * Outputs the necessary script tags, honoring configuration choices as
190     * to script caching.
191     *
192     * @param boolean $full  Return a full URL?
193     *
194     * @throws Horde_Exception
195     */
196    public function includeScriptFiles($full = false)
197    {
198        global $browser, $injector;
199
200        if (!$browser->hasFeature('javascript')) {
201            return;
202        }
203
204        if (!empty($this->smartmobileInit)) {
205            echo Horde::wrapInlineScript(array(
206                'var horde_jquerymobile_init = function() {' .
207                implode('', $this->smartmobileInit) . '};'
208            ));
209            $this->smartmobileInit = array();
210        }
211
212        $out = $injector->getInstance('Horde_Core_JavascriptCache')->process($this->hsl, $full);
213
214        $this->hsl->clear();
215
216        foreach ($out->script as $val) {
217            echo '<script type="text/javascript" src="' . $val . '"></script>';
218        }
219
220        if (($this->ajax || $this->growler) && $out->all) {
221            $out->jsvars['HordeCore.jsfiles'] = $out->all;
222        }
223        $this->addInlineJsVars($out->jsvars);
224    }
225
226    /**
227     * Add inline javascript to the output buffer.
228     *
229     * @param string|array $script    The script text(s) to add.
230     * @param boolean|string $onload  Load the script after the page (DOM) has
231     *                                loaded? If a string (either 'prototype'
232     *                                or 'jquery'), that JS framework's method
233     *                                is used. Defaults to Prototype. @since
234     *                                Horde_Core 2.28.0
235     * @param boolean $top            Add script to top of stack?
236     */
237    public function addInlineScript($script, $onload = false, $top = false)
238    {
239        $script = is_array($script)
240            ? implode(';', array_map('trim', $script))
241            : trim($script);
242        if (!strlen($script)) {
243            return;
244        }
245
246        $onload = is_bool($onload) && $onload ? 'prototype' : $onload;
247        $script = rtrim($script, ';') . ';';
248
249        if ($top && isset($this->inlineScript[$onload])) {
250            array_unshift($this->inlineScript[$onload], $script);
251        } else {
252            $this->inlineScript[$onload][] = $script;
253        }
254
255        // If headers have already been sent, we need to output a
256        // <script> tag directly.
257        if (!$this->deferScripts && Horde::contentSent()) {
258            $this->outputInlineScript();
259        }
260    }
261
262    /**
263     * Add inline javascript variable definitions to the output buffer.
264     *
265     * @param array $data  Keys are the variable names, values are the data
266     *                     to JSON encode.  If the key begins with a '-',
267     *                     the data will be added to the output as-is.
268     * @param mixed $opts  If boolean true, equivalent to setting the 'onload'
269     *                     option to true. Other options:
270     *   - onload: (boolean) Wrap the definition in an onload handler?
271     *             DEFAULT: false
272     *   - ret_vars: (boolean) If true, will return the list of variable
273     *               definitions instead of outputting to page.
274     *               DEFAULT: false
275     *   - top: (boolean) Add definitions to top of stack?
276     *          DEFAULT: false
277     *
278     * @return array  Returns the variable list of 'ret_vars' option is true.
279     */
280    public function addInlineJsVars($data, $opts = array())
281    {
282        $out = array();
283
284        if ($opts === true) {
285            $opts = array('onload' => true);
286        }
287        $opts = array_merge(array(
288            'onload' => false,
289            'ret_vars' => false,
290            'top' => false
291        ), $opts);
292
293        foreach ($data as $key => $val) {
294            if ($key[0] == '-') {
295                $key = substr($key, 1);
296            } else {
297                $val = Horde_Serialize::serialize($val, Horde_Serialize::JSON);
298            }
299
300            $out[] = $key . '=' . $val;
301        }
302
303        if ($opts['ret_vars']) {
304            return $out;
305        }
306
307        $this->addInlineScript($out, $opts['onload'], $opts['top']);
308    }
309
310    /**
311     * Print pending inline javascript to the output buffer.
312     *
313     * @param boolean $raw  Return the raw script (not wrapped in CDATA tags
314     *                      or observe wrappers)?
315     */
316    public function outputInlineScript($raw = false)
317    {
318        if (empty($this->inlineScript)) {
319            return;
320        }
321
322        $script = array();
323
324        foreach ($this->inlineScript as $key => $val) {
325            $val = implode('', $val);
326
327            if (!$raw && $key) {
328                switch ($key) {
329                case 'prototype':
330                // @todo Remove 'dom' which is here for BC only.
331                case 'dom':
332                    $script[] = 'document.observe("dom:loaded",function(){' . $val . '});';
333                    break;
334                case 'jquery':
335                    $script[] = '$(function(){' . $val . '});';
336                    break;
337                default:
338                    throw new RuntimeException('Unknown JS framework: ' . $key);
339                }
340            } else {
341                $script[] = $val;
342            }
343        }
344
345        echo $raw
346            ? implode('', $script)
347            : Horde::wrapInlineScript($script);
348
349        $this->inlineScript = array();
350    }
351
352    /**
353     * Generate and output the favicon tag for the current application.
354     */
355    public function includeFavicon()
356    {
357        $img = strval(Horde_Themes::img('favicon.ico', array(
358            'nohorde' => true
359        )));
360
361        if (!$img) {
362            $img = strval(Horde_Themes::img('favicon.ico', array(
363                'app' => 'horde'
364            )));
365        }
366
367        echo '<link type="image/x-icon" href="' . $img . '" rel="shortcut icon" />';
368    }
369
370    /**
371     * Adds a META tag to the page output.
372     *
373     * @param string $name         The name value.
374     * @param string $content      The content of the META tag.
375     * @param boolean $http_equiv  Output http-equiv instead of name?
376     */
377    public function addMetaTag($name, $content, $http_equiv = true)
378    {
379        $this->metaTags[$name] = array(
380            'c' => $content,
381            'h' => $http_equiv
382        );
383    }
384
385    /**
386     * Adds a META refresh tag.
387     *
388     * @param integer $time  Refresh time.
389     * @param string $url    Refresh URL
390     */
391    public function metaRefresh($time, $url)
392    {
393        if (!empty($time) && !empty($url)) {
394            $this->addMetaTag('refresh', $time . ';url=' . $url);
395        }
396    }
397
398    /**
399     * Adds a META tag to disable DNS prefetching.
400     * See Horde Bug #8836.
401     */
402    public function noDnsPrefetch()
403    {
404        $this->addMetaTag('x-dns-prefetch-control', 'off');
405    }
406
407    /**
408     * Output META tags to page.
409     */
410    public function outputMetaTags()
411    {
412        foreach ($this->metaTags as $key => $val) {
413            echo '<meta content="' . $val['c'] . '" ' .
414                ($val['h'] ? 'http-equiv' : 'name') .
415                '="' . $key . "\" />\n";
416        }
417
418        $this->metaTags = array();
419    }
420
421    /**
422     * Adds a LINK tag.
423     *
424     * All attributes are HTML-encoded. Only pass raw, unencoded attribute
425     * values to avoid double escaping.
426     *
427     * @param array $opts  Non-default tag elements.
428     */
429    public function addLinkTag(array $opts = array())
430    {
431        $opts = array_merge(array(
432            'rel' => 'alternate',
433            'type' => 'application/rss+xml'
434        ), $opts);
435
436        $out = '<link';
437
438        foreach ($opts as $key => $val) {
439            if (!is_null($val)) {
440                $out .= ' ' . $key . '="' . htmlspecialchars($val) . '"';
441            }
442        }
443
444        $this->linkTags[] = $out . ' />';
445    }
446
447    /**
448     * Output LINK tags.
449     */
450    public function outputLinkTags()
451    {
452        echo implode("\n", $this->linkTags);
453        $this->linkTags = array();
454    }
455
456    /**
457     * Adds an external stylesheet to the output.
458     *
459     * @param Horde_Themes_Element|string $file  Either a Horde_Themes_Element
460     *                                           object or the CSS filepath.
461     * @param string $url                        If $file is a string, this
462     *                                           must be a CSS URL.
463     */
464    public function addStylesheet($file, $url = null)
465    {
466        if ($file instanceof Horde_Themes_Element) {
467            $url = $file->uri;
468            $file = $file->fs;
469        }
470
471        $this->css->addStylesheet($file, $url);
472    }
473
474    /**
475     * Adds a themed stylesheet to the output.
476     *
477     * @param string $file  The stylesheet name.
478     */
479    public function addThemeStylesheet($file)
480    {
481        $this->css->addThemeStylesheet($file);
482    }
483
484    /**
485     * Generate the stylesheet tags for the current application.
486     *
487     * @param array $opts    Options to pass to
488     *                       Horde_Themes_Css::getStylesheetUrls().
489     * @param boolean $full  Return a full URL? @since Horde_Core 2.28.0
490     */
491    public function includeStylesheetFiles(array $opts = array(),
492                                           $full = false)
493    {
494        foreach ($this->css->getStylesheetUrls($opts) as $val) {
495            echo '<link href="' . $val->toString(false, $full) . '" rel="stylesheet" type="text/css" />';
496        }
497    }
498
499    /**
500     * Activates output compression.
501     */
502    public function startCompression()
503    {
504        if ($this->_compress) {
505            return;
506        }
507
508        /* Compress output if requested and possible. */
509        if ($GLOBALS['conf']['compress_pages'] &&
510            !$GLOBALS['browser']->hasQuirk('buggy_compression') &&
511            !(bool)ini_get('zlib.output_compression') &&
512            !(bool)ini_get('zend_accelerator.compress_all') &&
513            ini_get('output_handler') != 'ob_gzhandler') {
514            if (ob_get_level()) {
515                ob_end_clean();
516            }
517            ob_start('ob_gzhandler');
518        }
519
520        $this->_compress = true;
521    }
522
523    /**
524     * Disables output compression. If successful, throws out all data
525     * currently in the output buffer. Must be called before any data is sent
526     * to the browser.
527     */
528    public function disableCompression()
529    {
530        if ($this->_compress && (reset(ob_list_handlers()) == 'ob_gzhandler')) {
531            ob_end_clean();
532            /* Removing the ob_gzhandler ADDS the below headers, which breaks
533             * display on the browser (as of PHP 5.3.15). */
534            header_remove('content-encoding');
535            header_remove('vary');
536            $this->_compress = false;
537        }
538    }
539
540    /**
541     * Output the page header.
542     *
543     * @param array $opts  Options:
544     *   - body_class: (string)
545     *   - body_id: (string)
546     *   - html_id: (string)
547     *   - smartmobileinit: (string) (@deprecated; use $this->smartmobileInit
548     *                      instead)
549     *   - stylesheet_opts: (array)
550     *   - title: (string)
551     *   - view: (integer)
552     */
553    public function header(array $opts = array())
554    {
555        global $injector, $language, $registry, $session;
556
557        $view = new Horde_View(array(
558            'templatePath' => $registry->get('templates', 'horde') . '/common'
559        ));
560
561        $view->outputJs = !$this->deferScripts;
562        $view->stylesheetOpts = array();
563
564        $this->_view = empty($opts['view'])
565            ? ($registry->hasView($registry->getView()) ? $registry->getView() : Horde_Registry::VIEW_BASIC)
566            : $opts['view'];
567
568        if ($session->regenerate_due) {
569            $session->regenerate();
570        }
571
572        switch ($this->_view) {
573        case $registry::VIEW_BASIC:
574            $this->_addBasicScripts();
575            break;
576
577        case $registry::VIEW_DYNAMIC:
578            $this->ajax = true;
579            $this->growler = true;
580
581            $this->_addBasicScripts();
582            $this->addScriptPackage('Horde_Core_Script_Package_Popup');
583            break;
584
585        case $registry::VIEW_MINIMAL:
586            $view->stylesheetOpts['subonly'] = true;
587
588            $view->minimalView = true;
589
590            $this->sidebar = $this->topbar = false;
591            break;
592
593        case $registry::VIEW_SMARTMOBILE:
594            $smobile_files = array(
595                ($this->debug ? 'jquery.mobile/jquery.js' : 'jquery.mobile/jquery.min.js'),
596                'growler-jquery.js',
597                'horde-jquery.js',
598                'smartmobile.js',
599                'horde-jquery-init.js',
600                ($this->debug ? 'jquery.mobile/jquery.mobile.js' : 'jquery.mobile/jquery.mobile.min.js')
601            );
602            foreach ($smobile_files as $val) {
603                $ob = $this->addScriptFile(new Horde_Script_File_JsFramework($val, 'horde'));
604                $ob->cache = 'package_smartmobile';
605            }
606
607            $this->smartmobileInit = array_merge(array(
608                '$.mobile.page.prototype.options.backBtnText = "' . Horde_Core_Translation::t("Back") .'";',
609                '$.mobile.dialog.prototype.options.closeBtnText = "' . Horde_Core_Translation::t("Close") .'";',
610                '$.mobile.listview.prototype.options.filterPlaceholder = "' . Horde_Core_Translation::t("Filter items...") . '";',
611                '$.mobile.loader.prototype.options.text = "' . Horde_Core_Translation::t("loading") . '";'
612            ),
613                isset($opts['smartmobileinit']) ? $opts['smartmobileinit'] : array(),
614                $this->smartmobileInit
615            );
616
617            $this->addInlineJsVars(array(
618                'HordeMobile.conf' => array(
619                    'ajax_url' => $registry->getServiceLink('ajax', $registry->getApp())->url,
620                    'logout_url' => strval($registry->getServiceLink('logout')),
621                    'sid' => SID,
622                    'token' => $session->getToken()
623                )
624            ));
625
626            $this->addMetaTag('viewport', 'width=device-width, initial-scale=1', false);
627
628            $view->stylesheetOpts['subonly'] = true;
629
630            $this->addStylesheet(
631                $registry->get('jsfs', 'horde') . '/jquery.mobile/jquery.mobile.min.css',
632                $registry->get('jsuri', 'horde') . '/jquery.mobile/jquery.mobile.min.css'
633            );
634
635            $view->smartmobileView = true;
636
637            // Force JS to load at top of page, so we don't see flicker when
638            // mobile styles are applied.
639            $view->outputJs = true;
640
641            $this->sidebar = $this->topbar = false;
642            break;
643        }
644
645        $view->stylesheetOpts['sub'] = Horde_Themes::viewDir($this->_view);
646
647        if ($this->ajax || $this->growler) {
648            $this->addScriptFile(new Horde_Script_File_JsFramework('hordecore.js', 'horde'));
649
650            /* Configuration used in core javascript files. */
651            $js_conf = array_filter(array(
652                /* URLs */
653                'URI_AJAX' => $registry->getServiceLink('ajax', $registry->getApp())->url,
654                'URI_DLOAD' => strval($registry->getServiceLink('download', $registry->getApp())),
655                'URI_LOGOUT' => strval($registry->getServiceLink('logout')),
656                'URI_SNOOZE' => strval(Horde::url($registry->get('webroot', 'horde') . '/services/snooze.php', true, -1)),
657
658                /* Other constants */
659                'SID' => SID,
660                'TOKEN' => $session->getToken(),
661
662                /* Other config. */
663                'growler_log' => $this->topbar,
664                'popup_height' => 610,
665                'popup_width' => 820
666            ));
667
668            /* Gettext strings used in core javascript files. */
669            $js_text = array(
670                'ajax_error' => Horde_Core_Translation::t("Error when communicating with the server."),
671                'ajax_recover' => Horde_Core_Translation::t("The connection to the server has been restored."),
672                'ajax_timeout' => Horde_Core_Translation::t("There has been no contact with the server for several minutes. The server may be temporarily unavailable or network problems may be interrupting your session. You will not see any updates until the connection is restored."),
673                'snooze' => sprintf(Horde_Core_Translation::t("You can snooze it for %s or %s dismiss %s it entirely"), '#{time}', '#{dismiss_start}', '#{dismiss_end}'),
674                'snooze_select' => array(
675                    '0' => Horde_Core_Translation::t("Select..."),
676                    '5' => Horde_Core_Translation::t("5 minutes"),
677                    '15' => Horde_Core_Translation::t("15 minutes"),
678                    '60' => Horde_Core_Translation::t("1 hour"),
679                    '360' => Horde_Core_Translation::t("6 hours"),
680                    '1440' => Horde_Core_Translation::t("1 day")
681                ),
682                'dismissed' => Horde_Core_Translation::t("The alarm was dismissed.")
683            );
684
685            if ($this->topbar) {
686                $js_text['growlerclear'] = Horde_Core_Translation::t("Clear All");
687                $js_text['growlerinfo'] = Horde_Core_Translation::t("This is the notification log.");
688                $js_text['growlernoalerts'] = Horde_Core_Translation::t("No Alerts");
689            }
690
691            $this->addInlineJsVars(array(
692                'HordeCore.conf' => $js_conf,
693                'HordeCore.text' => $js_text
694            ), array('top' => true));
695        }
696
697        if ($this->growler) {
698            $this->addScriptFile('growler.js', 'horde');
699            $this->addScriptFile('scriptaculous/effects.js', 'horde');
700            $this->addScriptFile('scriptaculous/sound.js', 'horde');
701        }
702
703        if (isset($opts['stylesheet_opts'])) {
704            $view->stylesheetOpts = array_merge($view->stylesheetOpts, $opts['stylesheet_opts']);
705        }
706
707        $html = '';
708        if (isset($language)) {
709            $html .= ' lang="' . htmlspecialchars(strtr($language, '_', '-')) . '"';
710        }
711        if (isset($opts['html_id'])) {
712            $html .= ' id="' . htmlspecialchars($opts['html_id']) . '"';
713        }
714        $view->htmlAttr = $html;
715
716        $body = '';
717        if (isset($opts['body_class'])) {
718            $body .= ' class="' . htmlspecialchars($opts['body_class']) . '"';
719        }
720        if (isset($opts['body_id'])) {
721            $body .= ' id="' . htmlspecialchars($opts['body_id']) . '"';
722        }
723        $view->bodyAttr = $body;
724
725        $page_title = $registry->get('name');
726        if (isset($opts['title'])) {
727            $page_title .= ' :: ' . $opts['title'];
728        }
729        $view->pageTitle = htmlspecialchars($page_title);
730
731        $view->pageOutput = $this;
732
733        header('Content-type: text/html; charset=UTF-8');
734        if (isset($language)) {
735            header('Vary: Accept-Language');
736        }
737
738        echo $view->render('header');
739        if ($this->topbar) {
740            echo $injector->getInstance('Horde_View_Topbar')->render();
741        }
742
743        // Send what we have currently output so the browser can start
744        // loading CSS/JS. See:
745        // http://developer.yahoo.com/performance/rules.html#flush
746        if (Horde::contentSent()) {
747            echo Horde::endBuffer();
748            flush();
749        }
750    }
751
752    /**
753     * Add basic framework scripts to the output.
754     */
755    protected function _addBasicScripts()
756    {
757        global $prefs;
758
759        $base_js = array(
760            'prototype.js',
761            'horde.js'
762        );
763
764        foreach ($base_js as $val) {
765            $ob = $this->addScriptFile(new Horde_Script_File_JsFramework($val, 'horde'));
766            $ob->cache = 'package_basic';
767        }
768
769        if ($prefs->getValue('widget_accesskey')) {
770            $this->addScriptFile('accesskeys.js', 'horde');
771        }
772    }
773
774    /**
775     * Output files needed for smartmobile mode.
776     *
777     * @deprecated
778     */
779    public function outputSmartmobileFiles()
780    {
781    }
782
783    /**
784     * Output page footer.
785     *
786     * @param array $opts  Options:
787     *   - NONE currently
788     */
789    public function footer(array $opts = array())
790    {
791        global $browser, $notification, $registry;
792
793        $view = new Horde_View(array(
794            'templatePath' => $registry->get('templates', 'horde') . '/common'
795        ));
796
797        if (!$browser->isMobile()) {
798            $notification->notify(array('listeners' => array('audio')));
799        }
800        $view->outputJs = $this->deferScripts;
801        $view->pageOutput = $this;
802
803        switch ($this->_view) {
804        case $registry::VIEW_MINIMAL:
805            $view->minimalView = true;
806            break;
807
808        case $registry::VIEW_SMARTMOBILE:
809            $view->smartmobileView = true;
810            break;
811
812        case $registry::VIEW_BASIC:
813            $view->basicView = true;
814            if ($this->sidebar) {
815                $view->sidebar = Horde::sidebar();
816            }
817            break;
818        }
819
820        echo $view->render('footer');
821
822        $this->deferScripts = false;
823    }
824
825}
826