Merge pull request #1748 from master3395/v2.5.5-dev

V2.5.5 dev
This commit is contained in:
Master3395
2026-03-27 23:51:17 +01:00
committed by GitHub
4 changed files with 290 additions and 3 deletions

View File

@@ -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;

View File

@@ -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'),

View File

@@ -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}'

View File

@@ -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