From 9627770c99e57bc6ee3cb47fbcabc272ad44494b Mon Sep 17 00:00:00 2001 From: master3395 Date: Thu, 26 Mar 2026 01:04:48 +0100 Subject: [PATCH 01/19] fix(install): Ubuntu MaxScale apt repo (#1740), AlmaLinux 10 prereqs (#1736) - Add install_utils.strip_mariadb_maxscale_apt_repos() after mariadb_repo_setup so noble/jammy apt-get update succeeds (GH usmannasir/cyberpanel#1740). - AlmaLinux 10: skip early return after universal fixes; add is_almalinux10, fix_almalinux10_mariadb (EPEL, CRB, MariaDB.org repo, maxscale disable). - EL10 maps to rhel9 for OLS custom binary URLs until el10 builds exist. - Mirror MaxScale strip in install.py _attemptMariaDBUpgrade Ubuntu path. --- install/install.py | 51 +++++++++++++++++++++++++++++++- install/installCyberPanel.py | 7 +++-- install/install_utils.py | 56 ++++++++++++++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 3 deletions(-) diff --git a/install/install.py b/install/install.py index 765f8b5a3..1fa4829d7 100644 --- a/install/install.py +++ b/install/install.py @@ -236,6 +236,11 @@ class preFlightsChecks: os_info = self.detect_os_info() return os_info['name'] == 'almalinux' and os_info['major_version'] == 9 + def is_almalinux10(self): + """Check if running on AlmaLinux 10 (GH usmannasir/cyberpanel#1736)""" + os_info = self.detect_os_info() + return os_info['name'] == 'almalinux' and os_info['major_version'] == 10 + def is_ubuntu(self): """Check if running on Ubuntu""" os_info = self.detect_os_info() @@ -651,6 +656,34 @@ class preFlightsChecks: except Exception as e: self.stdOut(f"Error applying AlmaLinux 9 MariaDB fixes: {str(e)}", 0) + def fix_almalinux10_mariadb(self): + """EPEL/CRB + MariaDB official repo for AlmaLinux 10 (installer prereqs, GH #1736).""" + if not self.is_almalinux10(): + return + try: + self.stdOut("Applying AlmaLinux 10 MariaDB / repo fixes...", 1) + for cmd, desc in ( + ("dnf install -y epel-release", "EPEL"), + ("dnf config-manager --set-enabled crb 2>/dev/null || dnf config-manager --set-enabled powertools 2>/dev/null || true", "CRB/PowerTools"), + ("dnf install -y htop 2>/dev/null || true", "htop"), + ): + self.call(cmd, self.distro, desc, desc, 1, 0, os.EX_OSERR) + for cmd, desc in ( + ("dnf config-manager --disable mariadb-maxscale 2>/dev/null || true", "disable maxscale"), + ("rm -f /etc/yum.repos.d/mariadb-maxscale.repo /etc/yum.repos.d/mariadb-maxscale.repo.rpmnew 2>/dev/null || true", "remove maxscale repo files"), + ): + self.call(cmd, self.distro, desc, desc, 1, 0, os.EX_OSERR) + self.stdOut("Setting up MariaDB official repository (11.8 LTS, EL10)...", 1) + cmd = "curl -sS https://downloads.mariadb.com/MariaDB/mariadb_repo_setup | bash -s -- --mariadb-server-version='11.8'" + self.call(cmd, self.distro, cmd, cmd, 1, 0, os.EX_OSERR) + self.call("dnf config-manager --disable mariadb-maxscale 2>/dev/null || true", self.distro, "disable maxscale after setup", "disable maxscale after setup", 1, 0, os.EX_OSERR) + self.stdOut("Installing MariaDB packages from MariaDB.org repo...", 1) + pkgs = "MariaDB-server MariaDB-client MariaDB-backup MariaDB-devel" + self.call(f"dnf install -y --nobest {pkgs}", self.distro, "MariaDB packages", "MariaDB packages", 1, 0, os.EX_OSERR) + self.stdOut("AlmaLinux 10 MariaDB fixes applied successfully", 1) + except Exception as e: + self.stdOut(f"Error applying AlmaLinux 10 MariaDB fixes: {str(e)}", 0) + def install_package_with_fallbacks(self, package_name, dev_package_name=None): """Install package with comprehensive fallback methods for AlmaLinux 9.6+""" try: @@ -826,7 +859,11 @@ class preFlightsChecks: universal_fixes = UniversalOSFixes() if universal_fixes.run_comprehensive_setup(): self.stdOut("Universal OS fixes applied successfully", 1) - return True + os_i = self.detect_os_info() + if os_i.get('name') == 'almalinux' and os_i.get('major_version') == 10: + self.stdOut("AlmaLinux 10: running legacy RHEL integration steps after universal fixes...", 1) + else: + return True else: self.stdOut("Universal OS fixes failed, falling back to legacy fixes...", 1) except ImportError: @@ -842,6 +879,8 @@ class preFlightsChecks: for fix in fixes_needed: if fix == 'mariadb' and self.is_almalinux9(): self.fix_almalinux9_mariadb() + elif fix == 'mariadb' and self.is_almalinux10(): + self.fix_almalinux10_mariadb() elif fix == 'ubuntu_specific' and self.is_ubuntu(): self.fix_ubuntu_specific() elif fix == 'debian_specific' and self.is_debian(): @@ -889,6 +928,11 @@ class preFlightsChecks: if any(distro in content for distro in ['red hat', 'almalinux', 'rocky', 'cloudlinux', 'centos']): return 'rhel9' + # EL10: use rhel9 OLS/custom binaries until el10-specific builds ship (GLIBC-compatible) + if 'version="10.' in content or 'version_id="10.' in content or 'version_id="10"' in content: + if any(distro in content for distro in ['red hat', 'almalinux', 'rocky', 'cloudlinux', 'centos']): + return 'rhel9' + # Default to rhel8 if can't detect (safer default - rhel9 binaries may require GLIBC 2.35) self.stdOut("WARNING: Could not detect platform, defaulting to rhel8", 1) return 'rhel8' @@ -1711,6 +1755,11 @@ module cyberpanel_ols { if result.returncode != 0: logging.InstallLog.writeToFile(f"Failed to setup MariaDB repository: {result.stderr}") return False + try: + import install_utils + install_utils.strip_mariadb_maxscale_apt_repos() + except Exception: + pass command = 'DEBIAN_FRONTEND=noninteractive apt-get update -y' result = subprocess.run(command, shell=True, capture_output=True, universal_newlines=True) diff --git a/install/installCyberPanel.py b/install/installCyberPanel.py index 0e8b3e38a..8f04b70a4 100644 --- a/install/installCyberPanel.py +++ b/install/installCyberPanel.py @@ -38,6 +38,7 @@ def get_Ubuntu_code_name(): return "xenial" + # Using shared function from install_utils FetchCloudLinuxAlmaVersionVersion = install_utils.FetchCloudLinuxAlmaVersionVersion @@ -996,9 +997,11 @@ deb [arch=amd64,arm64,ppc64el,s390x signed-by=/usr/share/keyrings/mariadb-keyrin install_utils.writeToFile("Manual MariaDB repository configuration completed.") + # GH #1740: strip broken MaxScale apt entries after mariadb_repo_setup (noble/jammy+) + if get_Ubuntu_release() > 21.00: + install_utils.strip_mariadb_maxscale_apt_repos() - - command = 'DEBIAN_FRONTEND=noninteractive apt-get update -y' + command = 'DEBIAN_FRONTEND=noninteractive apt-get update -y' install_utils.call(command, self.distro, command, command, 1, 1, os.EX_OSERR, True) diff --git a/install/install_utils.py b/install/install_utils.py index 5e856a503..53285a8d8 100644 --- a/install/install_utils.py +++ b/install/install_utils.py @@ -5,6 +5,7 @@ This module contains shared functions used by both install.py and installCyberPa """ import os +import glob import sys import time import logging @@ -676,6 +677,61 @@ def generate_random_string(length=32, include_special=False): return ''.join(secrets.choice(alphabet) for _ in range(length)) + +def strip_mariadb_maxscale_apt_repos(): + """ + MariaDB mariadb_repo_setup adds MaxScale apt repo; Ubuntu noble has no Release (GH usmannasir/cyberpanel#1740). + """ + slist = '/etc/apt/sources.list.d' + try: + if not os.path.isdir(slist): + return + for pattern in ( + 'mariadb-maxscale*.list', 'mariadb-maxscale*.sources', + '*maxscale*.list', '*maxscale*.sources', + ): + for fp in glob.glob(os.path.join(slist, pattern)): + try: + os.remove(fp) + except OSError: + pass + for fp in glob.glob(os.path.join(slist, 'mariadb*.list')): + try: + with open(fp, 'r', encoding='utf-8', errors='replace') as handle: + lines = handle.readlines() + new_lines = [ + ln for ln in lines + if 'maxscale' not in ln.lower() + and 'dlm.mariadb.com/repo/maxscale' not in ln + ] + if new_lines != lines: + with open(fp, 'w', encoding='utf-8') as handle: + handle.writelines(new_lines) + except OSError: + pass + for fp in glob.glob(os.path.join(slist, 'mariadb*.sources')): + try: + with open(fp, 'r', encoding='utf-8', errors='replace') as handle: + content = handle.read() + if 'maxscale' not in content.lower() and 'dlm.mariadb.com/repo/maxscale' not in content: + continue + blocks = content.split('\n\n') + kept = [] + for block in blocks: + bl = block.lower() + if 'maxscale' in bl or 'dlm.mariadb.com/repo/maxscale' in block: + continue + kept.append(block) + new_content = '\n\n'.join(kept) + if new_content.strip() != content.strip(): + with open(fp, 'w', encoding='utf-8') as handle: + handle.write(new_content) + except OSError: + pass + except Exception: + pass + + def writeToFile(message): """ Write a message to the installation log file From 12475461a19411be9a7146a91a51a825686f6586 Mon Sep 17 00:00:00 2001 From: master3395 Date: Thu, 26 Mar 2026 01:06:26 +0100 Subject: [PATCH 02/19] fix(install): strip MaxScale apt repo in universal_os_fixes (Ubuntu/Debian) --- install/universal_os_fixes.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/install/universal_os_fixes.py b/install/universal_os_fixes.py index 1e29d4a04..4384ec4b6 100644 --- a/install/universal_os_fixes.py +++ b/install/universal_os_fixes.py @@ -487,6 +487,12 @@ class UniversalOSFixes: ] subprocess.run(' '.join(cmd), shell=True, check=True) + if os_id in ['ubuntu', 'debian']: + try: + import install_utils + install_utils.strip_mariadb_maxscale_apt_repos() + except Exception: + pass self.logger.info("MariaDB repository setup completed") return True From 35b705aaad0e6d8df4031ede159ca9e7af278693 Mon Sep 17 00:00:00 2001 From: Master3395 Date: Wed, 17 Sep 2025 01:05:48 +0200 Subject: [PATCH 03/19] Enhance OS detection and support for additional distributions - Updated OS detection logic to include CentOS Stream and Red Hat Enterprise Linux. - Added support for AlmaLinux 9 and 10, as well as Debian 11, 12, and 13. - Improved error messages to reflect the expanded list of supported operating systems. - Adjusted package management handling for Debian to treat it as Ubuntu for compatibility. --- CPScripts/mailscannerinstaller.sh | 44 +++++++++++++++++++++++++---- CPScripts/mailscanneruninstaller.sh | 21 ++++++++++---- 2 files changed, 55 insertions(+), 10 deletions(-) diff --git a/CPScripts/mailscannerinstaller.sh b/CPScripts/mailscannerinstaller.sh index 63925c14d..bd127609f 100644 --- a/CPScripts/mailscannerinstaller.sh +++ b/CPScripts/mailscannerinstaller.sh @@ -47,9 +47,15 @@ fi ### OS Detection Server_OS="" Server_OS_Version="" -if grep -q -E "CentOS Linux 7|CentOS Linux 8" /etc/os-release ; then +if grep -q -E "CentOS Linux 7|CentOS Linux 8|CentOS Stream" /etc/os-release ; then Server_OS="CentOS" -elif grep -q -E "AlmaLinux-8|AlmaLinux-9|AlmaLinux-10" /etc/os-release ; then +elif grep -q "Red Hat Enterprise Linux" /etc/os-release ; then + Server_OS="RedHat" +elif grep -q "AlmaLinux-8" /etc/os-release ; then + Server_OS="AlmaLinux" +elif grep -q "AlmaLinux-9" /etc/os-release ; then + Server_OS="AlmaLinux" +elif grep -q "AlmaLinux-10" /etc/os-release ; then Server_OS="AlmaLinux" elif grep -q -E "CloudLinux 7|CloudLinux 8" /etc/os-release ; then Server_OS="CloudLinux" @@ -57,11 +63,13 @@ elif grep -q -E "Rocky Linux" /etc/os-release ; then Server_OS="RockyLinux" elif grep -q -E "Ubuntu 18.04|Ubuntu 20.04|Ubuntu 20.10|Ubuntu 22.04|Ubuntu 24.04" /etc/os-release ; then Server_OS="Ubuntu" +elif grep -q -E "Debian GNU/Linux 11|Debian GNU/Linux 12|Debian GNU/Linux 13" /etc/os-release ; then + Server_OS="Debian" elif grep -q -E "openEuler 20.03|openEuler 22.03" /etc/os-release ; then Server_OS="openEuler" else echo -e "Unable to detect your system..." - echo -e "\nCyberPanel is supported on x86_64 based Ubuntu 18.04, Ubuntu 20.04, Ubuntu 20.10, Ubuntu 22.04, Ubuntu 24.04, CentOS 7, CentOS 8, AlmaLinux 8, AlmaLinux 9, AlmaLinux 10, RockyLinux 8, CloudLinux 7, CloudLinux 8, openEuler 20.03, openEuler 22.03...\n" + echo -e "\nCyberPanel is supported on x86_64 based Ubuntu 18.04, Ubuntu 20.04, Ubuntu 20.10, Ubuntu 22.04, Ubuntu 24.04, Ubuntu 24.04.3, Debian 11, Debian 12, Debian 13, CentOS 7, CentOS 8, CentOS 9, RHEL 8, RHEL 9, AlmaLinux 8, AlmaLinux 9, AlmaLinux 10, RockyLinux 8, RockyLinux 9, CloudLinux 7, CloudLinux 8, openEuler 20.03, openEuler 22.03...\n" exit fi @@ -69,10 +77,13 @@ Server_OS_Version=$(grep VERSION_ID /etc/os-release | awk -F[=,] '{print $2}' | echo -e "System: $Server_OS $Server_OS_Version detected...\n" -if [[ $Server_OS = "CloudLinux" ]] || [[ "$Server_OS" = "AlmaLinux" ]] || [[ "$Server_OS" = "RockyLinux" ]] ; then +if [[ $Server_OS = "CloudLinux" ]] || [[ "$Server_OS" = "AlmaLinux" ]] || [[ "$Server_OS" = "RockyLinux" ]] || [[ "$Server_OS" = "RedHat" ]] ; then Server_OS="CentOS" #CloudLinux gives version id like 7.8, 7.9, so cut it to show first number only - #treat CloudLinux, Rocky and Alma as CentOS + #treat CloudLinux, Rocky, Alma and RedHat as CentOS +elif [[ "$Server_OS" = "Debian" ]] ; then + Server_OS="Ubuntu" + #Treat Debian as Ubuntu for package management (both use apt-get) fi if [[ $Server_OS = "CentOS" ]] && [[ "$Server_OS_Version" = "7" ]] ; then @@ -114,6 +125,29 @@ elif [[ $Server_OS = "CentOS" ]] && [[ "$Server_OS_Version" = "8" ]] ; then freshclam -v +elif [[ $Server_OS = "CentOS" ]] && [[ "$Server_OS_Version" = "9" ]] ; then + + setenforce 0 + dnf install -y perl dnf-utils perl-CPAN + dnf --enablerepo=crb install -y perl-IO-stringy + dnf install -y gcc cpp perl bzip2 zip make patch automake rpm-build perl-Archive-Zip perl-Filesys-Df perl-OLE-Storage_Lite perl-Net-CIDR perl-DBI perl-MIME-tools perl-DBD-SQLite binutils glibc-devel perl-Filesys-Df zlib unzip zlib-devel wget mlocate clamav clamav-update "perl(DBD::mysql)" + + # Install unrar for AlmaLinux 9 (using EPEL) + dnf install -y unrar + + export PERL_MM_USE_DEFAULT=1 + curl -L https://cpanmin.us | perl - App::cpanminus + + perl -MCPAN -e 'install Encoding::FixLatin' + perl -MCPAN -e 'install Digest::SHA1' + perl -MCPAN -e 'install Geo::IP' + perl -MCPAN -e 'install Razor2::Client::Agent' + perl -MCPAN -e 'install Sys::Hostname::Long' + perl -MCPAN -e 'install Sys::SigAction' + perl -MCPAN -e 'install Net::Patricia' + + freshclam -v + elif [ "$CLNVERSION" = "ID=\"cloudlinux\"" ]; then setenforce 0 diff --git a/CPScripts/mailscanneruninstaller.sh b/CPScripts/mailscanneruninstaller.sh index 347deae60..eed23ddbb 100644 --- a/CPScripts/mailscanneruninstaller.sh +++ b/CPScripts/mailscanneruninstaller.sh @@ -4,9 +4,15 @@ ### OS Detection Server_OS="" Server_OS_Version="" -if grep -q -E "CentOS Linux 7|CentOS Linux 8" /etc/os-release ; then +if grep -q -E "CentOS Linux 7|CentOS Linux 8|CentOS Stream" /etc/os-release ; then Server_OS="CentOS" -elif grep -q -E "AlmaLinux-8|AlmaLinux-9|AlmaLinux-10" /etc/os-release ; then +elif grep -q "Red Hat Enterprise Linux" /etc/os-release ; then + Server_OS="RedHat" +elif grep -q "AlmaLinux-8" /etc/os-release ; then + Server_OS="AlmaLinux" +elif grep -q "AlmaLinux-9" /etc/os-release ; then + Server_OS="AlmaLinux" +elif grep -q "AlmaLinux-10" /etc/os-release ; then Server_OS="AlmaLinux" elif grep -q -E "CloudLinux 7|CloudLinux 8" /etc/os-release ; then Server_OS="CloudLinux" @@ -14,11 +20,13 @@ elif grep -q -E "Rocky Linux" /etc/os-release ; then Server_OS="RockyLinux" elif grep -q -E "Ubuntu 18.04|Ubuntu 20.04|Ubuntu 20.10|Ubuntu 22.04|Ubuntu 24.04" /etc/os-release ; then Server_OS="Ubuntu" +elif grep -q -E "Debian GNU/Linux 11|Debian GNU/Linux 12|Debian GNU/Linux 13" /etc/os-release ; then + Server_OS="Debian" elif grep -q -E "openEuler 20.03|openEuler 22.03" /etc/os-release ; then Server_OS="openEuler" else echo -e "Unable to detect your system..." - echo -e "\nCyberPanel is supported on x86_64 based Ubuntu 18.04, Ubuntu 20.04, Ubuntu 20.10, Ubuntu 22.04, Ubuntu 24.04, CentOS 7, CentOS 8, AlmaLinux 8, AlmaLinux 9, AlmaLinux 10, RockyLinux 8, CloudLinux 7, CloudLinux 8, openEuler 20.03, openEuler 22.03...\n" + echo -e "\nCyberPanel is supported on x86_64 based Ubuntu 18.04, Ubuntu 20.04, Ubuntu 20.10, Ubuntu 22.04, Ubuntu 24.04, Ubuntu 24.04.3, Debian 11, Debian 12, Debian 13, CentOS 7, CentOS 8, CentOS 9, RHEL 8, RHEL 9, AlmaLinux 8, AlmaLinux 9, AlmaLinux 10, RockyLinux 8, RockyLinux 9, CloudLinux 7, CloudLinux 8, openEuler 20.03, openEuler 22.03...\n" exit fi @@ -26,10 +34,13 @@ Server_OS_Version=$(grep VERSION_ID /etc/os-release | awk -F[=,] '{print $2}' | echo -e "System: $Server_OS $Server_OS_Version detected...\n" -if [[ $Server_OS = "CloudLinux" ]] || [[ "$Server_OS" = "AlmaLinux" ]] || [[ "$Server_OS" = "RockyLinux" ]] ; then +if [[ $Server_OS = "CloudLinux" ]] || [[ "$Server_OS" = "AlmaLinux" ]] || [[ "$Server_OS" = "RockyLinux" ]] || [[ "$Server_OS" = "RedHat" ]] ; then Server_OS="CentOS" #CloudLinux gives version id like 7.8, 7.9, so cut it to show first number only - #treat CloudLinux, Rocky and Alma as CentOS + #treat CloudLinux, Rocky, Alma and RedHat as CentOS +elif [[ "$Server_OS" = "Debian" ]] ; then + Server_OS="Ubuntu" + #Treat Debian as Ubuntu for package management (both use apt-get) fi systemctl stop mailscanner From 0610e07a4a20367776804a1e3b3799b17896a648 Mon Sep 17 00:00:00 2001 From: master3395 Date: Thu, 26 Mar 2026 01:24:35 +0100 Subject: [PATCH 04/19] Fix AlmaLinux 8 installation: Add python-dotenv to requirements - Install python-dotenv in virtual environment during CyberPanel setup - Fixes Django's inability to load .env file on AlmaLinux 8 - Resolves "Access denied for user 'cyberpanel'@'localhost'" errors - Added to all installation paths (normal, DEV, and after_install) This ensures Django can properly load database credentials from .env file on AlmaLinux 8 systems where python-dotenv was missing. --- install/venvsetup_modules/03_main_run_pip.sh | 3 +++ install/venvsetup_modules/04_after_install.sh | 1 + 2 files changed, 4 insertions(+) diff --git a/install/venvsetup_modules/03_main_run_pip.sh b/install/venvsetup_modules/03_main_run_pip.sh index bfd89c509..f4ec3c607 100644 --- a/install/venvsetup_modules/03_main_run_pip.sh +++ b/install/venvsetup_modules/03_main_run_pip.sh @@ -68,6 +68,8 @@ rm -rf requirements.txt wget -O requirements.txt https://raw.githubusercontent.com/usmannasir/cyberpanel/1.8.0/requirments.txt # Install packages with robust error handling to prevent broken pipe errors safe_pip_install "pip" "requirements.txt" "--ignore-installed" +# python-dotenv for Django .env loading (upstream f3437739; critical on some AlmaLinux 8 venvs) +pip install python-dotenv 2>/dev/null || echo "⚠️ python-dotenv install skipped or failed" fi if [[ $DEV == "ON" ]] ; then @@ -100,6 +102,7 @@ EOF fi safe_pip_install "pip3.6" "requirements.txt" "--ignore-installed" + pip3.6 install python-dotenv 2>/dev/null || echo "⚠️ python-dotenv (pip3.6) install skipped or failed" fi if [ -f requirements.txt ] && [ -d cyberpanel ] ; then diff --git a/install/venvsetup_modules/04_after_install.sh b/install/venvsetup_modules/04_after_install.sh index fd9bd3e50..ad98aceac 100644 --- a/install/venvsetup_modules/04_after_install.sh +++ b/install/venvsetup_modules/04_after_install.sh @@ -50,6 +50,7 @@ EOF fi safe_pip_install "pip3.6" "requirements.txt" "--ignore-installed" +pip3.6 install python-dotenv 2>/dev/null || echo "⚠️ python-dotenv (after_install) skipped or failed" systemctl restart lscpd fi From e09804f25af095e9fc9a9e7407478447b17cc890 Mon Sep 17 00:00:00 2001 From: usmannasir Date: Fri, 7 Nov 2025 21:53:02 +0500 Subject: [PATCH 05/19] Fix n8n deployment compatibility with OpenLiteSpeed proxy - Pin n8n to version 1.86.1 to avoid Origin header validation issues - Change N8N_HOST from 0.0.0.0 to domain for better compatibility - Add N8N_PROXY_HOPS=1 to fix X-Forwarded-For errors - Add N8N_ALLOWED_ORIGINS and N8N_ALLOW_CONNECTIONS_FROM for future compatibility - Fix SetupN8NVhost to remove malformed Origin header setting n8n versions 1.87.0+ introduced strict Origin header validation that is incompatible with OpenLiteSpeed proxy (which doesn't forward Origin headers). Version 1.86.1 works correctly with OLS and SSE push backend. --- plogical/DockerSites.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/plogical/DockerSites.py b/plogical/DockerSites.py index 47d1307a5..d49183edc 100644 --- a/plogical/DockerSites.py +++ b/plogical/DockerSites.py @@ -321,6 +321,7 @@ context / {{ websocket 1 extraHeaders << Date: Fri, 7 Nov 2025 21:53:02 +0500 Subject: [PATCH 06/19] Update n8n deployment to use latest version - Changed from pinned version 1.86.1 to latest - Requires OpenLiteSpeed binaries with Origin header forwarding support - Compatible with n8n 1.87.0+ which has strict Origin validation Note: This requires the OpenLiteSpeed binary that includes the Origin header forwarding patch in the proxy module. The patch is available in the CyberPanel OpenLiteSpeed distribution. --- plogical/DockerSites.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plogical/DockerSites.py b/plogical/DockerSites.py index d49183edc..67b60aba5 100644 --- a/plogical/DockerSites.py +++ b/plogical/DockerSites.py @@ -1395,7 +1395,7 @@ services: } n8n_config = { - 'image': 'docker.n8n.io/n8nio/n8n:1.86.1', + 'image': 'docker.n8n.io/n8nio/n8n', 'user': 'root', 'healthcheck': { 'test': ["CMD", "wget", "--spider", "http://localhost:5678"], From a835413b6369d2fec7f2094e83cc00aa9fe03fd6 Mon Sep 17 00:00:00 2001 From: usmannasir Date: Sat, 8 Nov 2025 00:32:16 +0500 Subject: [PATCH 07/19] Fix n8n proxy configuration for OpenLiteSpeed compatibility - Change N8N_HOST to 0.0.0.0 (internal bind address, not domain) - Simplify VHost extraHeaders to ONLY set Origin header - Remove duplicate X-Forwarded-* headers (OLS adds these automatically) - Remove N8N_ALLOWED_ORIGINS and N8N_ALLOW_CONNECTIONS_FROM (not needed) The key issue was duplicate X-Forwarded-Host headers. OpenLiteSpeed proxy contexts automatically add X-Forwarded-* headers, so explicitly setting them creates duplicates that cause n8n validation to fail. Only the Origin header needs explicit configuration in extraHeaders. This works with the patched OLS binary (MD5: b9c65aa2563778975d0d2361494e9d31) that forwards Origin headers from the client. --- plogical/DockerSites.py | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/plogical/DockerSites.py b/plogical/DockerSites.py index 67b60aba5..111b23b02 100644 --- a/plogical/DockerSites.py +++ b/plogical/DockerSites.py @@ -308,9 +308,8 @@ extprocessor docker{port} {{ logging.writeToFile("Context already exists, skipping...") return True - # Add proxy context with proper headers for n8n - # NOTE: Do NOT include "RequestHeader set Origin" - OpenLiteSpeed cannot override - # browser Origin headers, which is why NODE_ENV=development is required + # Add proxy context with proper headers for n8n (OLS adds X-Forwarded-*; Origin set for n8n) + # NOTE: OpenLiteSpeed cannot override browser Origin headers; NODE_ENV=development may be required proxy_context = f''' # N8N Proxy Configuration @@ -321,11 +320,7 @@ context / {{ websocket 1 extraHeaders << Date: Sat, 8 Nov 2025 15:28:34 +0500 Subject: [PATCH 08/19] Add ModSecurity compatibility detection for user installations Handle the scenario where users install ModSecurity after CyberPanel is already installed with custom OpenLiteSpeed binaries. Problem: - When users click "Install ModSecurity" in CyberPanel UI, the system used package manager (yum/apt) to install stock ModSecurity - Stock ModSecurity is NOT ABI-compatible with custom OLS binaries - This causes immediate server crashes (segfaults) when installed Solution: - Detect if custom OLS binary is already installed before installing ModSecurity - If custom OLS detected, download compatible ModSecurity from cyberpanel.net - If stock OLS detected, use package manager as usual Implementation: - isCustomOLSBinaryInstalled(): Detects custom OLS by scanning binary for markers - detectBinarySuffix(): Determines Ubuntu vs RHEL binaries needed - installCompatibleModSecurity(): Downloads, verifies, and installs compatible ModSecurity - Modified installModSec(): Main entry point - routes to compatible installer if needed User flow: 1. User with custom OLS clicks "Install ModSecurity" in UI 2. System detects custom OLS binary is installed 3. System writes "Detected custom OpenLiteSpeed binary" to install log 4. System downloads OS-specific compatible ModSecurity from cyberpanel.net 5. System verifies SHA256 checksum 6. System backs up any existing ModSecurity 7. System installs compatible version with OLS restart 8. User sees "ModSecurity Installed (ABI-compatible version).[200]" Safety features: - Checksum verification before installation - Automatic backup of existing ModSecurity - Graceful OLS restart with timeout handling - Detailed logging to /home/cyberpanel/modSecInstallLog This prevents server crashes when users install ModSecurity after custom OLS binaries are already deployed. --- plogical/modSec.py | 158 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 157 insertions(+), 1 deletion(-) diff --git a/plogical/modSec.py b/plogical/modSec.py index f1fba6bb2..e8caf28ff 100644 --- a/plogical/modSec.py +++ b/plogical/modSec.py @@ -12,6 +12,7 @@ from plogical.virtualHostUtilities import virtualHostUtilities import os import tarfile import shutil +import time from plogical.mailUtilities import mailUtilities from plogical.processUtilities import ProcessUtilities from plogical.installUtilities import installUtilities @@ -105,11 +106,166 @@ class modSec: return False @staticmethod - def installModSec(): + def isCustomOLSBinaryInstalled(): + """Detect if custom OpenLiteSpeed binary is installed""" try: + OLS_BINARY_PATH = "/usr/local/lsws/bin/openlitespeed" + if not os.path.exists(OLS_BINARY_PATH): + return False + + # Check for PHPConfig function signature in binary + command = f'strings {OLS_BINARY_PATH}' + result = subprocess.run(command, shell=True, capture_output=True, text=True, timeout=10) + + if result.returncode == 0: + # Look for custom binary markers + return 'set_php_config_value' in result.stdout or 'PHPConfig LSIAPI' in result.stdout + + return False + + except Exception as msg: + logging.CyberCPLogFileWriter.writeToFile(f"WARNING: Could not detect OLS binary type: {msg}") + return False + + @staticmethod + def detectBinarySuffix(): + """Detect which binary suffix to use based on OS distribution""" + try: + # Check if we're on RHEL/CentOS/AlmaLinux 8+ (uses libcrypt.so.2) + if os.path.exists('/etc/os-release'): + with open('/etc/os-release', 'r') as f: + os_release = f.read().lower() + + # AlmaLinux 9+, Rocky 9+, RHEL 9+, CentOS Stream 9+ + if any(x in os_release for x in ['almalinux', 'rocky', 'rhel']) and 'version="9' in os_release: + return 'rhel' + elif 'centos stream 9' in os_release: + return 'rhel' + + # Check CentOS/RHEL path + if os.path.exists('/etc/redhat-release'): + data = open('/etc/redhat-release', 'r').read() + # CentOS/AlmaLinux/Rocky 8+ → rhel suffix + if 'release 8' in data or 'release 9' in data: + return 'rhel' + + # Default to ubuntu + return 'ubuntu' + + except Exception as msg: + logging.CyberCPLogFileWriter.writeToFile(f"Error detecting OS: {msg}, defaulting to Ubuntu binaries") + return 'ubuntu' + + @staticmethod + def installCompatibleModSecurity(): + """Install ModSecurity compatible with custom OpenLiteSpeed binary""" + try: mailUtilities.checkHome() + with open(modSec.installLogPath, 'w') as f: + f.write("Installing ModSecurity compatible with custom OpenLiteSpeed binary...\n") + + MODSEC_PATH = "/usr/local/lsws/modules/mod_security.so" + + # Detect OS and select appropriate ModSecurity binary + binary_suffix = modSec.detectBinarySuffix() + + if binary_suffix == 'rhel': + MODSEC_URL = "https://cyberpanel.net/mod_security-compatible-rhel.so" + EXPECTED_SHA256 = "db580afc431fda40d46bdae2249ac74690d9175ff6d8b1843f2837d86f8d602f" + else: # ubuntu + MODSEC_URL = "https://cyberpanel.net/mod_security-compatible-ubuntu.so" + EXPECTED_SHA256 = "115971fcd44b74bc7c7b097b9cec33ddcfb0fb07bb9b562ec9f4f0691c388a6b" + + # Download to temp location + tmp_modsec = "/tmp/mod_security_custom.so" + + with open(modSec.installLogPath, 'a') as f: + f.write(f"Downloading compatible ModSecurity for {binary_suffix}...\n") + + command = f'wget -q --show-progress {MODSEC_URL} -O {tmp_modsec}' + result = subprocess.call(shlex.split(command)) + + if result != 0 or not os.path.exists(tmp_modsec): + with open(modSec.installLogPath, 'a') as f: + f.write("ERROR: Failed to download ModSecurity\n") + f.write("Can not be installed.[404]\n") + logging.CyberCPLogFileWriter.writeToFile("[Could not download compatible ModSecurity]") + return 0 + + # Verify checksum + with open(modSec.installLogPath, 'a') as f: + f.write("Verifying checksum...\n") + + result = subprocess.run(f'sha256sum {tmp_modsec}', shell=True, capture_output=True, text=True) + actual_sha256 = result.stdout.split()[0] + + if actual_sha256 != EXPECTED_SHA256: + with open(modSec.installLogPath, 'a') as f: + f.write(f"ERROR: Checksum verification failed\n") + f.write(f" Expected: {EXPECTED_SHA256}\n") + f.write(f" Got: {actual_sha256}\n") + f.write("Can not be installed.[404]\n") + os.remove(tmp_modsec) + logging.CyberCPLogFileWriter.writeToFile("[ModSecurity checksum verification failed]") + return 0 + + # Backup existing ModSecurity if present + if os.path.exists(MODSEC_PATH): + backup_path = f"{MODSEC_PATH}.backup.{int(time.time())}" + shutil.copy2(MODSEC_PATH, backup_path) + with open(modSec.installLogPath, 'a') as f: + f.write(f"Backed up existing ModSecurity to: {backup_path}\n") + + # Stop OpenLiteSpeed + subprocess.run(['/usr/local/lsws/bin/lswsctrl', 'stop'], timeout=30) + time.sleep(2) + + # Install compatible ModSecurity + os.makedirs(os.path.dirname(MODSEC_PATH), exist_ok=True) + shutil.copy2(tmp_modsec, MODSEC_PATH) + os.chmod(MODSEC_PATH, 0o755) + os.remove(tmp_modsec) + + # Start OpenLiteSpeed + subprocess.run(['/usr/local/lsws/bin/lswsctrl', 'start'], timeout=30) + + with open(modSec.installLogPath, 'a') as f: + f.write("Compatible ModSecurity installed successfully\n") + f.write("ModSecurity Installed (ABI-compatible version).[200]\n") + + logging.CyberCPLogFileWriter.writeToFile("[Compatible ModSecurity installed successfully]") + return 1 + + except subprocess.TimeoutExpired: + with open(modSec.installLogPath, 'a') as f: + f.write("ERROR: Timeout during OpenLiteSpeed restart\n") + f.write("Can not be installed.[404]\n") + logging.CyberCPLogFileWriter.writeToFile("[Timeout during ModSecurity installation]") + return 0 + except Exception as msg: + with open(modSec.installLogPath, 'a') as f: + f.write(f"ERROR: {str(msg)}\n") + f.write("Can not be installed.[404]\n") + logging.CyberCPLogFileWriter.writeToFile(str(msg) + "[installCompatibleModSecurity]") + return 0 + + @staticmethod + def installModSec(): + try: + mailUtilities.checkHome() + + # Check if custom OLS binary is installed + if modSec.isCustomOLSBinaryInstalled(): + # Install compatible ModSecurity for custom OLS + with open(modSec.installLogPath, 'w') as f: + f.write("Detected custom OpenLiteSpeed binary\n") + f.write("Installing ABI-compatible ModSecurity...\n") + + return modSec.installCompatibleModSecurity() + + # Stock OLS binary - use package manager as usual if ProcessUtilities.decideDistro() == ProcessUtilities.centos or ProcessUtilities.decideDistro() == ProcessUtilities.cent8: command = 'sudo yum install ols-modsecurity -y' else: From 421f085d264f024f3e7bac52911ffd789593d9ba Mon Sep 17 00:00:00 2001 From: Infinyte Solutions Date: Tue, 6 Jan 2026 13:07:23 -0500 Subject: [PATCH 09/19] Refactor: replace url() with path() for Django routes Update URL generation to use path() instead of url(), aligning with Django 4.x where url() is deprecated. --- pluginInstaller/pluginInstaller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pluginInstaller/pluginInstaller.py b/pluginInstaller/pluginInstaller.py index 9b0b98b09..1fd41e2e2 100644 --- a/pluginInstaller/pluginInstaller.py +++ b/pluginInstaller/pluginInstaller.py @@ -711,4 +711,4 @@ def main(): pluginInstaller.removePlugin(args.pluginName) if __name__ == "__main__": - main() \ No newline at end of file + main() From e71fe079522e8dac7ce3f63c1c9c0b19fc04b136 Mon Sep 17 00:00:00 2001 From: master3395 Date: Sun, 8 Mar 2026 23:28:34 +0100 Subject: [PATCH 10/19] install.sh: fetch cyberpanel.sh from repo so AlmaLinux 10 fixes are used --- install.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/install.sh b/install.sh index 560d22dbf..b9d348da4 100644 --- a/install.sh +++ b/install.sh @@ -118,8 +118,8 @@ rm -f "$SCRIPT_PATH" "$TEMP_DIR/cyberpanel.sh" "$TEMP_DIR/install.tar.gz" # Ensure temp directory exists and is writable mkdir -p "$TEMP_DIR" 2>/dev/null || true -# For v2.5.5-dev, try to get the cyberpanel.sh from the branch -if [ "$BRANCH_NAME" = "v2.5.5-dev" ] || [ "$BRANCH_NAME" = "stable" ]; then +# Prefer master3395/cyberpanel raw cyberpanel.sh for known branches (includes AlmaLinux 10 etc.) +if [ "$BRANCH_NAME" = "v2.5.5-dev" ] || [ "$BRANCH_NAME" = "stable" ] || [ "$BRANCH_NAME" = "v2.4.5" ]; then # Try to download from the branch-specific URL if curl --silent -o "$SCRIPT_PATH" "https://raw.githubusercontent.com/master3395/cyberpanel/$BRANCH_NAME/cyberpanel.sh" 2>/dev/null; then if [ -f "$SCRIPT_PATH" ] && [ -s "$SCRIPT_PATH" ]; then From c50b51dfbfa8748afcf52224f87b108238d35741 Mon Sep 17 00:00:00 2001 From: master3395 Date: Thu, 26 Mar 2026 01:36:55 +0100 Subject: [PATCH 11/19] install: port origin/v2.4.5 lscpd el9/10 selection, start retry, venv lscpd restart (e49ed16f) - Use lscpd.0.4.0 on Alma 9/10 and RHEL-family VERSION_ID 9/10 - daemon-reload + retry systemctl start lscpd in setupLSCPDDaemon - 04_after_install: _restart_lscpd_safe + libxcrypt-compat on EL10 prereqs --- install/install.py | 34 ++++++++++++++++++- install/venvsetup_modules/04_after_install.sh | 15 ++++++-- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/install/install.py b/install/install.py index 1fa4829d7..932091472 100644 --- a/install/install.py +++ b/install/install.py @@ -666,6 +666,7 @@ class preFlightsChecks: ("dnf install -y epel-release", "EPEL"), ("dnf config-manager --set-enabled crb 2>/dev/null || dnf config-manager --set-enabled powertools 2>/dev/null || true", "CRB/PowerTools"), ("dnf install -y htop 2>/dev/null || true", "htop"), + ("dnf install -y libxcrypt-compat 2>/dev/null || true", "libxcrypt-compat for lscpd"), ): self.call(cmd, self.distro, desc, desc, 1, 0, os.EX_OSERR) for cmd, desc in ( @@ -4995,6 +4996,21 @@ user_query = SELECT email as user, password, 'vmail' as uid, 'vmail' as gid, '/h result = open('/etc/lsb-release', 'r').read() if result.find('22.04') > -1 or result.find('24.04') > -1: lscpdSelection = 'lscpd.0.4.0' + # AlmaLinux/RHEL 9 and 10: lscpd.0.4.0 (el9 binary on el10; origin/v2.4.5) + try: + cl_al_ver = FetchCloudLinuxAlmaVersionVersion() + if cl_al_ver in ('al-93', 'al-100'): + lscpdSelection = 'lscpd.0.4.0' + except Exception: + pass + if os.path.exists('/etc/os-release'): + with open('/etc/os-release', 'r') as f: + osrel = f.read() + if (('VERSION_ID="9"' in osrel or 'VERSION_ID="10"' in osrel or + 'VERSION_ID="9.' in osrel or 'VERSION_ID="10.' in osrel) and + ('AlmaLinux' in osrel or 'Rocky' in osrel or 'Red Hat' in osrel or + 'CentOS' in osrel)): + lscpdSelection = 'lscpd.0.4.0' else: lscpdSelection = 'lscpd.aarch64' @@ -5005,6 +5021,12 @@ user_query = SELECT email as user, password, 'vmail' as uid, 'vmail' as gid, '/h result = open('/etc/lsb-release', 'r').read() if result.find('22.04') > -1 or result.find('24.04') > -1: lscpdSelection = 'lscpd.0.4.0' + try: + cl_al_ver = FetchCloudLinuxAlmaVersionVersion() + if cl_al_ver in ('al-93', 'al-100'): + lscpdSelection = 'lscpd.0.4.0' + except Exception: + pass command = f'cp -f /usr/local/CyberCP/{lscpdSelection} /usr/local/lscp/bin/{lscpdSelection}' @@ -5401,8 +5423,18 @@ user_query = SELECT email as user, password, 'vmail' as uid, 'vmail' as gid, '/h ## + command = 'systemctl daemon-reload' + preFlightsChecks.call(command, self.distro, command, command, 1, 0, os.EX_OSERR) command = 'systemctl start lscpd' - # preFlightsChecks.call(command, self.distro, command, command, 1, 0, os.EX_OSERR) + ret = preFlightsChecks.call(command, self.distro, command, command, 0, 0, os.EX_OSERR) + if ret != 0: + preFlightsChecks.stdOut("LSCPD start failed, reloading systemd and retrying...") + logging.InstallLog.writeToFile("LSCPD first start failed, retrying after daemon-reload") + preFlightsChecks.call('systemctl daemon-reload', self.distro, 'daemon-reload', 'daemon-reload', 1, 0, os.EX_OSERR) + ret = preFlightsChecks.call('systemctl start lscpd', self.distro, 'systemctl start lscpd', 'systemctl start lscpd', 0, 0, os.EX_OSERR) + if ret != 0: + preFlightsChecks.stdOut("[WARNING] LSCPD may not have started. Run: systemctl status lscpd") + logging.InstallLog.writeToFile("[WARNING] LSCPD start failed after retry - run systemctl status lscpd") preFlightsChecks.stdOut("LSCPD Daemon Set!") diff --git a/install/venvsetup_modules/04_after_install.sh b/install/venvsetup_modules/04_after_install.sh index ad98aceac..b14eb0e5d 100644 --- a/install/venvsetup_modules/04_after_install.sh +++ b/install/venvsetup_modules/04_after_install.sh @@ -2,6 +2,17 @@ # install/venvsetup part 4 – after_install after_install() { +# Robust lscpd restart (origin/v2.4.5 e49ed16f; EL9/10) +_restart_lscpd_safe() { + systemctl daemon-reload 2>/dev/null || true + systemctl restart lscpd 2>/dev/null || true + if ! systemctl is-active --quiet lscpd 2>/dev/null; then + systemctl daemon-reload + systemctl restart lscpd + fi + systemctl restart fastapi_ssh_server 2>/dev/null || true +} + if [ ! -d "/var/lib/php" ]; then mkdir /var/lib/php fi @@ -51,7 +62,7 @@ fi safe_pip_install "pip3.6" "requirements.txt" "--ignore-installed" pip3.6 install python-dotenv 2>/dev/null || echo "⚠️ python-dotenv (after_install) skipped or failed" -systemctl restart lscpd +_restart_lscpd_safe fi for version in $(ls /usr/local/lsws | grep lsphp); @@ -113,7 +124,7 @@ ELAPSED="$(($SECONDS / 3600)) hrs $((($SECONDS / 60) % 60)) min $(($SECONDS % 60 MYSQLPASSWD=$(cat /etc/cyberpanel/mysqlPassword) echo "$ADMIN_PASS" > /etc/cyberpanel/adminPass /usr/local/CyberPanel/bin/python2 /usr/local/CyberCP/plogical/adminPass.py --password $ADMIN_PASS -systemctl restart lscpd +_restart_lscpd_safe systemctl restart lsws echo "/usr/local/CyberPanel/bin/python2 /usr/local/CyberCP/plogical/adminPass.py --password \"\$@\"" > /usr/bin/adminPass echo "systemctl restart lscpd" >> /usr/bin/adminPass From ff93f0facb6cd617615d3035d8a69a5ed6e67bae Mon Sep 17 00:00:00 2001 From: master3395 Date: Thu, 26 Mar 2026 12:39:48 +0100 Subject: [PATCH 12/19] pluginHolder: persist premium activation keys in MariaDB. Store plugin activation entitlements in DB and use them in access checks so upgrades do not relock premium plugins. --- pluginHolder/models.py | 20 ++++- pluginHolder/plugin_access.py | 139 +++++++++++++++++++++++++++++++++- pluginHolder/urls.py | 1 + pluginHolder/views.py | 84 ++++++++++++++++++-- 4 files changed, 236 insertions(+), 8 deletions(-) diff --git a/pluginHolder/models.py b/pluginHolder/models.py index 4e6a8e76d..0043b69ca 100644 --- a/pluginHolder/models.py +++ b/pluginHolder/models.py @@ -3,4 +3,22 @@ from django.db import models -# Create your models here. + +class PluginActivationKey(models.Model): + """ + Optional ORM mirror for activation keys persisted in MariaDB. + Runtime code uses raw SQL CREATE TABLE IF NOT EXISTS for migration safety. + """ + plugin_name = models.CharField(max_length=191) + user_identity = models.CharField(max_length=191) + activation_key_hash = models.CharField(max_length=64) + key_last4 = models.CharField(max_length=4, blank=True, default='') + source = models.CharField(max_length=50, blank=True, default='manual') + is_active = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + managed = False + db_table = 'plugin_activation_keys' + unique_together = (('plugin_name', 'user_identity'),) diff --git a/pluginHolder/plugin_access.py b/pluginHolder/plugin_access.py index f460e0768..a508beca5 100644 --- a/pluginHolder/plugin_access.py +++ b/pluginHolder/plugin_access.py @@ -5,7 +5,133 @@ Checks if user has access to paid plugins """ from .patreon_verifier import PatreonVerifier -import logging +import hashlib +from django.db import connection +from plogical.CyberCPLogFileWriter import CyberCPLogFileWriter as logging + + +def _normalize_identity(value): + if not value: + return '' + return str(value).strip().lower() + + +def _hash_activation_key(raw_key): + return hashlib.sha256(raw_key.encode('utf-8')).hexdigest() + + +def _ensure_activation_table(): + """ + Create table on-demand so upgrade paths without Django migrations are safe. + """ + sql = """ + CREATE TABLE IF NOT EXISTS plugin_activation_keys ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + plugin_name VARCHAR(191) NOT NULL, + user_identity VARCHAR(191) NOT NULL, + activation_key_hash CHAR(64) NOT NULL, + key_last4 VARCHAR(4) NOT NULL DEFAULT '', + source VARCHAR(50) NOT NULL DEFAULT 'manual', + is_active TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uniq_plugin_identity (plugin_name, user_identity), + KEY idx_identity (user_identity), + KEY idx_plugin (plugin_name) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + """ + with connection.cursor() as cursor: + cursor.execute(sql) + + +def save_activation_key(plugin_name, user_identity, activation_key, source='manual'): + """ + Persist activation key hash in MariaDB (upsert by plugin_name + user_identity). + """ + plugin_name = _normalize_identity(plugin_name) + user_identity = _normalize_identity(user_identity) + activation_key = str(activation_key or '').strip() + if not plugin_name or not user_identity or not activation_key: + return False + + try: + _ensure_activation_table() + key_hash = _hash_activation_key(activation_key) + key_last4 = activation_key[-4:] if len(activation_key) >= 4 else activation_key + with connection.cursor() as cursor: + cursor.execute( + """ + INSERT INTO plugin_activation_keys + (plugin_name, user_identity, activation_key_hash, key_last4, source, is_active) + VALUES (%s, %s, %s, %s, %s, 1) + ON DUPLICATE KEY UPDATE + activation_key_hash = VALUES(activation_key_hash), + key_last4 = VALUES(key_last4), + source = VALUES(source), + is_active = 1 + """, + [plugin_name, user_identity, key_hash, key_last4, source] + ) + return True + except Exception as e: + logging.writeToFile('plugin_access.save_activation_key failed: %s' % str(e)) + return False + + +def has_saved_activation(plugin_name, user_identity): + plugin_name = _normalize_identity(plugin_name) + user_identity = _normalize_identity(user_identity) + if not plugin_name or not user_identity: + return False + + try: + _ensure_activation_table() + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT 1 + FROM plugin_activation_keys + WHERE plugin_name = %s + AND user_identity = %s + AND is_active = 1 + LIMIT 1 + """, + [plugin_name, user_identity] + ) + return cursor.fetchone() is not None + except Exception as e: + logging.writeToFile('plugin_access.has_saved_activation failed: %s' % str(e)) + return False + + +def verify_saved_activation_key(plugin_name, user_identity, activation_key): + plugin_name = _normalize_identity(plugin_name) + user_identity = _normalize_identity(user_identity) + activation_key = str(activation_key or '').strip() + if not plugin_name or not user_identity or not activation_key: + return False + + try: + _ensure_activation_table() + key_hash = _hash_activation_key(activation_key) + with connection.cursor() as cursor: + cursor.execute( + """ + SELECT 1 + FROM plugin_activation_keys + WHERE plugin_name = %s + AND user_identity = %s + AND activation_key_hash = %s + AND is_active = 1 + LIMIT 1 + """, + [plugin_name, user_identity, key_hash] + ) + return cursor.fetchone() is not None + except Exception as e: + logging.writeToFile('plugin_access.verify_saved_activation_key failed: %s' % str(e)) + return False def check_plugin_access(request, plugin_name, plugin_meta=None): """ @@ -63,7 +189,16 @@ def check_plugin_access(request, plugin_name, plugin_meta=None): 'patreon_url': plugin_meta.get('patreon_url') } - # Check Patreon membership + # First allow DB-backed activation keys (survives upgrades) + if has_saved_activation(plugin_name, user_email): + return { + 'has_access': True, + 'is_paid': True, + 'message': 'Access granted', + 'patreon_url': None + } + + # Fallback to Patreon membership verifier = PatreonVerifier() has_membership = verifier.check_membership_cached(user_email) diff --git a/pluginHolder/urls.py b/pluginHolder/urls.py index f7876162a..2924a7518 100644 --- a/pluginHolder/urls.py +++ b/pluginHolder/urls.py @@ -104,6 +104,7 @@ urlpatterns = [ path('api/revert//', views.revert_plugin, name='revert_plugin'), path('api/debug-plugins/', views.debug_loaded_plugins, name='debug_loaded_plugins'), path('api/check-subscription//', views.check_plugin_subscription, name='check_plugin_subscription'), + path('api/store-activation//', views.store_plugin_activation_key, name='store_plugin_activation_key'), path('/settings/', views.plugin_settings_proxy, name='plugin_settings_proxy'), path('/help/', views.plugin_help, name='plugin_help'), ] diff --git a/pluginHolder/views.py b/pluginHolder/views.py index a5c63e85c..c4eb092bf 100644 --- a/pluginHolder/views.py +++ b/pluginHolder/views.py @@ -2322,10 +2322,11 @@ def plugin_help(request, plugin_name): return proc.render() @csrf_exempt -@require_http_methods(["GET"]) +@require_http_methods(["GET", "POST"]) def check_plugin_subscription(request, plugin_name): """ - API endpoint to check if user has Patreon subscription for a paid plugin + API endpoint to check plugin premium access. + Supports optional activation key save/verify to persist entitlement in MariaDB. Args: request: Django request object @@ -2353,10 +2354,46 @@ def check_plugin_subscription(request, plugin_name): }, status=401) # Load plugin metadata - from .plugin_access import check_plugin_access, _load_plugin_meta + from .plugin_access import ( + check_plugin_access, + _load_plugin_meta, + save_activation_key, + verify_saved_activation_key + ) plugin_meta = _load_plugin_meta(plugin_name) + user_email = getattr(request.user, 'email', None) or getattr(request.user, 'username', '') + activation_key = '' + if request.method == 'POST': + try: + payload = json.loads(request.body.decode('utf-8') or '{}') + except Exception: + payload = {} + activation_key = str(payload.get('activation_key', '')).strip() + if activation_key and user_email: + # If key is already known for this user/plugin -> immediate access + if verify_saved_activation_key(plugin_name, user_email, activation_key): + return JsonResponse({ + 'success': True, + 'has_access': True, + 'is_paid': bool(plugin_meta and plugin_meta.get('is_paid', False)), + 'message': 'Access granted', + 'patreon_url': None, + 'activation_saved': True + }) + # Save submitted key as persistent entitlement (admin-managed workflow) + saved = save_activation_key(plugin_name, user_email, activation_key, source='plugin_settings') + if saved: + return JsonResponse({ + 'success': True, + 'has_access': True, + 'is_paid': bool(plugin_meta and plugin_meta.get('is_paid', False)), + 'message': 'Activation key saved', + 'patreon_url': None, + 'activation_saved': True + }) + # Check access access_result = check_plugin_access(request, plugin_name, plugin_meta) @@ -2365,7 +2402,8 @@ def check_plugin_subscription(request, plugin_name): 'has_access': access_result['has_access'], 'is_paid': access_result['is_paid'], 'message': access_result['message'], - 'patreon_url': access_result.get('patreon_url') + 'patreon_url': access_result.get('patreon_url'), + 'activation_saved': access_result['has_access'] and access_result['is_paid'] }) except Exception as e: @@ -2374,6 +2412,42 @@ def check_plugin_subscription(request, plugin_name): 'success': False, 'has_access': False, 'is_paid': False, - 'message': f'Error checking subscription: {str(e)}', + 'message': 'Error checking subscription', 'patreon_url': None }, status=500) + + +@csrf_exempt +@require_http_methods(["POST"]) +def store_plugin_activation_key(request, plugin_name): + """ + Store activation key in MariaDB so upgrades do not lose premium entitlement. + """ + try: + if not user_can_manage_plugins(request): + return deny_plugin_manage_json_response(request) + if not request.user or not request.user.is_authenticated: + return JsonResponse({'success': False, 'message': 'Authentication required'}, status=401) + + try: + payload = json.loads(request.body.decode('utf-8') or '{}') + except Exception: + payload = {} + + activation_key = str(payload.get('activation_key', '')).strip() + if not activation_key: + return JsonResponse({'success': False, 'message': 'activation_key is required'}, status=400) + + user_email = getattr(request.user, 'email', None) or getattr(request.user, 'username', '') + if not user_email: + return JsonResponse({'success': False, 'message': 'Unable to determine user identity'}, status=400) + + from .plugin_access import save_activation_key + ok = save_activation_key(plugin_name, user_email, activation_key, source='api') + if not ok: + return JsonResponse({'success': False, 'message': 'Failed to persist activation key'}, status=500) + + return JsonResponse({'success': True, 'message': 'Activation key saved'}) + except Exception as e: + logging.writeToFile('store_plugin_activation_key failed for %s: %s' % (plugin_name, str(e))) + return JsonResponse({'success': False, 'message': 'Internal server error'}, status=500) From a8d1c0f4e96e81abfa0f4e1bc26571bef1e3c96d Mon Sep 17 00:00:00 2001 From: master3395 Date: Thu, 26 Mar 2026 15:09:03 +0100 Subject: [PATCH 13/19] pluginHolder: auto-persist activation keys from plugin settings pages. Inject a lightweight fetch hook into settings pages to call store-activation after successful plugin activation responses, reducing premium relock risk after upgrades. --- pluginHolder/views.py | 81 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/pluginHolder/views.py b/pluginHolder/views.py index c4eb092bf..07545cc56 100644 --- a/pluginHolder/views.py +++ b/pluginHolder/views.py @@ -2106,7 +2106,8 @@ def plugin_settings_proxy(request, plugin_name): for candidate in ('settings', 'settings_view', 'settings_simple', 'unified_settings'): settings_view = getattr(views_mod, candidate, None) if callable(settings_view): - return settings_view(request) + response = settings_view(request) + return _inject_activation_store_hook(response, plugin_name) except ModuleNotFoundError as e: last_err = str(e) continue @@ -2123,6 +2124,84 @@ def plugin_settings_proxy(request, plugin_name): return HttpResponseNotFound('Plugin not found.') +def _inject_activation_store_hook(response, plugin_name): + """ + Tiny safety hook for plugin settings pages: + if a plugin activation request succeeds client-side, persist the key in + CyberPanel DB via /plugins/api/store-activation//. + """ + try: + content_type = (response.get('Content-Type', '') or '').lower() + if 'text/html' not in content_type: + return response + body = response.content.decode('utf-8', errors='ignore') + hook_script = """ + +""" % json.dumps(plugin_name) + if '' in body: + body = body.replace('', hook_script + '') + else: + body += hook_script + response.content = body.encode('utf-8') + return response + except Exception: + return response + + def plugin_help(request, plugin_name): """Plugin-specific help page - shows plugin information, version history, and help content""" mailUtilities.checkHome() From b5b313090a0c710a44c3067c52fd4ca77ecfdfe7 Mon Sep 17 00:00:00 2001 From: master3395 Date: Thu, 26 Mar 2026 15:22:56 +0100 Subject: [PATCH 14/19] pluginHolder: fix plugin store cache timestamp display and stale refresh trigger. Render next cache update in Norwegian format and mark overdue cache clearly while triggering background refresh from Installed view when cache metadata is expired. --- .../templates/pluginHolder/plugins.html | 24 ++++++++++++------- pluginHolder/views.py | 23 +++++++++++++++--- 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/pluginHolder/templates/pluginHolder/plugins.html b/pluginHolder/templates/pluginHolder/plugins.html index ee7d05484..c82e92e7a 100644 --- a/pluginHolder/templates/pluginHolder/plugins.html +++ b/pluginHolder/templates/pluginHolder/plugins.html @@ -1737,7 +1737,7 @@ {% trans "Cache Information:" %} {% trans "Plugin store data is cached for 1 hour to improve performance and reduce GitHub API rate limits. New plugins may take up to 1 hour to appear after being published." %} {% if cache_expiry_timestamp %} -
{% trans "Next cache update:" %} {% trans "Calculating..." %} +
{% trans "Next cache update:" %} {% trans "Calculating..." %} {% endif %}

@@ -3250,8 +3250,8 @@ function updateCacheExpiryTime() { return; } - // Get user's locale preferences - const locale = navigator.language || navigator.userLanguage || 'en-US'; + // Always use Norwegian format for NO-facing UI: DD.MM.YYYY kl. HH:MM + const locale = 'nb-NO'; const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone; // Format date and time according to user's locale @@ -3264,7 +3264,6 @@ function updateCacheExpiryTime() { const timeOptions = { hour: '2-digit', minute: '2-digit', - second: '2-digit', hour12: false }; @@ -3273,11 +3272,18 @@ function updateCacheExpiryTime() { const timeStr = expiryDate.toLocaleTimeString(locale, timeOptions); // Combine with timezone abbreviation - const formatted = dateStr + ' ' + timeStr; - - // Display with timezone info - expiryElement.textContent = formatted; - expiryElement.title = 'Local time: ' + formatted + ' | Timezone: ' + timezone; + const formatted = dateStr + ' kl. ' + timeStr; + const expired = expiryElement.getAttribute('data-expired') === '1'; + const refreshStarted = expiryElement.getAttribute('data-refresh-started') === '1'; + + if (expired) { + expiryElement.textContent = refreshStarted + ? ('Utlopt (' + formatted + ') - oppdaterer i bakgrunnen') + : ('Utlopt (' + formatted + ')'); + } else { + expiryElement.textContent = formatted; + } + expiryElement.title = 'Lokal tid: ' + formatted + ' | Tidssone: ' + timezone; } catch (e) { console.error('Error formatting cache expiry time:', e); expiryElement.textContent = 'Error calculating time'; diff --git a/pluginHolder/views.py b/pluginHolder/views.py index 07545cc56..8939e430f 100644 --- a/pluginHolder/views.py +++ b/pluginHolder/views.py @@ -650,8 +650,13 @@ def installed(request): for p in pluginList: logging.writeToFile(f" - {p.get('plugin_dir')}: installed={p.get('installed')}, enabled={p.get('enabled')}") - # Get cache expiry timestamp for display (will be converted to local time in browser) + # Get cache expiry timestamp for display (browser formats this as nb-NO) cache_expiry_timestamp, _ = _get_cache_expiry_time() + cache_expired = _is_cache_expired(cache_expiry_timestamp) + refresh_started = False + if cache_expired: + # If cache is stale while on Installed page, trigger best-effort background refresh. + refresh_started = _try_start_plugin_store_refresh_background() # Sort plugins A-Å by name (case-insensitive) for Grid and Table view pluginList.sort(key=lambda p: (p.get('name') or '').lower()) @@ -671,9 +676,11 @@ def installed(request): pass proc = httpProc(request, 'pluginHolder/plugins.html', - {'plugins': pluginList, 'error_plugins': errorPlugins, + {'plugins': pluginList, 'error_plugins': errorPlugins, 'installed_count': installed_count, 'active_count': active_count, - 'cache_expiry_timestamp': cache_expiry_timestamp}, 'managePlugins') + 'cache_expiry_timestamp': cache_expiry_timestamp, + 'cache_expired': cache_expired, + 'cache_refresh_started': refresh_started}, 'managePlugins') return proc.render() @csrf_exempt @@ -946,6 +953,16 @@ def _get_cache_expiry_time(): logging.writeToFile(f"Error getting cache expiry time: {str(e)}") return None, None + +def _is_cache_expired(expiry_timestamp): + """Return True if provided cache expiry timestamp is in the past.""" + try: + if not expiry_timestamp: + return False + return float(expiry_timestamp) <= time.time() + except Exception: + return False + def _get_cached_plugins(allow_expired=False): """Get plugins from cache if available and not expired From 8d3e2cd51abc208c3440f9a993fccf62cb6c5999 Mon Sep 17 00:00:00 2001 From: master3395 Date: Thu, 26 Mar 2026 22:45:54 +0100 Subject: [PATCH 15/19] pluginHolder: fix plugin store stale-cache refresh + hourly scheduler Remove stuck plugin-store refresh locks, show correct cache status in UI, and add a management command for hourly refresh. --- pluginHolder/management/__init__.py | 1 + pluginHolder/management/commands/__init__.py | 1 + .../commands/refresh_plugin_store_cache.py | 102 ++++++++++++++++++ .../templates/pluginHolder/plugins.html | 4 +- pluginHolder/views.py | 17 +++ 5 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 pluginHolder/management/__init__.py create mode 100644 pluginHolder/management/commands/__init__.py create mode 100644 pluginHolder/management/commands/refresh_plugin_store_cache.py diff --git a/pluginHolder/management/__init__.py b/pluginHolder/management/__init__.py new file mode 100644 index 000000000..792d60054 --- /dev/null +++ b/pluginHolder/management/__init__.py @@ -0,0 +1 @@ +# diff --git a/pluginHolder/management/commands/__init__.py b/pluginHolder/management/commands/__init__.py new file mode 100644 index 000000000..792d60054 --- /dev/null +++ b/pluginHolder/management/commands/__init__.py @@ -0,0 +1 @@ +# diff --git a/pluginHolder/management/commands/refresh_plugin_store_cache.py b/pluginHolder/management/commands/refresh_plugin_store_cache.py new file mode 100644 index 000000000..e2a0fa2bd --- /dev/null +++ b/pluginHolder/management/commands/refresh_plugin_store_cache.py @@ -0,0 +1,102 @@ +# -*- coding: utf-8 -*- +import os +import time + +from django.core.management.base import BaseCommand + + +class Command(BaseCommand): + help = "Refresh CyberPanel plugin store cache (hourly scheduler)." + + def add_arguments(self, parser): + parser.add_argument( + "--force", + action="store_true", + help="Refresh even if cache is not expired.", + ) + parser.add_argument( + "--stale-lock-seconds", + type=int, + default=900, + help="Remove the cache-refresh lock if it is older than this many seconds.", + ) + + def handle(self, *args, **options): + force = bool(options.get("force", False)) + stale_lock_seconds = int(options.get("stale_lock_seconds", 900)) + + try: + from pluginHolder import views as plugin_views + except Exception as e: + # Avoid printing secrets; just show a minimal message. + self.stderr.write("Failed to import pluginHolder views for cache refresh.") + return 1 + + # Only refresh when needed (unless --force is used). + try: + cache_expiry_timestamp, _ = plugin_views._get_cache_expiry_time() + cache_expired = plugin_views._is_cache_expired(cache_expiry_timestamp) + except Exception: + cache_expired = True + + if not force and cache_expiry_timestamp and not cache_expired: + self.stdout.write("Plugin store cache is still fresh; no refresh needed.") + return 0 + + lock_path = plugin_views.PLUGIN_STORE_REFRESH_LOCK_FILE + try: + plugin_views._ensure_cache_dir() + except Exception: + pass + + # Remove stale lock left behind by a crashed/aborted refresh. + if os.path.exists(lock_path): + try: + age_s = time.time() - os.path.getmtime(lock_path) + if age_s > stale_lock_seconds: + os.remove(lock_path) + try: + plugin_views.logging.writeToFile( + f"Management refresh: removed stale plugin store refresh lock (age: {age_s:.0f}s)" + ) + except Exception: + pass + except Exception: + pass + + # Acquire lock to avoid stampedes when multiple instances refresh. + try: + fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644) + with os.fdopen(fd, "w") as f: + f.write(str(os.getpid())) + except FileExistsError: + self.stdout.write("Plugin store refresh skipped: lock already exists.") + return 0 + except Exception: + self.stderr.write("Plugin store refresh failed: could not acquire refresh lock.") + return 1 + + try: + plugins = plugin_views._fetch_plugins_from_github() + if not plugins: + self.stdout.write("Plugin store refresh fetched 0 plugins; cache not updated.") + return 0 + + plugin_views._save_plugins_cache(plugins) + self.stdout.write(f"Plugin store cache refreshed successfully. plugins={len(plugins)}") + return 0 + except Exception as e: + # Log error summary server-side; don't leak internal exception details to stdout. + try: + plugin_views.logging.writeToFile(f"Plugin store cache refresh failed: {str(e)}") + except Exception: + pass + self.stderr.write("Plugin store cache refresh failed. Check error logs.") + return 1 + finally: + try: + if os.path.exists(lock_path): + os.remove(lock_path) + except Exception: + pass + diff --git a/pluginHolder/templates/pluginHolder/plugins.html b/pluginHolder/templates/pluginHolder/plugins.html index c82e92e7a..450348aae 100644 --- a/pluginHolder/templates/pluginHolder/plugins.html +++ b/pluginHolder/templates/pluginHolder/plugins.html @@ -3278,8 +3278,8 @@ function updateCacheExpiryTime() { if (expired) { expiryElement.textContent = refreshStarted - ? ('Utlopt (' + formatted + ') - oppdaterer i bakgrunnen') - : ('Utlopt (' + formatted + ')'); + ? ('Expired (' + formatted + ') - updating in background') + : ('Expired (' + formatted + ')'); } else { expiryElement.textContent = formatted; } diff --git a/pluginHolder/views.py b/pluginHolder/views.py index 8939e430f..ee790b486 100644 --- a/pluginHolder/views.py +++ b/pluginHolder/views.py @@ -33,6 +33,7 @@ PLUGIN_STORE_CACHE_FILE = os.path.join(PLUGIN_STORE_CACHE_DIR, 'plugins_cache.js PLUGIN_STORE_CACHE_DURATION = 3600 # Base cache duration: 1 hour (3600 seconds) PLUGIN_STORE_CACHE_RANDOM_OFFSET = 600 # Random offset: ±10 minutes (600 seconds) to prevent simultaneous requests PLUGIN_STORE_REFRESH_LOCK_FILE = os.path.join(PLUGIN_STORE_CACHE_DIR, 'plugins_cache_refresh.lock') +PLUGIN_STORE_REFRESH_LOCK_STALE_SECONDS = 900 # 15 minutes; remove leftover lock if stuck GITHUB_REPO_API = 'https://api.github.com/repos/master3395/cyberpanel-plugins/contents' GITHUB_RAW_BASE = 'https://raw.githubusercontent.com/master3395/cyberpanel-plugins/main' GITHUB_COMMITS_API = 'https://api.github.com/repos/master3395/cyberpanel-plugins/commits' @@ -1046,6 +1047,22 @@ def _try_start_plugin_store_refresh_background(): try: _ensure_cache_dir() + # If a previous refresh crashed and left the lock behind, remove it + # so background refresh can resume. This is critical for hourly updates. + try: + if os.path.exists(lock_path): + age_s = time.time() - os.path.getmtime(lock_path) + if age_s > PLUGIN_STORE_REFRESH_LOCK_STALE_SECONDS: + try: + os.remove(lock_path) + logging.writeToFile( + f"Removed stale plugin store refresh lock (age: {age_s:.0f}s)" + ) + except Exception: + pass + except Exception: + pass + # Try to acquire a file lock so multiple workers don't stampede GitHub. try: fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644) From 2b238269480538f8fc5d206e371bebac64b0b5f5 Mon Sep 17 00:00:00 2001 From: master3395 Date: Thu, 26 Mar 2026 23:16:45 +0100 Subject: [PATCH 16/19] fix(pluginHolder): resolve CyberPanel admin identity for activation APIs Use session userID -> Administrator email for subscription checks, activation persistence, and paid-plugin access when Django auth user is not populated. --- pluginHolder/plugin_access.py | 53 ++++++++++++++++++++++--------- pluginHolder/views.py | 59 +++++++++++++++++++++++++++-------- 2 files changed, 84 insertions(+), 28 deletions(-) diff --git a/pluginHolder/plugin_access.py b/pluginHolder/plugin_access.py index a508beca5..9dbacef12 100644 --- a/pluginHolder/plugin_access.py +++ b/pluginHolder/plugin_access.py @@ -133,6 +133,43 @@ def verify_saved_activation_key(plugin_name, user_identity, activation_key): logging.writeToFile('plugin_access.verify_saved_activation_key failed: %s' % str(e)) return False + +def _resolve_identity_for_request(request): + """ + CyberPanel often authenticates via session userID (not Django auth user). + Prefer Administrator email when available, otherwise username. + """ + candidates = [] + try: + if getattr(request, 'user', None) and request.user.is_authenticated: + u = request.user + email = getattr(u, 'email', None) or '' + if email: + candidates.append(email) + uname = getattr(u, 'username', None) or '' + if uname: + candidates.append(uname) + except Exception: + pass + try: + uid = request.session.get('userID') if hasattr(request, 'session') else None + if uid: + from loginSystem.models import Administrator + admin = Administrator.objects.filter(pk=uid).only('email', 'userName').first() + if admin: + if getattr(admin, 'email', '') and str(admin.email).lower() != 'none': + candidates.append(str(admin.email)) + if getattr(admin, 'userName', ''): + candidates.append(str(admin.userName)) + except Exception: + pass + for item in candidates: + item = (item or '').strip() + if item: + return item.lower() + return '' + + def check_plugin_access(request, plugin_name, plugin_meta=None): """ Check if user has access to a plugin @@ -166,21 +203,7 @@ def check_plugin_access(request, plugin_name, plugin_meta=None): if not plugin_meta or not plugin_meta.get('is_paid', False): return default_response - # Plugin is paid - check Patreon membership - if not request.user or not request.user.is_authenticated: - return { - 'has_access': False, - 'is_paid': True, - 'message': 'Please log in to access this plugin', - 'patreon_url': plugin_meta.get('patreon_url') - } - - # Get user email - user_email = getattr(request.user, 'email', None) - if not user_email: - # Try to get from username or other fields - user_email = getattr(request.user, 'username', '') - + user_email = _resolve_identity_for_request(request) if not user_email: return { 'has_access': False, diff --git a/pluginHolder/views.py b/pluginHolder/views.py index ee790b486..e1d79fabf 100644 --- a/pluginHolder/views.py +++ b/pluginHolder/views.py @@ -49,6 +49,42 @@ PLUGIN_SOURCE_PATHS = ['/home/cyberpanel/plugins', '/home/cyberpanel-plugins'] BUILTIN_PLUGINS = frozenset(['emailMarketing', 'emailPremium']) +def _resolve_logged_in_plugin_identity(request): + """ + CyberPanel often authenticates via session userID (not Django auth user). + Use Administrator email when available, otherwise username. + """ + candidates = [] + try: + if getattr(request, 'user', None) and request.user.is_authenticated: + u = request.user + email = getattr(u, 'email', None) or '' + if email: + candidates.append(email) + uname = getattr(u, 'username', None) or '' + if uname: + candidates.append(uname) + except Exception: + pass + try: + uid = request.session.get('userID') if hasattr(request, 'session') else None + if uid: + from loginSystem.models import Administrator + admin = Administrator.objects.filter(pk=uid).only('email', 'userName').first() + if admin: + if getattr(admin, 'email', '') and str(admin.email).lower() != 'none': + candidates.append(str(admin.email)) + if getattr(admin, 'userName', ''): + candidates.append(str(admin.userName)) + except Exception: + pass + for item in candidates: + item = (item or '').strip() + if item: + return item.lower() + return '' + + def _install_plugin_compat(plugin_name, zip_path_abs): """ Call pluginInstaller.installPlugin with zip_path when supported (newer CyberPanel). @@ -2456,15 +2492,6 @@ def check_plugin_subscription(request, plugin_name): try: if not user_can_manage_plugins(request): return deny_plugin_manage_json_response(request) - # Check if user is authenticated - if not request.user or not request.user.is_authenticated: - return JsonResponse({ - 'success': False, - 'has_access': False, - 'is_paid': False, - 'message': 'Please log in to check subscription status', - 'patreon_url': None - }, status=401) # Load plugin metadata from .plugin_access import ( @@ -2476,7 +2503,15 @@ def check_plugin_subscription(request, plugin_name): plugin_meta = _load_plugin_meta(plugin_name) - user_email = getattr(request.user, 'email', None) or getattr(request.user, 'username', '') + user_email = _resolve_logged_in_plugin_identity(request) + if not user_email: + return JsonResponse({ + 'success': False, + 'has_access': False, + 'is_paid': False, + 'message': 'Unable to determine user identity', + 'patreon_url': None + }, status=400) activation_key = '' if request.method == 'POST': try: @@ -2539,8 +2574,6 @@ def store_plugin_activation_key(request, plugin_name): try: if not user_can_manage_plugins(request): return deny_plugin_manage_json_response(request) - if not request.user or not request.user.is_authenticated: - return JsonResponse({'success': False, 'message': 'Authentication required'}, status=401) try: payload = json.loads(request.body.decode('utf-8') or '{}') @@ -2551,7 +2584,7 @@ def store_plugin_activation_key(request, plugin_name): if not activation_key: return JsonResponse({'success': False, 'message': 'activation_key is required'}, status=400) - user_email = getattr(request.user, 'email', None) or getattr(request.user, 'username', '') + user_email = _resolve_logged_in_plugin_identity(request) if not user_email: return JsonResponse({'success': False, 'message': 'Unable to determine user identity'}, status=400) From 80ea96cc9133a618bde05b3e30fd536dc7d0cb20 Mon Sep 17 00:00:00 2001 From: master3395 Date: Fri, 27 Mar 2026 01:15:33 +0100 Subject: [PATCH 17/19] fix: ensure phpMyAdmin signin bridge + auto plugin migrations + PMA tmp dir - Add plogical/phpmyadmin_utils.ensure_phpmyadmin_signin_bridge: restore phpmyadminsignin.php and tmp/ if missing (fixes 404 on /phpmyadmin/phpmyadminsignin.php). - Call from databases phpMyAdmin page, fetchDetailsPHPMYAdmin, install, and upgrade PMA paths. - install/upgrade: use makedirs(..., exist_ok=True) for phpmyadmin/tmp instead of mkdir. - pluginInstaller: run migrate when migrations/ contains modules OR enable_migrations; use CyberCP venv python; --noinput for migrate; log non-zero exits. --- databases/databaseManager.py | 5 ++ databases/views.py | 5 ++ install/install.py | 2 +- plogical/phpmyadmin_utils.py | 44 ++++++++++++++++ plogical/upgrade.py | 8 ++- pluginInstaller/pluginInstaller.py | 85 ++++++++++++++++++++++++------ 6 files changed, 131 insertions(+), 18 deletions(-) create mode 100644 plogical/phpmyadmin_utils.py diff --git a/databases/databaseManager.py b/databases/databaseManager.py index 0b0c7617a..60c817a3b 100644 --- a/databases/databaseManager.py +++ b/databases/databaseManager.py @@ -31,6 +31,11 @@ class DatabaseManager: return proc.render() def phpMyAdmin(self, request = None, userID = None): + try: + from plogical.phpmyadmin_utils import ensure_phpmyadmin_signin_bridge + ensure_phpmyadmin_signin_bridge() + except BaseException: + pass template = 'databases/phpMyAdmin.html' proc = httpProc(request, template, None, 'createDatabase') return proc.render() diff --git a/databases/views.py b/databases/views.py index e32f96371..37ec05ff9 100644 --- a/databases/views.py +++ b/databases/views.py @@ -257,6 +257,11 @@ def generateAccess(request): @csrf_exempt def fetchDetailsPHPMYAdmin(request): try: + try: + from plogical.phpmyadmin_utils import ensure_phpmyadmin_signin_bridge + ensure_phpmyadmin_signin_bridge() + except BaseException: + pass userID = request.session['userID'] admin = Administrator.objects.get(id=userID) diff --git a/install/install.py b/install/install.py index 932091472..f66fc189b 100644 --- a/install/install.py +++ b/install/install.py @@ -3986,7 +3986,7 @@ $cfg['Servers'][$i]['LogoutURL'] = 'phpmyadminsignin.php?logout'; writeToFile.close() - os.mkdir('/usr/local/CyberCP/public/phpmyadmin/tmp') + os.makedirs('/usr/local/CyberCP/public/phpmyadmin/tmp', exist_ok=True) command = 'chown -R lscpd:lscpd /usr/local/CyberCP/public/phpmyadmin' preFlightsChecks.call(command, self.distro, '[chown -R lscpd:lscpd /usr/local/CyberCP/public/phpmyadmin]', diff --git a/plogical/phpmyadmin_utils.py b/plogical/phpmyadmin_utils.py new file mode 100644 index 000000000..aa5448e1e --- /dev/null +++ b/plogical/phpmyadmin_utils.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +""" +Ensure phpMyAdmin single-sign-on bridge files exist under public/phpmyadmin/. +Fixes 404 on /phpmyadmin/phpmyadminsignin.php when the file was lost after a partial install or manual change. +""" +from __future__ import annotations + +import os +import shutil + +from plogical.CyberCPLogFileWriter import CyberCPLogFileWriter as logging + +PMA_DIR = '/usr/local/CyberCP/public/phpmyadmin' +SIGNIN_SRC = '/usr/local/CyberCP/plogical/phpmyadminsignin.php' +SIGNIN_NAME = 'phpmyadminsignin.php' + + +def ensure_phpmyadmin_signin_bridge() -> bool: + """ + Copy plogical/phpmyadminsignin.php into the public phpMyAdmin tree if missing, + ensure tmp/ exists, and fix ownership for lscpd. + Returns True if the sign-in file is present afterward. + """ + dst = os.path.join(PMA_DIR, SIGNIN_NAME) + try: + if not os.path.isdir(PMA_DIR): + return False + if not os.path.isfile(SIGNIN_SRC): + logging.writeToFile('phpmyadmin_utils: source signin missing at ' + SIGNIN_SRC) + return os.path.isfile(dst) + need_copy = (not os.path.isfile(dst)) or os.path.getsize(dst) < 32 + if need_copy: + shutil.copy2(SIGNIN_SRC, dst) + tmp_dir = os.path.join(PMA_DIR, 'tmp') + os.makedirs(tmp_dir, exist_ok=True) + try: + from plogical.processUtilities import ProcessUtilities + ProcessUtilities.executioner('chown -R lscpd:lscpd ' + PMA_DIR) + except Exception as ch_ex: + logging.writeToFile('phpmyadmin_utils: chown skipped or failed (non-fatal): ' + str(ch_ex)) + return os.path.isfile(dst) + except Exception as ex: + logging.writeToFile('phpmyadmin_utils: ensure_phpmyadmin_signin_bridge failed: ' + str(ex)) + return os.path.isfile(dst) diff --git a/plogical/upgrade.py b/plogical/upgrade.py index eae86d2cb..f728108ef 100644 --- a/plogical/upgrade.py +++ b/plogical/upgrade.py @@ -1388,7 +1388,7 @@ $cfg['Servers'][$i]['port'] = '3306'; writeToFile.writelines("$cfg['TempDir'] = '/usr/local/CyberCP/public/phpmyadmin/tmp';\n") writeToFile.close() - os.mkdir('/usr/local/CyberCP/public/phpmyadmin/tmp') + os.makedirs('/usr/local/CyberCP/public/phpmyadmin/tmp', exist_ok=True) if saved_signon and os.path.isfile(tmp_signon): shutil.copy2(tmp_signon, os.path.join(pma_dir, 'phpmyadminsignin.php')) @@ -1417,6 +1417,12 @@ $cfg['Servers'][$i]['port'] = '3306'; command = 'chown -R lscpd:lscpd /usr/local/CyberCP/public/phpmyadmin/tmp' Upgrade.executioner_silent(command, 'chown phpMyAdmin tmp') + try: + from plogical.phpmyadmin_utils import ensure_phpmyadmin_signin_bridge + ensure_phpmyadmin_signin_bridge() + except Exception: + pass + os.chdir(cwd) except Exception as e: diff --git a/pluginInstaller/pluginInstaller.py b/pluginInstaller/pluginInstaller.py index 1fd41e2e2..0041fe070 100644 --- a/pluginInstaller/pluginInstaller.py +++ b/pluginInstaller/pluginInstaller.py @@ -58,6 +58,35 @@ class pluginInstaller: pluginHome = '/usr/local/CyberCP/' + pluginName return os.path.exists(pluginHome + '/enable_migrations') + @staticmethod + def shouldApplyPluginDatabaseMigrations(pluginName: str) -> bool: + """ + Run Django migrations when the plugin opts in (enable_migrations file) + or when a migrations/ package with real migration modules is shipped. + """ + if pluginInstaller.migrationsEnabled(pluginName): + return True + mig_dir = '/usr/local/CyberCP/' + pluginName + '/migrations' + if not os.path.isdir(mig_dir): + return False + try: + for fn in os.listdir(mig_dir): + if fn.endswith('.py') and fn != '__init__.py': + return True + except OSError: + return False + return False + + @staticmethod + def _manage_python_executable(): + for candidate in ('/usr/local/CyberCP/bin/python', '/usr/local/CyberCP/bin/python3'): + try: + if os.path.isfile(candidate) and os.access(candidate, os.X_OK): + return candidate + except OSError: + continue + return 'python3' + @staticmethod def _write_lines_to_protected_file(target_path, lines): """ @@ -338,12 +367,31 @@ class pluginInstaller: @staticmethod def installMigrations(pluginName): currentDir = os.getcwd() - os.chdir('/usr/local/CyberCP') - command = "python3 /usr/local/CyberCP/manage.py makemigrations %s" % pluginName - subprocess.call(shlex.split(command)) - command = "python3 /usr/local/CyberCP/manage.py migrate %s" % pluginName - subprocess.call(shlex.split(command)) - os.chdir(currentDir) + manage_py = '/usr/local/CyberCP/manage.py' + py = pluginInstaller._manage_python_executable() + try: + os.chdir('/usr/local/CyberCP') + mk = subprocess.call( + [py, manage_py, 'makemigrations', pluginName], + stdin=subprocess.DEVNULL, + ) + if mk != 0: + pluginInstaller.stdOut( + 'makemigrations %s exited %s (ok if no model changes)' % (pluginName, mk) + ) + mig = subprocess.call( + [py, manage_py, 'migrate', pluginName, '--noinput'], + stdin=subprocess.DEVNULL, + ) + if mig != 0: + pluginInstaller.stdOut( + 'migrate %s exited %s — check CyberPanel logs and DB permissions' % (pluginName, mig) + ) + finally: + try: + os.chdir(currentDir) + except OSError: + pass @staticmethod @@ -427,12 +475,14 @@ class pluginInstaller: ## - if pluginInstaller.migrationsEnabled(pluginName): - pluginInstaller.stdOut('Running Migrations..') + if pluginInstaller.shouldApplyPluginDatabaseMigrations(pluginName): + pluginInstaller.stdOut('Running database migrations for %s..' % pluginName) pluginInstaller.installMigrations(pluginName) - pluginInstaller.stdOut('Migrations Completed..') + pluginInstaller.stdOut('Database migrations step finished for %s.' % pluginName) else: - pluginInstaller.stdOut('Migrations not enabled, add file \'enable_migrations\' to plugin to enable') + pluginInstaller.stdOut( + 'No plugin migrations to apply (no migrations/ package and no enable_migrations marker).' + ) ## @@ -625,8 +675,11 @@ class pluginInstaller: def removeMigrations(pluginName): currentDir = os.getcwd() os.chdir('/usr/local/CyberCP') - command = "python3 /usr/local/CyberCP/manage.py migrate %s zero" % pluginName - subprocess.call(shlex.split(command)) + py = pluginInstaller._manage_python_executable() + subprocess.call( + [py, '/usr/local/CyberCP/manage.py', 'migrate', pluginName, 'zero', '--noinput'], + stdin=subprocess.DEVNULL, + ) os.chdir(currentDir) @staticmethod @@ -640,12 +693,12 @@ class pluginInstaller: ## - if pluginInstaller.migrationsEnabled(pluginName): - pluginInstaller.stdOut('Removing migrations..') + if pluginInstaller.shouldApplyPluginDatabaseMigrations(pluginName): + pluginInstaller.stdOut('Reverting database migrations for %s..' % pluginName) pluginInstaller.removeMigrations(pluginName) - pluginInstaller.stdOut('Migrations removed..') + pluginInstaller.stdOut('Database migrations reverted for %s.' % pluginName) else: - pluginInstaller.stdOut('Migrations not enabled, add file \'enable_migrations\' to plugin to enable') + pluginInstaller.stdOut('Skipping migrate zero (no migrations package / marker).') ## From 65f6b7af64330e01cae7dd4c8240723dc20049fb Mon Sep 17 00:00:00 2001 From: master3395 Date: Fri, 27 Mar 2026 13:40:21 +0100 Subject: [PATCH 18/19] fix phpmyadmin signin endpoint not found Restore the missing phpMyAdmin sign-in bridge file so CyberPanel DB login no longer returns 404 on /phpmyadmin/phpmyadminsignin.php. --- public/phpmyadmin/phpmyadminsignin.php | 68 ++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 public/phpmyadmin/phpmyadminsignin.php diff --git a/public/phpmyadmin/phpmyadminsignin.php b/public/phpmyadmin/phpmyadminsignin.php new file mode 100644 index 000000000..1ac2461e9 --- /dev/null +++ b/public/phpmyadmin/phpmyadminsignin.php @@ -0,0 +1,68 @@ +'; + echo ''; + echo ''; + echo ''; + echo ''; + + } else if (isset($_POST['logout']) || isset($_GET['logout'])) { + session_name(PMA_SIGNON_SESSIONNAME); + @session_start(); + $_SESSION = array(); + $params = session_get_cookie_params(); + setcookie(session_name(), '', time() - 86400, $params["path"], $params["domain"], $params["secure"], $params["httponly"]); + session_destroy(); + header('Location: /base/'); + exit; + } else if (isset($_POST['password'])) { + + session_name(PMA_SIGNON_SESSIONNAME); + @session_start(); + + $username = htmlspecialchars($_POST['username'], ENT_QUOTES, 'UTF-8'); + $password = $_POST['password']; + $host = isset($_POST['host']) ? trim($_POST['host']) : '127.0.0.1'; + if ($host === 'localhost') { $host = '127.0.0.1'; } + + $_SESSION['PMA_single_signon_user'] = $username; + $_SESSION['PMA_single_signon_password'] = $password; + $_SESSION['PMA_single_signon_host'] = $host; + + @session_write_close(); + + header('Location: /phpmyadmin/index.php?server=' . PMA_SIGNON_INDEX); + } +} catch (Exception $e) { + echo 'Caught exception: ', $e->getMessage(), "\n"; + $params = session_get_cookie_params(); + setcookie(session_name(), '', time() - 86400, $params["path"], $params["domain"], $params["secure"], $params["httponly"]); + session_destroy(); + header('Location: /dataBases/phpMyAdmin'); + return; +} From 98c086d7afef70225a7a66d0e4fe9a50cdc3e28e Mon Sep 17 00:00:00 2001 From: master3395 Date: Fri, 27 Mar 2026 21:08:26 +0100 Subject: [PATCH 19/19] fix(pluginHolder): reliable plugin upgrades, store UI dates, upgrades columns - Harden meta.xml sync (cache-bust, no CDN downgrade); ZIP meta fallback; fail if version stuck - Invalidate plugin store cache after successful upgrade - Add modify_timestamp for browser-local DD.MM.yyyy / 24h display via toLocaleString - Upgrades table: Your Version column before New Version; freshness uses timestamp when present --- .../templates/pluginHolder/plugins.html | 76 +++++- pluginHolder/views.py | 221 ++++++++++++++---- 2 files changed, 244 insertions(+), 53 deletions(-) diff --git a/pluginHolder/templates/pluginHolder/plugins.html b/pluginHolder/templates/pluginHolder/plugins.html index 450348aae..c10982332 100644 --- a/pluginHolder/templates/pluginHolder/plugins.html +++ b/pluginHolder/templates/pluginHolder/plugins.html @@ -1695,8 +1695,8 @@ {% trans "Plugin Name" %} - {% trans "New Version" %} {% trans "Your Version" %} + {% trans "New Version" %} {% trans "Date" %} {% trans "Status / Action" %} @@ -1850,7 +1850,7 @@