mirror of
https://github.com/usmannasir/cyberpanel.git
synced 2026-05-07 11:57:25 +02:00
@@ -1151,6 +1151,17 @@
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(220,53,69,0.3);
|
||||
}
|
||||
|
||||
.btn-delete-source {
|
||||
background: #b45309;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-delete-source:hover:not(:disabled) {
|
||||
background: #92400e;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(180, 83, 9, 0.35);
|
||||
}
|
||||
|
||||
.btn-activate {
|
||||
background: #28a745;
|
||||
@@ -1506,6 +1517,11 @@
|
||||
<button class="btn-action btn-install btn-small" onclick="installPlugin('{{ plugin.plugin_dir }}')">
|
||||
<i class="fas fa-download"></i> {% trans "Install" %}
|
||||
</button>
|
||||
{% if plugin.has_local_source %}
|
||||
<button type="button" class="btn-action btn-delete-source btn-small" data-plugin-dir="{{ plugin.plugin_dir }}" onclick="deletePluginLocalSource('{{ plugin.plugin_dir }}')">
|
||||
<i class="fas fa-folder-minus"></i> {% trans "Delete local copy" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if not plugin.builtin %}
|
||||
@@ -1599,6 +1615,11 @@
|
||||
<button class="btn-action btn-install" onclick="installPlugin('{{ plugin.plugin_dir }}')">
|
||||
<i class="fas fa-download"></i> {% trans "Install" %}
|
||||
</button>
|
||||
{% if plugin.has_local_source %}
|
||||
<button type="button" class="btn-action btn-delete-source" data-plugin-dir="{{ plugin.plugin_dir }}" onclick="deletePluginLocalSource('{{ plugin.plugin_dir }}')">
|
||||
<i class="fas fa-folder-minus"></i> {% trans "Delete local copy" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
@@ -2287,9 +2308,14 @@ function displayStorePlugins() {
|
||||
}
|
||||
} else {
|
||||
// Show Install button
|
||||
actionHtml = `<button class="btn-action btn-install" onclick="installFromStore('${plugin.plugin_dir}')">
|
||||
actionHtml = `<button class="btn-action btn-install" onclick="installFromStore('${escapeHtml(plugin.plugin_dir)}')">
|
||||
<i class="fas fa-download"></i> Install
|
||||
</button>`;
|
||||
if (plugin.has_local_source && !plugin.builtin) {
|
||||
actionHtml += ` <button type="button" class="btn-action btn-delete-source" onclick="deletePluginLocalSource('${escapeHtml(plugin.plugin_dir)}')">
|
||||
<i class="fas fa-folder-minus"></i> Delete local copy
|
||||
</button>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Help column - always use consistent help URL
|
||||
@@ -3017,6 +3043,82 @@ function revertPlugin(pluginName, backupPath) {
|
||||
});
|
||||
}
|
||||
|
||||
function deletePluginLocalSource(pluginName) {
|
||||
const msg1 =
|
||||
'Permanently delete the local copy of "' + pluginName + '" on this server?\n\n' +
|
||||
'Use this only after Uninstall, if you want a clean reinstall from the Plugin Store.\n' +
|
||||
'This removes the plugin folder under the local source paths (not /usr/local/CyberCP).\n' +
|
||||
'This cannot be undone.';
|
||||
if (!confirm(msg1)) {
|
||||
return;
|
||||
}
|
||||
if (!confirm('Final confirmation: delete all local source files for "' + pluginName + '"?')) {
|
||||
return;
|
||||
}
|
||||
let btn = null;
|
||||
try {
|
||||
if (typeof event !== 'undefined' && event && event.target) {
|
||||
btn = event.target.closest('.btn-delete-source') || event.target;
|
||||
}
|
||||
} catch (e) {
|
||||
btn = null;
|
||||
}
|
||||
const originalText = btn ? btn.innerHTML : '';
|
||||
if (btn) {
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Deleting...';
|
||||
}
|
||||
fetch('/plugins/api/delete-source/' + encodeURIComponent(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: 'Deleted',
|
||||
text: data.message || 'Local plugin files removed.',
|
||||
type: 'success',
|
||||
});
|
||||
}
|
||||
setTimeout(() => location.reload(), 800);
|
||||
} else {
|
||||
if (typeof PNotify !== 'undefined') {
|
||||
new PNotify({
|
||||
title: 'Delete failed',
|
||||
text: data.error || 'Could not delete local copy',
|
||||
type: 'error',
|
||||
});
|
||||
} else {
|
||||
alert('Error: ' + (data.error || 'Could not delete local copy'));
|
||||
}
|
||||
if (btn) {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalText;
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (typeof PNotify !== 'undefined') {
|
||||
new PNotify({
|
||||
title: 'Error',
|
||||
text: 'Delete failed: ' + error.message,
|
||||
type: 'error',
|
||||
});
|
||||
} else {
|
||||
alert('Error: Delete failed — ' + 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;
|
||||
|
||||
@@ -95,6 +95,11 @@ urlpatterns = [
|
||||
path('help/', views.help_page, name='help'),
|
||||
path('api/install/<str:plugin_name>/', views.install_plugin, name='install_plugin'),
|
||||
path('api/uninstall/<str:plugin_name>/', views.uninstall_plugin, name='uninstall_plugin'),
|
||||
path(
|
||||
'api/delete-source/<str:plugin_name>/',
|
||||
views.delete_plugin_source,
|
||||
name='delete_plugin_source',
|
||||
),
|
||||
path('api/enable/<str:plugin_name>/', views.enable_plugin, name='enable_plugin'),
|
||||
path('api/disable/<str:plugin_name>/', views.disable_plugin, name='disable_plugin'),
|
||||
path('api/store/plugins/', views.fetch_plugin_store, name='fetch_plugin_store'),
|
||||
|
||||
@@ -9,6 +9,7 @@ import shutil
|
||||
import subprocess
|
||||
import shlex
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from xml.etree import ElementTree
|
||||
from plogical.httpProc import httpProc
|
||||
@@ -125,6 +126,20 @@ RESERVED_PLUGIN_DIRS = frozenset([
|
||||
'websiteFunctions', 'aiScanner', 'dns', 'help', 'installed',
|
||||
])
|
||||
|
||||
|
||||
def _is_safe_plugin_store_name(plugin_name):
|
||||
"""Reject path traversal and reserved/core names for plugin directory identifiers."""
|
||||
if not plugin_name or not isinstance(plugin_name, str):
|
||||
return False
|
||||
if len(plugin_name) > 128:
|
||||
return False
|
||||
if plugin_name in BUILTIN_PLUGINS or plugin_name in RESERVED_PLUGIN_DIRS:
|
||||
return False
|
||||
if '..' in plugin_name or '/' in plugin_name or '\\' in plugin_name:
|
||||
return False
|
||||
return bool(re.match(r'^[A-Za-z][A-Za-z0-9_]*$', plugin_name))
|
||||
|
||||
|
||||
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/).
|
||||
@@ -699,6 +714,14 @@ def installed(request):
|
||||
# If cache is stale while on Installed page, trigger best-effort background refresh.
|
||||
refresh_started = _try_start_plugin_store_refresh_background()
|
||||
|
||||
# Local source copy under PLUGIN_SOURCE_PATHS (for "delete local copy" after uninstall)
|
||||
for p in pluginList:
|
||||
pd = p.get('plugin_dir')
|
||||
if pd:
|
||||
p['has_local_source'] = _get_plugin_source_path(pd) is not None
|
||||
else:
|
||||
p['has_local_source'] = False
|
||||
|
||||
# Sort plugins A-Å by name (case-insensitive) for Grid and Table view
|
||||
pluginList.sort(key=lambda p: (p.get('name') or '').lower())
|
||||
|
||||
@@ -887,6 +910,107 @@ def uninstall_plugin(request, plugin_name):
|
||||
'error': str(e)
|
||||
}, status=500)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_http_methods(["POST"])
|
||||
def delete_plugin_source(request, plugin_name):
|
||||
"""
|
||||
Remove local plugin source under PLUGIN_SOURCE_PATHS after uninstall, so the Plugin Store
|
||||
can reinstall cleanly. Does not touch /usr/local/CyberCP (must uninstall first).
|
||||
"""
|
||||
try:
|
||||
if not user_can_manage_plugins(request):
|
||||
return deny_plugin_manage_json_response(request)
|
||||
if not _is_safe_plugin_store_name(plugin_name):
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': 'Invalid or reserved plugin name.',
|
||||
}, status=400)
|
||||
|
||||
plugin_installed = '/usr/local/CyberCP/' + plugin_name
|
||||
if os.path.exists(plugin_installed):
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': (
|
||||
'This plugin is still installed. Uninstall it first, then delete the local copy '
|
||||
'if you want a clean reinstall from the Plugin Store.'
|
||||
),
|
||||
}, status=400)
|
||||
|
||||
removed_paths = []
|
||||
for base in PLUGIN_SOURCE_PATHS:
|
||||
if not base or not os.path.isdir(base):
|
||||
continue
|
||||
candidate = os.path.join(base, plugin_name)
|
||||
try:
|
||||
candidate_real = os.path.realpath(candidate)
|
||||
base_real = os.path.realpath(base)
|
||||
if not candidate_real.startswith(base_real + os.sep) and candidate_real != base_real:
|
||||
logging.writeToFile(
|
||||
'delete_plugin_source: skipped path outside base (symlink?): %s' % candidate
|
||||
)
|
||||
continue
|
||||
except Exception:
|
||||
continue
|
||||
if not os.path.isdir(candidate):
|
||||
continue
|
||||
meta = os.path.join(candidate, 'meta.xml')
|
||||
if not os.path.isfile(meta):
|
||||
continue
|
||||
try:
|
||||
shutil.rmtree(candidate)
|
||||
removed_paths.append(candidate)
|
||||
except Exception as rm_exc:
|
||||
logging.writeToFile(
|
||||
'delete_plugin_source: failed to remove %s: %s' % (candidate, str(rm_exc))
|
||||
)
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': 'Could not remove local folder: %s' % candidate,
|
||||
}, status=500)
|
||||
|
||||
if not removed_paths:
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': (
|
||||
'No local plugin copy found under %s. Nothing to delete.'
|
||||
% ', '.join(PLUGIN_SOURCE_PATHS)
|
||||
),
|
||||
}, status=404)
|
||||
|
||||
try:
|
||||
pluginInstaller.informCyberPanelRemoval(plugin_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
state_file = _get_plugin_state_file(plugin_name)
|
||||
if os.path.isfile(state_file):
|
||||
os.remove(state_file)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
_invalidate_plugin_store_cache()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logging.writeToFile(
|
||||
'delete_plugin_source: removed %s paths for %s: %s'
|
||||
% (len(removed_paths), plugin_name, removed_paths)
|
||||
)
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'message': 'Local plugin files removed. You can install again from the Plugin Store.',
|
||||
})
|
||||
except Exception as e:
|
||||
logging.writeToFile('Error delete_plugin_source %s: %s' % (plugin_name, str(e)))
|
||||
return JsonResponse({
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
}, status=500)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_http_methods(["POST"])
|
||||
def enable_plugin(request, plugin_name):
|
||||
@@ -1487,6 +1611,9 @@ def _enrich_store_plugins(plugins):
|
||||
plugin['enabled'] = False
|
||||
plugin['update_available'] = False
|
||||
plugin['installed_version'] = None
|
||||
|
||||
plugin['has_local_source'] = _get_plugin_source_path(plugin_dir) is not None
|
||||
plugin['builtin'] = plugin_dir in BUILTIN_PLUGINS
|
||||
|
||||
# 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
|
||||
@@ -2009,6 +2136,12 @@ def revert_plugin(request, plugin_name):
|
||||
|
||||
# Restore from backup
|
||||
if _restore_plugin_from_backup(plugin_name, backup_path):
|
||||
try:
|
||||
pluginInstaller.restartGunicorn()
|
||||
except Exception as re:
|
||||
logging.writeToFile(
|
||||
'revert_plugin: restartGunicorn after restore failed (non-fatal): %s' % str(re)
|
||||
)
|
||||
return JsonResponse({
|
||||
'success': True,
|
||||
'message': f'Plugin {plugin_name} reverted successfully to version {backup_version}'
|
||||
|
||||
@@ -743,8 +743,55 @@ class pluginInstaller:
|
||||
|
||||
@staticmethod
|
||||
def restartGunicorn():
|
||||
command = 'systemctl restart lscpd'
|
||||
ProcessUtilities.normalExecutioner(command)
|
||||
"""
|
||||
Reload lscpd so Django reloads pluginHolder.urls and INSTALLED_APPS sync.
|
||||
When the panel runs as non-root, plain systemctl often fails; try sudo -n then systemctl.
|
||||
"""
|
||||
try:
|
||||
is_root = os.geteuid() == 0
|
||||
except AttributeError:
|
||||
is_root = True
|
||||
|
||||
if is_root:
|
||||
candidates = [['systemctl', 'restart', 'lscpd']]
|
||||
else:
|
||||
# Prefer non-interactive sudo (NOPASSWD in sudoers); avoid bare sudo — it can hang on password.
|
||||
candidates = [
|
||||
['sudo', '-n', 'systemctl', 'restart', 'lscpd'],
|
||||
['systemctl', 'restart', 'lscpd'],
|
||||
]
|
||||
|
||||
last_detail = None
|
||||
for cmd in candidates:
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=120,
|
||||
text=True,
|
||||
)
|
||||
if proc.returncode == 0:
|
||||
pluginInstaller.stdOut('lscpd restarted (%s)' % ' '.join(cmd))
|
||||
return
|
||||
err = (proc.stderr or proc.stdout or '').strip()
|
||||
last_detail = 'rc=%s %s' % (proc.returncode, err[:400])
|
||||
except subprocess.TimeoutExpired:
|
||||
last_detail = 'timeout after 120s for %s' % ' '.join(cmd)
|
||||
except Exception as exc:
|
||||
last_detail = str(exc)[:400]
|
||||
|
||||
msg = 'pluginInstaller.restartGunicorn: failed to restart lscpd. %s. Run as root or grant NOPASSWD: sudo systemctl restart lscpd' % (
|
||||
last_detail or 'no details'
|
||||
)
|
||||
pluginInstaller.stdOut(msg)
|
||||
try:
|
||||
from plogical.CyberCPLogFileWriter import CyberCPLogFileWriter as cp_log
|
||||
|
||||
cp_log.writeToFile(msg)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user