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 { + + + + @@ -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: ` + + + ` + }); + + const tmph = dojo.connect(install_dialog, 'onShow', function () { + dojo.disconnect(tmph); + + const container = install_dialog.domNode.querySelector(".contents"); + + xhr.json("backend.php", {op: "pref-prefs", method: "installPlugin", plugin: plugin}, (reply) => { + if (!reply) { + container.innerHTML = `
  • ${__("Operation failed: check event log.")}
  • `; + } else { + switch (reply.result) { + case dialog.PI_RES_SUCCESS: + container.innerHTML = `
  • ${__("Plugin has been installed.")}
  • ` + dialog.need_refresh = true; + break; + case dialog.PI_RES_ALREADY_INSTALLED: + container.innerHTML = `
  • ${__("Plugin is already installed.")}
  • ` + break; + default: + container.innerHTML = ` +
  • +

    ${plugin}

    + ${reply.stderr ? `
    ${reply.stderr}
    ` : ''} + ${reply.stdour ? `
    ${reply.stdout}
    ` : ''} +

    + ${App.FormFields.icon("error_outline") + " " + __("Exited with RC: %d").replace("%d", reply.git_status)} +

    +
  • + `; + } + } + }); + }); + + install_dialog.show(); + + }, + refresh: function() { + const container = dialog.domNode.querySelector(".contents"); + container.innerHTML = `
  • ${__("Looking for plugins...")}
  • `; + + xhr.json("backend.php", {op: "pref-prefs", method: "getAvailablePlugins"}, (reply) => { + + if (!reply) { + container.innerHTML = `
  • ${__("Operation failed: check event log.")}
  • `; + } else { + container.innerHTML = ""; + + reply.forEach((plugin) => { + container.innerHTML += ` +
  • +

    ${plugin.name} + + ${App.FormFields.icon("open_in_new_window")} + +

    + +

    ${plugin.description}

    + + ${App.FormFields.button_tag(__('Install plugin'), "", {class: 'alt-primary', + onclick: `App.dialogOf(this).performInstall("${App.escapeHtml(plugin.name)}")`})} + +
    +
  • + ` + }); + + dojo.parser.parse(container); + } + }); + }, + content: ` + + + + `, + }); + + const tmph = dojo.connect(dialog, 'onShow', function () { + dojo.disconnect(tmph); + dialog.refresh(); + }); + + dialog.show(); + }, update: function(name = null) { const dialog = new fox.SingleUseDialog({ - title: __("Plugin Updater"), + title: __("Update plugins"), need_refresh: false, plugins_to_update: [], onHide: function() { diff --git a/js/common.js b/js/common.js index 194fdcd9d..9c748f9c5 100755 --- a/js/common.js +++ b/js/common.js @@ -262,8 +262,11 @@ const Lists = { if (row) checked ? row.addClassName("Selected") : row.removeClassName("Selected"); }, - select: function(elemId, selected) { - $(elemId).querySelectorAll("li").forEach((row) => { + select: function(elem, selected) { + if (typeof elem == "string") + elem = document.getElementById(elem); + + elem.querySelectorAll("li").forEach((row) => { const checkNode = row.querySelector(".dijitCheckBox,input[type=checkbox]"); if (checkNode) { const widget = dijit.getEnclosingWidget(checkNode); @@ -278,6 +281,30 @@ const Lists = { } }); }, + getSelected: function(elem) { + const rv = []; + + if (typeof elem == "string") + elem = document.getElementById(elem); + + elem.querySelectorAll("li").forEach((row) => { + if (row.hasClassName("Selected")) { + const rowVal = row.getAttribute("data-row-value"); + + if (rowVal) { + rv.push(rowVal); + } else { + // either older prefix-XXX notation or separate attribute + const rowId = row.getAttribute("data-row-id") || row.id.replace(/^[A-Z]*?-/, ""); + + if (!isNaN(rowId)) + rv.push(parseInt(rowId)); + } + } + }); + + return rv; + } }; /* exported Tables */ @@ -293,8 +320,11 @@ const Tables = { checked ? row.addClassName("Selected") : row.removeClassName("Selected"); }, - select: function(elemId, selected) { - $(elemId).querySelectorAll("tr").forEach((row) => { + select: function(elem, selected) { + if (typeof elem == "string") + elem = document.getElementById(elem); + + elem.querySelectorAll("tr").forEach((row) => { const checkNode = row.querySelector(".dijitCheckBox,input[type=checkbox]"); if (checkNode) { const widget = dijit.getEnclosingWidget(checkNode); @@ -309,16 +339,25 @@ const Tables = { } }); }, - getSelected: function(elemId) { + getSelected: function(elem) { const rv = []; - $(elemId).querySelectorAll("tr").forEach((row) => { - if (row.hasClassName("Selected")) { - // either older prefix-XXX notation or separate attribute - const rowId = row.getAttribute("data-row-id") || row.id.replace(/^[A-Z]*?-/, ""); + if (typeof elem == "string") + elem = document.getElementById(elem); - if (!isNaN(rowId)) - rv.push(parseInt(rowId)); + elem.querySelectorAll("tr").forEach((row) => { + if (row.hasClassName("Selected")) { + const rowVal = row.getAttribute("data-row-value"); + + if (rowVal) { + rv.push(rowVal); + } else { + // either older prefix-XXX notation or separate attribute + const rowId = row.getAttribute("data-row-id") || row.id.replace(/^[A-Z]*?-/, ""); + + if (!isNaN(rowId)) + rv.push(parseInt(rowId)); + } } });