Merge pull request #1717 from RaspAP/feat/plugin-manager

Plugin Manager UI
This commit is contained in:
Bill Zimmerman 2025-02-08 08:31:07 +01:00 committed by GitHub
commit 32e191e55d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 984 additions and 50 deletions

2
.gitignore vendored
View File

@ -3,7 +3,7 @@ node_modules
yarn-error.log
*.swp
includes/config.php
plugins/
rootCA.pem
vendor
.env
locale/**/*.mo

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "plugins"]
path = plugins
url = https://github.com/RaspAP/plugins

View File

@ -0,0 +1,29 @@
<?php
require '../../includes/csrf.php';
require_once '../../includes/session.php';
require_once '../../includes/config.php';
require_once '../../src/RaspAP/Auth/HTTPAuth.php';
require_once '../../includes/authenticate.php';
require_once '../../src/RaspAP/Plugins/PluginInstaller.php';
$pluginInstaller = \RaspAP\Plugins\PluginInstaller::getInstance();
$plugin_uri = $_POST['plugin_uri'] ?? null;
$plugin_version = $_POST['plugin_version'] ?? null;
if (isset($plugin_uri) && isset($plugin_version)) {
$archiveUrl = rtrim($plugin_uri, '/') . '/archive/refs/tags/' . $plugin_version .'.zip';
try {
$return = $pluginInstaller->installPlugin($archiveUrl);
echo json_encode($return);
} catch (Exception $e) {
http_response_code(422); // Unprocessable Content
echo json_encode(['error' => $e->getMessage()]);
}
} else {
http_response_code(400); // Bad Request
echo json_encode(['error' => 'Plugin URI and version are required']);
exit;
}

View File

@ -377,3 +377,14 @@ button > i.fas {
border: 1px solid #ced4da;
}
textarea.plugin-log {
width: 100%;
height: 150px;
resize: none;
border: 1px solid #dee2e6;
border-radius: 0.25rem;
padding: 0.5rem;
background-color: #f8f9fa;
font-family: monospace;
font-size: 0.9rem;
}

View File

@ -468,6 +468,120 @@ $('#js-sys-reboot, #js-sys-shutdown').on('click', function (e) {
});
});
$('#install-user-plugin').on('shown.bs.modal', function (e) {
var button = $(e.relatedTarget);
var manifestData = button.data('plugin-manifest');
var installed = button.data('plugin-installed');
if (manifestData) {
$('#plugin-uri').html(manifestData.plugin_uri
? `<a href="${manifestData.plugin_uri}" target="_blank">${manifestData.plugin_uri}</a>`
: 'Unknown'
);
$('#plugin-icon').attr('class', `${manifestData.icon || 'fas fa-plug'} link-secondary h5 me-2`);
$('#plugin-name').text(manifestData.name || 'Unknown');
$('#plugin-version').text(manifestData.version || 'Unknown');
$('#plugin-description').text(manifestData.description || 'No description provided');
$('#plugin-author').html(manifestData.author
? manifestData.author + (manifestData.author_uri
? ` (<a href="${manifestData.author_uri}" target="_blank">profile</a>)` : '') : 'Unknown'
);
$('#plugin-license').text(manifestData.license || 'Unknown');
$('#plugin-locale').text(manifestData.default_locale || 'Unknown');
$('#plugin-configuration').html(formatProperty(manifestData.configuration || {}));
$('#plugin-dependencies').html(formatProperty(manifestData.dependencies || {}));
$('#plugin-sudoers').html(formatProperty(manifestData.sudoers || []));
$('#plugin-user-name').html(manifestData.user_nonprivileged.name || 'None');
}
if (installed) {
$('#js-install-plugin-confirm').html('OK');
} else {
$('#js-install-plugin-confirm').html('Install now');
}
});
$('#js-install-plugin-confirm').on('click', function (e) {
var progressText = $('#js-install-plugin-confirm').attr('data-message');
var successHtml = $('#plugin-install-message').attr('data-message');
var successText = $('<div>').text(successHtml).text();
var pluginUri = $('#plugin-uri a').attr('href');
var pluginVersion = $('#plugin-version').text();
var csrfToken = $('meta[name=csrf_token]').attr('content');
$("#install-user-plugin").modal('hide');
if ($('#js-install-plugin-confirm').text() === 'Install now') {
$("#install-plugin-progress").modal('show');
$.post(
'ajax/plugins/do_plugin_install.php',
{
'plugin_uri': pluginUri,
'plugin_version': pluginVersion,
'csrf_token': csrfToken
},
function (data) {
setTimeout(function () {
response = JSON.parse(data);
if (response === true) {
$('#plugin-install-message').contents().first().text(successText);
$('#plugin-install-message')
.find('i')
.removeClass('fas fa-cog fa-spin link-secondary')
.addClass('fas fa-check');
$('#js-install-plugin-ok').removeAttr("disabled");
} else {
const errorMessage = jsonData.error || 'An unknown error occurred.';
var errorLog = '<textarea class="plugin-log text-secondary" readonly>' + errorMessage + '</textarea>';
$('#plugin-install-message')
.contents()
.first()
.replaceWith('An error occurred installing the plugin:');
$('#plugin-install-message').append(errorLog);
$('#plugin-install-message').find('i').removeClass('fas fa-cog fa-spin link-secondary');
$('#js-install-plugin-ok').removeAttr("disabled");
}
}, 200);
}
).fail(function (xhr) {
const jsonData = JSON.parse(xhr.responseText);
const errorMessage = jsonData.error || 'An unknown error occurred.';
$('#plugin-install-message')
.contents()
.first()
.replaceWith('An error occurred installing the plugin:');
var errorLog = '<textarea class="plugin-log text-secondary" readonly>' + errorMessage + '</textarea>';
$('#plugin-install-message').append(errorLog);
$('#plugin-install-message').find('i').removeClass('fas fa-cog fa-spin link-secondary');
$('#js-install-plugin-ok').removeAttr("disabled");
});
}
});
$('#js-install-plugin-ok').on('click', function (e) {
$("#install-plugin-progress").modal('hide');
window.location.reload();
});
function formatProperty(prop) {
if (Array.isArray(prop)) {
if (typeof prop[0] === 'object') {
return prop.map(item => {
return Object.entries(item)
.map(([key, value]) => `${key}: ${value}`)
.join('<br/>');
}).join('<br/><br/>');
}
return prop.map(line => `${line}<br/>`).join('');
}
if (typeof prop === 'object') {
return Object.entries(prop)
.map(([key, value]) => `${key}: ${value}`)
.join('<br/>');
}
return prop || 'None';
}
$(document).ready(function(){
$("#PanelManual").hide();
$('.ip_address').mask('0ZZ.0ZZ.0ZZ.0ZZ', {
@ -507,7 +621,6 @@ $('#wg-upload,#wg-manual').on('click', function (e) {
}
});
// Add the following code if you want the name of the file appear on select
$(".custom-file-input").on("change", function() {
var fileName = $(this).val().split("\\").pop();
$(this).siblings(".custom-file-label").addClass("selected").html(fileName);

View File

@ -63,6 +63,7 @@ define('RASPI_VNSTAT_ENABLED', true);
define('RASPI_SYSTEM_ENABLED', true);
define('RASPI_MONITOR_ENABLED', false);
define('RASPI_RESTAPI_ENABLED', false);
define('RASPI_PLUGINS_ENABLED', true);
// Locale settings
define('LOCALE_ROOT', 'locale');

View File

@ -65,6 +65,7 @@ $defaults = [
'RASPI_SYSTEM_ENABLED' => true,
'RASPI_MONITOR_ENABLED' => false,
'RASPI_RESTAPI_ENABLED' => false,
'RASPI_PLUGINS_ENABLED' => true,
// Locale settings
'LOCALE_ROOT' => 'locale',

View File

@ -1040,3 +1040,25 @@ function renderStatus($hostapd_led, $hostapd_status, $memused_led, $memused, $cp
<?php
}
/**
* Executes a callback with a timeout
*
* @param callable $callback function to execute
* @param int $interval timeout in milliseconds
* @return mixed result of the callback
* @throws \Exception if the execution exceeds the timeout or an error occurs
*/
function callbackTimeout(callable $callback, int $interval)
{
$startTime = microtime(true); // use high-resolution timer
$result = $callback();
$elapsed = (microtime(true) - $startTime) * 1000;
if ($elapsed > $interval) {
throw new \Exception('Operation timed out');
}
return $result;
}

View File

@ -9,6 +9,7 @@ require_once 'config.php';
function DisplaySystem(&$extraFooterScripts)
{
$status = new \RaspAP\Messages\StatusMessage;
$pluginInstaller = \RaspAP\Plugins\PluginInstaller::getInstance();
if (isset($_POST['SaveLanguage'])) {
if (isset($_POST['locale'])) {
@ -85,53 +86,22 @@ function DisplaySystem(&$extraFooterScripts)
$kernel = $system->kernelVersion();
$systime = $system->systime();
$revision = $system->rpiRevision();
// mem used
// memory use
$memused = $system->usedMemory();
$memused_status = "primary";
if ($memused > 90) {
$memused_status = "danger";
$memused_led = "service-status-down";
} elseif ($memused > 75) {
$memused_status = "warning";
$memused_led = "service-status-warn";
} elseif ($memused > 0) {
$memused_status = "success";
$memused_led = "service-status-up";
}
$memStatus = getMemStatus($memused);
$memused_status = $memStatus['status'];
$memused_led = $memStatus['led'];
// cpu load
$cpuload = $system->systemLoadPercentage();
if ($cpuload > 90) {
$cpuload_status = "danger";
} elseif ($cpuload > 75) {
$cpuload_status = "warning";
} elseif ($cpuload >= 0) {
$cpuload_status = "success";
}
$cpuload_status = getCPULoadStatus($cpuload);
// cpu temp
$cputemp = $system->systemTemperature();
if ($cputemp > 70) {
$cputemp_status = "danger";
$cputemp_led = "service-status-down";
} elseif ($cputemp > 50) {
$cputemp_status = "warning";
$cputemp_led = "service-status-warn";
} else {
$cputemp_status = "success";
$cputemp_led = "service-status-up";
}
// hostapd status
$hostapd = $system->hostapdStatus();
if ($hostapd[0] == 1) {
$hostapd_status = "active";
$hostapd_led = "service-status-up";
} else {
$hostapd_status = "inactive";
$hostapd_led = "service-status-down";
}
$cpuStatus = getCPUTempStatus($cputemp);
$cputemp_status = $cpuStatus['status'];
$cputemp_led = $cpuStatus['led'];
// theme options
$themes = [
@ -147,6 +117,9 @@ function DisplaySystem(&$extraFooterScripts)
$extraFooterScripts[] = array('src'=>'app/js/huebee.js', 'defer'=>false);
$logLimit = isset($_SESSION['log_limit']) ? $_SESSION['log_limit'] : RASPI_LOG_SIZE_LIMIT;
$plugins = $pluginInstaller->getUserPlugins();
$pluginsTable = $pluginInstaller->getHTMLPluginsTable($plugins);
echo renderTemplate("system", compact(
"arrLocales",
"status",
@ -167,11 +140,62 @@ function DisplaySystem(&$extraFooterScripts)
"cputemp",
"cputemp_status",
"cputemp_led",
"hostapd",
"hostapd_status",
"hostapd_led",
"themes",
"selectedTheme",
"logLimit"
"logLimit",
"pluginsTable"
));
}
function getMemStatus($memused): array
{
$memused_status = "primary";
$memused_led = "";
if ($memused > 90) {
$memused_status = "danger";
$memused_led = "service-status-down";
} elseif ($memused > 75) {
$memused_status = "warning";
$memused_led = "service-status-warn";
} elseif ($memused > 0) {
$memused_status = "success";
$memused_led = "service-status-up";
}
return [
'status' => $memused_status,
'led' => $memused_led
];
}
function getCPULoadStatus($cpuload): string
{
if ($cpuload > 90) {
$status = "danger";
} elseif ($cpuload > 75) {
$status = "warning";
} elseif ($cpuload >= 0) {
$status = "success";
}
return $status;
}
function getCPUTempStatus($cputemp): array
{
if ($cputemp > 70) {
$cputemp_status = "danger";
$cputemp_led = "service-status-down";
} elseif ($cputemp > 50) {
$cputemp_status = "warning";
$cputemp_led = "service-status-warn";
} else {
$cputemp_status = "success";
$cputemp_led = "service-status-up";
}
return [
'status' => $cputemp_status,
'led' => $cputemp_led
];
}

View File

@ -51,6 +51,7 @@ function _install_raspap() {
_download_latest_files
_change_file_ownership
_create_hostapd_scripts
_create_plugin_scripts
_create_lighttpd_scripts
_install_lighttpd_configs
_default_configuration
@ -313,6 +314,19 @@ function _create_hostapd_scripts() {
_install_status 0
}
# Generate plugin helper scripts
function _create_plugin_scripts() {
_install_log "Creating plugin helper scripts"
sudo mkdir $raspap_dir/plugins || _install_status 1 "Unable to create directory '$raspap_dir/plugins'"
# Copy plugin helper script
sudo cp "$webroot_dir/installers/"plugin_helper.sh "$raspap_dir/plugins" || _install_status 1 "Unable to move plugin script"
# Change ownership and permissions of plugin script
sudo chown -c root:root "$raspap_dir/plugins/"*.sh || _install_status 1 "Unable change owner and/or group"
sudo chmod 750 "$raspap_dir/plugins/"*.sh || _install_status 1 "Unable to change file permissions"
_install_status 0
}
# Generate lighttpd service control scripts
function _create_lighttpd_scripts() {
_install_log "Creating lighttpd control scripts"
@ -584,14 +598,14 @@ function _download_latest_files() {
if [ "$repo" == "RaspAP/raspap-insiders" ]; then
if [ -n "$username" ] && [ -n "$acctoken" ]; then
insiders_source_url="https://${username}:${acctoken}@github.com/$repo"
git clone --branch $branch --depth 1 -c advice.detachedHead=false $insiders_source_url $source_dir || clone=false
git clone --branch $branch --depth 1 --recurse-submodules -c advice.detachedHead=false $insiders_source_url $source_dir || clone=false
else
_install_status 3
echo "Insiders please read this: https://docs.raspap.com/insiders/#authentication"
fi
fi
if [ -z "$insiders_source_url" ]; then
git clone --branch $branch --depth 1 -c advice.detachedHead=false $git_source_url $source_dir || clone=false
git clone --branch $branch --depth 1 --recurse-submodules -c advice.detachedHead=false $git_source_url $source_dir || clone=false
fi
if [ "$clone" = false ]; then
_install_status 1 "Unable to download files from GitHub"

113
installers/plugin_helper.sh Executable file
View File

@ -0,0 +1,113 @@
#!/bin/bash
#
# PluginInstaller helper for RaspAP
# @author billz
# license: GNU General Public License v3.0
# Exit on error
set -o errexit
readonly raspap_user="www-data"
[ $# -lt 1 ] && { echo "Usage: $0 <action> [parameters...]"; exit 1; }
action="$1" # action to perform
shift 1
case "$action" in
"sudoers")
[ $# -ne 1 ] && { echo "Usage: $0 sudoers <file>"; exit 1; }
file="$1"
plugin_name=$(basename "$file")
dest="/etc/sudoers.d/${plugin_name}"
mv "$file" "$dest" || { echo "Error: Failed to move $file to $dest."; exit 1; }
chown root:root "$dest" || { echo "Error: Failed to set ownership for $dest."; exit 1; }
chmod 0440 "$dest" || { echo "Error: Failed to set permissions for $dest."; exit 1; }
echo "OK"
;;
"packages")
[ $# -lt 1 ] && { echo "Usage: $0 packages <apt_packages...>"; exit 1; }
echo "Installing APT packages..."
for package in "$@"; do
echo "Installing package: $package"
apt-get install -y "$package" || { echo "Error: Failed to install $package."; exit 1; }
done
echo "OK"
;;
"user")
[ $# -lt 2 ] && { echo "Usage: $0 user <username> <password>."; exit 1; }
username=$1
password=$2
if id "$username" &>/dev/null; then # user already exists
echo "OK"
exit 0
fi
# create the user without shell access
useradd -r -s /bin/false "$username"
# set password non-interactively
echo "$username:$password" | chpasswd
echo "OK"
;;
"config")
[ $# -lt 2 ] && { echo "Usage: $0 config <source> <destination>"; exit 1; }
source=$1
destination=$2
if [ ! -f "$source" ]; then
echo "Source file $source does not exist."
exit 1
fi
mkdir -p "$(dirname "$destination")"
cp "$source" "$destination"
echo "OK"
;;
"plugin")
[ $# -lt 2 ] && { echo "Usage: $0 plugin <source> <destination>"; exit 1; }
source=$1
destination=$2
if [ ! -d "$source" ]; then
echo "Source directory $source does not exist."
exit 1
fi
plugin_dir=$(dirname "$destination")
if [ ! -d "$lugin_dir" ]; then
mkdir -p "$plugin_dir"
fi
cp -R "$source" "$destination"
chown -R $raspap_user:$raspap_user "$plugin_dir"
echo "OK"
;;
*)
echo "Invalid action: $action"
echo "Usage: $0 <action> [parameters...]"
echo "Actions:"
echo " sudoers <file> Install a sudoers file"
echo " packages <packages> Install aptitude package(s)"
echo " user <name> <password> Add user non-interactively"
echo " config <source <destination> Applies a config file"
echo " plugin <source <destination> Copies a plugin directory"
exit 1
;;
esac

View File

@ -78,3 +78,5 @@ www-data ALL=(ALL) NOPASSWD:/bin/rm /etc/wireguard/wg-*.key
www-data ALL=(ALL) NOPASSWD:/usr/sbin/netplan
www-data ALL=(ALL) NOPASSWD:/bin/truncate -s 0 /tmp/*.log,/bin/truncate -s 0 /var/log/dnsmasq.log
www-data ALL=(ALL) NOPASSWD:/usr/bin/vnstat *
www-data ALL=(ALL) NOPASSWD:/usr/sbin/visudo -cf *
www-data ALL=(ALL) NOPASSWD:/etc/raspap/plugins/plugin_helper.sh

View File

@ -920,6 +920,81 @@ msgstr "Changing log limit size to %s KB"
msgid "Information provided by raspap.sysinfo"
msgstr "Information provided by raspap.sysinfo"
msgid "The following user plugins are available to extend RaspAP's functionality."
msgstr "The following user plugins are available to extend RaspAP's functionality."
msgid "Choose <strong>Details</strong> for more information and to install a plugin."
msgstr "Choose <strong>Details</strong> for more information and to install a plugin."
msgid "Network error"
msgstr "Network error"
msgid "Unable to load plugins"
msgstr "Unable to load plugins"
msgid "Reload"
msgstr "Reload"
msgid "and try again"
msgstr "and try again"
msgid "Plugins"
msgstr "Plugins"
msgid "Plugin details"
msgstr "Plugin details"
msgid "Name"
msgstr "Name"
msgid "Version"
msgstr "Version"
msgid "Description"
msgstr "Description"
msgid "Plugin source"
msgstr "Plugin source"
msgid "Author"
msgstr "Author"
msgid "License"
msgstr "License"
msgid "Language locale"
msgstr "Language locale"
msgid "Configuration files"
msgstr "Configuration files"
msgid "Dependencies"
msgstr "Dependencies"
msgid "Permissions"
msgstr "Permissions"
msgid "Non-privileged users"
msgstr "Non-privileged users"
msgid "Install now"
msgstr "Install now"
msgid "Installing plugin"
msgstr "Installing plugin"
msgid "Plugin installation in progress..."
msgstr "Plugin installation in progress..."
msgid "Plugin install completed."
msgstr "Plugin install completed."
msgid "Details"
msgstr "Details"
msgid "Installed"
msgstr "Installed"
#: includes/data_usage.php
msgid "Data usage"
msgstr "Data usage"

1
plugins Submodule

@ -0,0 +1 @@
Subproject commit 825f74edf49ae34705deb4625d3f3c571dc69bda

View File

@ -0,0 +1,421 @@
<?php
/**
* Plugin Installer class
*
* @description Class to handle installation of user plugins
* @author Bill Zimmerman <billzimmerman@gmail.com>
* @license https://github.com/raspap/raspap-webgui/blob/master/LICENSE
*/
declare(strict_types=1);
namespace RaspAP\Plugins;
class PluginInstaller
{
private static $instance = null;
private $pluginName;
private $manifestRaw;
private $tempSudoers;
private $destSudoers;
private $refModules;
private $rootPath;
private $pluginsManifest;
public function __construct()
{
$this->pluginPath = 'plugins';
$this->manifestRaw = '/blob/master/manifest.json?raw=true';
$this->tempSudoers = '/tmp/090_';
$this->destSudoers = '/etc/sudoers.d/';
$this->refModules = '/refs/heads/master/.gitmodules';
$this->rootPath = $_SERVER['DOCUMENT_ROOT'];
$this->pluginsManifest = '/plugins/manifest.json';
}
// Returns a single instance of PluginInstaller
public static function getInstance(): PluginInstaller
{
if (self::$instance === null) {
self::$instance = new PluginInstaller();
}
return self::$instance;
}
/**
* Returns availble user plugin details from a manifest.json file
*
* @return array $plugins
*/
public function getUserPlugins()
{
try {
$manifestPath = $this->rootPath . $this->pluginsManifest;
if (!file_exists($manifestPath)) {
throw new \Exception("Manifest file not found at " . $manifestPath);
}
// decode manifest file contents
$manifestContents = file_get_contents($manifestPath);
$manifestData = json_decode($manifestContents, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \Exception("Error parsing manifest.json: " . json_last_error_msg());
}
// fetch installed plugins
$installedPlugins = $this->getPlugins();
$plugins = [];
foreach ($manifestData as $pluginManifest) {
$installed = false;
// Check if the plugin is installed
foreach ($installedPlugins as $plugin) {
if (str_contains($plugin, $pluginManifest[0]['namespace'])) {
$installed = true;
break;
}
}
$plugins[] = [
'manifest' => $pluginManifest,
'installed' => $installed
];
}
return $plugins;
} catch (\Exception $e) {
error_log("An error occurred: " . $e->getMessage());
throw $e; // re-throw to global ExceptionHandler
return [];
}
}
/**
* Returns an array of installed plugins in pluginPath
*
* @return array $plugins
*/
public function getPlugins(): array
{
$plugins = [];
if (file_exists($this->pluginPath)) {
$directories = scandir($this->pluginPath);
foreach ($directories as $directory) {
$pluginClass = "RaspAP\\Plugins\\$directory\\$directory";
$pluginFile = $this->pluginPath . "/$directory/$directory.php";
if (file_exists($pluginFile) && class_exists($pluginClass)) {
$plugins[] = $pluginClass;
}
}
}
return $plugins;
}
/**
* Retrieves a plugin archive and performs install actions defined in the manifest
*
* @param string $archiveUrl
* @return boolean
* @throws \Exception
*/
public function installPlugin($archiveUrl): bool
{
$tempFile = null;
$extractDir = null;
$pluginDir = null;
try {
list($tempFile, $extractDir, $pluginDir) = $this->getPluginArchive($archiveUrl);
$manifest = $this->parseManifest($pluginDir);
$this->pluginName = preg_replace('/\s+/', '', $manifest['name']);
$rollbackStack = []; // store actions to rollback on failure
try {
if (!empty($manifest['sudoers'])) {
$this->addSudoers($manifest['sudoers']);
$rollbackStack[] = 'removeSudoers';
}
if (!empty($manifest['dependencies'])) {
$this->installDependencies($manifest['dependencies']);
$rollbackStack[] = 'uninstallDependencies';
}
if (!empty($manifest['user_nonprivileged'])) {
$this->createUser($manifest['user_nonprivileged']);
$rollbackStack[] = 'deleteUser';
}
if (!empty($manifest['configuration'])) {
$this->copyConfigFiles($manifest['configuration'], $pluginDir);
$rollbackStack[] = 'removeConfigFiles';
}
$this->copyPluginFiles($pluginDir, $this->rootPath);
$rollbackStack[] = 'removePluginFiles';
return true;
} catch (\Exception $e) {
//$this->rollback($rollbackStack, $manifest, $pluginDir);
throw new \Exception('Installation step failed: ' . $e->getMessage());
error_log('Plugin installation failed: ' . $e->getMessage());
}
} catch (\Exception $e) {
error_log('An error occured: ' .$e->getMessage());
throw new \Exception( $e->getMessage());
//throw $e;
} finally {
if (!empty($tempFile) && file_exists($tempFile)) {
unlink($tempFile);
}
if (!empty($extractDir) && is_dir($extractDir)) {
$this->deleteDir($extractDir);
}
}
}
/**
* Adds sudoers entries to a temp file and copies to /etc/sudoers.d/
*
* @param array $sudoers
*/
private function addSudoers(array $sudoers): void
{
$tmpSudoers = $this->tempSudoers . $this->pluginName;
$destination = $this->destSudoers;
$content = implode("\n", $sudoers);
if (file_put_contents($tmpSudoers, $content) === false) {
throw new \Exception('Failed to update sudoers file.');
}
$cmd = sprintf('sudo visudo -cf %s', escapeshellarg($tmpSudoers));
$return = shell_exec($cmd);
if (strpos(strtolower($return), 'parsed ok') !== false) {
$cmd = sprintf('sudo /etc/raspap/plugins/plugin_helper.sh sudoers %s', escapeshellarg($tmpSudoers));
$return = shell_exec($cmd);
if (strpos(strtolower($return), 'ok') === false) {
throw new \Exception('Plugin helper failed to install sudoers.');
}
} else {
throw new \Exception('Sudoers check failed.');
}
}
/**
* Installs plugin dependencies from the aptitude package repository
*
* @param array $dependencies
*/
private function installDependencies(array $dependencies): void
{
$packages = array_keys($dependencies);
$packageList = implode(' ', $packages);
$cmd = sprintf('sudo /etc/raspap/plugins/plugin_helper.sh packages %s', escapeshellarg($packageList));
$return = shell_exec($cmd);
if (strpos(strtolower($return), 'ok') === false) {
throw new \Exception('Plugin helper failed to install depedencies.');
}
}
/**
* Creates a non-priviledged Linux user
*
* @param array $user
*/
private function createUser(array $user): void
{
if (empty($user['name']) || empty($user['pass'])) {
throw new \InvalidArgumentException('User name or password is missing.');
}
$username = escapeshellarg($user['name']);
$password = escapeshellarg($user['pass']);
$cmd = sprintf('sudo /etc/raspap/plugins/plugin_helper.sh user %s %s', $username, $password);
$return = shell_exec($cmd);
if (strpos(strtolower($return), 'ok') === false) {
throw new \Exception('Plugin helper failed to create user: ' . $user['name']);
}
}
/**
* Copies plugin configuration files to their destination
*
* @param array $configurations
* @param string $pluginDir
*/
private function copyConfigFiles(array $configurations, string $pluginDir): void
{
foreach ($configurations as $config) {
$source = escapeshellarg($pluginDir . DIRECTORY_SEPARATOR . $config['source']);
$destination = escapeshellarg($config['destination']);
$cmd = sprintf('sudo /etc/raspap/plugins/plugin_helper.sh config %s %s', $source, $destination);
$return = shell_exec($cmd);
if (strpos(strtolower($return), 'ok') === false) {
throw new \Exception("Failed to copy configuration file: $source to $destination");
}
}
}
/**
* Copies an extracted plugin directory from /tmp to /plugins
*
* @param string $source
* @param string $destination
*/
private function copyPluginFiles(string $source, string $destination): void
{
$source = escapeshellarg($source);
$destination = escapeshellarg($destination . DIRECTORY_SEPARATOR .$this->pluginPath . DIRECTORY_SEPARATOR . $this->pluginName);
$cmd = sprintf('sudo /etc/raspap/plugins/plugin_helper.sh plugin %s %s', $source, $destination);
$return = shell_exec($cmd);
if (strpos(strtolower($return), 'ok') === false) {
throw new \Exception('Failed to copy plugin files to: ' . $destination);
}
}
/**
* Parses and returns a downloaded plugin manifest
*
* @param string $pluginDir
* @return array json
*/
private function parseManifest($pluginDir): array
{
$manifestPath = $pluginDir . DIRECTORY_SEPARATOR . 'manifest.json';
if (!file_exists($manifestPath)) {
throw new \Exception('manifest.json file not found.');
}
$json = file_get_contents($manifestPath);
$manifest = json_decode($json, true);
if (json_last_error() !== JSON_ERROR_NONE) {
throw new \Exception('Failed to parse manifest.json: ' . json_last_error_msg());
}
return $manifest;
}
/**
* Retrieves a plugin archive and extracts it to /tmp
*
* @param string $archiveUrl
* @return array
* @throws \Exception
*/
private function getPluginArchive(string $archiveUrl): array
{
$tempFile = '';
$extractDir = '';
try {
$tempFile = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('plugin_', true) . '.zip';
$extractDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . uniqid('plugin_', true);
$data = @file_get_contents($archiveUrl); // suppress PHP warnings for better exception handling
if ($data === false) {
$error = error_get_last();
throw new \Exception('Failed to download archive: ' . ($error['message'] ?? 'Unknown error'));
}
file_put_contents($tempFile, $data);
if (!mkdir($extractDir) && !is_dir($extractDir)) {
throw new \Exception('Failed to create temp directory.');
}
$cmd = escapeshellcmd("unzip -o $tempFile -d $extractDir");
$output = shell_exec($cmd);
if ($output === null) {
throw new \Exception('Failed to extract plugin archive.');
}
$extractedDirs = glob($extractDir . DIRECTORY_SEPARATOR . '*', GLOB_ONLYDIR);
if (empty($extractedDirs)) {
throw new \Exception('No directories found in plugin archive.');
}
$pluginDir = $extractedDirs[0];
return [$tempFile, $extractDir, $pluginDir];
} catch (\Exception $e) {
if (!empty($tempFile) && file_exists($tempFile)) {
unlink($tempFile);
}
if (!empty($extractDir) && is_dir($extractDir)) {
rmdir($extractDir);
}
throw new \Exception('Error occurred during plugin archive retrieval: ' . $e->getMessage());
}
}
private function deleteDir(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$items = array_diff(scandir($dir), ['.', '..']);
foreach ($items as $item) {
$itemPath = $dir . DIRECTORY_SEPARATOR . $item;
is_dir($itemPath) ? $this->deleteDir($itemPath) : unlink($itemPath);
}
rmdir($dir);
}
/**
* Returns a list of available plugins formatted as an HTML table
*
* @param array $plugins
* @return string $html
*/
public function getHTMLPluginsTable(array $plugins): string
{
$html = '<table class="table table-striped table-hover">';
$html .= '<thead><tr>';
$html .= '<th scope="col">Name</th>';
$html .= '<th scope="col">Version</th>';
$html .= '<th scope="col">Description</th>';
$html .= '<th scope="col"></th>';
$html .= '</tr></thead><tbody>';
foreach ($plugins as $plugin) {
$manifestData = $plugin['manifest'][0] ?? []; // Access the first manifest entry or default to an empty array
$manifest = htmlspecialchars(json_encode($manifestData), ENT_QUOTES, 'UTF-8');
$installed = $plugin['installed'];
if ($installed === true) {
$button = '<button type="button" class="btn btn-outline btn-primary btn-sm text-nowrap"
name="plugin-details" data-bs-toggle="modal" data-bs-target="#install-user-plugin"
data-plugin-manifest="' .$manifest. '" data-plugin-installed="' .$installed. '"> ' . _("Installed") .'</button>';
} elseif (!RASPI_MONITOR_ENABLED) {
$button = '<button type="button" class="btn btn-outline btn-primary btn-sm text-nowrap"
name="install-plugin" data-bs-toggle="modal" data-bs-target="#install-user-plugin"
data-plugin-manifest="' .$manifest. '"> ' . _("Details") .'</button>';
}
$icon = htmlspecialchars($manifestData['icon'] ?? '');
$pluginUri = htmlspecialchars($manifestData['plugin_uri'] ?? '');
$nameText = htmlspecialchars($manifestData['name'] ?? 'Unknown Plugin');
$name = '<i class="' . $icon . ' link-secondary me-2"></i><a href="'
. $pluginUri
. '" target="_blank">'
. $nameText. '</a>';
$version = htmlspecialchars($manifestData['version'] ?? 'N/A');
$description = htmlspecialchars($manifestData['description'] ?? 'No description available');
$html .= '<tr><td>' .$name. '</td>';
$html .= '<td>' .$version. '</td>';
$html .= '<td>' .$description. '</td>';
$html .= '<td>' .$button. '</td></tr>';
}
$html .= '</tbody></table>';
return $html;
}
}

View File

@ -18,6 +18,9 @@
<li role="presentation" class="nav-item"><a class="nav-link" id="themetab" href="#theme" aria-controls="theme" role="tab" data-bs-toggle="tab"><?php echo _("Theme"); ?></a></li>
<li role="presentation" class="nav-item"><a class="nav-link" id="advancedtab" href="#advanced" aria-controls="advanced" role="tab" data-bs-toggle="tab"><?php echo _("Advanced"); ?></a></li>
<li role="presentation" class="nav-item"><a class="nav-link" id="toolstab" href="#tools" aria-controls="tools" role="tab" data-bs-toggle="tab"><?php echo _("Tools"); ?></a></li>
<?php if (RASPI_PLUGINS_ENABLED) : ?>
<li role="presentation" class="nav-item"><a class="nav-link" id="pluginstab" href="#plugins" aria-controls="plugins" role="tab" data-bs-toggle="tab"><?php echo _("Plugins"); ?></a></li>
<?php endif ?>
</ul>
<!-- Tab panes -->
<div class="tab-content">
@ -26,6 +29,9 @@
<?php echo renderTemplate("system/theme", $__template_data) ?>
<?php echo renderTemplate("system/advanced", $__template_data) ?>
<?php echo renderTemplate("system/tools", $__template_data) ?>
<?php if (RASPI_PLUGINS_ENABLED) : ?>
<?php echo renderTemplate("system/plugins", $__template_data) ?>
<?php endif ?>
</div><!-- /.tab-content -->
</form>
</div><!-- /.card-body -->
@ -105,3 +111,83 @@
</div>
</div>
<!-- modal install-plugin -->
<div class="modal fade" id="install-user-plugin" tabindex="-1" role="dialog" aria-labelledby="ModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg" role="document">
<div class="modal-content">
<div class="modal-header">
<div class="modal-title" id="ModalLabel"><i class="fas fa-plug me-2"></i><?php echo _("Plugin details"); ?></div>
</div>
<div class="modal-body">
<i id="plugin-icon" class="fas fa-plug link-secondary me-2"></i><span id="plugin-name" class="h4 mb-0"></span>
<p id="plugin-description" class="mb-3"></p>
<table class="table table-bordered">
<tbody>
<tr>
<th><?php echo _("Plugin source"); ?></th>
<td><span id="plugin-uri"></span></td>
</tr>
<tr>
<th><?php echo _("Version"); ?></th>
<td><span id="plugin-version"></span></td>
</tr>
<tr>
<th><?php echo _("Author"); ?></th>
<td><span id="plugin-author"></span></td>
</tr>
<tr>
<th><?php echo _("License"); ?></th>
<td><span id="plugin-license"></span></td>
</tr>
<tr>
<th><?php echo _("Language locale"); ?></th>
<td><small><code><span id="plugin-locale"></span></span></code></td>
</tr>
<tr>
<th><?php echo _("Configuration files"); ?></th>
<td><small><code><span id="plugin-configuration" class="mb-0"></span></code></small></td>
</td>
</tr>
<tr>
<th><?php echo _("Dependencies"); ?></th>
<td><small><code><span id="plugin-dependencies" class="mb-0"></span></code></small></td>
</tr>
<tr>
<th><?php echo _("Permissions"); ?></th>
<td><small><code><span id="plugin-sudoers" class="mb-0"></span></code></small></td>
</tr>
<tr>
<th><?php echo _("Non-privileged users"); ?></th>
<td><small><code><span id="plugin-user-name"></span></small></code></p></td>
</tr>
</tbody>
</table>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal"><?php echo _("Cancel"); ?></button>
<button type="button" id="js-install-plugin-confirm" class="btn btn-outline-success btn-activate"></button>
</div>
</div>
</div>
</div>
<!-- modal plugin-install-progress -->
<div class="modal fade" id="install-plugin-progress" tabindex="-1" role="dialog" aria-labelledby="ModalLabel" aria-hidden="true">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<div class="modal-title" id="ModalLabel"><i class="fas fa-download me-2"></i><?php echo _("Installing plugin"); ?></div>
</div>
<div class="modal-body">
<div class="col-md-12 mb-3 mt-1" data-message="<?php echo _("Plugin install completed."); ?>" id="plugin-install-message"><?php echo _("Plugin installation in progress..."); ?><i class="fas fa-cog fa-spin link-secondary ms-2"></i></div>
</div>
<div class="modal-footer">
<button type="button" id="js-install-plugin-ok" class="btn btn-outline-success btn-activate" disabled><?php echo _("OK"); ?></button>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,19 @@
<!-- plugins tab -->
<div role="tabpanel" class="tab-pane" id="plugins">
<h4 class="mt-3"><?php echo _("Plugins") ;?></h4>
<?php echo CSRFTokenFieldTag() ?>
<div class="row">
<div class="form-group col-lg-8 col-md-8">
<label>
<?php echo _("The following user plugins are available to extend RaspAP's functionality."); ?>
</label>
<?php if (!RASPI_MONITOR_ENABLED) : ?>
<div class="small mt-2">
<?php echo _("Choose <strong>Details</strong> for more information and to install a plugin."); ?>
</div>
<?php endif ?>
<?php echo $pluginsTable; ?>
</div>
</div>
</div>

View File

@ -1,4 +1,4 @@
<!-- reset tab -->
<!-- tools tab -->
<div role="tabpanel" class="tab-pane" id="tools">
<h4 class="mt-3"><?php echo _("System tools") ;?></h4>
<?php if (!RASPI_MONITOR_ENABLED) : ?>
@ -36,4 +36,3 @@
<?php endif ?>
</div>