Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions lib/Horde/PageOutput.php
Original file line number Diff line number Diff line change
Expand Up @@ -714,6 +714,8 @@ public function header(array $opts = [])

$view->stylesheetOpts['sub'] = Horde_Themes::viewDir($this->_view);

$this->_addThemeScripts();

if ($this->ajax || $this->growler) {
$this->addScriptFile(new Horde_Script_File_JsFramework('hordecore.js', 'horde'));

Expand Down Expand Up @@ -833,6 +835,39 @@ public function header(array $opts = [])
}
}

/**
* Adds the scripts shipped by the current theme, if any.
*
* A theme declares them in its info.php:
*
* $theme_scripts = array('theme.js');
*
* Themes already ship CSS, images and sounds; this lets them ship the
* behaviour that goes with their markup as well, without the installer
* having to wire anything up. Only plain file names inside the theme
* directory are accepted (see Horde_Themes_Cache::themeScripts()).
*/
protected function _addThemeScripts()
{
global $injector, $prefs, $registry;

$theme = $prefs->getValue('theme');
if (!strlen((string) $theme)) {
return;
}

/* The cache instance may come back unserialized; themeScripts() is
* derived on demand (not serialized), like the covered-apps list. */
$cache = $injector->getInstance('Horde_Core_Factory_ThemesCache')
->create($registry->getApp(), $theme);

foreach ($cache->themeScripts() as $script) {
$this->addScriptFile(
new Horde_Script_File_ThemeDir($script, $theme, 'horde')
);
}
}

/**
* Add basic framework scripts to the output.
*/
Expand Down
73 changes: 73 additions & 0 deletions lib/Horde/Script/File/ThemeDir.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?php

/**
* Copyright 2012-2026 Horde LLC (http://www.horde.org/)
*
* See the enclosed file LICENSE for license information (LGPL). If you
* did not receive this file, see http://www.horde.org/licenses/lgpl21.
*
* @category Horde
* @copyright 2012-2026 Horde LLC
* @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
* @package Core
*/

/**
* This class represents a javascript script file located in a theme's
* directory.
*
* Themes may ship their own scripts alongside their CSS, images and sounds,
* by listing them in the theme's info.php:
*
* $theme_scripts = array('theme.js');
*
* Only plain file names are accepted; they are resolved inside the theme
* directory. See Horde_Themes_Cache::themeScripts().
*
* @category Horde
* @copyright 2012-2026 Horde LLC
* @license http://www.horde.org/licenses/lgpl21 LGPL 2.1
* @package Core
*/
class Horde_Script_File_ThemeDir extends Horde_Script_File
{
/**
* The theme this file belongs to.
*
* @var string
*/
protected $_theme;

/**
* @param string $file The script file name.
* @param string $theme The theme name.
* @param string $app The application name. Defaults to the current
* application.
*/
public function __construct($file, $theme, $app = null)
{
parent::__construct($file, $app);
$this->_theme = $theme;
}

/**
*/
public function __get($name)
{
switch ($name) {
case 'path':
return $GLOBALS['registry']->get('themesfs', $this->_app) .
'/' . $this->_theme . '/';

case 'url':
case 'url_full':
return $this->_url(
$GLOBALS['registry']->get('themesuri', $this->_app) .
'/' . $this->_theme . '/' . $this->_file,
($name == 'url_full')
);
}

return parent::__get($name);
}
}
107 changes: 94 additions & 13 deletions lib/Horde/Themes/Cache.php
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,21 @@ class Horde_Themes_Cache implements Serializable
*/
protected $_covers;

/**
* Cached list of script files the theme ships. Read from the theme's
* info.php.
*
* @var string[]
*/
protected $_scripts;

/**
* Cached declarations read from the theme's info.php.
*
* @var array
*/
protected $_info;

/**
* Constructor.
*
Expand Down Expand Up @@ -249,23 +264,89 @@ protected function _getOutput($app, $theme, $item)
protected function _coveredApps()
{
if (!isset($this->_covers)) {
$theme_covers = [];

/* Theme names originate from user prefs/options, so guard against
* path traversal: only include info.php for a plain directory name
* (no separators, no '..'). Anything else inherits everything. */
if (preg_match('/^[A-Za-z0-9_-]+$/', (string) $this->_theme)) {
global $registry;
$info = $registry->get('themesfs', 'horde') . '/' . $this->_theme . '/info.php';
if (is_readable($info)) {
include $info;
}
$info = $this->_themeInfo();
$this->_covers = array_map(
'strtolower',
(array) ($info['theme_covers'] ?? [])
);
}

return $this->_covers;
}

/**
* Returns the declarations made by the current theme's info.php.
*
* @return array The variables the theme declares, keyed by name. Empty if
* the theme has no info.php (or an unsafe name).
*/
protected function _themeInfo()
{
if (isset($this->_info)) {
return $this->_info;
}

$theme_covers = [];
$theme_scripts = [];

/* Theme names originate from user prefs/options, so guard against
* path traversal: only include info.php for a plain directory name
* (no separators, no '..'). Anything else declares nothing. */
if (preg_match('/^[A-Za-z0-9_-]+$/', (string) $this->_theme)) {
global $registry;
$info = $registry->get('themesfs', 'horde') . '/' . $this->_theme . '/info.php';
if (is_readable($info)) {
include $info;
}
}

$this->_info = [
'theme_covers' => $theme_covers,
'theme_scripts' => $theme_scripts,
];

$this->_covers = array_map('strtolower', (array) $theme_covers);
return $this->_info;
}

/**
* Returns the javascript files the current theme ships.
*
* Themes may ship scripts alongside their CSS, images and sounds, by
* listing them in info.php:
*
* $theme_scripts = array('theme.js');
*
* Only plain file names are accepted, and they are resolved inside the
* theme directory: a theme cannot pull in a script from an arbitrary path
* or an external host. Files that do not exist are skipped.
*
* Themes without the declaration ship no script, exactly as before.
*
* @return string[] Script file names, relative to the theme directory.
*/
public function themeScripts()
{
if (!isset($this->_scripts)) {
$info = $this->_themeInfo();
$base = $GLOBALS['registry']->get('themesfs', 'horde') .
'/' . $this->_theme . '/';
$this->_scripts = [];

foreach ((array) ($info['theme_scripts'] ?? []) as $script) {
/* Plain file names only: no directory separators, no '..'.
* The theme directory is the only place a script may come
* from. */
if (!preg_match('/^[A-Za-z0-9_.-]+\.js$/', (string) $script) ||
strpos($script, '..') !== false) {
continue;
}
if (is_readable($base . $script)) {
$this->_scripts[] = $script;
}
}
}

return $this->_covers;
return $this->_scripts;
}

/**
Expand Down
Loading