summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAndrew Dolgov <[email protected]>2021-03-03 19:07:39 +0300
committerAndrew Dolgov <[email protected]>2021-03-03 19:07:39 +0300
commitcb7f322f09fb8ed9be95978db82f99a2e8a4661b (patch)
tree7c84b6ba264fd07a3a5105ce946390a5c5554dad
parent06cb181f7315b709d9d8ab926f7607f3227ad168 (diff)
add basic plugin installer (uses tt-rss.org)
-rw-r--r--classes/config.php4
-rw-r--r--classes/pref/prefs.php156
-rw-r--r--js/CommonFilters.js4
-rw-r--r--js/PrefHelpers.js120
-rwxr-xr-xjs/common.js59
5 files changed, 328 insertions, 15 deletions
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 {
}
</script>
- <?php if (Config::get(Config::CHECK_FOR_UPDATES) && $_SESSION["access_level"] >= 10) { ?>
+ <?php if (Config::get(Config::CHECK_FOR_UPDATES) && Config::get(Config::CHECK_FOR_PLUGIN_UPDATES) && $_SESSION["access_level"] >= 10) { ?>
<script type="dojo/method" event="onShow" args="evt">
Helpers.Plugins.checkForUpdate();
</script>
@@ -963,6 +972,13 @@ class Pref_Prefs extends Handler_Protected {
<?= \Controls\icon("update") ?>
<?= __("Update local plugins") ?>
</button>
+
+ <?php if (Config::get(Config::ENABLE_PLUGIN_INSTALLER)) { ?>
+ <button dojoType='dijit.form.Button' onclick="Helpers.Plugins.install()">
+ <?= \Controls\icon("add") ?>
+ <?= __("Install plugin") ?>
+ </button>
+ <?php } ?>
<?php } ?>
</div>
</div>
@@ -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)'})}
<span class="name" onclick='App.dialogOf(this).onRuleClicked(this)'>${reply}</span>
<span class="payload" >${App.FormFields.hidden_tag("rule[]", rule)}</span>`;
@@ -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)'})}
<span class="name" onclick='App.dialogOf(this).onActionClicked(this)'>${reply}</span>
<span class="payload">${App.FormFields.hidden_tag("action[]", action)}</span>`;
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: `
+ <ul class="panel panel-scrollable contents">
+ <li class='text-center'>${__("Installing %s, please wait...").replace("%s", plugin)}</li>
+ </ul>
+
+ <footer class='text-center'>
+ ${App.FormFields.submit_tag(__("Close this window"))}
+ </footer>`
+ });
+
+ 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 = `<li class='text-center text-error'>${__("Operation failed: check event log.")}</li>`;
+ } else {
+ switch (reply.result) {
+ case dialog.PI_RES_SUCCESS:
+ container.innerHTML = `<li class='text-success text-center'>${__("Plugin has been installed.")}</li>`
+ dialog.need_refresh = true;
+ break;
+ case dialog.PI_RES_ALREADY_INSTALLED:
+ container.innerHTML = `<li class='text-success text-center'>${__("Plugin is already installed.")}</li>`
+ break;
+ default:
+ container.innerHTML = `
+ <li>
+ <h3 style="margin-top: 0">${plugin}</h3>
+ ${reply.stderr ? `<pre class="small text-error">${reply.stderr}</pre>` : ''}
+ ${reply.stdour ? `<pre class="small text-success">${reply.stdout}</pre>` : ''}
+ <p class="small">
+ ${App.FormFields.icon("error_outline") + " " + __("Exited with RC: %d").replace("%d", reply.git_status)}
+ </p>
+ </li>
+ `;
+ }
+ }
+ });
+ });
+
+ install_dialog.show();
+
+ },
+ refresh: function() {
+ const container = dialog.domNode.querySelector(".contents");
+ container.innerHTML = `<li class='text-center'>${__("Looking for plugins...")}</li>`;
+
+ xhr.json("backend.php", {op: "pref-prefs", method: "getAvailablePlugins"}, (reply) => {
+
+ if (!reply) {
+ container.innerHTML = `<li class='text-center text-error'>${__("Operation failed: check event log.")}</li>`;
+ } else {
+ container.innerHTML = "";
+
+ reply.forEach((plugin) => {
+ container.innerHTML += `
+ <li data-row-value="${App.escapeHtml(plugin.name)}">
+ <h3 style="margin-top: 0">${plugin.name}
+ <a target="_blank" href="${App.escapeHtml(plugin.html_url)}">
+ ${App.FormFields.icon("open_in_new_window")}
+ </a>
+ </h3>
+
+ <p>${plugin.description}</p>
+
+ ${App.FormFields.button_tag(__('Install plugin'), "", {class: 'alt-primary',
+ onclick: `App.dialogOf(this).performInstall("${App.escapeHtml(plugin.name)}")`})}
+
+ <hr/>
+ </li>
+ `
+ });
+
+ dojo.parser.parse(container);
+ }
+ });
+ },
+ content: `
+ <ul class="panel panel-scrollable contents"> </ul>
+
+ <footer>
+ ${App.FormFields.button_tag(__("Refresh"), "", {class: 'alt-primary', onclick: 'App.dialogOf(this).refresh()'})}
+ ${App.FormFields.cancel_dialog_tag(__("Close"))}
+ </footer>
+ `,
+ });
+
+ 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 (typeof elem == "string")
+ elem = document.getElementById(elem);
+
+ elem.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]*?-/, "");
+ 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));
+ if (!isNaN(rowId))
+ rv.push(parseInt(rowId));
+ }
}
});