add basic plugin installer (uses tt-rss.org)
This commit is contained in:
parent
06cb181f73
commit
cb7f322f09
|
@ -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;
|
||||
|
|
|
@ -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/
|
||||
|
|
|
@ -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>`;
|
||||
|
||||
|
|
|
@ -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() {
|
||||
|
|
51
js/common.js
51
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,17 +339,26 @@ 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")) {
|
||||
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;
|
||||
|
|
Loading…
Reference in New Issue