Enhance plugin store: Add upgrade button, auto-backup, revert functionality, cache randomization, and local time display

- Add upgrade button in plugin store when updates are available
- Implement automatic plugin backup before upgrades
- Add revert version functionality with backup selection
- Randomize cache duration (±10 minutes) to prevent simultaneous GitHub API requests
- Display cache expiry time in user's local timezone and locale format
- Fix revert plugin function to work without event object
- Improve error handling in plugin store operations
This commit is contained in:
master3395
2026-01-27 00:32:45 +01:00
parent 8174d464c9
commit b482dc9776
3 changed files with 879 additions and 17 deletions

View File

@@ -709,6 +709,32 @@
cursor: not-allowed;
}
.btn-upgrade {
padding: 8px 16px;
background: #f59e0b;
color: white;
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
display: inline-flex;
align-items: center;
gap: 6px;
}
.btn-upgrade:hover:not(:disabled) {
background: #d97706;
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(245,158,11,0.3);
}
.btn-upgrade:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-link {
padding: 6px 12px;
background: var(--bg-secondary, #f8f9ff);
@@ -772,6 +798,17 @@
box-shadow: 0 4px 8px rgba(88,86,214,0.3);
}
.btn-revert {
background: #6c757d;
color: white;
}
.btn-revert:hover:not(:disabled) {
background: #5a6268;
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(108,117,125,0.3);
}
.btn-uninstall {
background: #dc3545;
color: white;
@@ -1042,6 +1079,9 @@
<i class="fas fa-toggle-off"></i> {% trans "Activate" %}
</button>
{% endif %}
<button class="btn-action btn-revert btn-small" onclick="showRevertDialog('{{ plugin.plugin_dir }}')" title="{% trans 'Revert to Previous Version' %}">
<i class="fas fa-undo"></i> {% trans "Revert Version" %}
</button>
<button class="btn-action btn-uninstall btn-small" onclick="uninstallPlugin('{{ plugin.plugin_dir }}')">
<i class="fas fa-trash"></i> {% trans "Uninstall" %}
</button>
@@ -1123,6 +1163,9 @@
<i class="fas fa-toggle-off"></i> {% trans "Activate" %}
</button>
{% endif %}
<button class="btn-action btn-revert" onclick="showRevertDialog('{{ plugin.plugin_dir }}')" title="{% trans 'Revert to Previous Version' %}">
<i class="fas fa-undo"></i> {% trans "Revert Version" %}
</button>
<button class="btn-action btn-uninstall" onclick="uninstallPlugin('{{ plugin.plugin_dir }}')">
<i class="fas fa-trash"></i> {% trans "Uninstall" %}
</button>
@@ -1194,6 +1237,17 @@
<!-- CyberPanel Plugin Store (always available) -->
<div id="storeView" style="display: {% if not plugins %}block{% else %}none{% endif %};">
<!-- Loading Indicator -->
<div id="storeLoading" class="store-loading" style="display: {% if not plugins %}block{% else %}none{% endif %};">
<i class="fas fa-spinner fa-spin"></i> {% trans "Loading plugins from store..." %}
</div>
<!-- Store Error -->
<div id="storeError" class="alert alert-danger" style="display: none;">
<i class="fas fa-exclamation-circle alert-icon"></i>
<span id="storeErrorText"></span>
</div>
<!-- Notice Section (similar to CMSMS Module Manager) -->
<div class="store-notice" id="pluginStoreNotice">
<div class="notice-header">
@@ -1208,6 +1262,9 @@
<i class="fas fa-info-circle" style="color: #5856d6;"></i>
<strong>{% trans "Cache Information:" %}</strong>
{% 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." %}
{% if cache_expiry_timestamp %}
<br><strong>{% trans "Next cache update:" %}</strong> <span id="cacheExpiryTime" style="font-family: monospace;" data-timestamp="{{ cache_expiry_timestamp }}">{% trans "Calculating..." %}</span>
{% endif %}
</p>
<p class="warning-text">
<i class="fas fa-exclamation-triangle"></i>
@@ -1410,12 +1467,20 @@ function displayStorePlugins() {
<i class="${iconClass}"></i>
</div>`;
// Action column - Store view only shows Install/Installed (no Deactivate/Uninstall)
// Action column - Store view only shows Install/Installed/Upgrade (no Deactivate/Uninstall)
// NOTE: Store view should NOT show Deactivate/Uninstall buttons - users manage from Grid/Table views
let actionHtml = '';
if (plugin.installed) {
// Show "Installed" text
actionHtml = '<span class="status-installed">Installed</span>';
// Check if update is available
if (plugin.update_available) {
// Show Upgrade button
actionHtml = `<button class="btn-action btn-upgrade" onclick="upgradePlugin('${plugin.plugin_dir}', '${plugin.installed_version || 'Unknown'}', '${plugin.version || 'Unknown'}')">
<i class="fas fa-arrow-up"></i> Upgrade
</button>`;
} else {
// Show "Installed" text
actionHtml = '<span class="status-installed">Installed</span>';
}
} else {
// Show Install button
actionHtml = `<button class="btn-action btn-install" onclick="installFromStore('${plugin.plugin_dir}')">
@@ -1486,6 +1551,83 @@ function filterByLetter(letter) {
displayStorePlugins();
}
function upgradePlugin(pluginName, currentVersion, newVersion) {
// Show confirmation dialog with backup warning
const message = `⚠️ WARNING: Plugin Upgrade\n\n` +
`You are about to upgrade ${pluginName} from version ${currentVersion} to ${newVersion}.\n\n` +
`⚠️ IMPORTANT: You could lose data during the upgrade process.\n\n` +
`Please ensure you have backed up:\n` +
`• Plugin configuration files\n` +
`• Plugin data and databases\n` +
`• Any custom modifications\n\n` +
`Do you want to continue with the upgrade?`;
if (!confirm(message)) {
return;
}
// Double confirmation
if (!confirm(`Final confirmation: Upgrade ${pluginName} now?\n\nThis action cannot be undone.`)) {
return;
}
const btn = event.target.closest('.btn-upgrade') || event.target;
const originalText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Upgrading...';
fetch(`/plugins/api/store/upgrade/${pluginName}/`, {
method: 'POST',
headers: {
'X-CSRFToken': getCookie('csrftoken'),
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
if (typeof PNotify !== 'undefined') {
new PNotify({
title: 'Upgrade Successful!',
text: data.message || `Plugin ${pluginName} upgraded successfully`,
type: 'success'
});
} else {
alert('Success: ' + (data.message || `Plugin ${pluginName} upgraded successfully`));
}
// Reload page after short delay to show success message
setTimeout(() => {
location.reload();
}, 1500);
} else {
if (typeof PNotify !== 'undefined') {
new PNotify({
title: 'Upgrade Failed!',
text: data.error || 'Failed to upgrade plugin',
type: 'error'
});
} else {
alert('Error: ' + (data.error || 'Failed to upgrade plugin'));
}
btn.disabled = false;
btn.innerHTML = originalText;
}
})
.catch(error => {
if (typeof PNotify !== 'undefined') {
new PNotify({
title: 'Error!',
text: 'Failed to upgrade plugin: ' + error.message,
type: 'error'
});
} else {
alert('Error: Failed to upgrade plugin - ' + error.message);
}
btn.disabled = false;
btn.innerHTML = originalText;
});
}
function installFromStore(pluginName) {
if (!confirm(`Install ${pluginName} from the CyberPanel Plugin Store?`)) {
return;
@@ -1659,6 +1801,165 @@ function installPlugin(pluginName) {
});
}
function showRevertDialog(pluginName) {
// Fetch available backups
fetch(`/plugins/api/backups/${pluginName}/`)
.then(response => response.json())
.then(data => {
if (!data.success) {
if (typeof PNotify !== 'undefined') {
new PNotify({
title: 'Error!',
text: data.error || 'Failed to load backups',
type: 'error'
});
} else {
alert('Error: ' + (data.error || 'Failed to load backups'));
}
return;
}
if (!data.backups || data.backups.length === 0) {
if (typeof PNotify !== 'undefined') {
new PNotify({
title: 'No Backups Available',
text: `No backups found for ${pluginName}. Backups are automatically created before upgrades.`,
type: 'info'
});
} else {
alert(`No backups found for ${pluginName}. Backups are automatically created before upgrades.`);
}
return;
}
// Show backup selection dialog
let backupList = 'Available backups:\n\n';
data.backups.forEach((backup, index) => {
const version = backup.version || 'unknown';
const timestamp = backup.timestamp || backup.created_at || 'unknown';
backupList += `${index + 1}. Version ${version} (${timestamp})\n`;
});
backupList += '\n⚠ WARNING: Reverting will replace the current plugin version with the selected backup.\n';
backupList += 'This action cannot be undone. Continue?';
if (!confirm(backupList)) {
return;
}
// Ask which backup to restore (for now, restore the most recent)
// In a more advanced UI, you could show a dropdown
const selectedBackup = data.backups[0]; // Most recent backup
if (!confirm(`Revert ${pluginName} to version ${selectedBackup.version}?\n\nThis will replace the current installation.`)) {
return;
}
// Perform revert
revertPlugin(pluginName, selectedBackup.backup_path);
})
.catch(error => {
const errorMessage = error && error.message ? error.message : (error ? String(error) : 'Unknown error');
if (typeof PNotify !== 'undefined') {
new PNotify({
title: 'Error!',
text: 'Failed to load backups: ' + errorMessage,
type: 'error'
});
} else {
alert('Error: Failed to load backups - ' + errorMessage);
}
});
}
function revertPlugin(pluginName, backupPath) {
// Find the revert button for this plugin (if it exists)
let btn = null;
let originalText = '';
// Try to find button by looking for onclick attribute containing the plugin name
const revertButtons = document.querySelectorAll('.btn-revert');
for (let button of revertButtons) {
const onclickAttr = button.getAttribute('onclick') || '';
if (onclickAttr.includes(pluginName)) {
btn = button;
originalText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Reverting...';
break;
}
}
// Show loading notification if button not found
if (!btn) {
if (typeof PNotify !== 'undefined') {
new PNotify({
title: 'Reverting...',
text: `Reverting ${pluginName} to previous version...`,
type: 'info'
});
}
}
fetch(`/plugins/api/revert/${pluginName}/`, {
method: 'POST',
headers: {
'X-CSRFToken': getCookie('csrftoken'),
'Content-Type': 'application/json'
},
body: JSON.stringify({
backup_path: backupPath
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
if (typeof PNotify !== 'undefined') {
new PNotify({
title: 'Revert Successful!',
text: data.message || `Plugin ${pluginName} reverted successfully`,
type: 'success'
});
} else {
alert('Success: ' + (data.message || `Plugin ${pluginName} reverted successfully`));
}
// Reload page after short delay
setTimeout(() => {
location.reload();
}, 1500);
} else {
if (typeof PNotify !== 'undefined') {
new PNotify({
title: 'Revert Failed!',
text: data.error || 'Failed to revert plugin',
type: 'error'
});
} else {
alert('Error: ' + (data.error || 'Failed to revert plugin'));
}
if (btn) {
btn.disabled = false;
btn.innerHTML = originalText;
}
}
})
.catch(error => {
if (typeof PNotify !== 'undefined') {
new PNotify({
title: 'Error!',
text: 'Failed to revert plugin: ' + error.message,
type: 'error'
});
} else {
alert('Error: Failed to revert plugin - ' + error.message);
}
if (btn) {
btn.disabled = false;
btn.innerHTML = originalText;
}
});
}
function uninstallPlugin(pluginName) {
if (!confirm(`Are you sure you want to uninstall ${pluginName}? All data from this plugin will be deleted.`)) {
return;
@@ -1848,7 +2149,63 @@ if (localStorage.getItem('pluginStoreNoticeDismissed') === 'true') {
}
// Initialize view on page load
// Convert cache expiry timestamp to local time
function updateCacheExpiryTime() {
const expiryElement = document.getElementById('cacheExpiryTime');
if (!expiryElement) return;
const timestamp = expiryElement.getAttribute('data-timestamp');
if (!timestamp) return;
try {
// Convert Unix timestamp (seconds) to milliseconds for JavaScript Date
const timestampMs = parseFloat(timestamp) * 1000;
const expiryDate = new Date(timestampMs);
// Check if date is valid
if (isNaN(expiryDate.getTime())) {
expiryElement.textContent = 'Invalid timestamp';
return;
}
// Get user's locale preferences
const locale = navigator.language || navigator.userLanguage || 'en-US';
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
// Format date and time according to user's locale
const dateOptions = {
year: 'numeric',
month: '2-digit',
day: '2-digit'
};
const timeOptions = {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
};
// Format date and time separately for better readability
const dateStr = expiryDate.toLocaleDateString(locale, dateOptions);
const timeStr = expiryDate.toLocaleTimeString(locale, timeOptions);
// Combine with timezone abbreviation
const formatted = dateStr + ' ' + timeStr;
// Display with timezone info
expiryElement.textContent = formatted;
expiryElement.title = 'Local time: ' + formatted + ' | Timezone: ' + timezone;
} catch (e) {
console.error('Error formatting cache expiry time:', e);
expiryElement.textContent = 'Error calculating time';
}
}
document.addEventListener('DOMContentLoaded', function() {
// Update cache expiry time to local timezone
updateCacheExpiryTime();
// Default to grid view if plugins exist, otherwise show store
const gridView = document.getElementById('gridView');
const storeView = document.getElementById('storeView');
@@ -1858,6 +2215,14 @@ document.addEventListener('DOMContentLoaded', function() {
// No plugins installed, show store by default
toggleView('store');
}
// Load store plugins if store view is visible (either from toggleView or already displayed)
setTimeout(function() {
const storeViewCheck = document.getElementById('storeView');
if (storeViewCheck && storeViewCheck.style.display !== 'none' && storePlugins.length === 0) {
loadPluginStore();
}
}, 100); // Small delay to ensure DOM is ready
});
</script>
{% endblock %}

View File

@@ -10,5 +10,8 @@ urlpatterns = [
path('api/disable/<str:plugin_name>/', views.disable_plugin, name='disable_plugin'),
path('api/store/plugins/', views.fetch_plugin_store, name='fetch_plugin_store'),
path('api/store/install/<str:plugin_name>/', views.install_from_store, name='install_from_store'),
path('api/store/upgrade/<str:plugin_name>/', views.upgrade_plugin, name='upgrade_plugin'),
path('api/backups/<str:plugin_name>/', views.get_plugin_backups, name='get_plugin_backups'),
path('api/revert/<str:plugin_name>/', views.revert_plugin, name='revert_plugin'),
path('<str:plugin_name>/help/', views.plugin_help, name='plugin_help'),
]

View File

@@ -26,11 +26,15 @@ 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)
PLUGIN_STORE_CACHE_DURATION = 3600 # Base cache duration: 1 hour (3600 seconds)
PLUGIN_STORE_CACHE_RANDOM_OFFSET = 600 # Random offset: ±10 minutes (600 seconds) to prevent simultaneous requests
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'
# Plugin backup configuration
PLUGIN_BACKUP_DIR = '/home/cyberpanel/plugin_backups'
def _get_plugin_state_file(plugin_name):
"""Get the path to the plugin state file"""
if not os.path.exists(PLUGIN_STATE_DIR):
@@ -364,9 +368,13 @@ def installed(request):
for p in pluginList:
logging.writeToFile(f" - {p.get('plugin_dir')}: installed={p.get('installed')}, enabled={p.get('enabled')}")
# Get cache expiry timestamp for display (will be converted to local time in browser)
cache_expiry_timestamp, _ = _get_cache_expiry_time()
proc = httpProc(request, 'pluginHolder/plugins.html',
{'plugins': pluginList, 'error_plugins': errorPlugins,
'installed_count': installed_count, 'active_count': active_count}, 'admin')
'installed_count': installed_count, 'active_count': active_count,
'cache_expiry_timestamp': cache_expiry_timestamp}, 'admin')
return proc.render()
@csrf_exempt
@@ -604,6 +612,37 @@ def _ensure_cache_dir():
except Exception as e:
logging.writeToFile(f"Error creating cache directory: {str(e)}")
def _get_cache_expiry_time():
"""Get the cache expiry time (when cache will be updated next)
Returns:
tuple: (expiry_timestamp, expiry_datetime_string) or (None, None) if no cache
expiry_timestamp is Unix timestamp for JavaScript conversion to local time
"""
try:
if not os.path.exists(PLUGIN_STORE_CACHE_FILE):
return None, None
# Try to read stored expiry time from cache metadata
try:
with open(PLUGIN_STORE_CACHE_FILE, 'r', encoding='utf-8') as f:
cache_data = json.load(f)
stored_expiry = cache_data.get('expiry_timestamp')
if stored_expiry:
# Return timestamp for JavaScript to convert to local time
return stored_expiry, None
except:
pass # Fall back to calculation if metadata not found
# Fallback: calculate from file modification time (for old cache files)
cache_mtime = os.path.getmtime(PLUGIN_STORE_CACHE_FILE)
expiry_timestamp = cache_mtime + PLUGIN_STORE_CACHE_DURATION
return expiry_timestamp, None
except Exception as e:
logging.writeToFile(f"Error getting cache expiry time: {str(e)}")
return None, None
def _get_cached_plugins(allow_expired=False):
"""Get plugins from cache if available and not expired
@@ -614,22 +653,32 @@ def _get_cached_plugins(allow_expired=False):
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
# Read cache file to get stored expiry time
with open(PLUGIN_STORE_CACHE_FILE, 'r', encoding='utf-8') as f:
cache_data = json.load(f)
if cache_age > PLUGIN_STORE_CACHE_DURATION:
# Check expiry using stored timestamp if available, otherwise fall back to file mtime
current_time = time.time()
stored_expiry = cache_data.get('expiry_timestamp')
if stored_expiry:
# Use stored expiry time (with randomization)
cache_age = current_time - (stored_expiry - cache_data.get('cache_duration', PLUGIN_STORE_CACHE_DURATION))
is_expired = current_time >= stored_expiry
else:
# Fallback for old cache files without expiry metadata
cache_mtime = os.path.getmtime(PLUGIN_STORE_CACHE_FILE)
cache_age = current_time - cache_mtime
is_expired = cache_age > PLUGIN_STORE_CACHE_DURATION
if is_expired:
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:
if not allow_expired or not is_expired:
logging.writeToFile(f"Using cached plugin store data (age: {cache_age:.0f}s)")
return cache_data.get('plugins', [])
except Exception as e:
@@ -637,20 +686,213 @@ def _get_cached_plugins(allow_expired=False):
return None
def _save_plugins_cache(plugins):
"""Save plugins to cache"""
"""Save plugins to cache with randomized expiry time"""
try:
_ensure_cache_dir()
# Generate random cache duration to prevent simultaneous requests from all CyberPanel instances
# Base duration ± random offset (e.g., 1 hour ± 10 minutes)
import random
random_offset = random.randint(-PLUGIN_STORE_CACHE_RANDOM_OFFSET, PLUGIN_STORE_CACHE_RANDOM_OFFSET)
actual_cache_duration = PLUGIN_STORE_CACHE_DURATION + random_offset
# Calculate expiry timestamp
current_time = time.time()
expiry_timestamp = current_time + actual_cache_duration
cache_data = {
'plugins': plugins,
'cached_at': datetime.now().isoformat(),
'cache_duration': PLUGIN_STORE_CACHE_DURATION
'expiry_timestamp': expiry_timestamp,
'cache_duration': actual_cache_duration,
'base_duration': PLUGIN_STORE_CACHE_DURATION,
'random_offset': random_offset
}
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")
expiry_datetime = datetime.fromtimestamp(expiry_timestamp)
logging.writeToFile(f"Plugin store cache saved successfully. Expires at: {expiry_datetime.strftime('%Y-%m-%d %H:%M:%S')} (duration: {actual_cache_duration}s, offset: {random_offset:+d}s)")
except Exception as e:
logging.writeToFile(f"Error saving plugin store cache: {str(e)}")
def _compare_versions(version1, version2):
"""
Compare two version strings (semantic versioning)
Returns: 1 if version1 > version2, -1 if version1 < version2, 0 if equal
"""
try:
# Split versions into parts
v1_parts = [int(x) for x in version1.split('.')]
v2_parts = [int(x) for x in version2.split('.')]
# Pad shorter version with zeros
max_len = max(len(v1_parts), len(v2_parts))
v1_parts.extend([0] * (max_len - len(v1_parts)))
v2_parts.extend([0] * (max_len - len(v2_parts)))
# Compare each part
for v1, v2 in zip(v1_parts, v2_parts):
if v1 > v2:
return 1
elif v1 < v2:
return -1
return 0
except:
# Fallback to string comparison if parsing fails
if version1 > version2:
return 1
elif version1 < version2:
return -1
return 0
def _get_installed_version(plugin_dir, plugin_install_dir):
"""Get installed version of a plugin from meta.xml"""
installed_path = os.path.join(plugin_install_dir, plugin_dir)
meta_path = os.path.join(installed_path, 'meta.xml')
if os.path.exists(meta_path):
try:
pluginMetaData = ElementTree.parse(meta_path)
root = pluginMetaData.getroot()
version_elem = root.find('version')
if version_elem is not None and version_elem.text:
return version_elem.text.strip()
except Exception as e:
logging.writeToFile(f"Error reading version from {meta_path}: {str(e)}")
return None
def _create_plugin_backup(plugin_name, plugin_install_dir='/usr/local/CyberCP'):
"""
Create a backup of a plugin before upgrade
Returns: (backup_path, backup_info) or (None, None) on failure
"""
try:
# Ensure backup directory exists
if not os.path.exists(PLUGIN_BACKUP_DIR):
os.makedirs(PLUGIN_BACKUP_DIR, mode=0o755)
plugin_path = os.path.join(plugin_install_dir, plugin_name)
if not os.path.exists(plugin_path):
return None, None
# Get current version
installed_version = _get_installed_version(plugin_name, plugin_install_dir) or 'unknown'
# Create backup directory with timestamp
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
backup_name = f"{plugin_name}_v{installed_version}_{timestamp}"
backup_path = os.path.join(PLUGIN_BACKUP_DIR, backup_name)
# Copy plugin directory
import shutil
shutil.copytree(plugin_path, backup_path)
# Create backup metadata
backup_info = {
'plugin_name': plugin_name,
'version': installed_version,
'timestamp': timestamp,
'backup_path': backup_path,
'created_at': datetime.now().isoformat()
}
# Save metadata as JSON
metadata_file = os.path.join(backup_path, '.backup_metadata.json')
with open(metadata_file, 'w') as f:
json.dump(backup_info, f, indent=2)
logging.writeToFile(f"Created backup for {plugin_name} version {installed_version} at {backup_path}")
return backup_path, backup_info
except Exception as e:
logging.writeToFile(f"Error creating backup for {plugin_name}: {str(e)}")
return None, None
def _get_plugin_backups(plugin_name):
"""Get list of available backups for a plugin"""
backups = []
if not os.path.exists(PLUGIN_BACKUP_DIR):
return backups
try:
for item in os.listdir(PLUGIN_BACKUP_DIR):
if item.startswith(plugin_name + '_'):
backup_path = os.path.join(PLUGIN_BACKUP_DIR, item)
if os.path.isdir(backup_path):
metadata_file = os.path.join(backup_path, '.backup_metadata.json')
if os.path.exists(metadata_file):
try:
with open(metadata_file, 'r') as f:
backup_info = json.load(f)
backups.append(backup_info)
except:
# Fallback: parse from directory name
parts = item.split('_')
if len(parts) >= 3:
version = parts[1].replace('v', '')
timestamp = '_'.join(parts[2:])
backups.append({
'plugin_name': plugin_name,
'version': version,
'timestamp': timestamp,
'backup_path': backup_path,
'created_at': timestamp
})
else:
# No metadata, try to parse from directory name
parts = item.split('_')
if len(parts) >= 3:
version = parts[1].replace('v', '')
timestamp = '_'.join(parts[2:])
backups.append({
'plugin_name': plugin_name,
'version': version,
'timestamp': timestamp,
'backup_path': backup_path,
'created_at': timestamp
})
# Sort by timestamp (newest first)
backups.sort(key=lambda x: x.get('timestamp', ''), reverse=True)
except Exception as e:
logging.writeToFile(f"Error listing backups for {plugin_name}: {str(e)}")
return backups
def _restore_plugin_from_backup(plugin_name, backup_path):
"""Restore a plugin from a backup"""
try:
plugin_install_dir = '/usr/local/CyberCP'
plugin_path = os.path.join(plugin_install_dir, plugin_name)
# Remove current plugin installation
if os.path.exists(plugin_path):
import shutil
shutil.rmtree(plugin_path)
# Restore from backup
import shutil
shutil.copytree(backup_path, plugin_path)
# Remove backup metadata file from restored plugin
metadata_file = os.path.join(plugin_path, '.backup_metadata.json')
if os.path.exists(metadata_file):
os.remove(metadata_file)
logging.writeToFile(f"Restored {plugin_name} from backup {backup_path}")
return True
except Exception as e:
logging.writeToFile(f"Error restoring {plugin_name} from backup: {str(e)}")
return False
def _enrich_store_plugins(plugins):
"""Enrich store plugins with installed/enabled status from local system"""
enriched = []
@@ -672,8 +914,22 @@ def _enrich_store_plugins(plugins):
# Check if plugin is enabled (only if installed)
if plugin['installed']:
plugin['enabled'] = _is_plugin_enabled(plugin_dir)
# Check for updates by comparing versions
installed_version = _get_installed_version(plugin_dir, plugin_install_dir)
store_version = plugin.get('version', '0.0.0')
if installed_version and store_version:
# Update available if store version is newer
plugin['update_available'] = _compare_versions(store_version, installed_version) > 0
plugin['installed_version'] = installed_version
else:
plugin['update_available'] = False
plugin['installed_version'] = installed_version or 'Unknown'
else:
plugin['enabled'] = False
plugin['update_available'] = False
plugin['installed_version'] = None
# Ensure is_paid field exists and is properly set (default to False if not set or invalid)
# Handle all possible cases: missing, None, empty string, string values, boolean
@@ -907,6 +1163,244 @@ def fetch_plugin_store(request):
'plugins': []
}, status=500)
@csrf_exempt
@require_http_methods(["POST"])
def upgrade_plugin(request, plugin_name):
"""Upgrade an installed plugin from GitHub store"""
mailUtilities.checkHome()
try:
# Check if plugin is installed
pluginInstalled = '/usr/local/CyberCP/' + plugin_name
if not os.path.exists(pluginInstalled):
return JsonResponse({
'success': False,
'error': f'Plugin not installed: {plugin_name}'
}, status=400)
# Get current version before upgrade
installed_version = _get_installed_version(plugin_name, '/usr/local/CyberCP')
# Create automatic backup before upgrade
backup_path, backup_info = _create_plugin_backup(plugin_name)
if backup_path:
logging.writeToFile(f"Created automatic backup for {plugin_name} before upgrade: {backup_path}")
else:
logging.writeToFile(f"Warning: Failed to create backup for {plugin_name}, continuing with upgrade anyway")
logging.writeToFile(f"Starting upgrade of {plugin_name} from version {installed_version}")
# Download and install plugin from GitHub (same as install_from_store)
import tempfile
import shutil
import zipfile
import io
# Create temporary directory
temp_dir = tempfile.mkdtemp()
zip_path = os.path.join(temp_dir, plugin_name + '.zip')
try:
# Download from GitHub
repo_zip_url = 'https://github.com/master3395/cyberpanel-plugins/archive/refs/heads/main.zip'
logging.writeToFile(f"Downloading plugin upgrade 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} in GitHub")
# Create plugin ZIP file from GitHub with correct structure
plugin_zip = zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED)
for file_path in plugin_files:
relative_path = file_path[len(plugin_prefix):]
if relative_path: # Skip directories
file_data = repo_zip.read(file_path)
arcname = os.path.join(plugin_name, relative_path)
plugin_zip.writestr(arcname, file_data)
plugin_zip.close()
repo_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:
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")
# Install using pluginInstaller (this will overwrite existing files)
try:
pluginInstaller.installPlugin(plugin_name)
except Exception as install_error:
error_msg = str(install_error)
logging.writeToFile(f"pluginInstaller.installPlugin raised exception: {error_msg}")
# Check if plugin directory exists despite the error
if not os.path.exists(pluginInstalled):
raise Exception(f'Plugin upgrade failed: {error_msg}')
# Wait for file system to sync
import time
time.sleep(3)
# Verify plugin was upgraded
if not os.path.exists(pluginInstalled):
raise Exception(f'Plugin upgrade failed: {pluginInstalled} does not exist after upgrade')
# Get new version
new_version = _get_installed_version(plugin_name, '/usr/local/CyberCP')
logging.writeToFile(f"Plugin {plugin_name} upgraded successfully from {installed_version} to {new_version}")
backup_message = ''
if backup_path:
backup_message = f' Backup created at: {backup_info.get("timestamp", "unknown")}'
return JsonResponse({
'success': True,
'message': f'Plugin {plugin_name} upgraded successfully from {installed_version} to {new_version}.{backup_message}',
'backup_created': backup_path is not None,
'backup_path': backup_path if backup_path else None
})
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 upgrading {plugin_name}: {error_msg}")
return JsonResponse({
'success': False,
'error': error_msg
}, status=500)
except Exception as e:
logging.writeToFile(f"Error upgrading 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)
@csrf_exempt
@require_http_methods(["GET"])
def get_plugin_backups(request, plugin_name):
"""Get list of available backups for a plugin"""
mailUtilities.checkHome()
try:
backups = _get_plugin_backups(plugin_name)
return JsonResponse({
'success': True,
'backups': backups,
'count': len(backups)
})
except Exception as e:
logging.writeToFile(f"Error getting backups for {plugin_name}: {str(e)}")
return JsonResponse({
'success': False,
'error': str(e)
}, status=500)
@csrf_exempt
@require_http_methods(["POST"])
def revert_plugin(request, plugin_name):
"""Revert a plugin to a previous version from backup"""
mailUtilities.checkHome()
try:
# Get backup path from request
data = json.loads(request.body)
backup_path = data.get('backup_path')
if not backup_path:
return JsonResponse({
'success': False,
'error': 'Backup path is required'
}, status=400)
# Verify backup exists
if not os.path.exists(backup_path):
return JsonResponse({
'success': False,
'error': f'Backup not found: {backup_path}'
}, status=404)
# Get backup version info
metadata_file = os.path.join(backup_path, '.backup_metadata.json')
backup_version = 'unknown'
if os.path.exists(metadata_file):
try:
with open(metadata_file, 'r') as f:
backup_info = json.load(f)
backup_version = backup_info.get('version', 'unknown')
except:
pass
logging.writeToFile(f"Reverting {plugin_name} to version {backup_version} from backup {backup_path}")
# Restore from backup
if _restore_plugin_from_backup(plugin_name, backup_path):
return JsonResponse({
'success': True,
'message': f'Plugin {plugin_name} reverted successfully to version {backup_version}'
})
else:
return JsonResponse({
'success': False,
'error': 'Failed to restore plugin from backup'
}, status=500)
except json.JSONDecodeError:
return JsonResponse({
'success': False,
'error': 'Invalid JSON data'
}, status=400)
except Exception as e:
logging.writeToFile(f"Error reverting 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)
@csrf_exempt
@require_http_methods(["POST"])
def install_from_store(request, plugin_name):