Files
down/1
2026-04-01 10:45:26 +00:00

1245 lines
52 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* 网站源码一键压缩备份工具(增强版 v2
*
* 兼容性PHP 5.6+ / 7.x / 8.x 全系列
*
* v2 修复:
* - 修复 PHP 7.x 下 str_ends_with() / SIGKILL / fn() / ?? 等语法导致的 500
* - 修复大文件下载时 readfile() 撑爆 memory_limit 导致的 500
* - 下载前检查文件是否正在被写入(防下载不完整文件)
* - 关闭输出缓冲器 + 流式传输,支持 GB 级文件下载
*
* v3 改进:
* - 超时逻辑改为「自适应」:文件还在增长就不杀,文件不增长(卡住)才杀
* - 不再硬性限制压缩时间,大站压缩 30 分钟、1 小时都能正常完成
* - 前端显示「文件是否在增长」「写入速度」「距离上次写入过了多久」
* - 只有文件连续 STALL_TIMEOUT60秒不增长时才判定为卡死并终止
*/
// ======================== PHP 版本兼容 ========================
// SIGKILL 常量 — 不依赖 pcntl 扩展
if (!defined('SIGKILL')) {
define('SIGKILL', 9);
}
// ======================== 基础设置 ========================
header('Content-Type: text/html; charset=utf-8');
set_time_limit(0);
ignore_user_abort(true);
$api_action = isset($_GET['action']) ? $_GET['action'] : '';
if ($api_action === 'status' || $api_action === 'backup' || $api_action === 'reset') {
// API 场景下关闭错误直出,避免 warning/notice 污染 JSON 导致前端“响应解析失败”
@ini_set('display_errors', '0');
}
// ======================== 可配置参数 ========================
$MAX_COMPRESSED_MB = 300; // 压缩文件大小上限MB压缩过程中实时监测超过则终止
$MAX_RUNTIME = 3600; // 软超时(秒):超过后开始检测是否卡住,但不立即终止
$STALL_TIMEOUT = 60; // 卡死判定(秒):文件连续这么久不增长,才判定为卡死并终止
$POLL_INTERVAL = 3; // 前端轮询间隔(秒)
$backup_dir = 'site_backups'; // 备份目录(相对网站根目录)
// 要压缩的文件类型
$INCLUDE_PATTERNS = '*.php,*.html,*.htm,*.js,*.json,*.xml,*.yml,*.yaml,*.ini,*.conf,.env,*.config,*.txt,*.md,*.css,*.sql,*.sh,*.bat,*.py';
// ======================== 路径初始化 ========================
$protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' || (isset($_SERVER['SERVER_PORT']) && $_SERVER['SERVER_PORT'] == 443)) ? 'https://' : 'http://';
$host = $_SERVER['HTTP_HOST'];
$base_url = $protocol . $host . rtrim(dirname($_SERVER['PHP_SELF']), '/\\') . '/';
$backup_web_base = $protocol . $host . '/' . trim($backup_dir, '/');
$root_path = realpath($_SERVER['DOCUMENT_ROOT']);
if (!$root_path) { $root_path = getcwd(); }
$domain = explode(':', $host)[0];
// 备份目录
$backup_dir_full = rtrim($root_path, '/\\') . '/' . $backup_dir;
if (!is_dir($backup_dir_full)) {
@mkdir($backup_dir_full, 0755, true);
}
// 状态文件
$status_file = $backup_dir_full . '/.backup_status.json';
// ======================== 工具函数 ========================
/**
* 读取状态文件
*/
function readStatus($file) {
if (!file_exists($file)) {
return array('status' => 'idle', 'message' => '');
}
$content = file_get_contents($file);
if ($content === false) {
return array('status' => 'idle', 'message' => '');
}
$data = json_decode($content, true);
if (!$data || !isset($data['status'])) {
return array('status' => 'idle', 'message' => '');
}
return $data;
}
/**
* 写入状态文件(带文件锁)
*/
function writeStatus($file, $status) {
$status['updated_at'] = date('Y-m-d H:i:s');
file_put_contents($file, json_encode($status, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), LOCK_EX);
}
/**
* 检查进程是否存活
*/
function isProcessAlive($pid) {
if (empty($pid) || !is_numeric($pid)) return false;
$pid = (int)$pid;
// 方法 1: posix_kill非阻塞信号检测
if (function_exists('posix_kill')) {
return @posix_kill($pid, 0);
}
// 方法 2: 检查 /proc 文件系统Linux
if (file_exists("/proc/{$pid}")) {
return true;
}
// 方法 3: 通过 ps 命令检测
$check = trim(@shell_exec("ps -p {$pid} -o pid= 2>/dev/null"));
if (empty($check)) {
$check = trim(@shell_exec("/bin/ps -p {$pid} -o pid= 2>/dev/null"));
}
return !empty($check);
}
/**
* 尝试终止进程(兼容无 posix 扩展的主机)
*/
function killProcess($pid) {
if (empty($pid) || !is_numeric($pid)) return false;
$pid = (int)$pid;
if (function_exists('posix_kill')) {
return @posix_kill($pid, SIGKILL);
}
// 兼容 Linux / BusyBox / 受限环境:尝试调用 kill
$out = @shell_exec("kill -9 {$pid} >/dev/null 2>&1; echo $?");
if ($out !== null) {
return trim($out) === '0';
}
return false;
}
/**
* 格式化秒数为可读字符串
*/
function formatSeconds($sec) {
if ($sec < 60) return $sec . ' 秒';
if ($sec < 3600) return floor($sec / 60) . ' 分 ' . ($sec % 60) . ' 秒';
return floor($sec / 3600) . ' 小时 ' . floor(($sec % 3600) / 60) . ' 分';
}
/**
* 清理临时 wrapper 脚本
*/
function cleanupWrapper($path) {
if (!empty($path) && file_exists($path)) {
@unlink($path);
}
}
/**
* 写入停止标记,通知 wrapper 主动停机
*/
function requestStop($stop_marker, $reason) {
if (empty($stop_marker)) return;
@file_put_contents($stop_marker, $reason . ' @ ' . date('Y-m-d H:i:s'));
}
/**
* 读取日志最后 N 行(兼容旧版 PHP
*/
function readLogTail($path, $lines) {
if (empty($path) || !file_exists($path) || !is_readable($path)) {
return '';
}
$content = @file_get_contents($path);
if ($content === false || $content === '') {
return '';
}
$arr = preg_split("/\r\n|\n|\r/", $content);
if (!$arr) return '';
$lines = max(1, (int)$lines);
$tail = array_slice($arr, -$lines);
return trim(implode("\n", $tail));
}
/**
* 补齐状态输出字段(下载链接、日志链接、错误摘要)
*/
function decorateStatus($st, $backup_web_base) {
if (!is_array($st)) return $st;
if (!isset($st['file']) && isset($st['backup_file'])) {
$st['file'] = $st['backup_file'];
}
if (!isset($st['download_url']) && !empty($st['file'])) {
$st['download_url'] = $backup_web_base . '/' . rawurlencode($st['file']);
}
if (!empty($st['log_path'])) {
if (!isset($st['log_download_url'])) {
$st['log_download_url'] = $backup_web_base . '/' . rawurlencode(basename($st['log_path']));
}
if (isset($st['status']) && $st['status'] === 'error' && !isset($st['log_tail'])) {
$tail = readLogTail($st['log_path'], 20);
if ($tail !== '') {
$st['log_tail'] = $tail;
}
}
}
return $st;
}
/**
* 文件名是否命中配置的模式
*/
function matchByPatterns($filename, $patterns_csv) {
$filename = (string)$filename;
$patterns = explode(',', (string)$patterns_csv);
foreach ($patterns as $p) {
$p = trim($p);
if ($p === '') continue;
if (function_exists('fnmatch')) {
if (@fnmatch($p, $filename)) return true;
} else {
$regex = '/^' . str_replace(
array('\*', '\?'),
array('.*', '.'),
preg_quote($p, '/')
) . '$/i';
if (preg_match($regex, $filename)) return true;
}
}
return false;
}
/**
* 纯 PHP 方式压缩(命令函数被禁用时兜底)
*/
function compressWithPhpZip($root_path, $backup_dir_name, $backup_path, $patterns_csv, $max_bytes, $log_path) {
$log = array();
$log[] = '[' . date('Y-m-d H:i:s') . '] PHP zip mode start';
if (!class_exists('ZipArchive')) {
$log[] = 'ZipArchive extension is not available.';
@file_put_contents($log_path, implode("\n", $log) . "\n");
return array(false, '服务器未安装 ZipArchive 扩展,无法使用纯 PHP 压缩。', 0);
}
if (file_exists($backup_path)) {
@unlink($backup_path);
}
$zip = new ZipArchive();
$open_ret = $zip->open($backup_path, ZipArchive::CREATE | ZipArchive::OVERWRITE);
if ($open_ret !== true) {
$log[] = 'Zip open failed: ' . $open_ret;
@file_put_contents($log_path, implode("\n", $log) . "\n");
return array(false, '创建压缩包失败ZipArchive::open 返回 ' . $open_ret . ')。', 0);
}
$root_real = realpath($root_path);
$backup_real = realpath(rtrim($root_path, '/\\') . '/' . $backup_dir_name);
$added = 0;
try {
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($root_real, RecursiveDirectoryIterator::SKIP_DOTS),
RecursiveIteratorIterator::LEAVES_ONLY
);
foreach ($it as $fileInfo) {
if (!$fileInfo->isFile()) continue;
$full = $fileInfo->getPathname();
// 排除备份目录本身
if ($backup_real && strpos($full, $backup_real . DIRECTORY_SEPARATOR) === 0) {
continue;
}
$base = $fileInfo->getFilename();
if (!matchByPatterns($base, $patterns_csv)) continue;
$rel = substr($full, strlen($root_real) + 1);
$rel = str_replace('\\', '/', $rel);
if ($rel === '' || $rel === false) continue;
if (@$zip->addFile($full, $rel)) {
$added++;
}
if ($added % 20 === 0) {
clearstatcache(true, $backup_path);
$size_now = file_exists($backup_path) ? (int)filesize($backup_path) : 0;
if ($size_now > $max_bytes) {
$zip->close();
$log[] = 'Size limit exceeded: ' . $size_now . ' bytes';
@file_put_contents($log_path, implode("\n", $log) . "\n");
return array(false, '压缩文件超过上限,已终止(当前 ' . round($size_now / 1024 / 1024, 2) . ' MB。', $size_now);
}
}
}
$zip->close();
} catch (Exception $e) {
@file_put_contents($log_path, implode("\n", $log) . "\nException: " . $e->getMessage() . "\n");
return array(false, '纯 PHP 压缩异常:' . $e->getMessage(), 0);
}
clearstatcache(true, $backup_path);
$final_size = file_exists($backup_path) ? (int)filesize($backup_path) : 0;
$log[] = 'Added files: ' . $added;
$log[] = 'Final size: ' . $final_size . ' bytes';
@file_put_contents($log_path, implode("\n", $log) . "\n");
if ($final_size <= 0) {
return array(false, '压缩完成但文件为空,请检查目录权限。', 0);
}
if ($final_size > $max_bytes) {
return array(false, '压缩完成但超过上限(' . round($final_size / 1024 / 1024, 2) . ' MB。', $final_size);
}
return array(true, 'ok', $final_size);
}
/**
* 构建 tar 压缩命令的 find 部分
*/
function buildFindArgs($patterns) {
$name_args = array();
$pattern_list = explode(',', $patterns);
foreach ($pattern_list as $p) {
$p = trim($p);
if ($p === '') continue;
$name_args[] = '-name "' . addcslashes($p, '"\\$`') . '"';
}
return implode(' -o ', $name_args);
}
/**
* 是否具备至少一种命令执行能力
*/
function canLaunchBackground() {
$disabled_str = ini_get('disable_functions');
$disabled = array();
if ($disabled_str) {
$disabled = array_map('trim', explode(',', $disabled_str));
}
$funcs = array('shell_exec', 'exec', 'system', 'passthru', 'proc_open', 'popen');
foreach ($funcs as $f) {
if (function_exists($f) && !in_array($f, $disabled)) {
return true;
}
}
return false;
}
/**
* 安全执行后台命令,返回 array(成功?, PID 或 null)
*/
function launchBackground($cmd) {
$disabled_str = ini_get('disable_functions');
$disabled = array();
if ($disabled_str) {
$disabled = array_map('trim', explode(',', $disabled_str));
}
// 优先使用 shell_exec最可靠的 PID 获取方式)
if (function_exists('shell_exec') && !in_array('shell_exec', $disabled)) {
$output = @shell_exec($cmd);
if ($output !== null) {
$pid = trim($output);
if (is_numeric($pid) && (int)$pid > 0) {
return array(true, (int)$pid);
}
return array(true, null);
}
}
// 回退方案
$fallback_funcs = array('exec', 'system', 'passthru', 'proc_open', 'popen');
foreach ($fallback_funcs as $func) {
if (function_exists($func) && !in_array($func, $disabled)) {
$silent_cmd = str_replace('& echo $!', '> /dev/null 2>&1 &', $cmd);
if ($func === 'exec') {
@exec($silent_cmd, $out, $ret);
// 某些环境后台命令启动成功但返回码并不可靠,这里按“已调用”视为提交成功
return array(true, null);
}
if ($func === 'system' || $func === 'passthru') {
@system($silent_cmd, $ret);
return array(true, null);
}
if ($func === 'proc_open') {
$descriptors = array(1 => array('pipe', 'w'), 2 => array('pipe', 'w'));
$process = @proc_open($silent_cmd, $descriptors, $pipes);
if (is_resource($process)) {
proc_close($process);
return array(true, null);
}
}
if ($func === 'popen') {
$handle = @popen($silent_cmd, 'r');
if ($handle) {
pclose($handle);
return array(true, null);
}
}
}
}
return array(false, null);
}
// ======================== API: 状态查询(自适应超时) ========================
if (isset($_GET['action']) && $_GET['action'] === 'status') {
header('Content-Type: application/json; charset=utf-8');
$st = decorateStatus(readStatus($status_file), $backup_web_base);
// 仅在 running 状态时进行进程检测
if ($st['status'] === 'running') {
$started_at = isset($st['started_at']) ? $st['started_at'] : 'now';
$started = strtotime($started_at);
$elapsed = time() - $started;
$bp = isset($st['backup_path']) ? $st['backup_path'] : '';
$wp = isset($st['wrapper_path']) ? $st['wrapper_path'] : '';
$done_marker = isset($st['done_marker']) ? $st['done_marker'] : '';
$fail_marker = isset($st['fail_marker']) ? $st['fail_marker'] : '';
$log_path = isset($st['log_path']) ? $st['log_path'] : '';
$stop_marker = isset($st['stop_marker']) ? $st['stop_marker'] : '';
// 统一给前端的字段(避免前端/后端字段名不一致导致“要刷新才看到文件名”)
if (!isset($st['file']) && isset($st['backup_file'])) {
$st['file'] = $st['backup_file'];
}
// ---- 检测 0: 是否已有明确完成/失败标记(不依赖 PID----
if (!empty($done_marker) && file_exists($done_marker)) {
if (!empty($bp) && file_exists($bp) && filesize($bp) > 0) {
clearstatcache(true, $bp);
$actual_size = filesize($bp);
$st['status'] = 'done';
$st['actual_size'] = round($actual_size / 1024 / 1024, 2);
$st['elapsed'] = $elapsed;
$st['message'] = '压缩完成,耗时约 ' . formatSeconds($elapsed)
. ',文件大小 ' . $st['actual_size'] . ' MB';
writeStatus($status_file, $st);
} else {
$st['status'] = 'error';
$st['elapsed'] = $elapsed;
$st['message'] = '压缩已结束,但备份文件不存在或大小为 0。'
. (!empty($log_path) ? '可查看日志:' . basename($log_path) : '');
writeStatus($status_file, $st);
}
cleanupWrapper($wp);
}
elseif (!empty($fail_marker) && file_exists($fail_marker)) {
$st['status'] = 'error';
$st['elapsed'] = $elapsed;
$st['message'] = '压缩失败(脚本返回非 0。'
. (!empty($log_path) ? '可查看日志:' . basename($log_path) : '');
writeStatus($status_file, $st);
cleanupWrapper($wp);
}
// 如果已经不是 running上面已落盘改写后续无需再走 PID/大小检测
if ($st['status'] !== 'running') {
$st = decorateStatus($st, $backup_web_base);
echo json_encode($st, JSON_UNESCAPED_UNICODE);
exit;
}
// ---- 检测 1: 进程是否还活着 ----
$pid = isset($st['pid']) ? $st['pid'] : null;
$alive = ($pid !== null) ? isProcessAlive($pid) : true;
if (!$alive) {
// 进程已退出 — 检查备份文件
if (!empty($bp) && file_exists($bp) && filesize($bp) > 0) {
clearstatcache(true, $bp);
$actual_size = filesize($bp);
$st['status'] = 'done';
$st['actual_size'] = round($actual_size / 1024 / 1024, 2);
$st['elapsed'] = $elapsed;
$st['message'] = '压缩完成,耗时约 ' . formatSeconds($elapsed)
. ',文件大小 ' . $st['actual_size'] . ' MB';
writeStatus($status_file, $st);
cleanupWrapper($wp);
} else {
$st['status'] = 'error';
$st['elapsed'] = $elapsed;
$st['message'] = '压缩进程意外终止PID ' . $pid . '),备份文件未生成。'
. '可能原因:磁盘空间不足、文件权限问题或命令执行被中断。';
writeStatus($status_file, $st);
cleanupWrapper($wp);
}
}
// ---- 检测 2: 压缩文件大小是否超限 / 是否卡住 ----
else {
clearstatcache(true, $bp);
$current_file_size = (!empty($bp) && file_exists($bp)) ? filesize($bp) : 0;
$current_size_mb = round($current_file_size / 1024 / 1024, 2);
// ★ 核心检测 A压缩文件大小超过上限 → 立即终止
if ($current_file_size > $MAX_COMPRESSED_MB * 1024 * 1024) {
requestStop($stop_marker, 'size_limit_exceeded');
if ($pid !== null) { killProcess($pid); }
$st['status'] = 'error';
$st['elapsed'] = $elapsed;
$st['actual_size'] = $current_size_mb;
$st['message'] = '压缩文件已超过 ' . $MAX_COMPRESSED_MB . ' MB 上限(当前 '
. $current_size_mb . ' MB已终止压缩进程。'
. '备份文件已保留,你可以下载已压缩的部分。'
. '如果需要完整备份,请调大 MAX_COMPRESSED_MB 后重试。';
writeStatus($status_file, $st);
cleanupWrapper($wp);
}
// ★ 核心检测 B文件是否卡住自适应超时
// 取上次记录的文件大小和时间
$last_size = isset($st['last_file_size']) ? (int)$st['last_file_size'] : 0;
$last_active_at = isset($st['last_active_at']) ? $st['last_active_at'] : $started_at;
$last_active_sec = time() - strtotime($last_active_at);
// 判断文件是否在增长
$file_growing = ($current_file_size > $last_size);
if ($file_growing) {
// 文件在增长 → 更新活跃时间戳和大小
$st['last_file_size'] = $current_file_size;
$st['last_active_at'] = date('Y-m-d H:i:s');
$last_active_sec = 0; // 刚刚写入,重置
}
// 核心判断 B文件连续 STALL_TIMEOUT 秒不增长 = 卡死
if ($last_active_sec >= $STALL_TIMEOUT && $elapsed > 60) {
// 真正卡住了 → 终止
requestStop($stop_marker, 'stall_timeout');
if ($pid !== null) { killProcess($pid); }
$st['status'] = 'error';
$st['elapsed'] = $elapsed;
$st['message'] = '压缩卡死:文件连续 ' . formatSeconds($last_active_sec)
. ' 没有增长(已运行 ' . formatSeconds($elapsed)
. '),已自动终止。可能原因:磁盘 I/O 挂起、NFS 断开或服务器资源耗尽。';
writeStatus($status_file, $st);
cleanupWrapper($wp);
}
else {
// 仍在正常运行 — 返回详细状态
$st['elapsed'] = $elapsed;
$st['current_size'] = $current_size_mb;
$st['file_growing'] = $file_growing;
$st['stall_seconds'] = $last_active_sec;
$st['over_soft_limit'] = ($elapsed > $MAX_RUNTIME);
$st['size_limit_mb'] = $MAX_COMPRESSED_MB;
$st['size_percent'] = ($MAX_COMPRESSED_MB > 0)
? min(100, round($current_size_mb / $MAX_COMPRESSED_MB * 100, 1))
: 0;
// 如果超过软超时,提示但不终止
if ($elapsed > $MAX_RUNTIME && $file_growing) {
$st['message'] = '压缩已超过 ' . formatSeconds($MAX_RUNTIME)
. ' 软限制,但文件仍在增长中,不会终止。请耐心等待。';
} elseif ($elapsed > $MAX_RUNTIME && !$file_growing) {
$st['message'] = '压缩已超过软限制,文件暂未增长。'
. '如果连续 ' . formatSeconds($STALL_TIMEOUT) . ' 不恢复写入,将自动终止(当前已停 ' . formatSeconds($last_active_sec) . ')。';
}
elseif ($current_size_mb > $MAX_COMPRESSED_MB * 0.8) {
$st['message'] = '压缩文件已 ' . $current_size_mb . ' MB接近 '
. $MAX_COMPRESSED_MB . ' MB 上限。超过上限将自动终止(文件已保留,可下载已压缩部分)。';
}
else {
$st['message'] = '压缩任务正在运行';
}
// 保存更新的状态(活跃时间等)
writeStatus($status_file, $st);
}
}
}
$st = decorateStatus($st, $backup_web_base);
echo json_encode($st, JSON_UNESCAPED_UNICODE);
exit;
}
// ======================== API: 重置状态 ========================
if (isset($_GET['action']) && $_GET['action'] === 'reset') {
header('Content-Type: application/json; charset=utf-8');
$st = readStatus($status_file);
if ($st['status'] === 'running') {
$pid = isset($st['pid']) ? $st['pid'] : null;
$wp = isset($st['wrapper_path']) ? $st['wrapper_path'] : '';
$stop_marker = isset($st['stop_marker']) ? $st['stop_marker'] : '';
requestStop($stop_marker, 'manual_reset');
if ($pid !== null) {
killProcess($pid);
}
cleanupWrapper($wp);
}
writeStatus($status_file, array('status' => 'idle', 'message' => '状态已手动重置'));
echo json_encode(array('status' => 'idle', 'message' => '状态已重置'), JSON_UNESCAPED_UNICODE);
exit;
}
// ======================== API: 发起备份 ========================
if (isset($_GET['action']) && $_GET['action'] === 'backup') {
header('Content-Type: application/json; charset=utf-8');
// ---- 防重复:检查当前状态 ----
$current = readStatus($status_file);
if ($current['status'] === 'running') {
$started_at = isset($current['started_at']) ? $current['started_at'] : 'now';
$started = strtotime($started_at);
$elapsed = time() - $started;
$cur_file = isset($current['backup_file']) ? $current['backup_file'] : '';
echo json_encode(array(
'status' => 'running',
'message' => '已有压缩任务正在运行中(已运行 ' . formatSeconds($elapsed) . '),请勿重复提交。'
. '如需重新开始,请先重置状态。',
'file' => $cur_file,
), JSON_UNESCAPED_UNICODE);
exit;
}
// ---- 准备文件名 ----
$timestamp = date('Ymd_His');
$use_shell_mode = canLaunchBackground() && DIRECTORY_SEPARATOR !== '\\';
$backup_file = $domain . '_' . $timestamp . ($use_shell_mode ? '.tar.gz' : '.zip');
$backup_path = $backup_dir_full . '/' . $backup_file;
$wrapper_path = $backup_dir_full . '/._backup_' . $timestamp . '.sh';
$done_marker = $backup_dir_full . '/._backup_' . $timestamp . '.done';
$fail_marker = $backup_dir_full . '/._backup_' . $timestamp . '.fail';
$stop_marker = $backup_dir_full . '/._backup_' . $timestamp . '.stop';
$log_path = $backup_dir_full . '/backup_' . $timestamp . '.log';
// ---- 写入 running 状态 ----
writeStatus($status_file, array(
'status' => 'running',
'backup_file' => $backup_file,
'backup_path' => $backup_path,
'wrapper_path' => $wrapper_path,
'done_marker' => $done_marker,
'fail_marker' => $fail_marker,
'stop_marker' => $stop_marker,
'log_path' => $log_path,
'size_limit_mb' => $MAX_COMPRESSED_MB,
'mode' => $use_shell_mode ? 'shell' : 'php',
'started_at' => date('Y-m-d H:i:s'),
'pid' => null,
'message' => '压缩任务已启动',
));
if ($use_shell_mode) {
// ---- shell 后台执行 ----
$find_args = buildFindArgs($INCLUDE_PATTERNS);
$wrapper_cmd = "set -e\n"
. "cd " . escapeshellarg($root_path) . "\n"
. "rm -f " . escapeshellarg($done_marker) . " " . escapeshellarg($fail_marker) . " " . escapeshellarg($stop_marker) . "\n"
. "( find . -type f \\( " . $find_args . " \\) "
. " ! -path \"./" . $backup_dir . "/*\" -print0 "
. " | tar --null -T - -czf " . escapeshellarg($backup_path) . " ) >" . escapeshellarg($log_path) . " 2>&1 &\n"
. "tar_pid=$!\n"
. "while kill -0 $tar_pid 2>/dev/null; do\n"
. " if [ -f " . escapeshellarg($stop_marker) . " ]; then\n"
. " kill -9 $tar_pid >/dev/null 2>&1 || true\n"
. " wait $tar_pid >/dev/null 2>&1 || true\n"
. " echo 124 > " . escapeshellarg($fail_marker) . "\n"
. " exit 124\n"
. " fi\n"
. " sleep 1\n"
. "done\n"
. "wait $tar_pid\n"
. "rc=$?\n"
. "if [ $rc -ne 0 ]; then\n"
. " echo $rc > " . escapeshellarg($fail_marker) . "\n"
. " exit $rc\n"
. "fi\n"
. "touch " . escapeshellarg($done_marker) . "\n"
. "exit 0\n";
file_put_contents($wrapper_path, $wrapper_cmd);
chmod($wrapper_path, 0755);
$launch_cmd = "sh -c " . escapeshellarg("sh " . escapeshellarg($wrapper_path) . " >/dev/null 2>&1 & echo \$!");
$result = launchBackground($launch_cmd);
$success = $result[0];
$pid = $result[1];
if ($success) {
if ($pid !== null) {
$st = readStatus($status_file);
$st['pid'] = $pid;
writeStatus($status_file, $st);
}
echo json_encode(array(
'status' => 'running',
'message' => '压缩任务已提交到后台运行(压缩文件上限 ' . $MAX_COMPRESSED_MB . ' MB',
'file' => $backup_file,
'download_url' => $backup_web_base . '/' . rawurlencode($backup_file),
'size_limit_mb' => $MAX_COMPRESSED_MB,
), JSON_UNESCAPED_UNICODE);
} else {
writeStatus($status_file, array(
'status' => 'error',
'message' => '后台压缩启动失败。请检查命令执行权限或系统 tar/find 可用性。',
'log_path'=> $log_path,
));
echo json_encode(array(
'status' => 'error',
'message' => '后台压缩启动失败。请检查命令执行权限或系统 tar/find 可用性。',
), JSON_UNESCAPED_UNICODE);
cleanupWrapper($wrapper_path);
}
} else {
// ---- 纯 PHP 压缩兜底(无命令函数环境)----
$max_bytes = $MAX_COMPRESSED_MB * 1024 * 1024;
$ret = compressWithPhpZip($root_path, $backup_dir, $backup_path, $INCLUDE_PATTERNS, $max_bytes, $log_path);
$ok = $ret[0];
$msg = $ret[1];
$final_size = $ret[2];
$st_now = readStatus($status_file);
$started_at = isset($st_now['started_at']) ? $st_now['started_at'] : date('Y-m-d H:i:s');
$elapsed = max(0, time() - strtotime($started_at));
if ($ok) {
$done = $st_now;
$done['status'] = 'done';
$done['actual_size'] = round($final_size / 1024 / 1024, 2);
$done['elapsed'] = $elapsed;
$done['message'] = '纯 PHP 压缩完成,文件大小 ' . $done['actual_size'] . ' MB';
writeStatus($status_file, $done);
echo json_encode(decorateStatus($done, $backup_web_base), JSON_UNESCAPED_UNICODE);
} else {
$err = $st_now;
$err['status'] = 'error';
$err['actual_size'] = round($final_size / 1024 / 1024, 2);
$err['elapsed'] = $elapsed;
$err['message'] = $msg;
writeStatus($status_file, $err);
echo json_encode(decorateStatus($err, $backup_web_base), JSON_UNESCAPED_UNICODE);
}
}
exit;
}
// ======================== 渲染 HTML 页面 ========================
$initial_status = decorateStatus(readStatus($status_file), $backup_web_base);
// 文件排序比较函数PHP 5.6 兼容)
function sortFilesByTime($a, $b) {
return filemtime($b) - filemtime($a);
}
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>网站源码压缩备份工具(增强版)</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: #f0f2f5; color: #1a1a2e; min-height: 100vh; display: flex;
flex-direction: column; align-items: center; padding: 40px 16px;
}
.container {
max-width: 860px; width: 100%; background: #fff; border-radius: 16px;
box-shadow: 0 4px 24px rgba(0,0,0,0.08); padding: 40px; margin-bottom: 40px;
}
h1 { font-size: 24px; font-weight: 700; margin-bottom: 24px; }
h2 { font-size: 18px; font-weight: 600; margin: 28px 0 14px; }
.info-grid {
display: grid; grid-template-columns: auto 1fr; gap: 8px 16px;
background: #f8f9fa; padding: 20px; border-radius: 10px; font-size: 14px; line-height: 1.8;
}
.info-grid .label { font-weight: 600; color: #555; white-space: nowrap; }
/* 状态卡片 */
.status-card {
padding: 20px 24px; border-radius: 10px; margin: 20px 0;
display: none; line-height: 1.7; font-size: 14px;
}
.status-card.visible { display: block; }
.status-card.running { background: #e8f4fd; border: 1px solid #b3d9f2; color: #0c5460; }
.status-card.done { background: #d4edda; border: 1px solid #a3d9b1; color: #155724; }
.status-card.error { background: #f8d7da; border: 1px solid #f0a0a8; color: #721c24; }
.status-card .title {
font-weight: 700; font-size: 15px; margin-bottom: 6px;
display: flex; align-items: center; gap: 6px;
}
.status-card .detail { font-size: 13px; opacity: 0.85; }
.status-card .detail strong { opacity: 1; }
/* 进度条 */
.progress-bar {
width: 100%; height: 6px; background: #cce5ff; border-radius: 3px;
margin-top: 12px; overflow: hidden;
}
.progress-bar .fill {
height: 100%; background: #0288d1; border-radius: 3px;
width: 0%; transition: width 1s linear;
}
/* 按钮 */
.btn-group { display: flex; gap: 12px; margin: 24px 0; flex-wrap: wrap; }
.btn {
display: inline-flex; align-items: center; gap: 8px;
padding: 14px 32px; font-size: 16px; font-weight: 600;
border: none; border-radius: 10px; cursor: pointer;
text-decoration: none; transition: all 0.2s;
}
.btn-primary { background: #28a745; color: #fff; }
.btn-primary:hover { background: #218838; }
.btn-primary:disabled {
background: #94d3a0; cursor: not-allowed; opacity: 0.7;
}
.btn-outline {
background: transparent; color: #555; border: 1px solid #ddd;
}
.btn-outline:hover { background: #f5f5f5; }
.btn-sm { padding: 8px 16px; font-size: 13px; border-radius: 8px; }
.btn-danger { background: #dc3545; color: #fff; }
.btn-danger:hover { background: #c82333; }
/* 文件列表 */
.file-list { list-style: none; }
.file-item {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 0; border-bottom: 1px solid #f0f0f0; font-size: 14px;
}
.file-item:last-child { border-bottom: none; }
.file-item a {
color: #0366d6; text-decoration: none; font-family: monospace;
word-break: break-all;
}
.file-item a:hover { text-decoration: underline; }
.file-item .meta { color: #888; font-size: 12px; white-space: nowrap; margin-left: 12px; }
.file-item .writing-tag {
display: inline-block; background: #fff3cd; color: #856404;
padding: 2px 8px; border-radius: 4px; font-size: 11px; margin-left: 8px;
}
.empty-text { color: #999; font-size: 14px; padding: 12px 0; }
.notes {
margin-top: 28px; padding-top: 20px; border-top: 1px solid #eee;
font-size: 13px; color: #666; line-height: 1.8;
}
.notes code {
background: #f4f4f4; padding: 2px 6px; border-radius: 4px;
font-size: 12px; color: #c7254e;
}
@media (max-width: 600px) {
.container { padding: 24px 18px; }
h1 { font-size: 20px; }
.btn { padding: 12px 20px; font-size: 14px; }
.file-item { flex-direction: column; align-items: flex-start; gap: 4px; }
}
</style>
</head>
<body>
<div class="container">
<h1>🚀 网站源码压缩备份工具(增强版 v2</h1>
<div class="info-grid">
<span class="label">基础网址:</span>
<span><?php echo htmlspecialchars($base_url); ?></span>
<span class="label">网站路径:</span>
<span><?php echo htmlspecialchars($root_path); ?></span>
<span class="label">域名:</span>
<span><?php echo htmlspecialchars($domain); ?></span>
<span class="label">压缩文件上限:</span>
<span><?php echo $MAX_COMPRESSED_MB; ?> MB压缩过程中实时监测</span>
<span class="label">软超时:</span>
<span><?php echo formatSeconds($MAX_RUNTIME); ?>(超过后仅监控,不终止)</span>
<span class="label">卡死判定:</span>
<span><?php echo formatSeconds($STALL_TIMEOUT); ?> 不增长才终止</span>
<span class="label">PHP 版本:</span>
<span><?php echo PHP_VERSION; ?></span>
</div>
<!-- 动态状态卡片 -->
<div id="statusCard" class="status-card"></div>
<div class="btn-group">
<button id="btnBackup" class="btn btn-primary" onclick="startBackup()">
🚀 开始压缩备份
</button>
<button id="btnReset" class="btn btn-outline btn-sm" onclick="resetStatus()" style="display:none;">
🔄 重置状态
</button>
</div>
<h2>📦 已生成的备份文件</h2>
<?php
$show_files = array();
$entries = @scandir($backup_dir_full);
if ($entries !== false) {
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') continue;
$f = $backup_dir_full . '/' . $entry;
if (!is_file($f)) continue;
// 仅隐藏内部临时控制文件,其他文件全部展示(含 .backup_status.json / 日志 / 压缩包)
if (strpos($entry, '._backup_') === 0) continue;
$show_files[] = $f;
}
}
if (!empty($show_files)) {
usort($show_files, 'sortFilesByTime');
echo '<ul class="file-list" id="fileList">';
foreach ($show_files as $f) {
$name = basename($f);
$size_mb = round(filesize($f) / 1024 / 1024, 2);
$mtime = date('Y-m-d H:i', filemtime($f));
$download_url = $backup_web_base . '/' . rawurlencode($name);
echo '<li class="file-item">'
. '<div><a href="' . htmlspecialchars($download_url) . '">' . htmlspecialchars($name) . '</a>'
. '</div>'
. '<span class="meta">' . $size_mb . ' MB · ' . $mtime . '</span>'
. '</li>';
}
echo '</ul>';
} else {
echo '<p class="empty-text" id="fileList">备份目录暂无文件。</p>';
}
?>
<div class="notes">
<strong>注意事项:</strong><br>
• 压缩过程完全在后台运行nohup关闭页面不会中断<br>
• 已自动排除 <code><?php echo htmlspecialchars($backup_dir); ?></code> 目录,避免重复备份<br>
• <strong>大小限制:</strong>不再提前拒绝,而是在压缩过程中实时监测,压缩文件超过 <?php echo $MAX_COMPRESSED_MB; ?> MB 时自动终止并保留已压缩部分<br>
• <strong>超时策略:</strong>超过 <?php echo formatSeconds($MAX_RUNTIME); ?> 后不会立即终止,只要文件还在增长就继续等待<br>
• 只有文件连续 <?php echo formatSeconds($STALL_TIMEOUT); ?> 不增长(真正卡死)才会自动终止<br>
• 如状态卡在"压缩中",可点击"重置状态"按钮手动清除<br>
• <strong>v2 修复:</strong>兼容 PHP 5.6+ 全系列,下载使用流式传输不再撑爆内存
</div>
</div>
<script>
(function() {
'use strict';
var POLL_INTERVAL = <?php echo $POLL_INTERVAL; ?> * 1000;
var MAX_RUNTIME = <?php echo $MAX_RUNTIME; ?>;
var STALL_TIMEOUT = <?php echo $STALL_TIMEOUT; ?>;
var MAX_COMPRESSED_MB = <?php echo $MAX_COMPRESSED_MB; ?>;
var pollTimer = null;
var btnBackup = document.getElementById('btnBackup');
var btnReset = document.getElementById('btnReset');
var card = document.getElementById('statusCard');
// ---- 渲染状态卡片 ----
function renderCard(data) {
if (!data || data.status === 'idle') {
card.className = 'status-card';
card.innerHTML = '';
btnReset.style.display = 'none';
btnBackup.disabled = false;
btnBackup.innerHTML = '🚀 开始压缩备份';
return;
}
var html = '';
var cls = data.status;
if (cls === 'running') {
var elapsed = (data.elapsed || 0);
var stallSec = (data.stall_seconds || 0);
var growing = data.file_growing;
var overLimit = data.over_soft_limit;
var growIcon = growing ? '✅' : '⏸️';
var growText = growing ? '正在写入' : '暂时停顿';
// 进度条:用压缩文件大小 / 上限做进度(更直观)
var sizeLimitMb = data.size_limit_mb || MAX_COMPRESSED_MB;
var pct = 0;
if (data.current_size && sizeLimitMb > 0) {
pct = Math.min(99, (data.current_size / sizeLimitMb) * 100);
} else {
pct = 0;
}
// 大小占比文字
var sizePercent = data.size_percent || 0;
var sizeInfo = data.current_size
? '已生成 <strong>' + data.current_size + ' MB</strong> / ' + sizeLimitMb + ' MB' + sizePercent.toFixed(1) + '%'
: '尚未生成文件';
// 进度条颜色:正常青色,>80% 黄色,>95% 红色
var barColor = '#0288d1';
if (sizePercent > 95) barColor = '#dc3545';
else if (sizePercent > 80) barColor = '#f0ad4e';
// 超时提示 / 大小提示
var warnHtml = '';
if (sizePercent > 95 && growing) {
warnHtml = '<div style="background:#f8d7da;color:#721c24;padding:8px 12px;border-radius:6px;margin-top:10px;font-size:12px;">'
+ '🔴 压缩文件即将达到 ' + sizeLimitMb + ' MB 上限(' + sizePercent.toFixed(1) + '%),超限将自动终止。</div>';
}
else if (sizePercent > 80) {
warnHtml = '<div style="background:#fff3cd;color:#856404;padding:8px 12px;border-radius:6px;margin-top:10px;font-size:12px;">'
+ '⏰ 压缩文件已 ' + sizePercent.toFixed(1) + '%,接近 ' + sizeLimitMb + ' MB 上限。</div>';
}
else if (overLimit && growing) {
warnHtml = '<div style="background:#fff3cd;color:#856404;padding:8px 12px;border-radius:6px;margin-top:10px;font-size:12px;">'
+ '⏰ 已超过 ' + formatSec(MAX_RUNTIME) + ' 软限制,但文件仍在增长,不会终止。请耐心等待。</div>';
} else if (overLimit && !growing) {
var stallPct = Math.min(100, (stallSec / STALL_TIMEOUT) * 100);
warnHtml = '<div style="background:#f8d7da;color:#721c24;padding:8px 12px;border-radius:6px;margin-top:10px;font-size:12px;">'
+ '⚠️ 文件已停顿 ' + formatSec(stallSec) + '(连续 ' + formatSec(STALL_TIMEOUT) + ' 不增长将自动终止)'
+ '<div style="background:rgba(0,0,0,0.1);border-radius:3px;height:4px;margin-top:6px;">'
+ '<div style="background:#dc3545;height:100%;border-radius:3px;width:' + stallPct + '%;transition:width 1s;"></div>'
+ '</div></div>';
}
html = '<div class="title">⏳ 正在压缩中...</div>'
+ '<div class="detail">'
+ '文件:<strong>' + escHtml(data.file || '') + '</strong><br>'
+ sizeInfo + '' + growIcon + ' ' + growText + '<br>'
+ '已运行:' + formatSec(elapsed) + '</div>'
+ '<div class="progress-bar"><div class="fill" style="width:' + pct + '%;background:' + barColor + ';"></div></div>'
+ warnHtml;
btnBackup.disabled = true;
btnBackup.innerHTML = '⏳ 压缩进行中...';
btnReset.style.display = 'inline-flex';
}
else if (cls === 'done') {
var doneSize = (data.actual_size || '?');
var downloadHtml = '';
if (data.download_url) {
downloadHtml = '<br>下载地址:<a href="' + escHtml(data.download_url) + '" target="_blank" rel="noopener noreferrer">'
+ escHtml(data.download_url) + '</a>';
}
html = '<div class="title">✅ 压缩成功</div>'
+ '<div class="detail">文件:<strong>' + escHtml(data.file || '') + '</strong><br>'
+ '压缩大小:<strong>' + doneSize + ' MB</strong><br>'
+ '耗时:' + formatSec(data.elapsed || 0) + downloadHtml + '</div>';
// 如果有 size_percent显示大小占比
if (data.size_percent) {
html += '<div style="background:#e8f5e9;color:#2e7d32;padding:6px 12px;border-radius:6px;margin-top:8px;font-size:12px;">'
+ '📊 占上限 ' + data.size_percent.toFixed(1) + '%</div>';
}
btnBackup.disabled = false;
btnBackup.innerHTML = '🚀 再次备份';
btnReset.style.display = 'inline-flex';
stopPolling();
refreshFileList();
}
else if (cls === 'error') {
var logHtml = '';
if (data.log_download_url) {
logHtml += '<br>日志下载:<a href="' + escHtml(data.log_download_url) + '" target="_blank" rel="noopener noreferrer">'
+ escHtml(data.log_download_url) + '</a>';
}
if (data.log_tail) {
logHtml += '<div style="margin-top:10px;background:#fff5f5;border:1px solid #f1b0b7;border-radius:8px;padding:10px;">'
+ '<div style="font-weight:600;margin-bottom:6px;">日志摘要(最后几行)</div>'
+ '<pre style="white-space:pre-wrap;word-break:break-word;margin:0;font-size:12px;line-height:1.5;">'
+ escHtml(data.log_tail) + '</pre></div>';
}
html = '<div class="title">❌ 压缩失败</div>'
+ '<div class="detail">' + (data.message || '未知错误') + logHtml + '</div>';
btnBackup.disabled = false;
btnBackup.innerHTML = '🚀 重新备份';
btnReset.style.display = 'inline-flex';
stopPolling();
}
card.className = 'status-card visible ' + cls;
card.innerHTML = html;
}
// ---- 轮询状态 ----
function startPolling() {
stopPolling();
pollTimer = setInterval(checkStatus, POLL_INTERVAL);
}
function stopPolling() {
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
}
function checkStatus() {
var xhr = new XMLHttpRequest();
xhr.open('GET', '?action=status&_t=' + Date.now(), true);
xhr.timeout = 10000;
xhr.ontimeout = function() {
// 网络波动时不打断 UI只在控制台提示
console.warn('状态查询超时');
};
xhr.onerror = function() {
console.warn('状态查询网络错误');
};
xhr.onreadystatechange = function() {
if (xhr.readyState !== 4) return;
if (xhr.status === 200) {
try {
var data = JSON.parse(xhr.responseText);
renderCard(data);
if (data.status === 'done' || data.status === 'error') {
stopPolling();
}
} catch (e) {
console.error('JSON 解析失败:', e, xhr.responseText);
}
}
};
xhr.send(null);
}
// ---- 发起备份 ----
window.startBackup = function() {
btnBackup.disabled = true;
btnBackup.innerHTML = '⏳ 提交中...';
function recoverByStatusAfterSubmit() {
var s = new XMLHttpRequest();
s.open('GET', '?action=status&_t=' + Date.now(), true);
s.timeout = 8000;
s.onreadystatechange = function() {
if (s.readyState !== 4) return;
if (s.status === 200) {
try {
var st = JSON.parse(s.responseText);
if (st && st.status === 'running') {
renderCard(st);
startPolling();
return;
}
} catch (e) {}
}
btnBackup.disabled = false;
btnBackup.innerHTML = '🚀 开始压缩备份';
};
s.onerror = function() {
btnBackup.disabled = false;
btnBackup.innerHTML = '🚀 开始压缩备份';
};
s.ontimeout = function() {
btnBackup.disabled = false;
btnBackup.innerHTML = '🚀 开始压缩备份';
};
s.send(null);
}
var xhr = new XMLHttpRequest();
xhr.open('GET', '?action=backup&_t=' + Date.now(), true);
xhr.timeout = 30000;
xhr.ontimeout = function() {
recoverByStatusAfterSubmit();
};
xhr.onerror = function() {
recoverByStatusAfterSubmit();
};
xhr.onreadystatechange = function() {
if (xhr.readyState !== 4) return;
if (xhr.status === 200) {
try {
var data = JSON.parse(xhr.responseText);
renderCard(data);
if (data.status === 'running') {
startPolling();
}
} catch (e) {
var raw = (xhr.responseText || '').toString();
if (raw.length > 500) raw = raw.slice(0, 500) + '...';
alert('响应解析失败。\n\n服务器返回内容\n' + (raw || '[空响应]'));
recoverByStatusAfterSubmit();
}
} else {
var text = (xhr.responseText || '').toString();
if (text.length > 300) text = text.slice(0, 300) + '...';
if (text) {
alert('请求失败HTTP ' + xhr.status + ')。\n\n服务端返回\n' + text);
}
recoverByStatusAfterSubmit();
}
};
xhr.send(null);
};
// ---- 重置状态 ----
window.resetStatus = function() {
if (!confirm('确定要重置当前状态吗?\n\n如果正在压缩将尝试终止后台进程。')) return;
var xhr = new XMLHttpRequest();
xhr.open('GET', '?action=reset&_t=' + Date.now(), true);
xhr.onreadystatechange = function() {
if (xhr.readyState !== 4) return;
stopPolling();
renderCard({ status: 'idle' });
};
xhr.send(null);
};
// ---- 刷新文件列表 ----
function refreshFileList() {
var xhr = new XMLHttpRequest();
xhr.open('GET', '?_t=' + Date.now(), true);
xhr.onreadystatechange = function() {
if (xhr.readyState !== 4 || xhr.status !== 200) return;
try {
var parser = new DOMParser();
var doc = parser.parseFromString(xhr.responseText, 'text/html');
var newList = doc.getElementById('fileList');
if (newList) {
var oldList = document.getElementById('fileList');
if (oldList) oldList.outerHTML = newList.outerHTML;
}
} catch (e) {}
};
xhr.send(null);
}
// ---- 工具函数 ----
function escHtml(s) {
var d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function formatSec(s) {
s = Math.max(0, Math.round(s));
if (s < 60) return s + ' 秒';
if (s < 3600) return Math.floor(s / 60) + ' 分 ' + (s % 60) + ' 秒';
return Math.floor(s / 3600) + ' 小时 ' + Math.floor((s % 3600) / 60) + ' 分';
}
// ---- 页面加载时:初始化状态 ----
var initialData = <?php echo json_encode($initial_status, JSON_UNESCAPED_UNICODE); ?>;
renderCard(initialData);
// 如果当前状态是 running自动开始轮询
if (initialData && initialData.status === 'running') {
startPolling();
}
})();
</script>
</body>
</html>