diff --git a/classes/config.php b/classes/config.php index e94bcabb6..7a37d4a86 100644 --- a/classes/config.php +++ b/classes/config.php @@ -53,6 +53,8 @@ class Config { const HTTP_PROXY = "HTTP_PROXY"; const FORBID_PASSWORD_CHANGES = "FORBID_PASSWORD_CHANGES"; const SESSION_NAME = "SESSION_NAME"; + const CHECK_FOR_PLUGIN_UPDATES = "CHECK_FOR_PLUGIN_UPDATES"; + const ENABLE_PLUGIN_INSTALLER = "ENABLE_PLUGIN_INSTALLER"; private const _DEFAULTS = [ Config::DB_TYPE => [ "pgsql", Config::T_STRING ], @@ -102,6 +104,8 @@ class Config { Config::HTTP_PROXY => [ "", Config::T_STRING ], Config::FORBID_PASSWORD_CHANGES => [ "", Config::T_BOOL ], Config::SESSION_NAME => [ "ttrss_sid", Config::T_STRING ], + Config::CHECK_FOR_PLUGIN_UPDATES => [ "true", Config::T_BOOL ], + Config::ENABLE_PLUGIN_INSTALLER => [ "true", Config::T_BOOL ], ]; private static $instance; diff --git a/classes/pref/prefs.php b/classes/pref/prefs.php index 484f7734b..275f41656 100644 --- a/classes/pref/prefs.php +++ b/classes/pref/prefs.php @@ -8,6 +8,15 @@ class Pref_Prefs extends Handler_Protected { private $pref_help_bottom = []; private $pref_blacklist = []; + const PI_RES_ALREADY_INSTALLED = "PI_RES_ALREADY_INSTALLED"; + const PI_RES_SUCCESS = "PI_RES_SUCCESS"; + const PI_ERR_NO_CLASS = "PI_ERR_NO_CLASS"; + const PI_ERR_NO_INIT_PHP = "PI_ERR_NO_INIT_PHP"; + const PI_ERR_EXEC_FAILED = "PI_ERR_EXEC_FAILED"; + const PI_ERR_NO_TEMPDIR = "PI_ERR_NO_TEMPDIR"; + const PI_ERR_PLUGIN_NOT_FOUND = "PI_ERR_PLUGIN_NOT_FOUND"; + const PI_ERR_NO_WORKDIR = "PI_ERR_NO_WORKDIR"; + function csrf_ignore($method) { $csrf_ignored = array("index", "updateself", "otpqrcode"); @@ -907,7 +916,7 @@ class Pref_Prefs extends Handler_Protected { } - = 10) { ?> + = 10) { ?> @@ -963,6 +972,13 @@ class Pref_Prefs extends Handler_Protected { = \Controls\icon("update") ?> = __("Update local plugins") ?> + + + + @@ -1179,8 +1195,144 @@ class Pref_Prefs extends Handler_Protected { return $rv; } - function checkForPluginUpdates() { + // https://gist.github.com/mindplay-dk/a4aad91f5a4f1283a5e2#gistcomment-2036828 + private function _recursive_rmdir(string $dir, bool $keep_root = false) { + // Handle bad arguments. + if (empty($dir) || !file_exists($dir)) { + return true; // No such file/dir$dir exists. + } elseif (is_file($dir) || is_link($dir)) { + return unlink($dir); // Delete file/link. + } + + // Delete all children. + $files = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($files as $fileinfo) { + $action = $fileinfo->isDir() ? 'rmdir' : 'unlink'; + if (!$action($fileinfo->getRealPath())) { + return false; // Abort due to the failure. + } + } + + return $keep_root ? true : rmdir($dir); + } + + // https://stackoverflow.com/questions/7153000/get-class-name-from-file + private function _get_class_name_from_file($file) { + $tokens = token_get_all(file_get_contents($file)); + + for ($i = 0; $i < count($tokens); $i++) { + if (isset($tokens[$i][0]) && $tokens[$i][0] == T_CLASS) { + for ($j = $i+1; $j < count($tokens); $j++) { + if (isset($tokens[$j][1]) && $tokens[$j][1] != " ") { + return $tokens[$j][1]; + } + } + } + } + } + + function installPlugin() { + if ($_SESSION["access_level"] >= 10 && Config::get(Config::ENABLE_PLUGIN_INSTALLER)) { + $plugin_name = clean($_REQUEST['plugin']); + $all_plugins = $this->_get_available_plugins(); + $plugin_dir = dirname(dirname(__DIR__)) . "/plugins.local"; + + $work_dir = "$plugin_dir/plugin-installer"; + + $rv = [ ]; + + if (is_dir($work_dir) || mkdir($work_dir)) { + foreach ($all_plugins as $plugin) { + if ($plugin['name'] == $plugin_name) { + + $tmp_dir = tempnam($work_dir, $plugin_name); + + if (file_exists($tmp_dir)) { + unlink($tmp_dir); + + $pipes = []; + + $descriptorspec = [ + 1 => ["pipe", "w"], // STDOUT + 2 => ["pipe", "w"], // STDERR + ]; + + $proc = proc_open("git clone " . escapeshellarg($plugin['clone_url']) . " " . $tmp_dir, + $descriptorspec, $pipes, sys_get_temp_dir()); + + $status = 0; + + if (is_resource($proc)) { + $rv["stdout"] = stream_get_contents($pipes[1]); + $rv["stderr"] = stream_get_contents($pipes[2]); + $status = proc_close($proc); + $rv["git_status"] = $status; + + // yeah I know about mysterious RC = -1 + if (file_exists("$tmp_dir/init.php")) { + $class_name = strtolower(basename($this->_get_class_name_from_file("$tmp_dir/init.php"))); + + if ($class_name) { + $dst_dir = "$plugin_dir/$class_name"; + + if (is_dir($dst_dir)) { + $rv['result'] = self::PI_RES_ALREADY_INSTALLED; + } else { + if (rename($tmp_dir, "$plugin_dir/$class_name")) { + $rv['result'] = self::PI_RES_SUCCESS; + } + } + } else { + $rv['result'] = self::PI_ERR_NO_CLASS; + } + } else { + $rv['result'] = self::PI_ERR_NO_INIT_PHP; + } + + } else { + $rv['result'] = self::PI_ERR_EXEC_FAILED; + } + } else { + $rv['result'] = self::PI_ERR_NO_TEMPDIR; + } + + // cleanup after failure + if ($tmp_dir && is_dir($tmp_dir)) { + $this->_recursive_rmdir($tmp_dir); + } + + break; + } + } + + if (empty($rv['result'])) + $rv['result'] = self::PI_ERR_PLUGIN_NOT_FOUND; + + } else { + $rv["result"] = self::PI_ERR_NO_WORKDIR; + } + + print json_encode($rv); + } + } + + private function _get_available_plugins() { + if ($_SESSION["access_level"] >= 10 && Config::get(Config::ENABLE_PLUGIN_INSTALLER)) { + return json_decode(UrlHelper::fetch(['url' => 'https://tt-rss.org/plugins.json']), true); + } + } + function getAvailablePlugins() { if ($_SESSION["access_level"] >= 10) { + print json_encode($this->_get_available_plugins()); + } + } + + function checkForPluginUpdates() { + if ($_SESSION["access_level"] >= 10 && Config::get(Config::CHECK_FOR_UPDATES) && Config::get(Config::CHECK_FOR_PLUGIN_UPDATES)) { $plugin_name = $_REQUEST["name"] ?? ""; $root_dir = dirname(dirname(__DIR__)); # we're in classes/pref/ diff --git a/js/CommonFilters.js b/js/CommonFilters.js index 0b907e2ba..606cf2076 100644 --- a/js/CommonFilters.js +++ b/js/CommonFilters.js @@ -115,7 +115,7 @@ const Filters = { const li = document.createElement('li'); li.addClassName("rule"); - li.innerHTML = `${App.FormFields.checkbox_tag("", false, {onclick: 'Lists.onRowChecked(this)'})} + li.innerHTML = `${App.FormFields.checkbox_tag("", false, "", {onclick: 'Lists.onRowChecked(this)'})} ${reply} ${App.FormFields.hidden_tag("rule[]", rule)}`; @@ -147,7 +147,7 @@ const Filters = { const li = document.createElement('li'); li.addClassName("action"); - li.innerHTML = `${App.FormFields.checkbox_tag("", false, {onclick: 'Lists.onRowChecked(this)'})} + li.innerHTML = `${App.FormFields.checkbox_tag("", false, "", {onclick: 'Lists.onRowChecked(this)'})} ${reply} ${App.FormFields.hidden_tag("action[]", action)}`; diff --git a/js/PrefHelpers.js b/js/PrefHelpers.js index fc59ebb70..5658ce9b0 100644 --- a/js/PrefHelpers.js +++ b/js/PrefHelpers.js @@ -349,10 +349,128 @@ const Helpers = { } }); }, + install: function() { + const dialog = new fox.SingleUseDialog({ + PI_RES_ALREADY_INSTALLED: "PI_RES_ALREADY_INSTALLED", + PI_RES_SUCCESS: "PI_RES_SUCCESS", + PI_ERR_NO_CLASS: "PI_ERR_NO_CLASS", + PI_ERR_NO_INIT_PHP: "PI_ERR_NO_INIT_PHP", + PI_ERR_EXEC_FAILED: "PI_ERR_EXEC_FAILED", + PI_ERR_NO_TEMPDIR: "PI_ERR_NO_TEMPDIR", + PI_ERR_PLUGIN_NOT_FOUND: "PI_ERR_PLUGIN_NOT_FOUND", + PI_ERR_NO_WORKDIR: "PI_ERR_NO_WORKDIR", + title: __("List of plugins"), + need_refresh: false, + onHide: function() { + if (this.need_refresh) { + Helpers.Prefs.refresh(); + } + }, + performInstall: function(plugin) { + + const install_dialog = new fox.SingleUseDialog({ + title: __("Plugin installer"), + content: ` +
${reply.stderr}` : ''} + ${reply.stdour ? `
${reply.stdout}` : ''} +
+ ${App.FormFields.icon("error_outline") + " " + __("Exited with RC: %d").replace("%d", reply.git_status)} +
+${plugin.description}
+ + ${App.FormFields.button_tag(__('Install plugin'), "", {class: 'alt-primary', + onclick: `App.dialogOf(this).performInstall("${App.escapeHtml(plugin.name)}")`})} + +