diff --git a/app/js/custom.js b/app/js/custom.js index 53ea7ae35..eac5e17bd 100644 --- a/app/js/custom.js +++ b/app/js/custom.js @@ -468,6 +468,95 @@ $('#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 + ? `${manifestData.plugin_uri}` + : '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 + ? ` (profile)` : '') : '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 = $('
').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().replaceWith(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 { + $('#plugin-install-message').contents().first().replaceWith('An error occurred installing the plugin.'); + $('#plugin-install-message').find('i').removeClass('fas fa-cog fa-spin link-secondary'); + $('#js-install-plugin-ok').removeAttr("disabled"); + } + },200); + }); + } +}); + +$('#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('
'); + }).join('

'); + } + return prop.map(line => `${line}
`).join(''); + } + if (typeof prop === 'object') { + return Object.entries(prop) + .map(([key, value]) => `${key}: ${value}`) + .join('
'); + } + return prop || 'None'; +} + $(document).ready(function(){ $("#PanelManual").hide(); $('.ip_address').mask('0ZZ.0ZZ.0ZZ.0ZZ', { @@ -507,7 +596,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); diff --git a/config/config.php b/config/config.php index 2950632db..afd386a20 100755 --- a/config/config.php +++ b/config/config.php @@ -38,6 +38,9 @@ // Constant for the GitHub API latest release endpoint define('RASPI_API_ENDPOINT', 'https://api.github.com/repos/RaspAP/raspap-webgui/releases/latest'); +// Constant for the GitHub plugin submodules URL +define("RASPI_PLUGINS_URL", "https://raw.githubusercontent.com/RaspAP/plugins"); + // Constant for the 5GHz wireless regulatory domain define("RASPI_5GHZ_CHANNEL_MIN", 100); define("RASPI_5GHZ_CHANNEL_MAX", 192); diff --git a/includes/footer.php b/includes/footer.php old mode 100644 new mode 100755 diff --git a/includes/restapi.php b/includes/restapi.php old mode 100644 new mode 100755 diff --git a/includes/system.php b/includes/system.php index 31feaf798..6ef6b74ef 100755 --- a/includes/system.php +++ b/includes/system.php @@ -9,6 +9,7 @@ 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 + ]; +} + diff --git a/installers/common.sh b/installers/common.sh index 472058d92..bd78ef006 100755 --- a/installers/common.sh +++ b/installers/common.sh @@ -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 @@ -298,6 +299,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" diff --git a/installers/plugin_helper.sh b/installers/plugin_helper.sh new file mode 100755 index 000000000..2d946e363 --- /dev/null +++ b/installers/plugin_helper.sh @@ -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 [parameters...]"; exit 1; } + +action="$1" # action to perform +shift 1 + +case "$action" in + + "sudoers") + [ $# -ne 1 ] && { echo "Usage: $0 sudoers "; 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 "; 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 ."; 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 "; 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 "; 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 [parameters...]" + echo "Actions:" + echo " sudoers Install a sudoers file" + echo " packages Install aptitude package(s)" + echo " user Add user non-interactively" + echo " config Applies a config file" + echo " plugin Copies a plugin directory" + exit 1 + ;; +esac diff --git a/installers/raspap.sudoers b/installers/raspap.sudoers index b87fdb803..c770a01b4 100644 --- a/installers/raspap.sudoers +++ b/installers/raspap.sudoers @@ -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 diff --git a/locale/en_US/LC_MESSAGES/messages.mo b/locale/en_US/LC_MESSAGES/messages.mo index fd8a0a655..23d44ab78 100644 Binary files a/locale/en_US/LC_MESSAGES/messages.mo and b/locale/en_US/LC_MESSAGES/messages.mo differ diff --git a/locale/en_US/LC_MESSAGES/messages.po b/locale/en_US/LC_MESSAGES/messages.po index 95fa1281e..b6b6e88b4 100644 --- a/locale/en_US/LC_MESSAGES/messages.po +++ b/locale/en_US/LC_MESSAGES/messages.po @@ -920,6 +920,69 @@ 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 Details for more information and to install a plugin." +msgstr "Choose Details for more information and to install a plugin." + +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" diff --git a/src/RaspAP/Plugins/PluginInstaller.php b/src/RaspAP/Plugins/PluginInstaller.php new file mode 100644 index 000000000..b5ba43af0 --- /dev/null +++ b/src/RaspAP/Plugins/PluginInstaller.php @@ -0,0 +1,451 @@ + + * @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; + + 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']; + } + + // Returns a single instance of PluginInstaller + public static function getInstance(): PluginInstaller + { + if (self::$instance === null) { + self::$instance = new PluginInstaller(); + } + return self::$instance; + } + + /** + * Returns user plugin details from associated manifest.json files + * + * @return array $plugins + */ + public function getUserPlugins() + { + $installedPlugins = $this->getPlugins(); + + try { + $submodules = $this->getSubmodules(RASPI_PLUGINS_URL); + $plugins = []; + foreach ($submodules as $submodule) { + $manifestUrl = $submodule['url'] .$this->manifestRaw; + $manifest = $this->getPluginManifest($manifestUrl); + + if ($manifest) { + $installed = false; + + foreach ($installedPlugins as $plugin) { + if (str_contains($plugin, $manifest['namespace'])) { + $installed = true; + break; + } + } + $plugins[] = [ + 'manifest' => $manifest, + 'installed' => $installed + ]; + } + } + return $plugins; + } catch (\Exception $e) { + echo "An error occured: " .$e->getMessage(); + } + } + + /** + * Retrieves a plugin's associated manifest JSON + * + * @param string $url + * @return array $json + */ + public function getPluginManifest(string $url): ?array + { + $options = [ + 'http' => [ + 'method' => 'GET', + 'follow_location' => 1, + ], + ]; + + $context = stream_context_create($options); + $content = file_get_contents($url, false, $context); + + if ($content === false) { + return null; + } + $json = json_decode($content, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + return null; + } + return $json; + } + + /** + * Returns git submodules for the specified repository + * + * @param string $repoURL + * @return array $submodules + */ + public function getSubmodules(string $repoUrl): array + { + $gitmodulesUrl = $repoUrl .$this->refModules; + $gitmodulesContent = file_get_contents($gitmodulesUrl); + + if ($gitmodulesContent === false) { + throw new \Exception('Unable to fetch .gitmodules file from the repository'); + } + + $submodules = []; + $lines = explode("\n", $gitmodulesContent); + $currentSubmodule = []; + + foreach ($lines as $line) { + $line = trim($line); + + if (strpos($line, '[submodule "') === 0) { + if (!empty($currentSubmodule)) { + $submodules[] = $currentSubmodule; + } + $currentSubmodule = []; + } elseif (strpos($line, 'path = ') === 0) { + $currentSubmodule['path'] = substr($line, strlen('path = ')); + } elseif (strpos($line, 'url = ') === 0) { + $currentSubmodule['url'] = substr($line, strlen('url = ')); + } + } + + if (!empty($currentSubmodule)) { + $submodules[] = $currentSubmodule; + } + + return $submodules; + } + + /** + * 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 + */ + public function installPlugin($archiveUrl): bool + { + 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); + error_log('Plugin installation failed: ' . $e->getMessage()); + return false; + } + + } catch (\Exception $e) { + throw new \Exception('error: ' .$e->getMessage()); + } finally { + // cleanup tmp files + if (file_exists($tempFile)) { + unlink($tempFile); + } + if (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 + */ + private function getPluginArchive(string $archiveUrl): array + { + 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); + + if ($data === false) { + throw new \Exception('Failed to download archive.'); + } + + 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 archive.'); + } + + $extractedDirs = glob($extractDir . DIRECTORY_SEPARATOR . '*', GLOB_ONLYDIR); + if (empty($extractedDirs)) { + throw new \Exception('No directories found in archive.'); + } + $pluginDir = $extractedDirs[0]; + + return [$tempFile, $extractDir, $pluginDir]; + + } catch (\Exception $e) { + throw new \Exception('Error occurred: ' .$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 = ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + + foreach ($plugins as $plugin) { + + $manifest = htmlspecialchars(json_encode($plugin['manifest']), ENT_QUOTES, 'UTF-8'); + $installed = $plugin['installed']; + if ($installed === true ) { + $button = ''; + } else { + $button = ''; + } + $name = '' + . htmlspecialchars($plugin['manifest']['name']). ''; + $html .= ''; + $html .= ''; + $html .= ''; + $html .= ''; + } + $html .= '
NameVersionDescription
' .$name. '' .htmlspecialchars($plugin['manifest']['version']). '' .htmlspecialchars($plugin['manifest']['description']). '' .$button. '
'; + return $html; + } +} + + diff --git a/templates/system.php b/templates/system.php index 29950e39e..1891bda0e 100755 --- a/templates/system.php +++ b/templates/system.php @@ -18,6 +18,7 @@ +
@@ -26,6 +27,7 @@ +
@@ -105,3 +107,83 @@ + + + + + + diff --git a/templates/system/plugins.php b/templates/system/plugins.php new file mode 100644 index 000000000..194e161e0 --- /dev/null +++ b/templates/system/plugins.php @@ -0,0 +1,19 @@ + +
+

+ + +
+
+ +
+ Details for more information and to install a plugin."); ?> +
+ +
+
+ +
+ diff --git a/templates/system/tools.php b/templates/system/tools.php index a30601db6..32eb19ca5 100644 --- a/templates/system/tools.php +++ b/templates/system/tools.php @@ -1,4 +1,4 @@ - +

@@ -36,4 +36,3 @@
-