<?php
/**
* @brief Modules handler
*
* Provides an object to handle modules (themes or plugins).
*
* @package Dotclear
* @subpackage Core
*
* @copyright Olivier Meunier & Association Dotclear
* @copyright GPL-2.0-only
*/
if (!defined('DC_RC_PATH')) {
return;
}
class dcModules
{
protected $path;
protected $ns;
protected $modules = [];
protected $disabled = [];
protected $errors = [];
protected $modules_names = [];
protected $all_modules = [];
protected $disabled_mode = false;
protected $disabled_meta = [];
protected $to_disable = [];
protected $id;
protected $mroot;
# Inclusion variables
protected static $superglobals = ['GLOBALS', '_SERVER', '_GET', '_POST', '_COOKIE', '_FILES', '_ENV', '_REQUEST', '_SESSION'];
protected static $_k;
protected static $_n;
protected static $type = null;
public $core; ///< <b>dcCore</b> dcCore instance
/**
* Constructs a new instance.
*
* @param dcCore $core The core
*/
public function __construct(dcCore $core)
{
$this->core = &$core;
}
/**
* Checks all modules dependencies
*
* Fills in the following information in module :
* * cannot_enable : list reasons why module cannot be enabled. Not set if module can be enabled
* * cannot_disable : list reasons why module cannot be disabled. Not set if module can be disabled
* * implies : reverse dependencies
*/
public function checkDependencies()
{
$dc_version = preg_replace('/\-dev.*$/', '', DC_VERSION);
$this->to_disable = [];
foreach ($this->all_modules as $k => &$m) {
if (isset($m['requires'])) {
$missing = [];
foreach ($m['requires'] as &$dep) {
if (!is_array($dep)) {
$dep = [$dep];
}
// grab missing dependencies
if (!isset($this->all_modules[$dep[0]]) && ($dep[0] != 'core')) {
// module not present
$missing[$dep[0]] = sprintf(__('Requires %s module which is not installed'), $dep[0]);
} elseif ((count($dep) > 1) && version_compare(($dep[0] == 'core' ? $dc_version : $this->all_modules[$dep[0]]['version']), $dep[1]) == -1) {
// module present, but version missing
if ($dep[0] == 'core') {
$missing[$dep[0]] = sprintf(
__('Requires Dotclear version %s, but version %s is installed'),
$dep[1],
$dc_version
);
} else {
$missing[$dep[0]] = sprintf(
__('Requires %s module version %s, but version %s is installed'),
$dep[0],
$dep[1],
$this->all_modules[$dep[0]]['version']
);
}
} elseif (($dep[0] != 'core') && !$this->all_modules[$dep[0]]['enabled']) {
// module disabled
$missing[$dep[0]] = sprintf(__('Requires %s module which is disabled'), $dep[0]);
}
$this->all_modules[$dep[0]]['implies'][] = $k;
}
if (count($missing)) {
$m['cannot_enable'] = $missing;
if ($m['enabled']) {
$this->to_disable[] = ['name' => $k, 'reason' => $missing];
}
}
}
}
// Check modules that cannot be disabled
foreach ($this->modules as $k => &$m) {
if (isset($m['implies']) && $m['enabled']) {
foreach ($m['implies'] as $im) {
if (isset($this->all_modules[$im]) && $this->all_modules[$im]['enabled']) {
$m['cannot_disable'][] = $im;
}
}
}
}
}
/**
* Checks all modules dependencies, and disable unmet dependencies
*/
/**
* Disables the dep modules.
*
* @param string $redir_url URL to redirect if modules are to disable
*
* @return bool true if a redirection has been performed
*/
public function disableDepModules($redir_url)
{
if (isset($_GET['dep'])) {
// Avoid infinite redirects
return false;
}
$reason = [];
foreach ($this->to_disable as $module) {
try {
$this->deactivateModule($module['name']);
$reason[] = sprintf('<li>%s : %s</li>', $module['name'], join(',', $module['reason']));
} catch (Exception $e) {
}
}
if (count($reason)) {
$message = sprintf(
'<p>%s</p><ul>%s</ul>',
__('The following extensions have been disabled :'),
join('', $reason)
);
dcPage::addWarningNotice($message, ['divtag' => true, 'with_ts' => false]);
$url = $redir_url . (strpos($redir_url, '?') ? '&' : '?') . 'dep=1';
http::redirect($url);
return true;
}
return false;
}
/**
* Loads modules. <var>$path</var> could be a separated list of paths
* (path separator depends on your OS).
*
* <var>$ns</var> indicates if an additionnal file needs to be loaded on plugin
* load, value could be:
* - admin (loads module's _admin.php)
* - public (loads module's _public.php)
* - xmlrpc (loads module's _xmlrpc.php)
*
* <var>$lang</var> indicates if we need to load a lang file on plugin
* loading.
*
* @param string $path The path
* @param mixed $ns
* @param mixed $lang The language
*/
public function loadModules($path, $ns = null, $lang = null)
{
$this->path = explode(PATH_SEPARATOR, $path);
$this->ns = $ns;
$disabled = isset($_SESSION['sess_safe_mode']) && $_SESSION['sess_safe_mode'];
$disabled = $disabled && !get_parent_class($this) ? true : false; // @phpstan-ignore-line
$ignored = [];
foreach ($this->path as $root) {
if (!is_dir($root) || !is_readable($root)) {
continue;
}
if (substr($root, -1) != '/') {
$root .= '/';
}
if (($d = @dir($root)) === false) {
continue;
}
while (($entry = $d->read()) !== false) {
$full_entry = $root . $entry;
if ($entry != '.' && $entry != '..' && is_dir($full_entry) && file_exists($full_entry . '/_define.php')) {
$this->id = $entry;
$this->mroot = $full_entry;
$module_enabled = !file_exists($full_entry . '/_disabled') && !$disabled;
if (!$module_enabled) {
$this->disabled_mode = true;
}
ob_start();
require $full_entry . '/_define.php';
ob_end_clean();
if ($module_enabled) {
$this->all_modules[$entry] = &$this->modules[$entry];
} else {
$this->disabled_mode = false;
$this->disabled[$entry] = $this->disabled_meta;
$this->all_modules[$entry] = &$this->disabled[$entry];
}
$this->id = null;
$this->mroot = null;
}
}
$d->close();
}
$this->checkDependencies();
# Sort plugins
uasort($this->modules, [$this, 'sortModules']);
foreach ($this->modules as $id => $m) {
# Load translation and _prepend
if (isset($m['root']) && file_exists($m['root'] . '/_prepend.php')) {
$r = $this->loadModuleFile($m['root'] . '/_prepend.php');
# If _prepend.php file returns null (ie. it has a void return statement)
if (is_null($r)) {
$ignored[] = $id;
continue;
}
unset($r);
}
$this->loadModuleL10N($id, $lang, 'main');
if ($ns == 'admin') {
$this->loadModuleL10Nresources($id, $lang);
$this->core->adminurl->register('admin.plugin.' . $id, 'plugin.php', ['p' => $id]);
}
}
// Give opportunity to do something before loading context (admin,public,xmlrpc) files
$this->core->callBehavior('coreBeforeLoadingNsFiles', $this->core, $this, $lang);
foreach ($this->modules as $id => $m) {
# If _prepend.php file returns null (ie. it has a void return statement)
if (in_array($id, $ignored)) {
continue;
}
# Load ns_file
$this->loadNsFile($id, $ns);
}
}
public function requireDefine($dir, $id)
{
if (file_exists($dir . '/_define.php')) {
$this->id = $id;
ob_start();
require $dir . '/_define.php';
ob_end_clean();
$this->id = null;
}
}
/**
* This method registers a module in modules list. You should use this to
* register a new module.
*
* <var>$permissions</var> is a comma separated list of permissions for your
* module. If <var>$permissions</var> is null, only super admin has access to
* this module.
*
* <var>$priority</var> is an integer. Modules are sorted by priority and name.
* Lowest priority comes first.
*
* @param string $name The module name
* @param string $desc The module description
* @param string $author The module author
* @param string $version The module version
* @param mixed $properties The properties
*/
public function registerModule($name, $desc, $author, $version, $properties = [])
{
if ($this->disabled_mode) {
$this->disabled_meta = array_merge(
$properties,
[
'root' => $this->mroot,
'name' => $name,
'desc' => $desc,
'author' => $author,
'version' => $version,
'enabled' => false,
'root_writable' => is_writable($this->mroot),
]
);
return;
}
# Fallback to legacy registerModule parameters
if (!is_array($properties)) {
$args = func_get_args();
$properties = [];
if (isset($args[4])) {
$properties['permissions'] = $args[4];
}
if (isset($args[5])) {
$properties['priority'] = (int) $args[5];
}
}
# Default module properties
$properties = array_merge(
[
'permissions' => null,
'priority' => 1000,
'standalone_config' => false,
'type' => null,
'enabled' => true,
'requires' => [],
'settings' => [],
'repository' => '',
],
$properties
);
# Check module type
if (self::$type !== null && $properties['type'] !== null && $properties['type'] != self::$type) {
$this->errors[] = sprintf(
__('Module "%s" has type "%s" that mismatch required module type "%s".'),
'<strong>' . html::escapeHTML($name) . '</strong>',
'<em>' . html::escapeHTML($properties['type']) . '</em>',
'<em>' . html::escapeHTML(self::$type) . '</em>'
);
return;
}
# Check module perms on admin side
$permissions = $properties['permissions'];
if ($this->ns == 'admin') {
if ($permissions == '' && !$this->core->auth->isSuperAdmin()) {
return;
} elseif (!$this->core->auth->check($permissions, $this->core->blog->id)) {
return;
}
}
# Check module install on multiple path
if ($this->id) {
$module_exists = array_key_exists($name, $this->modules_names);
$module_overwrite = $module_exists ? version_compare($this->modules_names[$name], $version, '<') : false;
if (!$module_exists || $module_overwrite) {
$this->modules_names[$name] = $version;
$this->modules[$this->id] = array_merge(
$properties,
[
'root' => $this->mroot,
'name' => $name,
'desc' => $desc,
'author' => $author,
'version' => $version,
'root_writable' => is_writable($this->mroot ?? ''),
]
);
} else {
$path1 = path::real($this->moduleInfo($name, 'root') ?? '');
$path2 = path::real($this->mroot ?? '');
$this->errors[] = sprintf(
__('Module "%s" is installed twice in "%s" and "%s".'),
'<strong>' . $name . '</strong>',
'<em>' . $path1 . '</em>',
'<em>' . $path2 . '</em>'
);
}
}
}
/**
* Reset modules list
*/
public function resetModulesList()
{
$this->modules = [];
$this->modules_names = [];
$this->errors = [];
}
/**
* Install a Package
*
* @param string $zip_file The zip file
* @param dcModules $modules The modules
*
* @throws Exception
*
* @return int
*/
public static function installPackage($zip_file, dcModules &$modules)
{
$zip = new fileUnzip($zip_file);
$zip->getList(false, '#(^|/)(__MACOSX|\.svn|\.hg.*|\.git.*|\.DS_Store|\.directory|Thumbs\.db)(/|$)#');
$zip_root_dir = $zip->getRootDir();
$define = '';
if ($zip_root_dir != false) {
$target = dirname($zip_file);
$destination = $target . '/' . $zip_root_dir;
$define = $zip_root_dir . '/_define.php';
$has_define = $zip->hasFile($define);
} else {
$target = dirname($zip_file) . '/' . preg_replace('/\.([^.]+)$/', '', basename($zip_file));
$destination = $target;
$define = '_define.php';
$has_define = $zip->hasFile($define);
}
if ($zip->isEmpty()) {
$zip->close();
unlink($zip_file);
throw new Exception(__('Empty module zip file.'));
}
if (!$has_define) {
$zip->close();
unlink($zip_file);
throw new Exception(__('The zip file does not appear to be a valid Dotclear module.'));
}
$ret_code = 1;
if (!is_dir($destination)) {
try {
files::makeDir($destination, true);
$sandbox = clone $modules;
$zip->unzip($define, $target . '/_define.php');
$sandbox->resetModulesList();
$sandbox->requireDefine($target, basename($destination));
unlink($target . '/_define.php');
$new_errors = $sandbox->getErrors();
if (!empty($new_errors)) {
$new_errors = implode(" \n", $new_errors);
throw new Exception($new_errors);
}
files::deltree($destination);
} catch (Exception $e) {
$zip->close();
unlink($zip_file);
files::deltree($destination);
throw new Exception($e->getMessage());
}
} else {
# test for update
$sandbox = clone $modules;
$zip->unzip($define, $target . '/_define.php');
$sandbox->resetModulesList();
$sandbox->requireDefine($target, basename($destination));
unlink($target . '/_define.php');
$new_modules = $sandbox->getModules();
if (!empty($new_modules)) {
$tmp = array_keys($new_modules);
$id = $tmp[0];
$cur_module = $modules->getModules($id);
if (!empty($cur_module) && (defined('DC_DEV') && DC_DEV === true || dcUtils::versionsCompare($new_modules[$id]['version'], $cur_module['version'], '>', true))) {
# delete old module
if (!files::deltree($destination)) {
throw new Exception(__('An error occurred during module deletion.'));
}
$ret_code = 2;
} else {
$zip->close();
unlink($zip_file);
throw new Exception(sprintf(__('Unable to upgrade "%s". (older or same version)'), basename($destination)));
}
} else {
$zip->close();
unlink($zip_file);
throw new Exception(sprintf(__('Unable to read new _define.php file')));
}
}
$zip->unzipAll($target);
$zip->close();
unlink($zip_file);
return $ret_code;
}
/**
*/
/**
* This method installs all modules having a _install file.
*
* @see dcModules::installModule
*
* @return array
*/
public function installModules()
{
$res = ['success' => [], 'failure' => []];
foreach ($this->modules as $id => &$m) {
$i = $this->installModule($id, $msg);
if ($i === true) {
$res['success'][$id] = true;
} elseif ($i === false) {
$res['failure'][$id] = $msg;
}
}
return $res;
}
/**
* This method installs module with ID <var>$id</var> and having a _install
* file. This file should throw exception on failure or true if it installs
* successfully.
*
* <var>$msg</var> is an out parameter that handle installer message.
*
* @param string $id The identifier
* @param string $msg The message
*
* @return mixed
*/
public function installModule($id, &$msg)
{
if (!isset($this->modules[$id])) {
return;
}
try {
$i = $this->loadModuleFile($this->modules[$id]['root'] . '/_install.php');
if ($i === true) {
return true;
}
} catch (Exception $e) {
$msg = $e->getMessage();
return false;
}
}
/**
* Delete a module
*
* @param string $id The module identifier
* @param bool $disabled Is module disabled
*
* @throws Exception (description)
*/
public function deleteModule($id, $disabled = false)
{
if ($disabled) {
$p = &$this->disabled;
} else {
$p = &$this->modules;
}
if (!isset($p[$id])) {
throw new Exception(__('No such module.'));
}
if (!files::deltree($p[$id]['root'])) {
throw new Exception(__('Cannot remove module files'));
}
}
/**
* Deactivate a module
*
* @param string $id The identifier
*
* @throws Exception
*/
public function deactivateModule($id)
{
if (!isset($this->modules[$id])) {
throw new Exception(__('No such module.'));
}
if (!$this->modules[$id]['root_writable']) {
throw new Exception(__('Cannot deactivate plugin.'));
}
if (@file_put_contents($this->modules[$id]['root'] . '/_disabled', '')) {
throw new Exception(__('Cannot deactivate plugin.'));
}
}
/**
* Activate a module
*
* @param string $id The identifier
*
* @throws Exception
*/
public function activateModule($id)
{
if (!isset($this->disabled[$id])) {
throw new Exception(__('No such module.'));
}
if (!$this->disabled[$id]['root_writable']) {
throw new Exception(__('Cannot activate plugin.'));
}
if (@unlink($this->disabled[$id]['root'] . '/_disabled') === false) {
throw new Exception(__('Cannot activate plugin.'));
}
}
/**
* Clone a module
*
* @param string $id The module identifier
*/
public function cloneModule($id)
{
}
/**
* This method will search for file <var>$file</var> in language
* <var>$lang</var> for module <var>$id</var>.
*
* <var>$file</var> should not have any extension.
*
* @param string $id The module identifier
* @param string $lang The language code
* @param string $file The filename (without extension)
*/
public function loadModuleL10N($id, $lang, $file)
{
if (!$lang || !isset($this->modules[$id])) {
return;
}
$lfile = $this->modules[$id]['root'] . '/locales/%s/%s';
if (l10n::set(sprintf($lfile, $lang, $file)) === false && $lang != 'en') {
l10n::set(sprintf($lfile, 'en', $file));
}
}
/**
* Loads module l10n resources.
*
* @param string $id The module identifier
* @param string $lang The language code
*/
public function loadModuleL10Nresources($id, $lang)
{
if (!$lang || !isset($this->modules[$id])) {
return;
}
$f = l10n::getFilePath($this->modules[$id]['root'] . '/locales', 'resources.php', $lang);
if ($f) {
$this->loadModuleFile($f);
}
}
/**
* Returns all modules associative array or only one module if <var>$id</var>
* is present.
*
* @param mixed $id The optionnal module identifier
*
* @return mixed The modules.
*/
public function getModules($id = null)
{
if ($id && isset($this->modules[$id])) {
return $this->modules[$id];
}
return $this->modules;
}
/**
* Determines if module exists.
*
* @param string $id The module identifier
*
* @return bool True if module exists, False otherwise.
*/
public function moduleExists($id)
{
return isset($this->modules[$id]);
}
/**
* Gets the disabled modules.
*
* @return array The disabled modules.
*/
public function getDisabledModules()
{
return $this->disabled;
}
/**
* Returns root path for module with ID <var>$id</var>.
*
* @param string $id The module identifier
*
* @return string
*/
public function moduleRoot($id)
{
return $this->moduleInfo($id, 'root');
}
/**
* Returns a module information that could be:
* - root
* - name
* - desc
* - author
* - version
* - permissions
* - priority
* - …
*
* @param string $id The module identifier
* @param string $info The information
*
* @return mixed
*/
public function moduleInfo($id, $info)
{
return $this->modules[$id][$info] ?? null;
}
/**
* Loads namespace <var>$ns</var> specific files for all modules.
*
* @param mixed $ns
*/
public function loadNsFiles($ns = null)
{
foreach ($this->modules as $k => $v) {
$this->loadNsFile($k, $ns);
}
}
/**
* Loads namespace <var>$ns</var> specific file for module with ID
* <var>$id</var>
*
* @param string $id The module identifier
* @param mixed $ns Namespace name
*/
public function loadNsFile($id, $ns = null)
{
if (!isset($this->modules[$id])) {
return;
}
switch ($ns) {
case 'admin':
$this->loadModuleFile($this->modules[$id]['root'] . '/_admin.php');
break;
case 'public':
$this->loadModuleFile($this->modules[$id]['root'] . '/_public.php');
break;
case 'xmlrpc':
$this->loadModuleFile($this->modules[$id]['root'] . '/_xmlrpc.php');
break;
}
}
/**
* Gets the errors.
*
* @return array The errors.
*/
public function getErrors()
{
return $this->errors;
}
protected function loadModuleFile($________, $catch = true)
{
if (!file_exists($________)) {
return;
}
self::$_k = array_keys($GLOBALS);
foreach (self::$_k as self::$_n) {
if (!in_array(self::$_n, self::$superglobals)) {
global ${self::$_n};
}
}
if ($catch) {
// Catch ouput to prevents hacked or corrupted modules
ob_start();
$ret = require $________;
ob_end_clean();
return $ret;
}
return require $________;
}
private function sortModules($a, $b)
{
if (!isset($a['priority']) || !isset($b['priority'])) {
return 1;
}
if ($a['priority'] == $b['priority']) {
return strcasecmp($a['name'], $b['name']);
}
return ($a['priority'] < $b['priority']) ? -1 : 1;
}
}