diff --git a/pluginHolder/urls.py b/pluginHolder/urls.py index b25be6087..f7876162a 100644 --- a/pluginHolder/urls.py +++ b/pluginHolder/urls.py @@ -104,10 +104,11 @@ urlpatterns = [ path('api/revert//', views.revert_plugin, name='revert_plugin'), path('api/debug-plugins/', views.debug_loaded_plugins, name='debug_loaded_plugins'), path('api/check-subscription//', views.check_plugin_subscription, name='check_plugin_subscription'), + path('/settings/', views.plugin_settings_proxy, name='plugin_settings_proxy'), path('/help/', views.plugin_help, name='plugin_help'), ] -# Include each installed plugin's URLs *before* the catch-all so /plugins//settings/ etc. match +# Include each installed plugin's URLs *before* the catch-all so /plugins//... (other than settings/help) match _loaded_plugins = [] _failed_plugins = {} for _plugin_name, _path_parent in _get_installed_plugin_list(): diff --git a/pluginHolder/views.py b/pluginHolder/views.py index 166a4f0f5..8e7d1dee1 100644 --- a/pluginHolder/views.py +++ b/pluginHolder/views.py @@ -54,6 +54,34 @@ RESERVED_PLUGIN_DIRS = frozenset([ 'websiteFunctions', 'aiScanner', 'dns', 'help', 'installed', ]) +def _find_plugin_prefix_in_archive(namelist, plugin_name): + """ + Find the path prefix for a plugin inside a GitHub archive (e.g. repo-main/pluginName/ or repo-main/Category/pluginName/). + Returns (top_level, plugin_prefix) or (None, None) if not found. + """ + top_level = None + for name in namelist: + if '/' in name: + top_level = name.split('/')[0] + break + if not top_level: + return None, None + plugin_name_lower = plugin_name.lower() + # Check every path: find one that has a segment equal to plugin_name (e.g. .../pm2Manager/ or .../snappymailAdmin/) + for name in namelist: + if '/' not in name: + continue + parts = name.split('/') + # parts[0] = top_level, then we need a segment that matches plugin_name + for i in range(1, len(parts)): + if parts[i].lower() == plugin_name_lower: + # Plugin folder is at top_level/parts[1]/.../parts[i]/ + prefix_parts = [top_level] + parts[1:i + 1] + plugin_prefix = '/'.join(prefix_parts) + '/' + return top_level, plugin_prefix + return top_level, None + + def _get_plugin_source_path(plugin_name): """Return the full path to a plugin's source directory, or None if not found.""" for base in PLUGIN_SOURCE_PATHS: @@ -607,19 +635,16 @@ def install_plugin(request, plugin_name): 'error': f'Failed to create zip file for {plugin_name}' }, status=500) - # Copy zip to current directory (pluginInstaller expects it in cwd) + zip_path_abs = os.path.abspath(zip_path) + if not os.path.exists(zip_path_abs): + raise Exception(f'Zip file not found: {zip_path_abs}') original_cwd = os.getcwd() os.chdir(temp_dir) try: - # Verify zip file exists in current directory - zip_file = plugin_name + '.zip' - if not os.path.exists(zip_file): - raise Exception(f'Zip file {zip_file} not found in temp directory') - - # Install using pluginInstaller + # Install using pluginInstaller with explicit zip path (avoids cwd races) try: - pluginInstaller.installPlugin(plugin_name) + pluginInstaller.installPlugin(plugin_name, zip_path=zip_path_abs) except Exception as install_error: # Log the full error for debugging error_msg = str(install_error) @@ -638,8 +663,8 @@ def install_plugin(request, plugin_name): # Verify plugin was actually installed pluginInstalled = '/usr/local/CyberCP/' + plugin_name if not os.path.exists(pluginInstalled): - # Check if files were extracted to root instead - root_files = ['README.md', 'apps.py', 'meta.xml', 'urls.py', 'views.py'] + # Check if plugin files were extracted to root (exclude README.md - main repo has it at root) + root_files = ['apps.py', 'meta.xml', 'urls.py', 'views.py'] found_root_files = [f for f in root_files if os.path.exists(os.path.join('/usr/local/CyberCP', f))] if found_root_files: raise Exception(f'Plugin installation failed: Files extracted to wrong location. Found {found_root_files} in /usr/local/CyberCP/ root instead of {pluginInstalled}/') @@ -1442,29 +1467,10 @@ def upgrade_plugin(request, plugin_name): repo_zip = zipfile.ZipFile(io.BytesIO(repo_zip_data)) namelist = repo_zip.namelist() - # Discover top-level folder (GitHub uses repo-name-branch, e.g. cyberpanel-plugins-main) - top_level = None - for name in namelist: - if '/' in name: - top_level = name.split('/')[0] - break - elif name and not name.endswith('/'): - top_level = name - break + # Find plugin folder (supports flat repo or nested e.g. Category/pluginName) + top_level, plugin_prefix = _find_plugin_prefix_in_archive(namelist, plugin_name) if not top_level: raise Exception('GitHub archive has no recognizable structure') - - # Find plugin folder in ZIP (case-insensitive: repo may have RedisManager vs redisManager) - plugin_prefix = None - plugin_name_lower = plugin_name.lower() - for name in namelist: - if '/' not in name: - continue - parts = name.split('/') - if len(parts) >= 2 and parts[0] == top_level and parts[1].lower() == plugin_name_lower: - # Use the actual casing from the ZIP for reading - plugin_prefix = f'{top_level}/{parts[1]}/' - break if not plugin_prefix: sample = namelist[:15] if len(namelist) > 15 else namelist logging.writeToFile(f"Plugin {plugin_name} not in archive. Top-level={top_level}, sample paths: {sample}") @@ -1495,20 +1501,18 @@ def upgrade_plugin(request, plugin_name): logging.writeToFile(f"Created plugin ZIP: {zip_path}") - # Copy ZIP to current directory (pluginInstaller expects it in cwd) + zip_path_abs = os.path.abspath(zip_path) + if not os.path.exists(zip_path_abs): + raise Exception(f'Zip file not found: {zip_path_abs}') original_cwd = os.getcwd() os.chdir(temp_dir) try: - zip_file = plugin_name + '.zip' - if not os.path.exists(zip_file): - raise Exception(f'Zip file {zip_file} not found in temp directory') + logging.writeToFile(f"Upgrading plugin using pluginInstaller (zip={zip_path_abs})") - logging.writeToFile(f"Upgrading plugin using pluginInstaller") - - # Install using pluginInstaller (this will overwrite existing files) + # Install using pluginInstaller with explicit zip path (this will overwrite existing files) try: - pluginInstaller.installPlugin(plugin_name) + pluginInstaller.installPlugin(plugin_name, zip_path=zip_path_abs) except Exception as install_error: error_msg = str(install_error) logging.writeToFile(f"pluginInstaller.installPlugin raised exception: {error_msg}") @@ -1702,23 +1706,10 @@ def install_from_store(request, plugin_name): repo_zip = zipfile.ZipFile(io.BytesIO(repo_zip_data)) namelist = repo_zip.namelist() - # Discover top-level folder and find plugin (case-insensitive) - top_level = None - for name in namelist: - if '/' in name: - top_level = name.split('/')[0] - break + # Find plugin folder (supports flat repo or nested e.g. Category/pluginName) + top_level, plugin_prefix = _find_plugin_prefix_in_archive(namelist, plugin_name) if not top_level: raise Exception('GitHub archive has no recognizable structure') - plugin_prefix = None - plugin_name_lower = plugin_name.lower() - for name in namelist: - if '/' not in name: - continue - parts = name.split('/') - if len(parts) >= 2 and parts[0] == top_level and parts[1].lower() == plugin_name_lower: - plugin_prefix = f'{top_level}/{parts[1]}/' - break if not plugin_prefix: repo_zip.close() logging.writeToFile(f"Plugin {plugin_name} not found in GitHub repository, trying local source") @@ -1775,21 +1766,20 @@ def install_from_store(request, plugin_name): logging.writeToFile(f"Created plugin ZIP: {zip_path}") - # Copy ZIP to current directory (pluginInstaller expects it in cwd) + if not os.path.exists(zip_path): + raise Exception(f'Zip file not found: {zip_path}') + + # Pass absolute path so extraction does not depend on cwd (installPlugin may change cwd) + zip_path_abs = os.path.abspath(zip_path) original_cwd = os.getcwd() os.chdir(temp_dir) try: - # Verify zip file exists in current directory - zip_file = plugin_name + '.zip' - if not os.path.exists(zip_file): - raise Exception(f'Zip file {zip_file} not found in temp directory') + logging.writeToFile(f"Installing plugin using pluginInstaller (zip={zip_path_abs})") - logging.writeToFile(f"Installing plugin using pluginInstaller") - - # Install using pluginInstaller (direct call, not via command line) + # Install using pluginInstaller with explicit zip path (avoids cwd races) try: - pluginInstaller.installPlugin(plugin_name) + pluginInstaller.installPlugin(plugin_name, zip_path=zip_path_abs) except Exception as install_error: # Log the full error for debugging error_msg = str(install_error) @@ -1808,8 +1798,8 @@ def install_from_store(request, plugin_name): # Verify plugin was actually installed pluginInstalled = '/usr/local/CyberCP/' + plugin_name if not os.path.exists(pluginInstalled): - # Check if files were extracted to root instead - root_files = ['README.md', 'apps.py', 'meta.xml', 'urls.py', 'views.py'] + # Exclude README.md - main CyberPanel repo has it at root + root_files = ['apps.py', 'meta.xml', 'urls.py', 'views.py'] found_root_files = [f for f in root_files if os.path.exists(os.path.join('/usr/local/CyberCP', f))] if found_root_files: raise Exception(f'Plugin installation failed: Files extracted to wrong location. Found {found_root_files} in /usr/local/CyberCP/ root instead of {pluginInstalled}/') @@ -1872,6 +1862,38 @@ def debug_loaded_plugins(request): except Exception as e: return JsonResponse({'success': False, 'error': str(e)}, status=500) + +@require_http_methods(["GET", "POST"]) +def plugin_settings_proxy(request, plugin_name): + """ + Proxy for /plugins//settings/ so plugin settings pages work even when + the plugin was installed after the worker started (dynamic URL list is built at import time). + """ + mailUtilities.checkHome() + plugin_path = '/usr/local/CyberCP/' + plugin_name + urls_py = os.path.join(plugin_path, 'urls.py') + if not plugin_name or not os.path.isdir(plugin_path) or not os.path.exists(urls_py): + from django.http import HttpResponseNotFound + return HttpResponseNotFound('Plugin not found or has no URL configuration.') + if plugin_name in RESERVED_PLUGIN_DIRS or plugin_name in ( + 'api', 'installed', 'help', 'emailMarketing', 'emailPremium', 'pluginHolder' + ): + from django.http import HttpResponseNotFound + return HttpResponseNotFound('Invalid plugin.') + try: + import importlib + views_mod = importlib.import_module(plugin_name + '.views') + settings_view = getattr(views_mod, 'settings', None) + if not callable(settings_view): + from django.http import HttpResponseNotFound + return HttpResponseNotFound('Plugin has no settings view.') + return settings_view(request) + except Exception as e: + logging.writeToFile(f"plugin_settings_proxy for {plugin_name}: {str(e)}") + from django.http import HttpResponseServerError + return HttpResponseServerError(f'Plugin settings error: {str(e)}') + + def plugin_help(request, plugin_name): """Plugin-specific help page - shows plugin information, version history, and help content""" mailUtilities.checkHome() diff --git a/pluginInstaller/pluginInstaller.py b/pluginInstaller/pluginInstaller.py index 30288ccf5..c71e33e56 100644 --- a/pluginInstaller/pluginInstaller.py +++ b/pluginInstaller/pluginInstaller.py @@ -6,6 +6,8 @@ import argparse import os import shutil import time +import tempfile +import zipfile import django from plogical.processUtilities import ProcessUtilities @@ -58,16 +60,96 @@ class pluginInstaller: ### Functions Related to plugin installation. @staticmethod - def extractPlugin(pluginName): - pathToPlugin = pluginName + '.zip' - command = 'unzip -o ' + pathToPlugin + ' -d /usr/local/CyberCP' - result = subprocess.run(shlex.split(command), capture_output=True, text=True) - if result.returncode != 0: - raise Exception(f"Failed to extract plugin {pluginName}: {result.stderr}") - # Verify extraction succeeded + def extractPlugin(pluginName, zip_path=None): + """ + Extract plugin zip so that all files end up in /usr/local/CyberCP/pluginName/. + Handles zips with: (1) top-level folder pluginName/, (2) top-level folder with + another name (e.g. repo-main/), or (3) files at root (no top-level folder). + If zip_path is given (absolute path), use it; otherwise use pluginName + '.zip' in cwd. + """ + if zip_path is not None: + pathToPlugin = os.path.abspath(zip_path) + else: + pathToPlugin = os.path.abspath(pluginName + '.zip') + if not os.path.exists(pathToPlugin): + raise Exception(f"Plugin zip not found: {pathToPlugin}") pluginPath = '/usr/local/CyberCP/' + pluginName - if not os.path.exists(pluginPath): - raise Exception(f"Plugin extraction failed: {pluginPath} does not exist after extraction") + # Remove existing plugin dir so we start clean (e.g. from a previous failed install) + if os.path.exists(pluginPath): + shutil.rmtree(pluginPath) + extract_dir = tempfile.mkdtemp(prefix='cyberpanel_plugin_') + try: + with zipfile.ZipFile(pathToPlugin, 'r') as zf: + zf.extractall(extract_dir) + top_level = os.listdir(extract_dir) + plugin_name_lower = pluginName.lower() + # Prefer a top-level directory whose name matches pluginName (case-insensitive) + matching_dir = None + for name in top_level: + if os.path.isdir(os.path.join(extract_dir, name)) and name.lower() == plugin_name_lower: + matching_dir = name + break + if len(top_level) == 1: + single = os.path.join(extract_dir, top_level[0]) + if os.path.isdir(single): + # One top-level directory + single_name = top_level[0] + # If it's the repo root (e.g. cyberpanel-plugins-main), check for plugin subdir + if single_name.lower() != plugin_name_lower: + plugin_subdir = os.path.join(single, pluginName) + if not os.path.isdir(plugin_subdir): + # Try case-insensitive subdir match + for entry in os.listdir(single): + if os.path.isdir(os.path.join(single, entry)) and entry.lower() == plugin_name_lower: + plugin_subdir = os.path.join(single, entry) + break + if os.path.isdir(plugin_subdir): + # Use the plugin subdir as the plugin content (avoid nesting repo root) + shutil.move(plugin_subdir, pluginPath) + shutil.rmtree(single, ignore_errors=True) + else: + shutil.move(single, pluginPath) + else: + shutil.move(single, pluginPath) + else: + # Single file at root + os.makedirs(pluginPath, exist_ok=True) + shutil.move(single, os.path.join(pluginPath, top_level[0])) + elif matching_dir: + # Multiple items: one is a dir matching pluginName - use it as plugin, put rest inside pluginPath + os.makedirs(pluginPath, exist_ok=True) + src_match = os.path.join(extract_dir, matching_dir) + # Move the matching plugin dir to pluginPath (replace if exists) + if os.path.exists(pluginPath): + shutil.rmtree(pluginPath) + shutil.move(src_match, pluginPath) + for name in top_level: + if name == matching_dir: + continue + src = os.path.join(extract_dir, name) + dst = os.path.join(pluginPath, name) + if os.path.exists(dst): + if os.path.isdir(dst): + shutil.rmtree(dst) + else: + os.remove(dst) + shutil.move(src, dst) + else: + # Multiple items or empty: place everything inside pluginName/ + os.makedirs(pluginPath, exist_ok=True) + for name in top_level: + src = os.path.join(extract_dir, name) + dst = os.path.join(pluginPath, name) + if os.path.exists(dst): + if os.path.isdir(dst): + shutil.rmtree(dst) + else: + os.remove(dst) + shutil.move(src, dst) + if not os.path.exists(pluginPath): + raise Exception(f"Plugin extraction failed: {pluginPath} does not exist after extraction") + finally: + shutil.rmtree(extract_dir, ignore_errors=True) @staticmethod def upgradingSettingsFile(pluginName): @@ -210,12 +292,12 @@ class pluginInstaller: @staticmethod - def installPlugin(pluginName): + def installPlugin(pluginName, zip_path=None): try: ## pluginInstaller.stdOut('Extracting plugin..') - pluginInstaller.extractPlugin(pluginName) + pluginInstaller.extractPlugin(pluginName, zip_path=zip_path) pluginInstaller.stdOut('Plugin extracted.') ## @@ -385,41 +467,50 @@ class pluginInstaller: @staticmethod def removeFromSettings(pluginName): - data = open("/usr/local/CyberCP/CyberCP/settings.py", 'r', encoding='utf-8').readlines() - writeToFile = open("/usr/local/CyberCP/CyberCP/settings.py", 'w', encoding='utf-8') - + settings_path = "/usr/local/CyberCP/CyberCP/settings.py" + try: + with open(settings_path, 'r', encoding='utf-8') as f: + data = f.readlines() + except (OSError, IOError) as e: + raise Exception(f'Cannot read {settings_path}: {e}. Ensure the panel user can read it.') in_installed_apps = False + out_lines = [] for i, items in enumerate(data): - # Track if we're in INSTALLED_APPS section if 'INSTALLED_APPS' in items and '=' in items: in_installed_apps = True elif in_installed_apps and items.strip().startswith(']'): in_installed_apps = False - - # More precise matching: look for plugin name in quotes (e.g., 'pluginName' or "pluginName") - # Only match if we're in INSTALLED_APPS section to prevent false positives if in_installed_apps and (f"'{pluginName}'" in items or f'"{pluginName}"' in items): continue - else: - writeToFile.writelines(items) - writeToFile.close() + out_lines.append(items) + try: + with open(settings_path, 'w', encoding='utf-8') as writeToFile: + writeToFile.writelines(out_lines) + except (OSError, IOError) as e: + raise Exception( + f'Cannot write {settings_path}: {e}. ' + 'Ensure the file is writable by the panel user (e.g. chgrp lscpd ... ; chmod g+w ...).' + ) @staticmethod def removeFromURLs(pluginName): - data = open("/usr/local/CyberCP/CyberCP/urls.py", 'r', encoding='utf-8').readlines() - writeToFile = open("/usr/local/CyberCP/CyberCP/urls.py", 'w', encoding='utf-8') - + urls_path = "/usr/local/CyberCP/CyberCP/urls.py" + try: + with open(urls_path, 'r', encoding='utf-8') as f: + data = f.readlines() + except (OSError, IOError) as e: + raise Exception(f'Cannot read {urls_path}: {e}.') + out_lines = [] for items in data: - # More precise matching: look for plugin name in path() or include() calls - # Match patterns like: path('plugins/pluginName/', include('pluginName.urls')) - # This prevents partial matches - if (f"plugins/{pluginName}/" in items or f"'{pluginName}.urls'" in items or f'"{pluginName}.urls"' in items or + if (f"plugins/{pluginName}/" in items or f"'{pluginName}.urls'" in items or f'"{pluginName}.urls"' in items or f"include('{pluginName}.urls')" in items or f'include("{pluginName}.urls")' in items): continue - else: - writeToFile.writelines(items) - - writeToFile.close() + out_lines.append(items) + try: + with open(urls_path, 'w', encoding='utf-8') as f: + f.writelines(out_lines) + except (OSError, IOError) as e: + raise Exception(f'Cannot write {urls_path}: {e}. Ensure the file is writable by the panel user (chgrp lscpd; chmod g+w).') @staticmethod def informCyberPanelRemoval(pluginName): diff --git a/upgrade_modules/06_components.sh b/upgrade_modules/06_components.sh index b76d62850..b7eab9ad5 100644 --- a/upgrade_modules/06_components.sh +++ b/upgrade_modules/06_components.sh @@ -37,12 +37,13 @@ Pre_Upgrade_Required_Components() { # Check if CyberCP directory exists but is incomplete/damaged echo -e "[$(date +"%Y-%m-%d %H:%M:%S")] Checking CyberCP directory integrity..." | tee -a /var/log/cyberpanel_upgrade_debug.log -# Define essential CyberCP components +# Define essential CyberCP components (do not add /usr/local/CyberCP/manage - it does not exist; see usmannasir/cyberpanel#1721) CYBERCP_ESSENTIAL_DIRS=( "/usr/local/CyberCP/CyberCP" "/usr/local/CyberCP/plogical" "/usr/local/CyberCP/websiteFunctions" - "/usr/local/CyberCP/manage" + "/usr/local/CyberCP/pluginHolder" + "/usr/local/CyberCP/pluginInstaller" ) CYBERCP_MISSING=0