From 087863134c26ef99e154f3d7c40f457389689e29 Mon Sep 17 00:00:00 2001 From: master3395 Date: Fri, 27 Mar 2026 21:48:08 +0100 Subject: [PATCH 1/8] feat(firewall): merge Auto Ban Security Alerts logs into banned IPs API - getBannedIPs: append AutoBanLog rows (latest per IP) not already in DB/JSON - Skip expired timed bans; tag rows with ban_source autoBanSecurityAlerts - removeBannedIP/deleteBannedIP: handle synthetic id ablog- via unban by IP --- firewall/firewallManager.py | 177 ++++++++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) diff --git a/firewall/firewallManager.py b/firewall/firewallManager.py index 10a0fc913..126a9c35e 100644 --- a/firewall/firewallManager.py +++ b/firewall/firewallManager.py @@ -39,6 +39,174 @@ class FirewallManager: def __init__(self, request = None): self.request = request + @staticmethod + def _normalize_banned_ip_key(ip): + return (ip or '').strip().lower() + + def _autoban_duration_seconds(self, duration): + duration_map = {'1h': 3600, '24h': 86400, '7d': 604800, '30d': 2592000} + d = (duration or '').strip().lower() + if d == 'permanent' or not d: + return None + return duration_map.get(d, 86400) + + def _autoban_log_expired(self, log, current_time): + """True if timed ban from AutoBanLog is past expiry (permanent never expires here).""" + d = (log.ban_duration or '').strip().lower() + if d == 'permanent' or not d: + return False + sec = self._autoban_duration_seconds(log.ban_duration) + if sec is None: + return False + if not log.banned_at: + return False + try: + ts = int(log.banned_at.timestamp()) + except Exception: + return False + return (ts + sec) <= int(current_time) + + def _merge_autoban_security_alerts_logs(self, active_banned_ips, current_time): + """ + List Auto Ban Security Alerts log rows whose IP is not already in the firewall + merged list (DB + JSON), so /firewall/#banned-ips matches the plugin settings view. + """ + try: + from django.apps import apps + + if not apps.is_installed('autoBanSecurityAlerts'): + return + from autoBanSecurityAlerts.models import AutoBanLog + except Exception: + return + + seen = set() + for row in active_banned_ips: + k = self._normalize_banned_ip_key(row.get('ip') or row.get('ip_address')) + if k: + seen.add(k) + + try: + autoban_by_ip = {} + for log in AutoBanLog.objects.order_by('-banned_at'): + k = self._normalize_banned_ip_key(str(log.ip_address)) + if not k: + continue + if k not in autoban_by_ip: + autoban_by_ip[k] = log + + for _k, log in autoban_by_ip.items(): + ip_key = self._normalize_banned_ip_key(str(log.ip_address)) + if ip_key in seen: + continue + if self._autoban_log_expired(log, current_time): + continue + banned_on_str = 'N/A' + if log.banned_at: + try: + banned_on_str = log.banned_at.strftime('%Y-%m-%d %H:%M:%S') + except Exception: + banned_on_str = str(log.banned_at) + expires_display = 'Never' + dlow = (log.ban_duration or '').strip().lower() + if dlow != 'permanent' and dlow: + sec = self._autoban_duration_seconds(log.ban_duration) + if sec is not None and log.banned_at: + try: + from datetime import datetime + + exp_ts = int(log.banned_at.timestamp()) + sec + expires_display = datetime.fromtimestamp(exp_ts).strftime( + '%Y-%m-%d %H:%M:%S' + ) + except Exception: + expires_display = 'Never' + active_banned_ips.append( + { + 'id': 'ablog-%s' % log.pk, + 'ip': str(log.ip_address), + 'reason': log.ban_reason or '', + 'duration': log.ban_duration or 'permanent', + 'banned_on': banned_on_str, + 'expires': expires_display, + 'active': True, + 'ban_source': 'autoBanSecurityAlerts', + } + ) + except Exception as e: + logging.CyberCPLogFileWriter.writeToFile( + 'getBannedIPs: AutoBanSecurityAlerts merge failed: %s' % str(e) + ) + + def _remove_autoban_log_by_suffix_id(self, userID, ablog_id_str): + """Handle unban when UI sends id like ablog-123 (orphan log-only row).""" + try: + pk = int(str(ablog_id_str).split('-', 1)[1]) + except Exception: + final_dic = {'status': 0, 'error_message': 'Invalid auto-ban log id'} + return HttpResponse(json.dumps(final_dic), content_type='application/json') + try: + admin = Administrator.objects.get(pk=userID) + if admin.acl.adminStatus != 1: + return ACLManager.loadError() + from django.apps import apps + + if not apps.is_installed('autoBanSecurityAlerts'): + final_dic = {'status': 0, 'error_message': 'Auto Ban plugin is not installed'} + return HttpResponse(json.dumps(final_dic), content_type='application/json') + from autoBanSecurityAlerts.models import AutoBanLog + + log = AutoBanLog.objects.filter(pk=pk).first() + if not log: + final_dic = {'status': 0, 'error_message': 'Auto-ban log entry not found'} + return HttpResponse(json.dumps(final_dic), content_type='application/json') + ip = str(log.ip_address).strip() + admin0 = Administrator.objects.filter(acl__adminStatus=1).first() + if not admin0: + final_dic = {'status': 0, 'error_message': 'No admin user found'} + return HttpResponse(json.dumps(final_dic), content_type='application/json') + + result = self.removeBannedIP(admin0.pk, {'ip': ip}) + raw = getattr(result, 'content', None) + parsed = {} + if raw is not None: + try: + if isinstance(raw, bytes): + raw = raw.decode('utf-8', errors='replace') + parsed = json.loads(raw) + except Exception: + parsed = {} + err_raw = (parsed.get('error_message') or parsed.get('error') or '').strip() + err_msg = err_raw.lower() + + if parsed.get('status') == 1: + log.delete() + return HttpResponse( + json.dumps( + { + 'status': 1, + 'message': parsed.get('message', 'IP unbanned successfully'), + } + ), + content_type='application/json', + ) + if 'not found' in err_msg: + log.delete() + return HttpResponse( + json.dumps( + { + 'status': 1, + 'message': 'Log cleared (ban was already removed or not found in firewall)', + } + ), + content_type='application/json', + ) + final_dic = {'status': 0, 'error_message': err_raw or 'Failed to remove ban'} + return HttpResponse(json.dumps(final_dic), content_type='application/json') + except BaseException as msg: + final_dic = {'status': 0, 'error_message': str(msg)} + return HttpResponse(json.dumps(final_dic), content_type='application/json') + def _load_banned_ips_store(self): """ Load banned IPs from the primary store, falling back to legacy path. @@ -2094,6 +2262,9 @@ class FirewallManager: 'active': True }) + # Auto Ban Security Alerts: show log-only / plugin-visible bans on same list as firewall UI + self._merge_autoban_security_alerts_logs(active_banned_ips, current_time) + # Optional server-side search: filter by IP or reason (case-insensitive substring) # Normalize: strip query and compare against stripped IP/reason so "1.2.3.4" matches stored " 1.2.3.4 " search_q = (data.get('search') or data.get('q') or '').strip() if data else '' @@ -2296,6 +2467,9 @@ class FirewallManager: return ACLManager.loadError() banned_ip_id = data.get('id') + if isinstance(banned_ip_id, str) and banned_ip_id.startswith('ablog-'): + return self._remove_autoban_log_by_suffix_id(userID, banned_ip_id) + requested_ip = (data.get('ip') or '').strip() ip_to_unban = None @@ -2388,6 +2562,9 @@ class FirewallManager: return ACLManager.loadError() banned_ip_id = data.get('id') + if isinstance(banned_ip_id, str) and banned_ip_id.startswith('ablog-'): + return self._remove_autoban_log_by_suffix_id(userID, banned_ip_id) + requested_ip = (data.get('ip') or '').strip() try: From 1c6ab7a188bb380258560306f6c5bdbde532d724 Mon Sep 17 00:00:00 2001 From: master3395 Date: Fri, 27 Mar 2026 21:48:19 +0100 Subject: [PATCH 2/8] feat(baseTemplate): plugin sidebar context; LPMA link for grant-only users - plugin_sidebar_context: show_plugins_menu, Limited phpMyAdmin for cpuser grants - index.html: conditional Installed/Store vs grant-only LPMA submenu --- CyberCP/settings.py | 1 + baseTemplate/context_processors.py | 82 ++++++++++++++++++- .../templates/baseTemplate/index.html | 11 ++- 3 files changed, 91 insertions(+), 3 deletions(-) diff --git a/CyberCP/settings.py b/CyberCP/settings.py index c5ce2e71f..8d59217a6 100644 --- a/CyberCP/settings.py +++ b/CyberCP/settings.py @@ -159,6 +159,7 @@ TEMPLATES = [ 'baseTemplate.context_processors.notification_preferences_context', 'baseTemplate.context_processors.firewall_static_context', 'baseTemplate.context_processors.dns_static_context', + 'baseTemplate.context_processors.plugin_sidebar_context', ], }, }, diff --git a/baseTemplate/context_processors.py b/baseTemplate/context_processors.py index 83215a38a..911f698f0 100644 --- a/baseTemplate/context_processors.py +++ b/baseTemplate/context_processors.py @@ -102,4 +102,84 @@ def dns_static_context(request): version = int(time.time()) return { 'DNS_STATIC_VERSION': version - } \ No newline at end of file + } + + +def plugin_sidebar_context(request): + """ + Sidebar Plugins menu: Installed / Plugin Store (ACL), Limited phpMyAdmin link + for admins, plugin managers, or cpuser grantees; grant-only users see LPMA only. + """ + defaults = { + 'lpma_plugin_installed': False, + 'lpma_has_cpuser_grant': False, + 'show_plugin_management_links': False, + 'show_lpma_sidebar_link': False, + 'show_plugins_menu': False, + 'grant_only_lpma_sidebar': False, + 'plugin_sidebar_extra_links': [], + 'lpma_sidebar_url': '/plugins/limitedPhpmyAdmin/', + } + try: + if 'userID' not in request.session: + return defaults + uid = request.session['userID'] + from plogical.acl import ACLManager + + acl = ACLManager.loadedACL(uid) + admin = bool(acl.get('admin')) + manage_plugins = bool(acl.get('managePlugins')) + show_plugin_management_links = admin or manage_plugins + + lpma_plugin_installed = os.path.isdir('/usr/local/CyberCP/limitedPhpmyAdmin') + + lpma_has_cpuser_grant = False + if lpma_plugin_installed: + try: + from django.apps import apps + + if apps.is_installed('limitedPhpmyAdmin'): + from limitedPhpmyAdmin.models import LimitedPhpmyAdminGrant + + lpma_has_cpuser_grant = LimitedPhpmyAdminGrant.objects.filter( + enabled=True, + subject_type='cpuser', + administrator_id=uid, + ).exists() + except Exception: + lpma_has_cpuser_grant = False + + show_lpma_sidebar_link = lpma_plugin_installed and ( + show_plugin_management_links or lpma_has_cpuser_grant + ) + show_plugins_menu = show_plugin_management_links or ( + lpma_has_cpuser_grant and lpma_plugin_installed + ) + grant_only_lpma_sidebar = ( + lpma_has_cpuser_grant and lpma_plugin_installed and not show_plugin_management_links + ) + + extra = [] + if show_lpma_sidebar_link: + extra.append( + { + 'url': '/plugins/limitedPhpmyAdmin/', + 'label_key': 'limited_phpmyadmin_sidebar', + } + ) + + defaults.update( + { + 'lpma_plugin_installed': lpma_plugin_installed, + 'lpma_has_cpuser_grant': lpma_has_cpuser_grant, + 'show_plugin_management_links': show_plugin_management_links, + 'show_lpma_sidebar_link': show_lpma_sidebar_link, + 'show_plugins_menu': show_plugins_menu, + 'grant_only_lpma_sidebar': grant_only_lpma_sidebar, + 'plugin_sidebar_extra_links': extra, + 'lpma_sidebar_url': '/plugins/limitedPhpmyAdmin/', + } + ) + return defaults + except Exception: + return defaults \ No newline at end of file diff --git a/baseTemplate/templates/baseTemplate/index.html b/baseTemplate/templates/baseTemplate/index.html index bcd21031b..a090cfd21 100644 --- a/baseTemplate/templates/baseTemplate/index.html +++ b/baseTemplate/templates/baseTemplate/index.html @@ -2396,8 +2396,8 @@ {% endif %} {% endif %} - {% if admin or managePlugins %} - {% if managePlugins and not admin %} + {% if show_plugins_menu %} + {% if grant_only_lpma_sidebar or managePlugins and not admin %}
{% trans "PLUGINS" %}
{% endif %} @@ -2408,12 +2408,19 @@ {% endif %} From bbcfec196d6b3bb6f56427039e17bb865871f9f4 Mon Sep 17 00:00:00 2001 From: master3395 Date: Fri, 27 Mar 2026 21:54:17 +0100 Subject: [PATCH 3/8] fix: LPMA launch routes in secMiddleware; strict sign-on + lpma_policy_read - secMiddleware: allow Limited phpMyAdmin launch URLs and phpmyadminsignin without JSON-body filtering that breaks sign-on POSTs - plogical/public phpmyadminsignin: lpma_policy_read.inc.php, strict cookie helpers - webmail: section header comments only --- CyberCP/secMiddleware.py | 14 ++- plogical/lpma_policy_read.inc.php | 134 +++++++++++++++++++++++++ plogical/phpmyadminsignin.php | 24 +++++ public/phpmyadmin/phpmyadminsignin.php | 35 +++++++ webmail/services/imap_client.py | 4 +- webmail/views.py | 30 ++++-- 6 files changed, 228 insertions(+), 13 deletions(-) create mode 100644 plogical/lpma_policy_read.inc.php diff --git a/CyberCP/secMiddleware.py b/CyberCP/secMiddleware.py index 3f30e0941..8d58422f6 100644 --- a/CyberCP/secMiddleware.py +++ b/CyberCP/secMiddleware.py @@ -38,8 +38,13 @@ class secMiddleware: import re webhook_pattern = re.compile(r'^/websites/[^/]+/(webhook|gitNotify)/?$') - if pathActual == "/backup/localInitiate" or pathActual == '/' or pathActual == '/verifyLogin' or pathActual == '/logout' or pathActual.startswith('/api')\ - or webhook_pattern.match(pathActual) or pathActual.startswith('/cloudAPI') or pathActual.startswith('/static/'): + # Public one-time phpMyAdmin launch links (limitedPhpmyAdmin plugin); must work when admin is logged out. + _lpma_public_launch = pathActual.startswith('/plugins/limitedPhpmyAdmin/launch/') + _lpma_pma_signon = pathActual == '/phpmyadmin/phpmyadminsignin.php' + + if pathActual == "/backup/localInitiate" or pathActual == '/' or pathActual == '/verifyLogin' or pathActual == '/logout' or pathActual.startswith('/api')\ + or webhook_pattern.match(pathActual) or pathActual.startswith('/cloudAPI') or pathActual.startswith('/static/')\ + or _lpma_public_launch or _lpma_pma_signon: pass else: # Session check logging removed @@ -108,6 +113,11 @@ class secMiddleware: response = self.get_response(request) return response + # phpMyAdmin sign-on POST carries MySQL password; skip character filter (may contain $ ( ) etc.). + if pathActual == '/phpmyadmin/phpmyadminsignin.php': + response = self.get_response(request) + return response + # logging.writeToFile(request.body) try: data = json.loads(request.body) diff --git a/plogical/lpma_policy_read.inc.php b/plogical/lpma_policy_read.inc.php new file mode 100644 index 000000000..2281ab389 --- /dev/null +++ b/plogical/lpma_policy_read.inc.php @@ -0,0 +1,134 @@ + true, + 'two_factor' => true, + 'features' => true, + 'sql' => true, + 'navigation' => true, + 'main_panel' => true, + 'export' => true, + 'import' => true, + ]; + $policy = [ + 'strict_mode' => true, + 'blocked_tabs' => $defaultBlocked, + ]; + $paths = [ + '/usr/local/CyberCP/pluginState/limited_phpmyadmin_policy.json', + '/var/lib/cyberpanel-panelstate/limited_phpmyadmin_policy.json', + '/etc/cyberpanel/limited_phpmyadmin_policy.json', + ]; + foreach ($paths as $policyPath) { + if (! @is_readable($policyPath)) { + continue; + } + $raw = @file_get_contents($policyPath); + if ($raw === false) { + continue; + } + $decoded = @json_decode($raw, true); + if (! is_array($decoded)) { + continue; + } + $policy['strict_mode'] = isset($decoded['strict_mode']) ? (bool) $decoded['strict_mode'] : true; + if (isset($decoded['blocked_tabs']) && is_array($decoded['blocked_tabs'])) { + foreach ($defaultBlocked as $k => $_v) { + $policy['blocked_tabs'][$k] = isset($decoded['blocked_tabs'][$k]) + ? (bool) $decoded['blocked_tabs'][$k] + : true; + } + } + break; + } + + return $policy; +} + +/** + * True if a cpma_* request to this application route must be turned away (Settings prefs + main menu targets). + * Does not block table browse at route "/sql" (that is Browse, not the SQL runner). + */ +function lpma_cpma_route_blocked(string $requestedRoute, array $policy): bool +{ + if ($requestedRoute === '') { + return false; + } + $bt = $policy['blocked_tabs'] ?? []; + $blocked = static function (string $k) use ($bt): bool { + return (($bt[$k] ?? true) === true); + }; + + if (strpos($requestedRoute, '/preferences') === 0) { + $routeToTab = [ + '/preferences/manage' => 'manage', + '/preferences/two-factor' => 'two_factor', + '/preferences/features' => 'features', + '/preferences/sql' => 'sql', + '/preferences/navigation' => 'navigation', + '/preferences/main-panel' => 'main_panel', + '/preferences/export' => 'export', + '/preferences/import' => 'import', + ]; + if (isset($routeToTab[$requestedRoute])) { + return $blocked($routeToTab[$requestedRoute]); + } + + return (($policy['strict_mode'] ?? true) === true); + } + + if ($blocked('sql')) { + if (preg_match('#^/(server|database|table)/sql$#', $requestedRoute) === 1) { + return true; + } + if ($requestedRoute === '/database/multi-table-query' || $requestedRoute === '/database/qbe') { + return true; + } + } + + if ($blocked('export') && preg_match('#^/(server|database|table)/export$#', $requestedRoute) === 1) { + return true; + } + + if ($blocked('import') && preg_match('#^/(server|database|table)/import$#', $requestedRoute) === 1) { + return true; + } + + if ($blocked('main_panel')) { + if ( + $requestedRoute === '/server/databases' + || $requestedRoute === '/server/variables' + || $requestedRoute === '/server/collations' + ) { + return true; + } + if (strpos($requestedRoute, '/server/status') === 0) { + return true; + } + } + + if ($blocked('features')) { + if ( + $requestedRoute === '/server/engines' + || $requestedRoute === '/server/plugins' + || $requestedRoute === '/server/binlog' + ) { + return true; + } + if ( + $requestedRoute === '/database/designer' + || $requestedRoute === '/database/central-columns' + || $requestedRoute === '/database/tracking' + || $requestedRoute === '/table/tracking' + ) { + return true; + } + } + + return false; +} diff --git a/plogical/phpmyadminsignin.php b/plogical/phpmyadminsignin.php index 1ac2461e9..051bfd595 100644 --- a/plogical/phpmyadminsignin.php +++ b/plogical/phpmyadminsignin.php @@ -3,10 +3,28 @@ define("PMA_SIGNON_INDEX", 1); +require_once __DIR__ . '/lpma_policy_read.inc.php'; + try { define('PMA_SIGNON_SESSIONNAME', 'SignonSession'); define('PMA_DISABLE_SSL_PEER_VALIDATION', TRUE); + function lpma_set_strict_cookie($enabled) { + $opts = array( + 'expires' => $enabled ? (time() + 86400) : (time() - 86400), + 'path' => '/phpmyadmin/', + 'secure' => isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off', + 'httponly' => true, + 'samesite' => 'Lax', + ); + setcookie('PMA_LPMA_STRICT', $enabled ? '1' : '', $opts); + } + + function lpma_global_strict_mode_enabled() { + $p = lpma_read_limited_policy(); + return ! empty($p['strict_mode']); + } + // Handle both GET and POST parameters for token and username $token = isset($_POST['token']) ? $_POST['token'] : (isset($_GET['token']) ? $_GET['token'] : null); $username = isset($_POST['username']) ? $_POST['username'] : (isset($_GET['username']) ? $_GET['username'] : null); @@ -32,6 +50,7 @@ try { echo ''; } else if (isset($_POST['logout']) || isset($_GET['logout'])) { + lpma_set_strict_cookie(false); session_name(PMA_SIGNON_SESSIONNAME); @session_start(); $_SESSION = array(); @@ -47,9 +66,14 @@ try { $username = htmlspecialchars($_POST['username'], ENT_QUOTES, 'UTF-8'); $password = $_POST['password']; + $strictMode = (isset($_POST['lpma_strict']) && $_POST['lpma_strict'] === '1'); + $isLimitedUser = (strpos($username, 'cpma_') === 0); $host = isset($_POST['host']) ? trim($_POST['host']) : '127.0.0.1'; if ($host === 'localhost') { $host = '127.0.0.1'; } + $effectiveStrictMode = ($strictMode || lpma_global_strict_mode_enabled()) && $isLimitedUser; + lpma_set_strict_cookie($effectiveStrictMode); + $_SESSION['PMA_single_signon_user'] = $username; $_SESSION['PMA_single_signon_password'] = $password; $_SESSION['PMA_single_signon_host'] = $host; diff --git a/public/phpmyadmin/phpmyadminsignin.php b/public/phpmyadmin/phpmyadminsignin.php index 1ac2461e9..4a2a04b2f 100644 --- a/public/phpmyadmin/phpmyadminsignin.php +++ b/public/phpmyadmin/phpmyadminsignin.php @@ -3,10 +3,39 @@ define("PMA_SIGNON_INDEX", 1); +// Policy helper ships in plogical/ (same layout as phpmyadmin index.php) +$_lpma_policy = dirname(dirname(__DIR__)) . '/plogical/lpma_policy_read.inc.php'; +if (is_readable($_lpma_policy)) { + require_once $_lpma_policy; +} elseif (is_readable(__DIR__ . '/lpma_policy_read.inc.php')) { + require_once __DIR__ . '/lpma_policy_read.inc.php'; +} else { + http_response_code(500); + header('Content-Type: text/plain; charset=utf-8'); + echo 'phpMyAdmin sign-on is misconfigured: lpma_policy_read.inc.php is missing.'; + exit; +} + try { define('PMA_SIGNON_SESSIONNAME', 'SignonSession'); define('PMA_DISABLE_SSL_PEER_VALIDATION', TRUE); + function lpma_set_strict_cookie($enabled) { + $opts = array( + 'expires' => $enabled ? (time() + 86400) : (time() - 86400), + 'path' => '/phpmyadmin/', + 'secure' => isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off', + 'httponly' => true, + 'samesite' => 'Lax', + ); + setcookie('PMA_LPMA_STRICT', $enabled ? '1' : '', $opts); + } + + function lpma_global_strict_mode_enabled() { + $p = lpma_read_limited_policy(); + return ! empty($p['strict_mode']); + } + // Handle both GET and POST parameters for token and username $token = isset($_POST['token']) ? $_POST['token'] : (isset($_GET['token']) ? $_GET['token'] : null); $username = isset($_POST['username']) ? $_POST['username'] : (isset($_GET['username']) ? $_GET['username'] : null); @@ -32,6 +61,7 @@ try { echo ''; } else if (isset($_POST['logout']) || isset($_GET['logout'])) { + lpma_set_strict_cookie(false); session_name(PMA_SIGNON_SESSIONNAME); @session_start(); $_SESSION = array(); @@ -47,9 +77,14 @@ try { $username = htmlspecialchars($_POST['username'], ENT_QUOTES, 'UTF-8'); $password = $_POST['password']; + $strictMode = (isset($_POST['lpma_strict']) && $_POST['lpma_strict'] === '1'); + $isLimitedUser = (strpos($username, 'cpma_') === 0); $host = isset($_POST['host']) ? trim($_POST['host']) : '127.0.0.1'; if ($host === 'localhost') { $host = '127.0.0.1'; } + $effectiveStrictMode = ($strictMode || lpma_global_strict_mode_enabled()) && $isLimitedUser; + lpma_set_strict_cookie($effectiveStrictMode); + $_SESSION['PMA_single_signon_user'] = $username; $_SESSION['PMA_single_signon_password'] = $password; $_SESSION['PMA_single_signon_host'] = $host; diff --git a/webmail/services/imap_client.py b/webmail/services/imap_client.py index 5bd36e043..66c1fdfea 100644 --- a/webmail/services/imap_client.py +++ b/webmail/services/imap_client.py @@ -98,7 +98,9 @@ class IMAPClient: def _folder_type(self, folder_name): """Identify special folder type for UI (icons, sidebar grouping). - CyberPanel/Dovecot uses INBOX.* names; accounts may also use Spam, Trash, etc. + CyberPanel/Dovecot uses INBOX.* names, but some accounts also have + INBOX.spam, Trash, Archive, etc. Classify those so they are not treated + as generic user folders. """ fn = (folder_name or '').strip() if not fn: diff --git a/webmail/views.py b/webmail/views.py index 4629fee1d..638534da2 100644 --- a/webmail/views.py +++ b/webmail/views.py @@ -5,7 +5,8 @@ from loginSystem.views import loadLoginPage from .webmailManager import WebmailManager -# --- Page Views --- +# ── Page Views ──────────────────────────────────────────────── + def loadWebmail(request): try: wm = WebmailManager(request) @@ -23,7 +24,8 @@ def loadLogin(request): return wm.loadLogin() -# --- Auth APIs --- +# ── Auth APIs ───────────────────────────────────────────────── + def apiLogin(request): try: wm = WebmailManager(request) @@ -70,7 +72,8 @@ def apiSwitchAccount(request): return _error_response(e) -# --- Folder APIs --- +# ── Folder APIs ─────────────────────────────────────────────── + def apiListFolders(request): try: wm = WebmailManager(request) @@ -111,7 +114,8 @@ def apiDeleteFolder(request): return _error_response(e) -# --- Message APIs --- +# ── Message APIs ────────────────────────────────────────────── + def apiListMessages(request): try: wm = WebmailManager(request) @@ -152,7 +156,8 @@ def apiGetAttachment(request): return _error_response(e) -# --- Action APIs --- +# ── Action APIs ─────────────────────────────────────────────── + def apiSendMessage(request): try: wm = WebmailManager(request) @@ -223,7 +228,8 @@ def apiMarkFlagged(request): return _error_response(e) -# --- Contact APIs --- +# ── Contact APIs ────────────────────────────────────────────── + def apiListContacts(request): try: wm = WebmailManager(request) @@ -323,7 +329,8 @@ def apiImportRulesFromSnappymail(request): return _error_response(e) -# --- Sieve Rule APIs --- +# ── Sieve Rule APIs ────────────────────────────────────────── + def apiListRules(request): try: wm = WebmailManager(request) @@ -374,7 +381,8 @@ def apiActivateRules(request): return _error_response(e) -# --- Settings APIs --- +# ── Settings APIs ───────────────────────────────────────────── + def apiGetSettings(request): try: wm = WebmailManager(request) @@ -395,7 +403,8 @@ def apiSaveSettings(request): return _error_response(e) -# --- Image Proxy --- +# ── Image Proxy ─────────────────────────────────────────────── + def apiProxyImage(request): try: wm = WebmailManager(request) @@ -404,7 +413,8 @@ def apiProxyImage(request): return _error_response(e) -# --- Helpers --- +# ── Helpers ─────────────────────────────────────────────────── + def _error_response(e): data = {'status': 0, 'error_message': str(e)} return HttpResponse(json.dumps(data), content_type='application/json') From cbbb1b8dbaa1b745fa9724f78d1a848da763e60c Mon Sep 17 00:00:00 2001 From: master3395 Date: Fri, 27 Mar 2026 21:58:48 +0100 Subject: [PATCH 4/8] fix(versionManagment): compare dev fork installs to fork branch on GitHub Official usmannasir/cyberpanel origin still uses upstream v2.5.5-dev tip; forks now resolve latest commit from origin owner/repo so local HEAD can match Latest Commit without false upgrade notices. --- baseTemplate/views.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/baseTemplate/views.py b/baseTemplate/views.py index 510311506..8a0fcebf5 100644 --- a/baseTemplate/views.py +++ b/baseTemplate/views.py @@ -330,11 +330,17 @@ def versionManagment(request): if not on_dev_branch and notechk and _version_compare(currentVersion, latestVersion) > 0: notechk = False elif notechk: - # Dev branch: always use usmannasir v2.5.5-dev as canonical "latest" - # Forks: use usmannasir for Latest Commit so all dev users compare to same upstream + # Dev branch: official remote compares to usmannasir/cyberpanel; forks compare to their own GitHub repo fetch_branch = branch_ref if (is_usmannasir or on_dev_branch) else None if fetch_branch: - u = "https://api.github.com/repos/usmannasir/cyberpanel/commits?sha=%s" % fetch_branch + u = None + if on_dev_branch and not is_usmannasir: + m = re.search(r'github\.com[:/]([^/]+)/([^/]+?)(?:\.git)?$', (remote_out or '').strip()) + if m: + owner, repo = m.group(1), m.group(2).rstrip('.git') + u = "https://api.github.com/repos/%s/%s/commits?sha=%s" % (owner, repo, fetch_branch) + if u is None: + u = "https://api.github.com/repos/usmannasir/cyberpanel/commits?sha=%s" % fetch_branch logging.CyberCPLogFileWriter.writeToFile(u) try: r = requests.get(u, timeout=10) From ce11e643bdcc10e26b177a95496303b2d0aa12fb Mon Sep 17 00:00:00 2001 From: master3395 Date: Fri, 27 Mar 2026 22:08:45 +0100 Subject: [PATCH 5/8] feat(versionManagment): fork vs official upstream rows and layout refresh - views: remote_display, branch_ref, fork_remote_commit, upstream_commit, short SHAs, GitHub commit URLs, notecheck_compare_remote, local_behind_official - template: installation grid, full-width meta rows, i18n upgrade note, info notice when local differs from official upstream on dev --- .../baseTemplate/versionManagment.html | 139 ++++++++++++++++-- baseTemplate/views.py | 57 ++++++- 2 files changed, 183 insertions(+), 13 deletions(-) diff --git a/baseTemplate/templates/baseTemplate/versionManagment.html b/baseTemplate/templates/baseTemplate/versionManagment.html index 202db35ec..734fb1391 100644 --- a/baseTemplate/templates/baseTemplate/versionManagment.html +++ b/baseTemplate/templates/baseTemplate/versionManagment.html @@ -165,6 +165,72 @@ font-weight: 600; } + .alert-info { + background: var(--bg-hover, #eef0ff); + border: 1px solid var(--border-color, #c7c9f0); + color: var(--text-primary, #2f3640); + } + + .alert-info a { + color: var(--accent-color, #5b5fcf); + font-weight: 600; + } + + .version-section { + margin-top: 20px; + margin-bottom: 10px; + font-size: 14px; + font-weight: 700; + color: var(--text-primary, #2f3640); + } + + .version-section:first-of-type { + margin-top: 0; + } + + .version-meta-row { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; + margin-bottom: 14px; + padding: 12px 14px; + border-radius: 8px; + border: 1px solid var(--border-color, #e8e9ff); + background: var(--bg-hover, #f8f9ff); + } + + .version-meta-row-label { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary, #8893a7); + text-transform: uppercase; + letter-spacing: 0.5px; + } + + .version-meta-row-value { + font-size: 14px; + color: var(--text-primary, #2f3640); + word-break: break-word; + line-height: 1.5; + } + + .version-meta-row-value code, + .version-sha { + font-family: 'SF Mono', Monaco, monospace; + font-size: 13px; + background: var(--bg-secondary, white); + padding: 2px 8px; + border-radius: 4px; + border: 1px solid var(--border-color, #e8e9ff); + } + + .version-meta-row-value a { + font-weight: 600; + color: var(--accent-color, #5b5fcf); + margin-left: 4px; + } + /* Info grid */ .info-grid { display: grid; @@ -269,7 +335,16 @@ {% if Notecheck %}
- {% trans "Note: Latest commit does not match, please upgrade CyberPanel." %} Learn how to upgrade CyberPanel. + + {% blocktrans with cmp=notecheck_compare_remote %}Note: The current commit does not match the latest commit on {{ cmp }}. Please upgrade CyberPanel.{% endblocktrans %} + {% trans "Learn how to upgrade CyberPanel." %} + +
+ {% endif %} + {% if local_behind_official and not Notecheck %} +
+ + {% trans "Your commit differs from the latest on the official CyberPanel repository (usmannasir/cyberpanel). This is informational if you intentionally track a fork." %}
{% endif %} @@ -294,8 +369,9 @@
-

Version Information

+

{% trans "Version Information" %}

+

{% trans "This installation" %}

{% trans "Current Version" %} @@ -306,22 +382,65 @@ {{ build }}
- {% trans "Current Commit" %} - {{ Currentcomt }} + {% trans "Current commit" %} + + {% if Currentcomt_short %}{{ Currentcomt_short }}{% else %}—{% endif %} +
- {% trans "Latest Version" %} + {% trans "Published latest version" %} {{ latestVersion }}
- {% trans "Latest Build" %} + {% trans "Published latest build" %} {{ latestBuild }}
-
- {% trans "Latest Commit" %} - {{ latestcomit }} -
+ +

{% trans "Git remotes and branch tips" %}

+
+ {% trans "Origin (git remote)" %} + {{ remote_display|default:"—" }} +
+
+ {% trans "Tracking branch" %} + {{ branch_ref }} +
+ + {% if fork_remote_commit %} +
+ {% trans "Your fork — latest on GitHub" %} + + {{ fork_remote_commit_short }} + {% if fork_commit_url %} + {% trans "View commit" %} + {% endif %} + +
+ {% endif %} + + {% if on_dev_branch %} +
+ {% trans "Official upstream (usmannasir/cyberpanel) — latest on GitHub" %} + + {% if upstream_commit %} + {{ upstream_commit_short }} + {% if upstream_commit_url %} + {% trans "View commit" %} + {% endif %} + {% else %} + {% trans "Unavailable" %} (API) + {% endif %} + +
+ {% elif latestcomit and not fork_remote_commit and not on_dev_branch %} +
+ {% trans "Latest commit on origin (comparison)" %} + + {{ latestcomit_short }} + +
+ {% endif %}
diff --git a/baseTemplate/views.py b/baseTemplate/views.py index 8a0fcebf5..0ffd00142 100644 --- a/baseTemplate/views.py +++ b/baseTemplate/views.py @@ -325,6 +325,14 @@ def versionManagment(request): remote_cmd = 'git -C /usr/local/CyberCP remote get-url origin 2>/dev/null || true' remote_out = ProcessUtilities.outputExecutioner(remote_cmd) is_usmannasir = 'usmannasir/cyberpanel' in (remote_out or '') + github_owner, github_repo = None, None + m_remote = re.search(r'github\.com[:/]([^/]+)/([^/]+?)(?:\.git)?$', (remote_out or '').strip()) + if m_remote: + github_owner = m_remote.group(1) + github_repo = m_remote.group(2).rstrip('.git') + remote_display = '%s/%s' % (github_owner, github_repo) + else: + remote_display = ((remote_out or '').strip() or '') # Stable: newer than cyberpanel.net = up to date; dev: compare commits if not on_dev_branch and notechk and _version_compare(currentVersion, latestVersion) > 0: @@ -365,10 +373,53 @@ def versionManagment(request): except (requests.RequestException, IndexError, KeyError): pass + # Fork remote tip for UI (empty when origin is official). + fork_remote_commit = latestcomit if (latestcomit and not is_usmannasir) else '' + + upstream_commit = '' + if on_dev_branch: + up_url = "https://api.github.com/repos/usmannasir/cyberpanel/commits?sha=%s" % branch_ref + logging.CyberCPLogFileWriter.writeToFile(up_url) + try: + up_r = requests.get(up_url, timeout=10) + up_r.raise_for_status() + upstream_commit = up_r.json()[0]['sha'] + except (requests.RequestException, IndexError, KeyError) as e: + logging.CyberCPLogFileWriter.writeToFile('[versionManagment] upstream GitHub API failed: %s' % str(e)) + + def _short_sha(commit_hash): + if not commit_hash or len(commit_hash) < 7: + return commit_hash or '' + return commit_hash[:7] + + fork_commit_url = '' + if github_owner and github_repo and fork_remote_commit: + fork_commit_url = 'https://github.com/%s/%s/commit/%s' % ( + github_owner, github_repo, fork_remote_commit) + upstream_commit_url = '' + if upstream_commit: + upstream_commit_url = 'https://github.com/usmannasir/cyberpanel/commit/%s' % upstream_commit + + local_behind_official = bool( + on_dev_branch and Currentcomt and upstream_commit and Currentcomt != upstream_commit) + notecheck_compare_remote = 'usmannasir/cyberpanel' if is_usmannasir else remote_display + template = 'baseTemplate/versionManagment.html' - finalData = {'build': currentBuild, 'currentVersion': currentVersion, 'latestVersion': latestVersion, - 'latestBuild': latestBuild, 'latestcomit': latestcomit, "Currentcomt": Currentcomt, - "Notecheck": notechk} + finalData = { + 'build': currentBuild, 'currentVersion': currentVersion, 'latestVersion': latestVersion, + 'latestBuild': latestBuild, 'latestcomit': latestcomit, 'Currentcomt': Currentcomt, + 'Notecheck': notechk, 'branch_ref': branch_ref, 'remote_display': remote_display, + 'is_usmannasir': is_usmannasir, 'fork_remote_commit': fork_remote_commit, + 'upstream_commit': upstream_commit, 'fork_commit_url': fork_commit_url, + 'upstream_commit_url': upstream_commit_url, + 'Currentcomt_short': _short_sha(Currentcomt), + 'latestcomit_short': _short_sha(latestcomit), + 'fork_remote_commit_short': _short_sha(fork_remote_commit), + 'upstream_commit_short': _short_sha(upstream_commit), + 'local_behind_official': local_behind_official, + 'notecheck_compare_remote': notecheck_compare_remote, + 'on_dev_branch': on_dev_branch, + } proc = httpProc(request, template, finalData, 'versionManagement') return proc.render() From 35fe6abba6abcb09c2c4d14e1d11a48636a7638e Mon Sep 17 00:00:00 2001 From: master3395 Date: Fri, 27 Mar 2026 22:29:58 +0100 Subject: [PATCH 6/8] Version Management: show fork + upstream tips; upstream-only when origin is official - Parse git origin; show fork block only when not usmannasir/cyberpanel - Always fetch official upstream branch tip; fetch fork tip when fork - Notecheck: fork installs vs fork tip; official vs upstream tip - Template: tracking branch, optional fork rows, drift info; clarify warning --- .../baseTemplate/versionManagment.html | 13 +- baseTemplate/views.py | 232 +++++++++--------- 2 files changed, 117 insertions(+), 128 deletions(-) diff --git a/baseTemplate/templates/baseTemplate/versionManagment.html b/baseTemplate/templates/baseTemplate/versionManagment.html index 734fb1391..3d1d745bd 100644 --- a/baseTemplate/templates/baseTemplate/versionManagment.html +++ b/baseTemplate/templates/baseTemplate/versionManagment.html @@ -341,7 +341,12 @@
{% endif %} - {% if local_behind_official and not Notecheck %} + {% if show_fork_block and fork_drift_upstream %} +
+ + {% trans "Info: Your fork branch tip on GitHub differs from the official upstream tip on the same branch. That is normal if you have not merged upstream yet." %} +
+ {% elif local_behind_official and not Notecheck %}
{% trans "Your commit differs from the latest on the official CyberPanel repository (usmannasir/cyberpanel). This is informational if you intentionally track a fork." %} @@ -419,18 +424,14 @@
{% endif %} - {% if on_dev_branch %} + {% if upstream_commit %}
{% trans "Official upstream (usmannasir/cyberpanel) — latest on GitHub" %} - {% if upstream_commit %} {{ upstream_commit_short }} {% if upstream_commit_url %} {% trans "View commit" %} {% endif %} - {% else %} - {% trans "Unavailable" %} (API) - {% endif %}
{% elif latestcomit and not fork_remote_commit and not on_dev_branch %} diff --git a/baseTemplate/views.py b/baseTemplate/views.py index 0ffd00142..871a8551e 100644 --- a/baseTemplate/views.py +++ b/baseTemplate/views.py @@ -55,6 +55,34 @@ def _version_compare(a, b): return 0 +def _parse_github_origin(remote_out): + """Return (owner, repo) or (None, None) if unparseable.""" + if not remote_out: + return None, None + m = re.search(r'github\.com[:/]([^/]+)/([^/]+?)(?:\.git)?$', remote_out.strip()) + if not m: + return None, None + owner, repo = m.group(1), m.group(2).rstrip('.git') + return owner, repo + + +def _github_branch_tip_sha(owner, repo, branch_ref): + """First commit SHA on branch via GitHub API, or empty string on failure.""" + try: + u = 'https://api.github.com/repos/%s/%s/commits?sha=%s' % (owner, repo, branch_ref) + r = requests.get(u, timeout=10) + r.raise_for_status() + data = r.json() + if not data: + return '' + sha = data[0].get('sha', '') or '' + return sha + except (requests.RequestException, IndexError, KeyError, TypeError) as e: + logging.CyberCPLogFileWriter.writeToFile( + '[versionManagment] GitHub API %s/%s @%s failed: %s' % (owner, repo, branch_ref, str(e))) + return '' + + @ensure_csrf_cookie def renderBase(request): template = 'baseTemplate/homePage.html' @@ -67,49 +95,8 @@ def renderBase(request): @ensure_csrf_cookie def versionManagement(request): - currentVersion = VERSION - currentBuild = str(BUILD) - - getVersion = requests.get('https://cyberpanel.net/version.txt') - latest = getVersion.json() - latestVersion = latest['version'] - latestBuild = latest['build'] - branch_ref = 'v%s.%s' % (latestVersion, latestBuild) - - notechk = True - Currentcomt = '' - latestcomit = '' - - if _version_compare(currentVersion, latestVersion) > 0: - notechk = False - else: - remote_cmd = 'git -C /usr/local/CyberCP remote get-url origin 2>/dev/null || true' - remote_out = ProcessUtilities.outputExecutioner(remote_cmd) - is_usmannasir = 'usmannasir/cyberpanel' in (remote_out or '') - - if is_usmannasir: - u = "https://api.github.com/repos/usmannasir/cyberpanel/commits?sha=%s" % branch_ref - logging.CyberCPLogFileWriter.writeToFile(u) - try: - r = requests.get(u, timeout=10) - r.raise_for_status() - latestcomit = r.json()[0]['sha'] - except (requests.RequestException, IndexError, KeyError) as e: - logging.CyberCPLogFileWriter.writeToFile('[versionManagement] GitHub API failed: %s' % str(e)) - head_cmd = 'git -C /usr/local/CyberCP rev-parse HEAD 2>/dev/null || true' - Currentcomt = ProcessUtilities.outputExecutioner(head_cmd).rstrip('\n') - if latestcomit and Currentcomt == latestcomit: - notechk = False - else: - notechk = False - - template = 'baseTemplate/versionManagment.html' - finalData = {'build': currentBuild, 'currentVersion': currentVersion, 'latestVersion': latestVersion, - 'latestBuild': latestBuild, 'latestcomit': latestcomit, "Currentcomt": Currentcomt, - "Notecheck": notechk} - - proc = httpProc(request, template, finalData, 'versionManagement') - return proc.render() + """Legacy entrypoint; same UI as versionManagment (URLs use versionManagment).""" + return versionManagment(request) @ensure_csrf_cookie @@ -297,6 +284,13 @@ def versionManagment(request): latestcomit = '' latestVersion = '0' latestBuild = '0' + upstream_latest_sha = '' + fork_latest_sha = '' + show_fork_block = False + fork_display = '' + fork_commit_url = '' + upstream_commit_url = '' + fork_drift_upstream = False on_dev_branch = (currentVersion == '2.5.5' and currentBuild == 'dev') @@ -311,114 +305,108 @@ def versionManagment(request): if on_dev_branch: latestVersion, latestBuild = '2.5.5', 'dev' - # Dev branch: compare against v2.5.5-dev, show dev version info if on_dev_branch: branch_ref = 'v2.5.5-dev' latestVersion, latestBuild = '2.5.5', 'dev' else: branch_ref = 'v%s.%s' % (latestVersion, latestBuild) - # Always fetch local HEAD for display head_cmd = 'git -C /usr/local/CyberCP rev-parse HEAD 2>/dev/null || true' Currentcomt = (ProcessUtilities.outputExecutioner(head_cmd) or '').rstrip('\n') remote_cmd = 'git -C /usr/local/CyberCP remote get-url origin 2>/dev/null || true' - remote_out = ProcessUtilities.outputExecutioner(remote_cmd) - is_usmannasir = 'usmannasir/cyberpanel' in (remote_out or '') - github_owner, github_repo = None, None - m_remote = re.search(r'github\.com[:/]([^/]+)/([^/]+?)(?:\.git)?$', (remote_out or '').strip()) - if m_remote: - github_owner = m_remote.group(1) - github_repo = m_remote.group(2).rstrip('.git') - remote_display = '%s/%s' % (github_owner, github_repo) + remote_out = (ProcessUtilities.outputExecutioner(remote_cmd) or '') + origin_owner, origin_repo = _parse_github_origin(remote_out) + if origin_owner and origin_repo: + remote_display = '%s/%s' % (origin_owner, origin_repo) + is_official = (origin_owner.lower() == 'usmannasir' and origin_repo.lower() == 'cyberpanel') + show_fork_block = not is_official + if show_fork_block: + fork_display = remote_display else: - remote_display = ((remote_out or '').strip() or '') + remote_display = (remote_out.strip() or '—') + logging.CyberCPLogFileWriter.writeToFile( + '[versionManagment] Unparseable git origin, upstream-only UI: %s' + % (remote_out[:200] if remote_out else '(empty)')) + show_fork_block = False + + upstream_latest_sha = _github_branch_tip_sha('usmannasir', 'cyberpanel', branch_ref) + latestcomit = upstream_latest_sha + if upstream_latest_sha: + upstream_commit_url = 'https://github.com/usmannasir/cyberpanel/commit/%s' % upstream_latest_sha + + if show_fork_block and origin_owner and origin_repo: + fork_latest_sha = _github_branch_tip_sha(origin_owner, origin_repo, branch_ref) + if fork_latest_sha: + fork_commit_url = 'https://github.com/%s/%s/commit/%s' % ( + origin_owner, origin_repo, fork_latest_sha) + + if (fork_latest_sha and upstream_latest_sha and fork_latest_sha != upstream_latest_sha): + fork_drift_upstream = True - # Stable: newer than cyberpanel.net = up to date; dev: compare commits if not on_dev_branch and notechk and _version_compare(currentVersion, latestVersion) > 0: notechk = False elif notechk: - # Dev branch: official remote compares to usmannasir/cyberpanel; forks compare to their own GitHub repo - fetch_branch = branch_ref if (is_usmannasir or on_dev_branch) else None - if fetch_branch: - u = None - if on_dev_branch and not is_usmannasir: - m = re.search(r'github\.com[:/]([^/]+)/([^/]+?)(?:\.git)?$', (remote_out or '').strip()) - if m: - owner, repo = m.group(1), m.group(2).rstrip('.git') - u = "https://api.github.com/repos/%s/%s/commits?sha=%s" % (owner, repo, fetch_branch) - if u is None: - u = "https://api.github.com/repos/usmannasir/cyberpanel/commits?sha=%s" % fetch_branch - logging.CyberCPLogFileWriter.writeToFile(u) - try: - r = requests.get(u, timeout=10) - r.raise_for_status() - latestcomit = r.json()[0]['sha'] - if Currentcomt and latestcomit and Currentcomt == latestcomit: - notechk = False - except (requests.RequestException, IndexError, KeyError) as e: - logging.CyberCPLogFileWriter.writeToFile('[versionManagment] GitHub API failed: %s' % str(e)) - elif not on_dev_branch: - # Stable fork: fetch from fork's branch - m = re.search(r'github\.com[:/]([^/]+)/([^/]+?)(?:\.git)?$', (remote_out or '').strip()) - if m: - owner, repo = m.group(1), m.group(2).rstrip('.git') - try: - u = "https://api.github.com/repos/%s/%s/commits?sha=%s" % (owner, repo, branch_ref) - r = requests.get(u, timeout=10) - r.raise_for_status() - latestcomit = r.json()[0]['sha'] - if Currentcomt and latestcomit and Currentcomt == latestcomit: - notechk = False - except (requests.RequestException, IndexError, KeyError): - pass + cur = (Currentcomt or '').strip().lower() + if show_fork_block: + fk = (fork_latest_sha or '').strip().lower() + up = (upstream_latest_sha or '').strip().lower() + if fk: + notechk = not (bool(cur) and cur == fk) + elif up: + notechk = not (bool(cur) and cur == up) + else: + notechk = False + else: + up = (upstream_latest_sha or '').strip().lower() + if up: + notechk = not (bool(cur) and cur == up) + else: + notechk = False - # Fork remote tip for UI (empty when origin is official). - fork_remote_commit = latestcomit if (latestcomit and not is_usmannasir) else '' - - upstream_commit = '' - if on_dev_branch: - up_url = "https://api.github.com/repos/usmannasir/cyberpanel/commits?sha=%s" % branch_ref - logging.CyberCPLogFileWriter.writeToFile(up_url) - try: - up_r = requests.get(up_url, timeout=10) - up_r.raise_for_status() - upstream_commit = up_r.json()[0]['sha'] - except (requests.RequestException, IndexError, KeyError) as e: - logging.CyberCPLogFileWriter.writeToFile('[versionManagment] upstream GitHub API failed: %s' % str(e)) + is_usmannasir = not show_fork_block + fork_remote_commit = fork_latest_sha if show_fork_block else '' + upstream_commit = upstream_latest_sha + notecheck_compare_remote = fork_display if show_fork_block else 'usmannasir/cyberpanel' + local_behind_official = bool( + on_dev_branch and Currentcomt and upstream_commit and Currentcomt != upstream_commit) def _short_sha(commit_hash): if not commit_hash or len(commit_hash) < 7: return commit_hash or '' return commit_hash[:7] - fork_commit_url = '' - if github_owner and github_repo and fork_remote_commit: - fork_commit_url = 'https://github.com/%s/%s/commit/%s' % ( - github_owner, github_repo, fork_remote_commit) - upstream_commit_url = '' - if upstream_commit: - upstream_commit_url = 'https://github.com/usmannasir/cyberpanel/commit/%s' % upstream_commit - - local_behind_official = bool( - on_dev_branch and Currentcomt and upstream_commit and Currentcomt != upstream_commit) - notecheck_compare_remote = 'usmannasir/cyberpanel' if is_usmannasir else remote_display - template = 'baseTemplate/versionManagment.html' finalData = { - 'build': currentBuild, 'currentVersion': currentVersion, 'latestVersion': latestVersion, - 'latestBuild': latestBuild, 'latestcomit': latestcomit, 'Currentcomt': Currentcomt, - 'Notecheck': notechk, 'branch_ref': branch_ref, 'remote_display': remote_display, - 'is_usmannasir': is_usmannasir, 'fork_remote_commit': fork_remote_commit, - 'upstream_commit': upstream_commit, 'fork_commit_url': fork_commit_url, + 'build': currentBuild, + 'currentVersion': currentVersion, + 'latestVersion': latestVersion, + 'latestBuild': latestBuild, + 'latestcomit': latestcomit, + 'Currentcomt': Currentcomt, + 'Notecheck': notechk, + 'show_fork_block': show_fork_block, + 'tracking_branch': branch_ref, + 'branch_ref': branch_ref, + 'fork_display': fork_display, + 'fork_latest_sha': fork_latest_sha, + 'upstream_latest_sha': upstream_latest_sha, + 'fork_commit_url': fork_commit_url, 'upstream_commit_url': upstream_commit_url, + 'fork_drift_upstream': fork_drift_upstream, + 'remote_display': remote_display, + 'is_usmannasir': is_usmannasir, + 'fork_remote_commit': fork_remote_commit, + 'upstream_commit': upstream_commit, + 'notecheck_compare_remote': notecheck_compare_remote, + 'local_behind_official': local_behind_official, + 'on_dev_branch': on_dev_branch, 'Currentcomt_short': _short_sha(Currentcomt), 'latestcomit_short': _short_sha(latestcomit), 'fork_remote_commit_short': _short_sha(fork_remote_commit), 'upstream_commit_short': _short_sha(upstream_commit), - 'local_behind_official': local_behind_official, - 'notecheck_compare_remote': notecheck_compare_remote, - 'on_dev_branch': on_dev_branch, + 'fork_latest_sha_short': _short_sha(fork_latest_sha), + 'upstream_latest_sha_short': _short_sha(upstream_latest_sha), } proc = httpProc(request, template, finalData, 'versionManagement') From d2e0a1bda7e2eb1822b6da8c070f389e4b4276d1 Mon Sep 17 00:00:00 2001 From: master3395 Date: Fri, 27 Mar 2026 22:47:09 +0100 Subject: [PATCH 7/8] upgrade: fail fast if CyberCP clone fails; retry clone; quarantine old tree - Honor downloadAndUpgrade return value; exit 1 instead of printing Upgrade Completed - Restart lscpd if code update fails so panel is reachable on old tree - CYBERPANEL_UPGRADE_CLONE_ATTEMPTS (default 2) for transient clone errors - On rmtree failure, move /usr/local/CyberCP aside instead of aborting when possible - Export CYBERPANEL_UPGRADE_CLONE_ATTEMPTS from 08_main_upgrade.sh --- plogical/upgrade.py | 56 ++++++++++++++++++++++++------ upgrade_modules/08_main_upgrade.sh | 2 ++ 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/plogical/upgrade.py b/plogical/upgrade.py index f728108ef..8241e1c1f 100644 --- a/plogical/upgrade.py +++ b/plogical/upgrade.py @@ -4577,17 +4577,22 @@ class Migration(migrations.Migration): # Change to parent directory os.chdir('/usr/local') - # Remove old CyberCP directory + # Remove old CyberCP directory (quarantine if rmtree fails — e.g. busy files) if os.path.exists('CyberCP'): Upgrade.stdOut("Removing old CyberCP directory...") try: shutil.rmtree('CyberCP') Upgrade.stdOut("Old CyberCP directory removed successfully.") - except Exception as e: - Upgrade.stdOut(f"Error removing CyberCP directory: {str(e)}") - # Try to restore backup if removal fails - Upgrade.restoreCriticalFiles(backup_dir, backed_up_files) - return 0, 'Failed to remove old CyberCP directory' + except OSError as e: + Upgrade.stdOut("rmtree failed (%s); quarantining old CyberCP..." % str(e)) + quarantine = '/usr/local/CyberCP.legacy.%s' % int(time.time()) + try: + shutil.move('CyberCP', quarantine) + Upgrade.stdOut("Moved old tree to %s" % quarantine) + except OSError as e2: + Upgrade.stdOut(f"Error removing or moving CyberCP directory: {str(e2)}") + Upgrade.restoreCriticalFiles(backup_dir, backed_up_files) + return 0, 'Failed to remove or quarantine old CyberCP directory' # Clone the new repository (use CYBERPANEL_GIT_USER for fork, e.g. master3395) git_user = os.environ.get('CYBERPANEL_GIT_USER', 'master3395') @@ -4612,10 +4617,15 @@ class Migration(migrations.Migration): if os.path.exists('CyberCP'): try: shutil.rmtree('CyberCP') - except Exception as e: - Upgrade.stdOut("Error removing CyberCP: %s" % str(e)) - Upgrade.restoreCriticalFiles(backup_dir, backed_up_files) - return 0, 'Failed to remove CyberCP for upstream clone' + except OSError as e: + Upgrade.stdOut("rmtree failed (%s); quarantining..." % str(e)) + quarantine = '/usr/local/CyberCP.legacy.%s' % int(time.time()) + try: + shutil.move('CyberCP', quarantine) + except OSError as e2: + Upgrade.stdOut("Error removing CyberCP: %s" % str(e2)) + Upgrade.restoreCriticalFiles(backup_dir, backed_up_files) + return 0, 'Failed to remove CyberCP for upstream clone' command = 'git clone https://github.com/%s/cyberpanel CyberCP' % upstream_user if not Upgrade.executioner(command, command, 1): Upgrade.restoreCriticalFiles(backup_dir, backed_up_files) @@ -6581,7 +6591,31 @@ slowlog = /var/log/php{version}-fpm-slow.log # execPath = execPath + " removeCSF" # Upgrade.executioner(execPath, 'fix csf if there', 0) - Upgrade.downloadAndUpgrade(versionNumbring, branch) + clone_attempts = int(os.environ.get('CYBERPANEL_UPGRADE_CLONE_ATTEMPTS', '2')) + if clone_attempts < 1: + clone_attempts = 1 + download_ok = False + download_err = None + for attempt in range(1, clone_attempts + 1): + ok, download_err = Upgrade.downloadAndUpgrade(versionNumbring, branch) + if ok: + download_ok = True + break + Upgrade.stdOut( + 'downloadAndUpgrade failed (attempt %d/%d): %s' + % (attempt, clone_attempts, download_err or 'unknown'), 0) + if attempt < clone_attempts: + Upgrade.stdOut('Retrying full CyberPanel tree replacement in 5 seconds...', 0) + time.sleep(5) + if not download_ok: + Upgrade.stdOut( + 'CRITICAL: CyberPanel code update failed after %d attempt(s): %s. ' + 'The panel was not replaced with the new branch. Check logs and disk permissions on /usr/local.' + % (clone_attempts, download_err or 'unknown'), 0) + if Upgrade.SoftUpgrade == 0: + Upgrade.executioner('systemctl start lscpd', 'Start LSCPD after failed code update', 0) + sys.exit(1) + versionNumbring = Upgrade.downloadLink() Upgrade.download_install_phpmyadmin() Upgrade.downoad_and_install_raindloop() diff --git a/upgrade_modules/08_main_upgrade.sh b/upgrade_modules/08_main_upgrade.sh index 101cf6b31..a0638cb0f 100644 --- a/upgrade_modules/08_main_upgrade.sh +++ b/upgrade_modules/08_main_upgrade.sh @@ -30,6 +30,8 @@ echo -e "[$(date +"%Y-%m-%d %H:%M:%S")] Running: $CP_PYTHON upgrade.py $Branch_N # Export Git user so upgrade.py clones from the same repo (master3395 or --repo override) export CYBERPANEL_GIT_USER="${Git_User:-master3395}" +# Retry full /usr/local/CyberCP re-clone this many times if download/checkout fails (default 2) +export CYBERPANEL_UPGRADE_CLONE_ATTEMPTS="${CYBERPANEL_UPGRADE_CLONE_ATTEMPTS:-2}" # Run from directory that contains upgrade.py (downloaded by Pre_Upgrade_Required_Components) for d in /root/cyberpanel_upgrade_tmp /usr/local/CyberCP; do From b89ed169ca4c837ed9b1ef43557a6ae15484d887 Mon Sep 17 00:00:00 2001 From: master3395 Date: Fri, 27 Mar 2026 23:02:32 +0100 Subject: [PATCH 8/8] upgrade sync: force match origin when checkout blocked; fail script if sync fails - 09_sync: stash -u + reset --hard; quarantine untracked paths; verify HEAD==origin/branch - Remove subshell/tee that hid git failures; set /etc/cyberpanel/last_git_sync_failed on error - cyberpanel_upgrade: exit 1 after final display if git sync failed - 11_display_final: show warning banner when CYBERPANEL_GIT_SYNC_OK!=1 --- cyberpanel_upgrade.sh | 11 ++- upgrade_modules/09_sync.sh | 103 ++++++++++++++++++++++------ upgrade_modules/11_display_final.sh | 7 +- 3 files changed, 99 insertions(+), 22 deletions(-) diff --git a/cyberpanel_upgrade.sh b/cyberpanel_upgrade.sh index 5310008d9..212cfb199 100644 --- a/cyberpanel_upgrade.sh +++ b/cyberpanel_upgrade.sh @@ -102,6 +102,15 @@ Pre_Upgrade_Setup_Repository Pre_Upgrade_Setup_Git_URL Pre_Upgrade_Required_Components Main_Upgrade -Sync_CyberCP_To_Latest +CYBERPANEL_GIT_SYNC_OK=0 +if Sync_CyberCP_To_Latest; then + CYBERPANEL_GIT_SYNC_OK=1 +else + echo -e "\e[31m[$(date +"%Y-%m-%d %H:%M:%S")] ERROR: Git sync of /usr/local/CyberCP to origin/$Branch_Name failed. Panel code may be outdated. See /var/log/cyberpanel_upgrade_debug.log and /etc/cyberpanel/last_git_sync_failed\e[0m" | tee -a /var/log/cyberpanel_upgrade_debug.log +fi +export CYBERPANEL_GIT_SYNC_OK Post_Upgrade_System_Tweak Post_Install_Display_Final_Info +if [[ "$CYBERPANEL_GIT_SYNC_OK" -ne 1 ]]; then + exit 1 +fi diff --git a/upgrade_modules/09_sync.sh b/upgrade_modules/09_sync.sh index 3d385e818..4b9e9f038 100644 --- a/upgrade_modules/09_sync.sh +++ b/upgrade_modules/09_sync.sh @@ -1,30 +1,97 @@ #!/usr/bin/env bash # CyberPanel upgrade – sync CyberCP to latest commit. Sourced by cyberpanel_upgrade.sh. +# If local edits or untracked files block checkout, stash/quarantine and reset --hard to match origin. Sync_CyberCP_To_Latest() { + local SYNC_STATE_FILE="/etc/cyberpanel/last_git_sync_failed" + mkdir -p /etc/cyberpanel + rm -f "$SYNC_STATE_FILE" 2>/dev/null || true + if [[ ! -d /usr/local/CyberCP/.git ]]; then echo -e "[$(date +"%Y-%m-%d %H:%M:%S")] No .git in /usr/local/CyberCP, skipping sync" | tee -a /var/log/cyberpanel_upgrade_debug.log return 0 fi + echo -e "[$(date +"%Y-%m-%d %H:%M:%S")] Syncing /usr/local/CyberCP to latest commit for branch: $Branch_Name" | tee -a /var/log/cyberpanel_upgrade_debug.log - # Backup production settings so sync does not overwrite DB credentials / local config + if [[ -f /usr/local/CyberCP/CyberCP/settings.py ]]; then cp /usr/local/CyberCP/CyberCP/settings.py /tmp/cyberpanel_settings_backup.py echo -e "[$(date +"%Y-%m-%d %H:%M:%S")] Backed up settings.py for restore after sync" | tee -a /var/log/cyberpanel_upgrade_debug.log fi - ( - cd /usr/local/CyberCP - git fetch origin 2>&1 | tee -a /var/log/cyberpanel_upgrade_debug.log - if git show-ref -q "refs/remotes/origin/$Branch_Name"; then - git checkout -B "$Branch_Name" "origin/$Branch_Name" 2>&1 | tee -a /var/log/cyberpanel_upgrade_debug.log - else - git checkout "$Branch_Name" 2>/dev/null || true - git pull --ff-only origin "$Branch_Name" 2>&1 | tee -a /var/log/cyberpanel_upgrade_debug.log || true + + if ! cd /usr/local/CyberCP; then + echo "1" > "$SYNC_STATE_FILE" + return 1 + fi + + local remote_ref="origin/$Branch_Name" + local fetch_rc sync_ok=0 + + git fetch origin "$Branch_Name" 2>&1 | tee -a /var/log/cyberpanel_upgrade_debug.log + fetch_rc="${PIPESTATUS[0]}" + if [[ "$fetch_rc" -ne 0 ]]; then + echo -e "[$(date +"%Y-%m-%d %H:%M:%S")] ERROR: git fetch origin $Branch_Name failed (exit $fetch_rc)" | tee -a /var/log/cyberpanel_upgrade_debug.log + echo "1" > "$SYNC_STATE_FILE" + return 1 + fi + + if ! git show-ref -q "refs/remotes/$remote_ref"; then + echo -e "[$(date +"%Y-%m-%d %H:%M:%S")] ERROR: remote ref $remote_ref not found after fetch" | tee -a /var/log/cyberpanel_upgrade_debug.log + echo "1" > "$SYNC_STATE_FILE" + return 1 + fi + + _cp_force_sync_to_remote_tip() { + local ref="$1" quarantine dest rel + git checkout -B "$Branch_Name" "$ref" >> /var/log/cyberpanel_upgrade_debug.log 2>&1 + if [[ $? -eq 0 ]]; then + return 0 fi - ) - local sync_code=$? - # Merge production DATABASES into branch settings.py so DB creds survive without stripping - # new INSTALLED_APPS (webmail, emailDelivery, etc.). Blind full restore broke integrated webmail. + echo -e "[$(date +"%Y-%m-%d %H:%M:%S")] Checkout blocked; stashing tracked+untracked changes and resetting --hard to $ref" | tee -a /var/log/cyberpanel_upgrade_debug.log + git stash push -u -m "cyberpanel-upgrade-auto-$(date +%s)" >> /var/log/cyberpanel_upgrade_debug.log 2>&1 || true + git reset --hard "$ref" >> /var/log/cyberpanel_upgrade_debug.log 2>&1 + if [[ $? -eq 0 ]]; then + return 0 + fi + quarantine="/root/cyberpanel_git_untracked_quarantine_$(date +%s)" + mkdir -p "$quarantine" + echo -e "[$(date +"%Y-%m-%d %H:%M:%S")] reset --hard still blocked; moving untracked paths to $quarantine" | tee -a /var/log/cyberpanel_upgrade_debug.log + # shellcheck disable=SC2162 + while IFS= read -r line; do + case "$line" in + \?\?*) + rel="${line#??}" + rel="${rel#"${rel%%[![:space:]]*}"}" + if [[ -n "$rel" ]] && [[ -e "$rel" ]]; then + dest="$quarantine/$rel" + mkdir -p "$(dirname "$dest")" + mv "$rel" "$dest" 2>/dev/null || mv "$rel" "$quarantine/" 2>/dev/null || true + fi + ;; + esac + done < <(git status --porcelain) + git reset --hard "$ref" >> /var/log/cyberpanel_upgrade_debug.log 2>&1 + return $? + } + + if _cp_force_sync_to_remote_tip "$remote_ref"; then + local local_h remote_h + local_h=$(git rev-parse HEAD 2>/dev/null) + remote_h=$(git rev-parse "$remote_ref" 2>/dev/null) + if [[ -n "$local_h" ]] && [[ -n "$remote_h" ]] && [[ "$local_h" == "$remote_h" ]]; then + sync_ok=1 + fi + fi + + if [[ "$sync_ok" -ne 1 ]]; then + echo -e "[$(date +"%Y-%m-%d %H:%M:%S")] ERROR: /usr/local/CyberCP HEAD does not match $remote_ref after forced sync (local=$(git rev-parse HEAD 2>/dev/null), remote=$(git rev-parse "$remote_ref" 2>/dev/null))" | tee -a /var/log/cyberpanel_upgrade_debug.log + echo "1" > "$SYNC_STATE_FILE" + if [[ -f /tmp/cyberpanel_settings_backup.py ]]; then + cp /tmp/cyberpanel_settings_backup.py /usr/local/CyberCP/CyberCP/settings.py + fi + return 1 + fi + if [[ -f /tmp/cyberpanel_settings_backup.py ]] && [[ -f /usr/local/CyberCP/upgrade_modules/merge_production_settings.py ]]; then python3 /usr/local/CyberCP/upgrade_modules/merge_production_settings.py /tmp/cyberpanel_settings_backup.py /usr/local/CyberCP/CyberCP/settings.py 2>&1 | tee -a /var/log/cyberpanel_upgrade_debug.log if [[ "${PIPESTATUS[0]}" -eq 0 ]]; then @@ -36,22 +103,18 @@ Sync_CyberCP_To_Latest() { cp /tmp/cyberpanel_settings_backup.py /usr/local/CyberCP/CyberCP/settings.py echo -e "[$(date +"%Y-%m-%d %H:%M:%S")] Restored settings.py after sync (merge script missing)" | tee -a /var/log/cyberpanel_upgrade_debug.log fi - # LiteSpeed serves /static/ from public/static/; ensure it has latest baseTemplate static files (e.g. dashboard JS) + if [[ -d /usr/local/CyberCP/public/static ]] && [[ -d /usr/local/CyberCP/baseTemplate/static/baseTemplate ]]; then rsync -a /usr/local/CyberCP/baseTemplate/static/baseTemplate/ /usr/local/CyberCP/public/static/baseTemplate/ 2>/dev/null || \ cp -r /usr/local/CyberCP/baseTemplate/static/baseTemplate/* /usr/local/CyberCP/public/static/baseTemplate/ 2>/dev/null || true echo -e "[$(date +"%Y-%m-%d %H:%M:%S")] Synced baseTemplate static to public/static" | tee -a /var/log/cyberpanel_upgrade_debug.log fi - # Firewall UI (firewall.js) – so Firewall Rules and Banned IPs load correct layout and Modify buttons if [[ -d /usr/local/CyberCP/public/static ]] && [[ -f /usr/local/CyberCP/firewall/static/firewall/firewall.js ]]; then mkdir -p /usr/local/CyberCP/public/static/firewall cp -f /usr/local/CyberCP/firewall/static/firewall/firewall.js /usr/local/CyberCP/public/static/firewall/ 2>/dev/null && \ echo -e "[$(date +"%Y-%m-%d %H:%M:%S")] Synced firewall static to public/static" | tee -a /var/log/cyberpanel_upgrade_debug.log || true fi - if [[ $sync_code -eq 0 ]]; then - echo -e "[$(date +"%Y-%m-%d %H:%M:%S")] Sync completed. Current HEAD: $(git -C /usr/local/CyberCP rev-parse HEAD 2>/dev/null || echo 'unknown')" | tee -a /var/log/cyberpanel_upgrade_debug.log - else - echo -e "[$(date +"%Y-%m-%d %H:%M:%S")] Sync returned code $sync_code (non-fatal)" | tee -a /var/log/cyberpanel_upgrade_debug.log - fi + + echo -e "[$(date +"%Y-%m-%d %H:%M:%S")] Sync completed. Current HEAD: $(git -C /usr/local/CyberCP rev-parse HEAD 2>/dev/null || echo 'unknown')" | tee -a /var/log/cyberpanel_upgrade_debug.log return 0 } diff --git a/upgrade_modules/11_display_final.sh b/upgrade_modules/11_display_final.sh index 81b884c43..a1dcedcd6 100644 --- a/upgrade_modules/11_display_final.sh +++ b/upgrade_modules/11_display_final.sh @@ -22,7 +22,12 @@ _b " ▒▒▒▒▒▒▒▒▒ ▒▒▒▒▒███ ▒▒▒▒▒▒ _b " ███ ▒███" _b " ▒▒██████" _b " ▒▒▒▒▒▒" -_b " *** UPGRADE COMPLETED SUCCESSFULLY! ***" +if [[ "${CYBERPANEL_GIT_SYNC_OK:-1}" -eq 1 ]]; then + _b " *** UPGRADE COMPLETED SUCCESSFULLY! ***" +else + _b " *** UPGRADE FINISHED BUT GIT SYNC FAILED - /usr/local/CyberCP MAY BE OUTDATED ***" + _b " See /var/log/cyberpanel_upgrade_debug.log (exit code will be non-zero)" +fi _b "" _bl