diff --git a/CyberCP/settings.py b/CyberCP/settings.py index ea1b2d679..807773d13 100644 --- a/CyberCP/settings.py +++ b/CyberCP/settings.py @@ -209,6 +209,9 @@ STATIC_ROOT = os.path.join(BASE_DIR, "static/") STATIC_URL = '/static/' +# Panel public directory (SnappyMail, phpMyAdmin, etc.) – served so /snappymail/ and /phpmyadmin/ work when panel is behind Django +PUBLIC_ROOT = os.path.join(BASE_DIR, 'public') + LOCALE_PATHS = ( os.path.join(BASE_DIR, 'locale'), ) @@ -247,4 +250,25 @@ LOGIN_REDIRECT_URL = '/' # Default primary key field type # https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field -DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' \ No newline at end of file +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +# Sync INSTALLED_APPS with plugins on disk so /plugins// and /plugins//settings/ work. +# Plugins installed under /usr/local/CyberCP/ (or BASE_DIR) are added here if they have meta.xml + urls.py. +_cybercp_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if os.path.isdir(_cybercp_root): + try: + _existing_apps = set(INSTALLED_APPS) + for _name in os.listdir(_cybercp_root): + if _name.startswith('.'): + continue + _plugin_dir = os.path.join(_cybercp_root, _name) + if not os.path.isdir(_plugin_dir): + continue + if _name in _existing_apps: + continue + if (os.path.exists(os.path.join(_plugin_dir, 'meta.xml')) and + os.path.exists(os.path.join(_plugin_dir, 'urls.py'))): + INSTALLED_APPS.append(_name) + _existing_apps.add(_name) + except (OSError, IOError): + pass \ No newline at end of file diff --git a/pluginHolder/templates/pluginHolder/plugins.html b/pluginHolder/templates/pluginHolder/plugins.html index c4ad4998e..98f195e29 100644 --- a/pluginHolder/templates/pluginHolder/plugins.html +++ b/pluginHolder/templates/pluginHolder/plugins.html @@ -430,13 +430,20 @@ .installed-sort-filter-bar { flex-basis: 100%; display: flex; - flex-wrap: wrap; - align-items: center; - gap: 12px; + flex-direction: column; + align-items: flex-start; + gap: 10px; margin-top: 12px; padding: 12px 0; border-top: 1px solid var(--border-primary, #e2e8f0); } + .installed-sort-filter-bar .installed-filter-row, + .installed-sort-filter-bar .installed-sort-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + } .installed-sort-filter-bar .sort-label { font-size: 13px; font-weight: 600; @@ -1224,10 +1231,25 @@ {% trans "Plugin Development Guide" %} - +
{% for plugin in plugins %} -
+
{% if plugin.type|lower == "security" %} @@ -1388,7 +1411,7 @@ {% for plugin in plugins %} - + {{ plugin.name }} {% if plugin.freshness_badge %} @@ -1658,6 +1681,7 @@ let currentCategory = 'all'; let currentSearchQuery = ''; let isSettingHash = false; // Flag to prevent infinite loops let currentInstalledSort = 'name-asc'; // name-asc, name-desc, type, date-desc, date-asc +let currentInstalledFilter = 'all'; // all, installed, active // Get CSRF cookie helper function function getCookie(name) { @@ -2035,14 +2059,37 @@ function clearPluginSearch() { } } +function setInstalledFilter(filter) { + currentInstalledFilter = filter; + var bar = document.getElementById('installedSortFilterBar'); + if (bar) { + try { + bar.querySelectorAll('.filter-btn').forEach(function(btn) { + btn.classList.toggle('active', (btn.getAttribute('data-filter') || '') === filter); + }); + } catch (e) { console.warn('setInstalledFilter: filter buttons', e); } + } + try { + filterInstalledPlugins(); + } catch (e) { console.warn('setInstalledFilter: filterInstalledPlugins', e); } +} + function filterInstalledPlugins() { const query = (document.getElementById('installedPluginSearchInput') && document.getElementById('installedPluginSearchInput').value) || ''; const terms = query.trim().toLowerCase().split(/\s+/).filter(function(t) { return t.length > 0; }); + const filter = currentInstalledFilter || 'all'; const gridView = document.getElementById('gridView'); const tableView = document.getElementById('tableView'); const noResultsGrid = document.getElementById('installedPluginsNoResultsGrid'); const noResultsTable = document.getElementById('installedPluginsNoResultsTable'); + if (!gridView && !tableView) return; var visibleCount = 0; + function matchesFilter(installed, enabled) { + if (filter === 'all') return true; + if (filter === 'installed') return installed === 'true'; + if (filter === 'active') return installed === 'true' && enabled === 'true'; + return true; + } if (gridView) { var cards = gridView.querySelectorAll('.plugin-card'); cards.forEach(function(card) { @@ -2050,7 +2097,11 @@ function filterInstalledPlugins() { var desc = (card.getAttribute('data-plugin-desc') || '').toLowerCase(); var type = (card.getAttribute('data-plugin-type') || '').toLowerCase(); var combined = name + ' ' + desc + ' ' + type; - var show = terms.length === 0 || terms.every(function(term) { return combined.indexOf(term) !== -1; }); + var searchMatch = terms.length === 0 || terms.every(function(term) { return combined.indexOf(term) !== -1; }); + var installed = card.getAttribute('data-installed') || 'false'; + var enabled = card.getAttribute('data-enabled') || 'false'; + var filterMatch = matchesFilter(installed, enabled); + var show = searchMatch && filterMatch; card.style.display = show ? '' : 'none'; if (show) visibleCount++; }); @@ -2065,17 +2116,22 @@ function filterInstalledPlugins() { var desc = (row.getAttribute('data-plugin-desc') || '').toLowerCase(); var type = (row.getAttribute('data-plugin-type') || '').toLowerCase(); var combined = name + ' ' + desc + ' ' + type; - var show = terms.length === 0 || terms.every(function(term) { return combined.indexOf(term) !== -1; }); + var searchMatch = terms.length === 0 || terms.every(function(term) { return combined.indexOf(term) !== -1; }); + var installed = row.getAttribute('data-installed') || 'false'; + var enabled = row.getAttribute('data-enabled') || 'false'; + var filterMatch = matchesFilter(installed, enabled); + var show = searchMatch && filterMatch; row.style.display = show ? '' : 'none'; if (show) visibleCount++; }); } } + var hasFilterOrSearch = terms.length > 0 || (filter !== 'all'); if (noResultsGrid) { - noResultsGrid.style.display = (terms.length > 0 && visibleCount === 0) ? 'block' : 'none'; + noResultsGrid.style.display = (hasFilterOrSearch && visibleCount === 0) ? 'block' : 'none'; } if (noResultsTable) { - noResultsTable.style.display = (terms.length > 0 && visibleCount === 0) ? 'table-row' : 'none'; + noResultsTable.style.display = (hasFilterOrSearch && visibleCount === 0) ? 'table-row' : 'none'; } } @@ -2386,43 +2442,54 @@ function installPlugin(pluginName) { btn.disabled = true; btn.innerHTML = ' Installing...'; - fetch(`/plugins/api/install/${pluginName}/`, { - method: 'POST', - headers: { - 'X-CSRFToken': getCookie('csrftoken'), - 'Content-Type': 'application/json' - } - }) - .then(response => response.json()) - .then(data => { - if (data.success) { - location.reload(); - } else { - if (typeof PNotify !== 'undefined') { - new PNotify({ - title: 'Installation Failed!', - text: data.error || 'Failed to install plugin', - type: 'error' - }); - } else { - alert('Error: ' + (data.error || 'Failed to install plugin')); - } - btn.disabled = false; - btn.innerHTML = originalText; - } - }) - .catch(error => { + const headers = { + 'X-CSRFToken': getCookie('csrftoken'), + 'Content-Type': 'application/json' + }; + + function showError(msg) { if (typeof PNotify !== 'undefined') { - new PNotify({ - title: 'Error!', - text: 'Failed to install plugin: ' + error.message, - type: 'error' - }); + new PNotify({ title: 'Installation Failed!', text: msg || 'Failed to install plugin', type: 'error' }); } else { - alert('Error: Failed to install plugin - ' + error.message); + alert('Error: ' + (msg || 'Failed to install plugin')); } btn.disabled = false; btn.innerHTML = originalText; + } + + function tryLocalInstall() { + return fetch(`/plugins/api/install/${pluginName}/`, { method: 'POST', headers: headers }) + .then(function(r) { return r.json().then(function(data) { return { response: r, data: data }; }); }); + } + + function tryStoreInstall() { + return fetch(`/plugins/api/store/install/${pluginName}/`, { method: 'POST', headers: headers }) + .then(function(r) { return r.json().then(function(data) { return { response: r, data: data }; }); }); + } + + tryLocalInstall() + .then(function(result) { + if (result.data.success) { + location.reload(); + return; + } + var err = result.data.error || ''; + if (result.response.status === 404 || (err && err.indexOf('Plugin source not found') !== -1)) { + btn.innerHTML = ' Installing from store...'; + return tryStoreInstall(); + } + showError(err); + }) + .then(function(result) { + if (!result || !result.data) return; + if (result.data.success) { + location.reload(); + } else { + showError(result.data.error || 'Failed to install plugin'); + } + }) + .catch(function(error) { + showError(error && error.message ? error.message : 'Failed to install plugin'); }); } @@ -2978,7 +3045,14 @@ document.addEventListener('DOMContentLoaded', function() { // Set initial view without updating hash (only update hash if there was already one) const hadHash = hash.length > 0; - toggleView(initialView, hadHash); + try { + toggleView(initialView, hadHash); + } catch (e) { + console.warn('plugins: toggleView on load failed', e); + if (storeView) storeView.style.display = 'block'; + if (gridView) gridView.style.display = 'none'; + if (tableView) tableView.style.display = 'none'; + } } else { // Elements don't exist (no plugins installed), just show store view directly if (storeView) { diff --git a/pluginHolder/urls.py b/pluginHolder/urls.py index 4bc0c147b..a9324815d 100644 --- a/pluginHolder/urls.py +++ b/pluginHolder/urls.py @@ -98,16 +98,11 @@ urlpatterns = [ path('/help/', views.plugin_help, name='plugin_help'), ] -# Dynamically include each installed plugin's URLs so /plugins//settings/ etc. work -# Only include plugins that are in INSTALLED_APPS so Django can load their models. -from django.conf import settings -_installed_apps = getattr(settings, 'INSTALLED_APPS', ()) - +# Dynamically include each installed plugin's URLs so /plugins//settings/ etc. work. +# Include every plugin found on disk (INSTALLED_PLUGINS_PATH or PLUGIN_SOURCE_PATHS) so plugin +# pages work even if the app was not added to INSTALLED_APPS (e.g. after git pull overwrote settings). for _plugin_name, _path_parent in _get_installed_plugin_list(): - if _plugin_name not in _installed_apps: - continue try: - # If plugin is from a source path, ensure it is on sys.path so import works if _path_parent not in sys.path: sys.path.insert(0, _path_parent) __import__(_plugin_name + '.urls') diff --git a/pluginHolder/views.py b/pluginHolder/views.py index be0419456..066725ad5 100644 --- a/pluginHolder/views.py +++ b/pluginHolder/views.py @@ -5,6 +5,7 @@ from django.views.decorators.csrf import csrf_exempt from django.views.decorators.http import require_http_methods from plogical.mailUtilities import mailUtilities import os +import shutil import subprocess import shlex import json @@ -42,6 +43,17 @@ PLUGIN_SOURCE_PATHS = ['/home/cyberpanel/plugins', '/home/cyberpanel-plugins'] # These plugins show "Built-in" badge and only Settings button (no Deactivate/Uninstall) BUILTIN_PLUGINS = frozenset(['emailMarketing', 'emailPremium']) +# Core CyberPanel app dirs under /usr/local/CyberCP that must not be counted as "installed plugins" +# (matches pluginHolder.urls so Installed count = store/plugin dirs only, not core apps) +RESERVED_PLUGIN_DIRS = frozenset([ + 'api', 'backup', 'baseTemplate', 'cloudAPI', 'CLManager', 'containerization', 'CyberCP', + 'databases', 'dns', 'dockerManager', 'emailMarketing', 'emailPremium', 'filemanager', + 'firewall', 'ftp', 'highAvailability', 'IncBackups', 'loginSystem', 'mailServer', + 'managePHP', 'manageSSL', 'manageServices', 'packages', 'pluginHolder', 'plogical', + 'pluginInstaller', 'serverLogs', 'serverStatus', 's3Backups', 'tuning', 'userManagment', + 'websiteFunctions', 'aiScanner', 'dns', 'help', 'installed', +]) + 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: @@ -51,6 +63,30 @@ def _get_plugin_source_path(plugin_name): return path return None +def _ensure_plugin_meta_xml(plugin_name): + """ + If plugin is installed (directory exists) but meta.xml is missing, + restore it from source or from GitHub so the grid and version checks work. + """ + installed_dir = os.path.join('/usr/local/CyberCP', plugin_name) + installed_meta = os.path.join(installed_dir, 'meta.xml') + if not os.path.isdir(installed_dir) or os.path.exists(installed_meta): + return + source_path = _get_plugin_source_path(plugin_name) + if source_path: + source_meta = os.path.join(source_path, 'meta.xml') + if os.path.exists(source_meta): + try: + shutil.copy2(source_meta, installed_meta) + logging.writeToFile(f"Restored meta.xml for {plugin_name} from source") + except Exception as e: + logging.writeToFile(f"Could not restore meta.xml for {plugin_name}: {e}") + return + try: + _sync_meta_xml_from_github(plugin_name) + except Exception: + pass + def _get_plugin_state_file(plugin_name): """Get the path to the plugin state file""" if not os.path.exists(PLUGIN_STATE_DIR): @@ -121,6 +157,15 @@ def installed(request): errorPlugins = [] processed_plugins = set() # Track which plugins we've already processed + # Repair pass: ensure every installed plugin dir has meta.xml (from source or GitHub) so counts and grid are correct + if os.path.exists(installedPath): + for plugin in os.listdir(installedPath): + if plugin.startswith('.') or plugin in RESERVED_PLUGIN_DIRS: + continue + plugin_dir = os.path.join(installedPath, plugin) + if os.path.isdir(plugin_dir): + _ensure_plugin_meta_xml(plugin) + # First, process plugins from source directories (multiple paths: /home/cyberpanel/plugins, /home/cyberpanel-plugins) # BUT: Skip plugins that are already installed - we'll process those from the installed location instead for pluginPath in PLUGIN_SOURCE_PATHS: @@ -134,20 +179,20 @@ def installed(request): for plugin in os.listdir(pluginPath): if plugin in processed_plugins: continue - # Skip if plugin is already installed - we'll process it from installed location instead - completePath = installedPath + '/' + plugin + '/meta.xml' - if os.path.exists(completePath): - # Plugin is installed, skip source path - DON'T mark as processed yet - # The installed location loop will handle it and mark it as processed - continue # Skip files (like .zip files) - only process directories pluginDir = os.path.join(pluginPath, plugin) if not os.path.isdir(pluginDir): continue + # Use same "installed" criterion as install endpoint: plugin directory in /usr/local/CyberCP/ + installed_dir = os.path.join(installedPath, plugin) + completePath = os.path.join(installedPath, plugin, 'meta.xml') + if os.path.exists(completePath): + # Plugin is fully installed (dir + meta.xml), skip - second loop will add it + continue + data = {} # Try installed location first, then fallback to source location - completePath = installedPath + '/' + plugin + '/meta.xml' sourcePath = os.path.join(pluginDir, 'meta.xml') # Determine which meta.xml to use @@ -200,9 +245,9 @@ def installed(request): data['plugin_dir'] = plugin # Plugin directory name # Set builtin flag (core CyberPanel plugins vs user-installable plugins) data['builtin'] = plugin in BUILTIN_PLUGINS - # Check if plugin is installed (only if it exists in /usr/local/CyberCP/) - # Source directory presence doesn't mean installed - it just means the source files are available - data['installed'] = os.path.exists(completePath) + # Installed = plugin directory exists (must match install endpoint which uses directory existence) + # Fixes grid showing "Not Installed" when directory exists but meta.xml is missing + data['installed'] = os.path.isdir(installed_dir) # Get plugin enabled state (only for installed plugins) if data['installed']: @@ -247,11 +292,9 @@ def installed(request): # Special handling for emailMarketing if plugin == 'emailMarketing': data['manage_url'] = '/emailMarketing/' - elif os.path.exists(completePath): - # Check if settings route exists, otherwise use main plugin URL - settings_route = f'/plugins/{plugin}/settings/' + elif data['installed']: + # Plugin directory exists; use main plugin URL main_route = f'/plugins/{plugin}/' - # Default to main route - most plugins have a main route even if no settings data['manage_url'] = main_route else: data['manage_url'] = None @@ -282,20 +325,14 @@ def installed(request): errorPlugins.append({'name': plugin, 'error': f'XML parse error: {str(e)}'}) logging.writeToFile(f"Plugin {plugin}: XML parse error - {str(e)}") # Don't mark as processed if it failed - let installed check handle it - # This ensures plugins that exist in /usr/local/CyberCP/ but have bad source meta.xml still get counted - if not os.path.exists(completePath): - # Only skip if it's not actually installed + if not os.path.isdir(installed_dir): continue - # If it exists in installed location, don't mark as processed so it gets checked there continue except Exception as e: errorPlugins.append({'name': plugin, 'error': f'Error loading plugin: {str(e)}'}) logging.writeToFile(f"Plugin {plugin}: Error loading - {str(e)}") - # Don't mark as processed if it failed - let installed check handle it - if not os.path.exists(completePath): - # Only skip if it's not actually installed + if not os.path.isdir(installed_dir): continue - # If it exists in installed location, don't mark as processed so it gets checked there continue # Also check for installed plugins that don't have source directories @@ -311,6 +348,7 @@ def installed(request): if not os.path.isdir(pluginInstalledDir): continue + _ensure_plugin_meta_xml(plugin) metaXmlPath = os.path.join(pluginInstalledDir, 'meta.xml') if not os.path.exists(metaXmlPath): continue @@ -475,29 +513,30 @@ def installed(request): except Exception as e: logging.writeToFile(f"Plugin {plugin_name} fallback load error: {str(e)}") - # Calculate installed and active counts - # Double-check by also counting plugins that actually exist in /usr/local/CyberCP/ + # Calculate installed and active counts: only count real plugins (have meta.xml, not core apps) installed_plugins_in_filesystem = set() if os.path.exists(installedPath): for plugin in os.listdir(installedPath): + if plugin.startswith('.') or plugin in RESERVED_PLUGIN_DIRS: + continue pluginInstalledDir = os.path.join(installedPath, plugin) - if os.path.isdir(pluginInstalledDir): - metaXmlPath = os.path.join(pluginInstalledDir, 'meta.xml') - if os.path.exists(metaXmlPath): - installed_plugins_in_filesystem.add(plugin) + if not os.path.isdir(pluginInstalledDir): + continue + if not os.path.exists(os.path.join(pluginInstalledDir, 'meta.xml')): + continue + installed_plugins_in_filesystem.add(plugin) - # Count installed plugins from the list installed_count = len([p for p in pluginList if p.get('installed', False)]) active_count = len([p for p in pluginList if p.get('installed', False) and p.get('enabled', False)]) - # If there's a discrepancy, use the filesystem count as the source of truth + # Use the larger of list count and filesystem count so header never shows less than grid filesystem_installed_count = len(installed_plugins_in_filesystem) - if filesystem_installed_count != installed_count: - logging.writeToFile(f"WARNING: Plugin count mismatch! List says {installed_count}, filesystem has {filesystem_installed_count}") - logging.writeToFile(f"Plugins in filesystem: {sorted(installed_plugins_in_filesystem)}") - logging.writeToFile(f"Plugins in list with installed=True: {[p.get('plugin_dir') for p in pluginList if p.get('installed', False)]}") - # Use filesystem count as source of truth - installed_count = filesystem_installed_count + list_installed_count = len([p for p in pluginList if p.get('installed', False)]) + if filesystem_installed_count != list_installed_count: + logging.writeToFile(f"Plugin count: list installed={list_installed_count}, filesystem with meta.xml={filesystem_installed_count}") + installed_count = max(list_installed_count, filesystem_installed_count) + if active_count > installed_count: + active_count = installed_count # Debug logging to help identify discrepancies logging.writeToFile(f"Plugin count: Total={len(pluginList)}, Installed={installed_count}, Active={active_count}") @@ -609,6 +648,7 @@ def install_plugin(request, plugin_name): # Set plugin to enabled by default after installation _set_plugin_state(plugin_name, True) + _ensure_plugin_meta_xml(plugin_name) logging.writeToFile(f"Plugin {plugin_name} installed successfully (upload)") return JsonResponse({ 'success': True, @@ -1783,6 +1823,7 @@ def install_from_store(request, plugin_name): # Set plugin to enabled by default after installation _set_plugin_state(plugin_name, True) + _ensure_plugin_meta_xml(plugin_name) return JsonResponse({ 'success': True, 'message': f'Plugin {plugin_name} installed successfully from store'