|
#!/usr/bin/env bash |
|
|
|
set -e |
|
set -o pipefail |
|
|
|
SCRIPT_DIR="$(pwd)" |
|
|
|
echo "======================================================" |
|
echo " WHMCS Fully Automated Incremental Patch Updater " |
|
echo "======================================================" |
|
echo "" |
|
|
|
# 1. Dependency check & PHP Binary Detection |
|
for cmd in curl wget unzip rsync stat tar mysqldump; do |
|
if ! command -v "$cmd" &>/dev/null; then |
|
echo "Error: Required command '$cmd' is not installed." |
|
exit 1 |
|
fi |
|
done |
|
|
|
# Auto-detect best PHP binary (supporting cPanel / DirectAdmin / CloudLinux paths) |
|
PHP_BIN="php" |
|
for candidate in php /usr/local/bin/php /opt/cpanel/ea-php82/root/usr/bin/php /opt/cpanel/ea-php81/root/usr/bin/php /usr/bin/php8.2 /usr/bin/php8.1; do |
|
if command -v "$candidate" &>/dev/null; then |
|
if "$candidate" -m 2>/dev/null | grep -qiE 'pdo_mysql|mysqli'; then |
|
PHP_BIN="$candidate" |
|
break |
|
fi |
|
fi |
|
done |
|
|
|
# 2. Get installation directory |
|
read -r -p "Enter WHMCS installation path (e.g. /home/username/public_html): " WHMCS_PATH </dev/tty |
|
WHMCS_PATH="${WHMCS_PATH%/}" |
|
|
|
if [ ! -d "$WHMCS_PATH" ] || [ ! -f "$WHMCS_PATH/configuration.php" ]; then |
|
echo "Error: Directory does not contain a valid configuration.php file." |
|
exit 1 |
|
fi |
|
|
|
# 3. Detect file owner and group automatically |
|
FILE_OWNER=$(stat -c '%U:%G' "$WHMCS_PATH/configuration.php" 2>/dev/null || stat -f '%Su:%Sg' "$WHMCS_PATH/configuration.php" 2>/dev/null || true) |
|
|
|
# 4. Extract configuration and DB details safely via PHP |
|
ENV_INFO=$("$PHP_BIN" -r ' |
|
$path = $argv[1]; |
|
chdir($path); |
|
if (!file_exists("configuration.php")) { exit(1); } |
|
|
|
// Read customadminpath directly even if DB fails |
|
$configContent = file_get_contents("configuration.php"); |
|
$adminDir = "admin"; |
|
if (preg_match("/\\\$customadminpath\s*=\s*[\x27\x22]([^\x27\x22]+)[\x27\x22]/", $configContent, $m)) { |
|
$adminDir = trim($m[1]); |
|
} |
|
|
|
include "configuration.php"; |
|
|
|
$version = ""; |
|
$systemUrl = ""; |
|
$db_host = $db_host ?? "localhost"; |
|
$db_port = $db_port ?? 3306; |
|
$db_name = $db_name ?? ""; |
|
$db_username = $db_username ?? ""; |
|
$db_password = $db_password ?? ""; |
|
|
|
// 1. Try PDO MySQL |
|
if (extension_loaded("pdo_mysql")) { |
|
try { |
|
$pdo = new PDO("mysql:host={$db_host};port={$db_port};dbname={$db_name};charset=utf8", $db_username, $db_password, [ |
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, |
|
PDO::ATTR_TIMEOUT => 3 |
|
]); |
|
$stmt = $pdo->query("SELECT value FROM tblconfiguration WHERE setting = '\''Version'\'' LIMIT 1"); |
|
if ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { $version = trim($row["value"]); } |
|
|
|
$stmt = $pdo->query("SELECT value FROM tblconfiguration WHERE setting = '\''SystemURL'\'' LIMIT 1"); |
|
if ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { $systemUrl = rtrim(trim($row["value"]), "/"); } |
|
} catch (\Throwable $e) {} |
|
} |
|
|
|
// 2. Fallback to mysqli if PDO failed or not loaded |
|
if (empty($version) && extension_loaded("mysqli")) { |
|
try { |
|
$mysqli = @new mysqli($db_host, $db_username, $db_password, $db_name, (int)$db_port); |
|
if (!$mysqli->connect_error) { |
|
$res = $mysqli->query("SELECT value FROM tblconfiguration WHERE setting = '\''Version'\'' LIMIT 1"); |
|
if ($row = $res->fetch_assoc()) { $version = trim($row["value"]); } |
|
|
|
$res = $mysqli->query("SELECT value FROM tblconfiguration WHERE setting = '\''SystemURL'\'' LIMIT 1"); |
|
if ($row = $res->fetch_assoc()) { $systemUrl = rtrim(trim($row["value"]), "/"); } |
|
$mysqli->close(); |
|
} |
|
} catch (\Throwable $e) {} |
|
} |
|
|
|
echo "ADMIN_DIR=" . escapeshellarg($adminDir) . "\n"; |
|
echo "CURRENT_VER=" . escapeshellarg($version) . "\n"; |
|
echo "SYSTEM_URL=" . escapeshellarg($systemUrl) . "\n"; |
|
echo "DB_HOST=" . escapeshellarg($db_host) . "\n"; |
|
echo "DB_PORT=" . escapeshellarg($db_port) . "\n"; |
|
echo "DB_NAME=" . escapeshellarg($db_name) . "\n"; |
|
echo "DB_USER=" . escapeshellarg($db_username) . "\n"; |
|
echo "DB_PASS=" . escapeshellarg($db_password) . "\n"; |
|
' "$WHMCS_PATH" 2>/dev/null || true) |
|
|
|
eval "$ENV_INFO" |
|
|
|
ADMIN_DIR="${ADMIN_DIR:-admin}" |
|
CURRENT_VER=$(echo "$CURRENT_VER" | grep -oE '^[0-9]+\.[0-9]+\.[0-9]+' || true) |
|
|
|
if [ -z "$CURRENT_VER" ]; then |
|
echo "Warning: Failed to auto-detect installed WHMCS version from database." |
|
read -r -p "Enter current version manually (e.g. 8.13.1): " CURRENT_VER </dev/tty |
|
CURRENT_VER="${CURRENT_VER#[vV]}" |
|
fi |
|
|
|
if [ -n "$SYSTEM_URL" ]; then |
|
ADMIN_URL="${SYSTEM_URL}/${ADMIN_DIR}/index.php" |
|
else |
|
ADMIN_URL="(Relative) /${ADMIN_DIR}/index.php" |
|
fi |
|
|
|
echo "" |
|
echo "Detected Environment:" |
|
echo "------------------------------------------------------" |
|
echo " Installation Path : $WHMCS_PATH" |
|
echo " Custom Admin Path : $ADMIN_DIR" |
|
echo " File Permissions : ${FILE_OWNER:-Not Detected}" |
|
echo " Current Version : v$CURRENT_VER" |
|
echo " Admin Panel URL : $ADMIN_URL" |
|
echo " Database Name : $DB_NAME" |
|
echo "------------------------------------------------------" |
|
|
|
# 5. Fetch available patches from WHMCS API |
|
API_URL="https://www.whmcs.com/assets/scripts/get-downloads.php" |
|
echo "Fetching official patch catalog..." |
|
|
|
TMP_DIR=$(mktemp -d -t whmcs_patch_XXXXXX) |
|
cleanup() { |
|
echo "" |
|
echo "Cleaning up temporary files..." |
|
if [ -n "$TMP_DIR" ] && [ -d "$TMP_DIR" ]; then |
|
rm -rf "$TMP_DIR" |
|
fi |
|
} |
|
trap cleanup EXIT INT TERM |
|
|
|
API_JSON="$TMP_DIR/downloads.json" |
|
curl -sSL "$API_URL" -o "$API_JSON" |
|
|
|
if [ ! -s "$API_JSON" ]; then |
|
echo "Error: Unable to fetch patch catalog from $API_URL" |
|
exit 1 |
|
fi |
|
|
|
# 6. Calculate upgrade plan safely by disabling 'set -e' temporarily |
|
set +e |
|
UPGRADE_PLAN=$("$PHP_BIN" -r ' |
|
$jsonFile = $argv[1]; |
|
$currentVer = $argv[2]; |
|
|
|
$data = json_decode(file_get_contents($jsonFile), true); |
|
if (!$data || !isset($data["patchSets"])) { exit(1); } |
|
|
|
$parts = explode(".", $currentVer); |
|
if (count($parts) < 2) { exit(2); } |
|
$branch = $parts[0] . "." . $parts[1]; |
|
|
|
if (!isset($data["patchSets"][$branch])) { exit(3); } |
|
|
|
$versions = $data["patchSets"][$branch]["versions"] ?? []; |
|
$versions = array_reverse($versions); |
|
|
|
$chain = []; |
|
$pointer = $currentVer; |
|
|
|
foreach ($versions as $item) { |
|
$from = trim(ltrim($item["compatibleWith"], "vV")); |
|
$to = trim(ltrim($item["version"], "vV")); |
|
$url = $item["downloadUrl"]; |
|
|
|
if ($from === $pointer) { |
|
$chain[] = "{$from}|{$to}|{$url}"; |
|
$pointer = $to; |
|
} |
|
} |
|
|
|
if (empty($chain)) { exit(4); } |
|
|
|
echo implode("\n", $chain); |
|
' "$API_JSON" "$CURRENT_VER" 2>/dev/null) |
|
|
|
PLAN_STATUS=$? |
|
set -e |
|
|
|
if [ $PLAN_STATUS -eq 3 ]; then |
|
echo "Error: Branch for version $CURRENT_VER is not present in official patchsets." |
|
exit 1 |
|
elif [ $PLAN_STATUS -eq 4 ] || [ -z "$UPGRADE_PLAN" ]; then |
|
echo "Your WHMCS is already up to date for this branch (v$CURRENT_VER). No patches needed." |
|
exit 0 |
|
elif [ $PLAN_STATUS -ne 0 ]; then |
|
echo "Error: Failed to calculate upgrade plan due to an unexpected JSON/PHP issue." |
|
exit 1 |
|
fi |
|
|
|
IFS=$'\n' read -rd '' -a PATCHES_TO_APPLY <<< "$UPGRADE_PLAN" || true |
|
|
|
TOTAL_STEPS=${#PATCHES_TO_APPLY[@]} |
|
LAST_ITEM="${PATCHES_TO_APPLY[$((TOTAL_STEPS - 1))]}" |
|
TARGET_VER=$(echo "$LAST_ITEM" | awk -F'|' '{print $2}') |
|
|
|
echo "" |
|
echo "Upgrade Chain Found:" |
|
for item in "${PATCHES_TO_APPLY[@]}"; do |
|
IFS="|" read -r from_ver to_ver _ <<< "$item" |
|
echo " -> Upgrade from v$from_ver to v$to_ver" |
|
done |
|
echo "" |
|
|
|
# 7. Backup Prompt and Execution |
|
echo "******************************************************" |
|
echo " WARNING " |
|
echo " Modifying core files and applying patches carries " |
|
echo " risks. Always maintain a full backup before updating." |
|
echo "******************************************************" |
|
read -r -p "Have you already backed up your files and database? [y/N]: " HAS_BACKUP </dev/tty |
|
|
|
DO_AUTO_BACKUP="n" |
|
if [[ ! "$HAS_BACKUP" =~ ^[yY]$ ]]; then |
|
echo "" |
|
read -r -p "Would you like this script to create a backup now before continuing? [Y/n]: " DO_AUTO_BACKUP </dev/tty |
|
DO_AUTO_BACKUP="${DO_AUTO_BACKUP:-y}" |
|
fi |
|
|
|
if [[ "$DO_AUTO_BACKUP" =~ ^[yY]$ ]]; then |
|
TIMESTAMP=$(date +"%Y%m%d_%H%M%S") |
|
BACKUP_DIR="${SCRIPT_DIR}/whmcs_backup_${TIMESTAMP}" |
|
mkdir -p "$BACKUP_DIR" |
|
|
|
echo "" |
|
echo "Starting pre-flight backup to: $BACKUP_DIR" |
|
|
|
# Backup Database |
|
echo "Dumping MySQL database ($DB_NAME)..." |
|
MYSQL_PWD="$DB_PASS" mysqldump -h "$DB_HOST" -P "$DB_PORT" -u "$DB_USER" "$DB_NAME" | gzip > "${BACKUP_DIR}/database_${DB_NAME}_${TIMESTAMP}.sql.gz" |
|
|
|
if [ ! -s "${BACKUP_DIR}/database_${DB_NAME}_${TIMESTAMP}.sql.gz" ]; then |
|
echo "Error: Database backup failed or produced an empty file." |
|
exit 1 |
|
fi |
|
|
|
# Backup Files (Excluding attachments, downloads, and cache safely) |
|
echo "Archiving WHMCS core files..." |
|
tar -czf "${BACKUP_DIR}/whmcs_files_${TIMESTAMP}.tar.gz" \ |
|
--exclude='./attachments/*' \ |
|
--exclude='./downloads/*' \ |
|
--exclude='./templates_c/*' \ |
|
-C "$WHMCS_PATH" . |
|
|
|
# Restore ownership of backup directory if root was used |
|
if [ -n "$FILE_OWNER" ]; then |
|
chown -R "$FILE_OWNER" "$BACKUP_DIR" |
|
fi |
|
|
|
echo "Backup completed successfully:" |
|
echo " - ${BACKUP_DIR}/database_${DB_NAME}_${TIMESTAMP}.sql.gz" |
|
echo " - ${BACKUP_DIR}/whmcs_files_${TIMESTAMP}.tar.gz" |
|
echo "" |
|
elif [[ ! "$HAS_BACKUP" =~ ^[yY]$ ]]; then |
|
echo "Aborted: You must have a verified backup before applying patches." |
|
exit 1 |
|
fi |
|
|
|
read -r -p "Proceed with upgrading from v$CURRENT_VER to v$TARGET_VER? [y/N]: " CONFIRM </dev/tty |
|
if [[ ! "$CONFIRM" =~ ^[yY]$ ]]; then |
|
echo "Upgrade canceled." |
|
exit 0 |
|
fi |
|
|
|
cd "$TMP_DIR" |
|
|
|
# 8. Apply patches sequentially |
|
STEP=1 |
|
for item in "${PATCHES_TO_APPLY[@]}"; do |
|
IFS="|" read -r from_ver to_ver dl_url <<< "$item" |
|
ZIP_NAME="patch_${from_ver}_to_${to_ver}.zip" |
|
|
|
echo "" |
|
echo "======================================================" |
|
echo "[$STEP/$TOTAL_STEPS] Applying patch: v$from_ver -> v$to_ver" |
|
echo "======================================================" |
|
|
|
echo "Downloading patch file..." |
|
wget -q --show-progress "$dl_url" -O "$ZIP_NAME" |
|
|
|
rm -rf whmcs |
|
echo "Extracting patch..." |
|
unzip -q -o "$ZIP_NAME" |
|
|
|
if [ ! -d "whmcs" ]; then |
|
echo "Error: Unexpected archive structure. 'whmcs' directory not found in patch." |
|
exit 1 |
|
fi |
|
|
|
# Rename admin directory cleanly |
|
if [ -d "whmcs/admin" ] && [ "$ADMIN_DIR" != "admin" ]; then |
|
echo "Renaming patch admin directory to '$ADMIN_DIR'..." |
|
rm -rf "whmcs/$ADMIN_DIR" |
|
mv whmcs/admin "whmcs/$ADMIN_DIR" |
|
fi |
|
|
|
# Set ownership on temporary directory beforehand (lightning fast) |
|
if [ -n "$FILE_OWNER" ]; then |
|
chown -R "$FILE_OWNER" whmcs/ |
|
fi |
|
|
|
# Synchronize updated files |
|
echo "Syncing files to WHMCS installation..." |
|
rsync -a --remove-source-files whmcs/ "$WHMCS_PATH/" |
|
|
|
rm -f "$ZIP_NAME" |
|
|
|
echo "" |
|
echo "[Action Required]" |
|
echo "Open the admin panel to trigger database migration for v$to_ver:" |
|
echo ">>> $ADMIN_URL <<<" |
|
echo "" |
|
read -r -p "Press [Enter] after the admin dashboard loads successfully to continue..." </dev/tty |
|
|
|
STEP=$((STEP + 1)) |
|
done |
|
|
|
echo "" |
|
echo "======================================================" |
|
echo " Upgrade Complete!" |
|
echo " WHMCS has been updated from v$CURRENT_VER to v$TARGET_VER." |
|
echo "======================================================" |