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