From f83a57324240fc2ba86e942b90c51ac950e27ecc Mon Sep 17 00:00:00 2001 From: master3395 Date: Fri, 27 Mar 2026 23:49:36 +0100 Subject: [PATCH] plugins: delete local source after uninstall (with confirmations) - POST /plugins/api/delete-source// removes copies under plugin source paths only - Require safe plugin id, block if still installed under CyberCP; symlink-aware paths - Clear state file, informCyberPanelRemoval, invalidate store cache - UI: Delete local copy in grid/table/store; two-step confirm; amber button style - Enrich store JSON with has_local_source and builtin --- .../templates/pluginHolder/plugins.html | 104 +++++++++++++- pluginHolder/urls.py | 5 + pluginHolder/views.py | 127 ++++++++++++++++++ 3 files changed, 235 insertions(+), 1 deletion(-) diff --git a/pluginHolder/templates/pluginHolder/plugins.html b/pluginHolder/templates/pluginHolder/plugins.html index c10982332..c989aa639 100644 --- a/pluginHolder/templates/pluginHolder/plugins.html +++ b/pluginHolder/templates/pluginHolder/plugins.html @@ -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 @@ + {% if plugin.has_local_source %} + + {% endif %} {% endif %} {% if not plugin.builtin %} @@ -1599,6 +1615,11 @@ + {% if plugin.has_local_source %} + + {% endif %} {% endif %} @@ -2287,9 +2308,14 @@ function displayStorePlugins() { } } else { // Show Install button - actionHtml = ``; + if (plugin.has_local_source && !plugin.builtin) { + actionHtml += ` `; + } } // 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 = ' 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; diff --git a/pluginHolder/urls.py b/pluginHolder/urls.py index 2924a7518..732c81e9d 100644 --- a/pluginHolder/urls.py +++ b/pluginHolder/urls.py @@ -95,6 +95,11 @@ urlpatterns = [ 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/delete-source//', + views.delete_plugin_source, + name='delete_plugin_source', + ), 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'), diff --git a/pluginHolder/views.py b/pluginHolder/views.py index a4887f07f..99f4a9a7f 100644 --- a/pluginHolder/views.py +++ b/pluginHolder/views.py @@ -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