{% trans "CyberPanel's plugin system allows developers to extend the control panel's functionality with custom features. Plugins integrate seamlessly with CyberPanel's Django-based architecture, providing access to the full power of the platform while maintaining security and consistency." %}
+
+
{% trans "What Can Plugins Do?" %}
+
+
{% trans "Add new administrative features" %}
+
{% trans "Integrate with external services (APIs, webhooks, etc.)" %}
+
{% trans "Customize the user interface" %}
+
{% trans "Extend database functionality" %}
+
{% trans "Add automation and monitoring capabilities" %}
+
{% trans "Create custom reporting tools" %}
+
{% trans "Integrate security features" %}
+
+
+
{% trans "Prerequisites" %}
+
{% trans "Required Knowledge" %}
+
+
Python 3.6+: {% trans "Basic to intermediate Python knowledge" %}
+
Django Framework: {% trans "Understanding of Django views, URLs, templates, and models" %}
+
HTML/CSS/JavaScript: {% trans "For creating user interfaces" %}
+
Linux/Unix: {% trans "Basic command-line familiarity" %}
+
XML: {% trans "Understanding of XML structure for meta.xml" %}
+
+
+
{% trans "Plugin Architecture Overview" %}
+
{% trans "How Plugins Work" %}
+
+
{% trans "Plugin Source Location" %}: /home/cyberpanel/plugins/
+
+
{% trans "Plugins are stored here before installation" %}
+
{% trans "Can be uploaded as ZIP files or placed directly" %}
+
+
+
{% trans "Installed Location" %}: /usr/local/CyberCP/
+
+
{% trans "After installation, plugins are copied here" %}
+
{% trans "This is where CyberPanel loads plugins from" %}
+
+
+
+
+
{% trans "Creating Your First Plugin" %}
+
{% trans "Step 1: Create Plugin Directory Structure" %}
{% trans "Templates must extend baseTemplate/index.html:" %}
+ {% verbatim %}
+
{% extends "baseTemplate/index.html" %}
+{% load static %}
+{% load i18n %}
+
+{% block title %}
+ My First Plugin - {% trans "CyberPanel" %}
+{% endblock %}
+
+{% block content %}
+
+
{{ plugin_name }}
+
Version {{ version }}
+
+{% endblock %}
+ {% endverbatim %}
+
+
+ {% trans "Important" %}: {% trans "Always use the @cyberpanel_login_required decorator for all views to ensure users are authenticated." %}
+
+
+
{% trans "Plugin Structure & Files" %}
+
{% trans "Required Files" %}
+
+
__init__.py - {% trans "Required" %} - {% trans "Python package marker" %}
+
meta.xml - {% trans "Required" %} - {% trans "Plugin metadata" %}
+
urls.py - {% trans "Required" %} - {% trans "URL routing" %}
+
views.py - {% trans "Required" %} - {% trans "View functions" %}
+
+
+
{% trans "Optional Files" %}
+
+
models.py - {% trans "Optional" %} - {% trans "Database models" %}
+
forms.py - {% trans "Optional" %} - {% trans "Django forms" %}
+
utils.py - {% trans "Optional" %} - {% trans "Utility functions" %}
+
templates/ - {% trans "Optional" %} - {% trans "HTML templates" %}
+
static/ - {% trans "Optional" %} - {% trans "CSS, JS, images" %}
+
+
+
{% trans "Version Numbering (Semantic Versioning)" %}
+
{% trans "CyberPanel plugins use semantic versioning (SemVer) with a three-number format (X.Y.Z) to help users understand the impact of each update:" %}
+
+
+
{% trans "Major Version (X.0.0)" %} ▼
+
{% trans "Breaking changes that may require action from users. New major features, complete redesigns, or changes that break existing functionality." %}
+
{% trans "Example" %}: 2.8.0 → 3.0.0
+
+
+
+
{% trans "Minor Version (0.X.0)" %} ▼
+
{% trans "New features added in a backward-compatible manner. Enhancements and improvements that don't break existing functionality." %}
+
{% trans "Example" %}: 3.0.0 → 3.1.0
+
+
+
+
{% trans "Patch Version (0.0.X)" %} ▼
+
{% trans "Bug fixes and minor improvements. Backward-compatible fixes that address issues without adding new features." %}
+
{% trans "Example" %}: 3.1.0 → 3.1.1
+
+
+
+ {% trans "Important" %}: {% trans "Always use the three-number format (X.Y.Z) in your meta.xml file. This makes it easier to track updates and understand the scope of changes. Start with version 1.0.0 for your first release." %}
+
+
+
{% trans "Version Format in meta.xml" %}
+
<version>1.0.0</version>
+
{% trans "Never use formats like '1.0' or 'v1.0'. Always use the full semantic version: '1.0.0'" %}
+
+
{% trans "Core Components" %}
+
{% trans "1. Authentication & Security" %}
+
{% trans "Always use the cyberpanel_login_required decorator:" %}
+
@cyberpanel_login_required
+def my_view(request):
+ # Your view code
+ pass
+ {% trans "Ready to Start?" %} {% trans "Begin with a simple plugin and gradually add more features as you become familiar with the system. Check the examplePlugin and testPlugin directories for complete working examples." %}
+
+
+
+
+ {% trans "Author" %}: master3395 |
+ {% trans "Version" %}: 2.0.0 |
+ {% trans "Last Updated" %}: 2026-01-04
+
{% trans "The versions displayed here represent the latest plugins from the CyberPanel Plugin Store repository. They may or may not represent the latest available versions. Additionally, the plugin repository may only contain plugins released within the last few months." %}
+
+
+ {% trans "Cache Information:" %}
+ {% trans "Plugin store data is cached for 1 hour to improve performance and reduce GitHub API rate limits. New plugins may take up to 1 hour to appear after being published." %}
+
+
+
+ {% trans "Use at Your Own Risk" %}
+
+
{% trans "The plugins displayed here are contributed by both the CyberPanel Developers and independent third parties. We make no guarantees that the plugins available here are functional, tested, or compatible with your system. You are encouraged to read the information found in the help and about links for each plugin before attempting the installation." %}
+
+
+
+
+
+
+
{% trans "Loading plugins from store..." %}
+
+
+
+
+
+
+
+
+
+
+
+
+ {% for letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ" %}
+
+ {% endfor %}
+
+
+
+
+
+
+
+
+
{% trans "Plugin Name" %}
+
{% trans "Version" %}
+
{% trans "Modify Date" %}
+
{% trans "Status" %}
+
{% trans "Action" %}
+
{% trans "Active" %}
+
{% trans "Help" %}
+
{% trans "About" %}
+
+
+
+
+
+
+
+
+
+
{% trans "Cannot list plugins. Error message:" %} {$ errorMessage $}
@@ -475,9 +1132,29 @@
{% endblock %}
\ No newline at end of file
diff --git a/pluginHolder/urls.py b/pluginHolder/urls.py
index a3fdf954d..b6d0fe13d 100644
--- a/pluginHolder/urls.py
+++ b/pluginHolder/urls.py
@@ -3,8 +3,12 @@ from . import views
urlpatterns = [
path('installed', views.installed, name='installed'),
+ path('help/', views.help_page, name='help'),
path('api/install//', views.install_plugin, name='install_plugin'),
path('api/uninstall//', views.uninstall_plugin, name='uninstall_plugin'),
path('api/enable//', views.enable_plugin, name='enable_plugin'),
path('api/disable//', views.disable_plugin, name='disable_plugin'),
+ path('api/store/plugins/', views.fetch_plugin_store, name='fetch_plugin_store'),
+ path('api/store/install//', views.install_from_store, name='install_from_store'),
+ path('/help/', views.plugin_help, name='plugin_help'),
]
diff --git a/pluginHolder/views.py b/pluginHolder/views.py
index 1f5a0507c..07b752341 100644
--- a/pluginHolder/views.py
+++ b/pluginHolder/views.py
@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
-from django.shortcuts import render
+from django.shortcuts import render, redirect
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
@@ -8,16 +8,28 @@ import os
import subprocess
import shlex
import json
+from datetime import datetime, timedelta
from xml.etree import ElementTree
from plogical.httpProc import httpProc
from plogical.CyberCPLogFileWriter import CyberCPLogFileWriter as logging
import sys
+import urllib.request
+import urllib.error
+import time
sys.path.append('/usr/local/CyberCP')
from pluginInstaller.pluginInstaller import pluginInstaller
# Plugin state file location
PLUGIN_STATE_DIR = '/home/cyberpanel/plugin_states'
+# Plugin store cache configuration
+PLUGIN_STORE_CACHE_DIR = '/home/cyberpanel/plugin_store_cache'
+PLUGIN_STORE_CACHE_FILE = os.path.join(PLUGIN_STORE_CACHE_DIR, 'plugins_cache.json')
+PLUGIN_STORE_CACHE_DURATION = 3600 # Cache for 1 hour (3600 seconds)
+GITHUB_REPO_API = 'https://api.github.com/repos/master3395/cyberpanel-plugins/contents'
+GITHUB_RAW_BASE = 'https://raw.githubusercontent.com/master3395/cyberpanel-plugins/main'
+GITHUB_COMMITS_API = 'https://api.github.com/repos/master3395/cyberpanel-plugins/commits'
+
def _get_plugin_state_file(plugin_name):
"""Get the path to the plugin state file"""
if not os.path.exists(PLUGIN_STATE_DIR):
@@ -48,12 +60,21 @@ def _set_plugin_state(plugin_name, enabled):
logging.writeToFile(f"Error writing plugin state for {plugin_name}: {str(e)}")
return False
+def help_page(request):
+ """Display plugin development help page"""
+ mailUtilities.checkHome()
+ proc = httpProc(request, 'pluginHolder/help.html', {}, 'admin')
+ return proc.render()
+
def installed(request):
mailUtilities.checkHome()
pluginPath = '/home/cyberpanel/plugins'
+ installedPath = '/usr/local/CyberCP'
pluginList = []
errorPlugins = []
+ processed_plugins = set() # Track which plugins we've already processed
+ # First, process plugins from source directory
if os.path.exists(pluginPath):
for plugin in os.listdir(pluginPath):
# Skip files (like .zip files) - only process directories
@@ -63,7 +84,7 @@ def installed(request):
data = {}
# Try installed location first, then fallback to source location
- completePath = '/usr/local/CyberCP/' + plugin + '/meta.xml'
+ completePath = installedPath + '/' + plugin + '/meta.xml'
sourcePath = os.path.join(pluginDir, 'meta.xml')
# Determine which meta.xml to use
@@ -114,24 +135,55 @@ def installed(request):
else:
data['enabled'] = False
+ # Get modify date from local file (fast, no API calls)
+ # GitHub commit dates are fetched in the plugin store, not here to avoid timeouts
+ modify_date = 'N/A'
+ try:
+ if os.path.exists(metaXmlPath):
+ modify_time = os.path.getmtime(metaXmlPath)
+ modify_date = datetime.fromtimestamp(modify_time).strftime('%Y-%m-%d %H:%M:%S')
+ except Exception:
+ modify_date = 'N/A'
+
+ data['modify_date'] = modify_date
+
# Extract settings URL or main URL for "Manage" button
settings_url_elem = root.find('settings_url')
url_elem = root.find('url')
# Priority: settings_url > url > default pattern
- if settings_url_elem is not None and settings_url_elem.text:
+ # Special handling for core plugins that don't use /plugins/ prefix
+ if plugin == 'emailMarketing':
+ # emailMarketing is a core CyberPanel plugin, uses /emailMarketing/ not /plugins/emailMarketing/
+ data['manage_url'] = '/emailMarketing/'
+ elif settings_url_elem is not None and settings_url_elem.text:
data['manage_url'] = settings_url_elem.text
elif url_elem is not None and url_elem.text:
data['manage_url'] = url_elem.text
else:
# Default: try /plugins/{plugin_dir}/settings/ or /plugins/{plugin_dir}/
# Only set if plugin is installed (we can't know if the URL exists otherwise)
- if os.path.exists(completePath):
- data['manage_url'] = f'/plugins/{plugin}/settings/'
+ # 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/'
+ 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
+
+ # Extract author information
+ author_elem = root.find('author')
+ if author_elem is not None and author_elem.text:
+ data['author'] = author_elem.text
+ else:
+ data['author'] = 'Unknown'
pluginList.append(data)
+ processed_plugins.add(plugin) # Mark as processed
except ElementTree.ParseError as e:
errorPlugins.append({'name': plugin, 'error': f'XML parse error: {str(e)}'})
logging.writeToFile(f"Plugin {plugin}: XML parse error - {str(e)}")
@@ -140,6 +192,100 @@ def installed(request):
errorPlugins.append({'name': plugin, 'error': f'Error loading plugin: {str(e)}'})
logging.writeToFile(f"Plugin {plugin}: Error loading - {str(e)}")
continue
+
+ # Also check for installed plugins that don't have source directories
+ # This handles plugins installed from the store that may not be in /home/cyberpanel/plugins/
+ if os.path.exists(installedPath):
+ for plugin in os.listdir(installedPath):
+ # Skip if already processed
+ if plugin in processed_plugins:
+ continue
+
+ # Only check directories that look like plugins (have meta.xml)
+ pluginInstalledDir = os.path.join(installedPath, plugin)
+ if not os.path.isdir(pluginInstalledDir):
+ continue
+
+ metaXmlPath = os.path.join(pluginInstalledDir, 'meta.xml')
+ if not os.path.exists(metaXmlPath):
+ continue
+
+ # This is an installed plugin without a source directory - process it
+ try:
+ data = {}
+ pluginMetaData = ElementTree.parse(metaXmlPath)
+ root = pluginMetaData.getroot()
+
+ # Validate required fields
+ name_elem = root.find('name')
+ type_elem = root.find('type')
+ desc_elem = root.find('description')
+ version_elem = root.find('version')
+
+ if name_elem is None or desc_elem is None or version_elem is None:
+ continue
+
+ if name_elem.text is None or desc_elem.text is None or version_elem.text is None:
+ continue
+
+ data['name'] = name_elem.text
+ data['type'] = type_elem.text if type_elem is not None and type_elem.text is not None else 'Plugin'
+ data['desc'] = desc_elem.text
+ data['version'] = version_elem.text
+ data['plugin_dir'] = plugin
+ data['installed'] = True # This is an installed plugin
+ data['enabled'] = _is_plugin_enabled(plugin)
+
+ # Get modify date from installed location
+ modify_date = 'N/A'
+ try:
+ if os.path.exists(metaXmlPath):
+ modify_time = os.path.getmtime(metaXmlPath)
+ modify_date = datetime.fromtimestamp(modify_time).strftime('%Y-%m-%d %H:%M:%S')
+ except Exception:
+ modify_date = 'N/A'
+
+ data['modify_date'] = modify_date
+
+ # Extract settings URL or main URL
+ settings_url_elem = root.find('settings_url')
+ url_elem = root.find('url')
+
+ # Priority: settings_url > url > default pattern
+ # Special handling for core plugins that don't use /plugins/ prefix
+ if plugin == 'emailMarketing':
+ # emailMarketing is a core CyberPanel plugin, uses /emailMarketing/ not /plugins/emailMarketing/
+ data['manage_url'] = '/emailMarketing/'
+ elif settings_url_elem is not None and settings_url_elem.text:
+ data['manage_url'] = settings_url_elem.text
+ elif url_elem is not None and url_elem.text:
+ data['manage_url'] = url_elem.text
+ else:
+ # Default to /plugins/{plugin}/ for regular plugins
+ # Special handling for emailMarketing
+ if plugin == 'emailMarketing':
+ data['manage_url'] = '/emailMarketing/'
+ else:
+ # Default to main plugin route (most plugins work from main route)
+ data['manage_url'] = f'/plugins/{plugin}/'
+
+ # Extract author information
+ author_elem = root.find('author')
+ if author_elem is not None and author_elem.text:
+ data['author'] = author_elem.text
+ else:
+ data['author'] = 'Unknown'
+
+ pluginList.append(data)
+
+ except ElementTree.ParseError as e:
+ errorPlugins.append({'name': plugin, 'error': f'XML parse error: {str(e)}'})
+ logging.writeToFile(f"Installed plugin {plugin}: XML parse error - {str(e)}")
+ continue
+ except Exception as e:
+ errorPlugins.append({'name': plugin, 'error': f'Error loading installed plugin: {str(e)}'})
+ logging.writeToFile(f"Installed plugin {plugin}: Error loading - {str(e)}")
+ continue
proc = httpProc(request, 'pluginHolder/plugins.html',
{'plugins': pluginList, 'error_plugins': errorPlugins}, 'admin')
@@ -336,4 +482,583 @@ def disable_plugin(request, plugin_name):
return JsonResponse({
'success': False,
'error': str(e)
+ }, status=500)
+
+def _ensure_cache_dir():
+ """Ensure cache directory exists"""
+ try:
+ if not os.path.exists(PLUGIN_STORE_CACHE_DIR):
+ os.makedirs(PLUGIN_STORE_CACHE_DIR, mode=0o755)
+ except Exception as e:
+ logging.writeToFile(f"Error creating cache directory: {str(e)}")
+
+def _get_cached_plugins(allow_expired=False):
+ """Get plugins from cache if available and not expired
+
+ Args:
+ allow_expired: If True, return cache even if expired (for fallback)
+ """
+ try:
+ if not os.path.exists(PLUGIN_STORE_CACHE_FILE):
+ return None
+
+ # Check if cache is expired
+ cache_mtime = os.path.getmtime(PLUGIN_STORE_CACHE_FILE)
+ cache_age = time.time() - cache_mtime
+
+ if cache_age > PLUGIN_STORE_CACHE_DURATION:
+ if not allow_expired:
+ logging.writeToFile(f"Plugin store cache expired (age: {cache_age:.0f}s)")
+ return None
+ else:
+ logging.writeToFile(f"Using expired cache as fallback (age: {cache_age:.0f}s)")
+
+ # Read cache file
+ with open(PLUGIN_STORE_CACHE_FILE, 'r', encoding='utf-8') as f:
+ cache_data = json.load(f)
+
+ if not allow_expired or cache_age <= PLUGIN_STORE_CACHE_DURATION:
+ logging.writeToFile(f"Using cached plugin store data (age: {cache_age:.0f}s)")
+ return cache_data.get('plugins', [])
+ except Exception as e:
+ logging.writeToFile(f"Error reading plugin store cache: {str(e)}")
+ return None
+
+def _save_plugins_cache(plugins):
+ """Save plugins to cache"""
+ try:
+ _ensure_cache_dir()
+ cache_data = {
+ 'plugins': plugins,
+ 'cached_at': datetime.now().isoformat(),
+ 'cache_duration': PLUGIN_STORE_CACHE_DURATION
+ }
+ with open(PLUGIN_STORE_CACHE_FILE, 'w', encoding='utf-8') as f:
+ json.dump(cache_data, f, indent=2, ensure_ascii=False)
+ logging.writeToFile("Plugin store cache saved successfully")
+ except Exception as e:
+ logging.writeToFile(f"Error saving plugin store cache: {str(e)}")
+
+def _enrich_store_plugins(plugins):
+ """Enrich store plugins with installed/enabled status from local system"""
+ enriched = []
+ plugin_source_dir = '/home/cyberpanel/plugins'
+ plugin_install_dir = '/usr/local/CyberCP'
+
+ for plugin in plugins:
+ plugin_dir = plugin.get('plugin_dir', '')
+ if not plugin_dir:
+ continue
+
+ # Check if plugin is installed locally
+ installed_path = os.path.join(plugin_install_dir, plugin_dir)
+ source_path = os.path.join(plugin_source_dir, plugin_dir)
+
+ plugin['installed'] = os.path.exists(installed_path) or os.path.exists(source_path)
+
+ # Check if plugin is enabled (only if installed)
+ if plugin['installed']:
+ plugin['enabled'] = _is_plugin_enabled(plugin_dir)
+ else:
+ plugin['enabled'] = False
+
+ enriched.append(plugin)
+
+ return enriched
+
+def _fetch_plugins_from_github():
+ """Fetch plugins from GitHub repository"""
+ plugins = []
+
+ try:
+ # Fetch repository contents
+ req = urllib.request.Request(
+ GITHUB_REPO_API,
+ headers={
+ 'User-Agent': 'CyberPanel-Plugin-Store/1.0',
+ 'Accept': 'application/vnd.github.v3+json'
+ }
+ )
+
+ with urllib.request.urlopen(req, timeout=10) as response:
+ contents = json.loads(response.read().decode('utf-8'))
+
+ # Filter for directories (plugins)
+ plugin_dirs = [item for item in contents if item.get('type') == 'dir' and not item.get('name', '').startswith('.')]
+
+ for plugin_dir in plugin_dirs:
+ plugin_name = plugin_dir.get('name', '')
+ if not plugin_name:
+ continue
+
+ try:
+ # Fetch meta.xml from raw GitHub
+ meta_xml_url = f"{GITHUB_RAW_BASE}/{plugin_name}/meta.xml"
+ meta_req = urllib.request.Request(
+ meta_xml_url,
+ headers={'User-Agent': 'CyberPanel-Plugin-Store/1.0'}
+ )
+
+ with urllib.request.urlopen(meta_req, timeout=10) as meta_response:
+ meta_xml_content = meta_response.read().decode('utf-8')
+
+ # Parse meta.xml
+ root = ElementTree.fromstring(meta_xml_content)
+
+ # Fetch last commit date for this plugin from GitHub
+ modify_date = 'N/A'
+ try:
+ commits_url = f"{GITHUB_COMMITS_API}?path={plugin_name}&per_page=1"
+ commits_req = urllib.request.Request(
+ commits_url,
+ headers={
+ 'User-Agent': 'CyberPanel-Plugin-Store/1.0',
+ 'Accept': 'application/vnd.github.v3+json'
+ }
+ )
+
+ with urllib.request.urlopen(commits_req, timeout=10) as commits_response:
+ commits_data = json.loads(commits_response.read().decode('utf-8'))
+ if commits_data and len(commits_data) > 0:
+ commit_date = commits_data[0].get('commit', {}).get('author', {}).get('date', '')
+ if commit_date:
+ # Parse ISO 8601 date and format it
+ try:
+ from datetime import datetime
+ dt = datetime.fromisoformat(commit_date.replace('Z', '+00:00'))
+ modify_date = dt.strftime('%Y-%m-%d %H:%M:%S')
+ except Exception:
+ modify_date = commit_date[:19].replace('T', ' ') # Fallback formatting
+ except Exception as e:
+ logging.writeToFile(f"Could not fetch commit date for {plugin_name}: {str(e)}")
+ modify_date = 'N/A'
+
+ plugin_data = {
+ 'plugin_dir': plugin_name,
+ 'name': root.find('name').text if root.find('name') is not None else plugin_name,
+ 'type': root.find('type').text if root.find('type') is not None else 'Plugin',
+ 'description': root.find('description').text if root.find('description') is not None else '',
+ 'version': root.find('version').text if root.find('version') is not None else '1.0.0',
+ 'url': root.find('url').text if root.find('url') is not None else f'/plugins/{plugin_name}/',
+ 'settings_url': root.find('settings_url').text if root.find('settings_url') is not None else f'/plugins/{plugin_name}/settings/',
+ 'author': root.find('author').text if root.find('author') is not None else 'Unknown',
+ 'github_url': f'https://github.com/master3395/cyberpanel-plugins/tree/main/{plugin_name}',
+ 'about_url': f'https://github.com/master3395/cyberpanel-plugins/tree/main/{plugin_name}',
+ 'modify_date': modify_date
+ }
+
+ plugins.append(plugin_data)
+ logging.writeToFile(f"Fetched plugin: {plugin_name} (last modified: {modify_date})")
+
+ except urllib.error.HTTPError as e:
+ if e.code == 403:
+ # Rate limit hit - log and break
+ logging.writeToFile(f"GitHub API rate limit exceeded (403) for plugin {plugin_name}")
+ raise # Re-raise to be caught by outer handler
+ elif e.code == 404:
+ # meta.xml not found, skip this plugin
+ logging.writeToFile(f"meta.xml not found for plugin {plugin_name}, skipping")
+ continue
+ else:
+ logging.writeToFile(f"HTTP error {e.code} fetching {plugin_name}: {str(e)}")
+ continue
+ except Exception as e:
+ logging.writeToFile(f"Error processing plugin {plugin_name}: {str(e)}")
+ continue
+
+ return plugins
+
+ except urllib.error.HTTPError as e:
+ if e.code == 403:
+ error_msg = "GitHub API rate limit exceeded. Using cached data if available."
+ logging.writeToFile(f"GitHub API 403 error: {error_msg}")
+ raise Exception(error_msg)
+ else:
+ error_msg = f"GitHub API error {e.code}: {str(e)}"
+ logging.writeToFile(error_msg)
+ raise Exception(error_msg)
+ except urllib.error.URLError as e:
+ error_msg = f"Network error fetching plugins: {str(e)}"
+ logging.writeToFile(error_msg)
+ raise Exception(error_msg)
+ except Exception as e:
+ error_msg = f"Error fetching plugins from GitHub: {str(e)}"
+ logging.writeToFile(error_msg)
+ raise Exception(error_msg)
+
+@csrf_exempt
+@require_http_methods(["GET"])
+def fetch_plugin_store(request):
+ """Fetch plugins from the plugin store with caching"""
+ mailUtilities.checkHome()
+
+ # Try to get from cache first
+ cached_plugins = _get_cached_plugins()
+ if cached_plugins is not None:
+ # Enrich cached plugins with installed/enabled status
+ enriched_plugins = _enrich_store_plugins(cached_plugins)
+ return JsonResponse({
+ 'success': True,
+ 'plugins': enriched_plugins,
+ 'cached': True
+ })
+
+ # Cache miss or expired - fetch from GitHub
+ try:
+ plugins = _fetch_plugins_from_github()
+
+ # Enrich plugins with installed/enabled status
+ enriched_plugins = _enrich_store_plugins(plugins)
+
+ # Save to cache (save original, not enriched, to keep cache clean)
+ if plugins:
+ _save_plugins_cache(plugins)
+
+ return JsonResponse({
+ 'success': True,
+ 'plugins': enriched_plugins,
+ 'cached': False
+ })
+
+ except Exception as e:
+ error_message = str(e)
+
+ # If rate limited, try to use stale cache as fallback
+ if '403' in error_message or 'rate limit' in error_message.lower():
+ stale_cache = _get_cached_plugins(allow_expired=True) # Get cache even if expired
+ if stale_cache is not None:
+ logging.writeToFile("Using stale cache due to rate limit")
+ enriched_plugins = _enrich_store_plugins(stale_cache)
+ return JsonResponse({
+ 'success': True,
+ 'plugins': enriched_plugins,
+ 'cached': True,
+ 'warning': 'Using cached data due to GitHub rate limit. Data may be outdated.'
+ })
+
+ # No cache available, return error
+ return JsonResponse({
+ 'success': False,
+ 'error': error_message,
+ 'plugins': []
}, status=500)
+
+@csrf_exempt
+@require_http_methods(["POST"])
+def install_from_store(request, plugin_name):
+ """Install plugin from GitHub store"""
+ mailUtilities.checkHome()
+
+ try:
+ # Check if already installed
+ pluginInstalled = '/usr/local/CyberCP/' + plugin_name
+ if os.path.exists(pluginInstalled):
+ return JsonResponse({
+ 'success': False,
+ 'error': f'Plugin already installed: {plugin_name}'
+ }, status=400)
+
+ # Download plugin from GitHub
+ import tempfile
+ import shutil
+ import zipfile
+ import io
+
+ logging.writeToFile(f"Starting installation of {plugin_name} from GitHub store")
+
+ # Create temporary directory
+ temp_dir = tempfile.mkdtemp()
+ zip_path = os.path.join(temp_dir, plugin_name + '.zip')
+
+ try:
+ # Download repository as ZIP
+ repo_zip_url = 'https://github.com/master3395/cyberpanel-plugins/archive/refs/heads/main.zip'
+ logging.writeToFile(f"Downloading plugin from: {repo_zip_url}")
+
+ repo_req = urllib.request.Request(
+ repo_zip_url,
+ headers={
+ 'User-Agent': 'CyberPanel-Plugin-Store/1.0',
+ 'Accept': 'application/zip'
+ }
+ )
+
+ with urllib.request.urlopen(repo_req, timeout=30) as repo_response:
+ repo_zip_data = repo_response.read()
+
+ # Extract plugin directory from repository ZIP
+ repo_zip = zipfile.ZipFile(io.BytesIO(repo_zip_data))
+
+ # Find plugin directory in ZIP
+ plugin_prefix = f'cyberpanel-plugins-main/{plugin_name}/'
+ plugin_files = [f for f in repo_zip.namelist() if f.startswith(plugin_prefix)]
+
+ if not plugin_files:
+ raise Exception(f'Plugin {plugin_name} not found in GitHub repository')
+
+ logging.writeToFile(f"Found {len(plugin_files)} files for plugin {plugin_name}")
+
+ # Create plugin ZIP file
+ plugin_zip = zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED)
+
+ for file_path in plugin_files:
+ # Remove the repository root prefix
+ relative_path = file_path[len(plugin_prefix):]
+ if relative_path: # Skip directories
+ file_data = repo_zip.read(file_path)
+ plugin_zip.writestr(relative_path, file_data)
+
+ plugin_zip.close()
+
+ # Verify ZIP was created
+ if not os.path.exists(zip_path):
+ raise Exception(f'Failed to create plugin ZIP file')
+
+ logging.writeToFile(f"Created plugin ZIP: {zip_path}")
+
+ # Copy ZIP to current directory (pluginInstaller expects it in cwd)
+ 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")
+
+ # Install using pluginInstaller (direct call, not via command line)
+ pluginInstaller.installPlugin(plugin_name)
+
+ # Wait a moment for file system to sync and service to restart
+ import time
+ time.sleep(2)
+
+ # Verify plugin was actually installed
+ pluginInstalled = '/usr/local/CyberCP/' + plugin_name
+ if not os.path.exists(pluginInstalled):
+ raise Exception(f'Plugin installation failed: {pluginInstalled} does not exist after installation')
+
+ logging.writeToFile(f"Plugin {plugin_name} installed successfully")
+
+ # Set plugin to enabled by default after installation
+ _set_plugin_state(plugin_name, True)
+
+ return JsonResponse({
+ 'success': True,
+ 'message': f'Plugin {plugin_name} installed successfully from store'
+ })
+ finally:
+ os.chdir(original_cwd)
+
+ finally:
+ # Cleanup
+ shutil.rmtree(temp_dir, ignore_errors=True)
+
+ except urllib.error.HTTPError as e:
+ error_msg = f'Failed to download plugin from GitHub: HTTP {e.code}'
+ if e.code == 404:
+ error_msg = f'Plugin {plugin_name} not found in GitHub repository'
+ logging.writeToFile(f"Error installing {plugin_name}: {error_msg}")
+ return JsonResponse({
+ 'success': False,
+ 'error': error_msg
+ }, status=500)
+ except Exception as e:
+ logging.writeToFile(f"Error installing plugin {plugin_name}: {str(e)}")
+ import traceback
+ error_details = traceback.format_exc()
+ logging.writeToFile(f"Traceback: {error_details}")
+ return JsonResponse({
+ 'success': False,
+ 'error': str(e)
+ }, status=500)
+
+def plugin_help(request, plugin_name):
+ """Plugin-specific help page - shows plugin information, version history, and help content"""
+ mailUtilities.checkHome()
+
+ # Paths for the plugin
+ plugin_path = '/usr/local/CyberCP/' + plugin_name
+ meta_xml_path = os.path.join(plugin_path, 'meta.xml')
+
+ # Check if plugin exists
+ if not os.path.exists(plugin_path) or not os.path.exists(meta_xml_path):
+ proc = httpProc(request, 'pluginHolder/plugin_not_found.html', {
+ 'plugin_name': plugin_name
+ }, 'admin')
+ return proc.render()
+
+ # Parse meta.xml
+ try:
+ plugin_meta = ElementTree.parse(meta_xml_path)
+ root = plugin_meta.getroot()
+
+ # Extract plugin information
+ plugin_display_name = root.find('name').text if root.find('name') is not None else plugin_name
+ plugin_description = root.find('description').text if root.find('description') is not None else ''
+ plugin_version = root.find('version').text if root.find('version') is not None else 'Unknown'
+ plugin_author = root.find('author').text if root.find('author') is not None else 'Unknown'
+ plugin_type = root.find('type').text if root.find('type') is not None else 'Plugin'
+
+ # Check if plugin is installed
+ installed = os.path.exists(plugin_path)
+
+ except Exception as e:
+ logging.writeToFile(f"Error parsing meta.xml for {plugin_name}: {str(e)}")
+ proc = httpProc(request, 'pluginHolder/plugin_not_found.html', {
+ 'plugin_name': plugin_name
+ }, 'admin')
+ return proc.render()
+
+ # Look for help content files (README.md, CHANGELOG.md, HELP.md, etc.)
+ help_content = ''
+ changelog_content = ''
+
+ # Check for README.md or HELP.md
+ help_files = ['HELP.md', 'README.md', 'docs/HELP.md', 'docs/README.md']
+ help_file_path = None
+ for help_file in help_files:
+ potential_path = os.path.join(plugin_path, help_file)
+ if os.path.exists(potential_path):
+ help_file_path = potential_path
+ break
+
+ if help_file_path:
+ try:
+ with open(help_file_path, 'r', encoding='utf-8') as f:
+ help_content = f.read()
+ except Exception as e:
+ logging.writeToFile(f"Error reading help file for {plugin_name}: {str(e)}")
+ help_content = ''
+
+ # Check for CHANGELOG.md
+ changelog_paths = ['CHANGELOG.md', 'changelog.md', 'CHANGELOG.txt', 'docs/CHANGELOG.md']
+ for changelog_file in changelog_paths:
+ potential_path = os.path.join(plugin_path, changelog_file)
+ if os.path.exists(potential_path):
+ try:
+ with open(potential_path, 'r', encoding='utf-8') as f:
+ changelog_content = f.read()
+ break
+ except Exception as e:
+ logging.writeToFile(f"Error reading changelog for {plugin_name}: {str(e)}")
+
+ # If no local changelog, try fetching from GitHub (non-blocking)
+ if not changelog_content:
+ try:
+ github_changelog_url = f'{GITHUB_RAW_BASE}/{plugin_name}/CHANGELOG.md'
+ try:
+ with urllib.request.urlopen(github_changelog_url, timeout=3) as response:
+ if response.getcode() == 200:
+ changelog_content = response.read().decode('utf-8')
+ logging.writeToFile(f"Fetched CHANGELOG.md from GitHub for {plugin_name}")
+ except (urllib.error.HTTPError, urllib.error.URLError, Exception):
+ # Silently fail - GitHub fetch is optional
+ pass
+ except Exception:
+ # Silently fail - GitHub fetch is optional
+ pass
+
+ # If no help content and no local README, try fetching README.md from GitHub
+ if not help_content:
+ try:
+ github_readme_url = f'{GITHUB_RAW_BASE}/{plugin_name}/README.md'
+ try:
+ with urllib.request.urlopen(github_readme_url, timeout=3) as response:
+ if response.getcode() == 200:
+ help_content = response.read().decode('utf-8')
+ logging.writeToFile(f"Fetched README.md from GitHub for {plugin_name}")
+ except (urllib.error.HTTPError, urllib.error.URLError, Exception):
+ # Silently fail - GitHub fetch is optional
+ pass
+ except Exception:
+ # Silently fail - GitHub fetch is optional
+ pass
+
+ # If no help content found, create default content from meta.xml
+ if not help_content:
+ help_content = f"""
+
Plugin Information
+
Name: {plugin_display_name}
+
Type: {plugin_type}
+
Version: {plugin_version}
+
Author: {plugin_author}
+
+
Description
+
{plugin_description}
+
+
Usage
+
For detailed information about this plugin, please visit the GitHub repository or check the plugin's documentation.
', help_content, flags=re.MULTILINE)
+ # Wrap paragraphs (but preserve HTML tags and images)
+ lines = help_content.split('\n')
+ processed_lines = []
+ for line in lines:
+ line = line.strip()
+ if line and not line.startswith('<') and not line.startswith('http') and not '{line}
')
+ elif line:
+ processed_lines.append(line)
+ help_content = '\n'.join(processed_lines)
+
+ # Add changelog if available
+ if changelog_content:
+ # Convert changelog markdown to HTML
+ import re
+ changelog_html = changelog_content
+ changelog_html = re.sub(r'^## (.*?)$', r'