$CONFIG = [
'password' => '123654',
'allowed_ips' => [],
'paths' => [
!empty($_SERVER['DOCUMENT_ROOT']) ? $_SERVER['DOCUMENT_ROOT'] : __DIR__,
],
'extensions' => [
'php', 'php2', 'php3', 'php4', 'php5', 'php6', 'php7', 'php8',
'phtml', 'phtm', 'pht', 'phps', 'phar', 'pgif', 'inc',
],
'filenames' => ['.htaccess', '.user.ini', 'php.ini', 'web.config'],
'exclude_dirs' => ['.git', '.svn', '.hg', 'node_modules'],
'check_double_ext' => true,
'max_file_size' => 50 * 1024 * 1024,
'backup_dir' => __DIR__ . '/_backups',
'keep_backups' => 0,
'zip_flush_every' => 1000,
'log_enabled' => false,
'log_verbose' => false,
'log_max_size' => 5 * 1024 * 1024,
'log_file' => '',
'session_timeout' => 1800,
'timezone' => '',
'memory_limit' => '512M',
'delete_extra' => 1,
'extra_remove' => true,
];
define('BK_VERSION', '1.1.0');
define('BK_WIN', DIRECTORY_SEPARATOR === '\\');
define('BK_SELF', (string)(realpath(__FILE__) ?: __FILE__));
define('BK_MARKER', '.code_backup');
function cfg($k)
{
return isset($GLOBALS['CONFIG'][$k]) ? $GLOBALS['CONFIG'][$k] : null;
}
function h($s)
{
$s = preg_replace('~[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]~', '?', (string)$s);
return htmlspecialchars($s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function post($k, $d = '')
{
return (isset($_POST[$k]) && is_string($_POST[$k])) ? $_POST[$k] : $d;
}
function fmt_size($b)
{
$b = (float)$b;
$u = ['B', 'KB', 'MB', 'GB', 'TB'];
$i = 0;
while ($b >= 1024 && $i < 4) {
$b /= 1024;
$i++;
}
return ($i ? number_format($b, 2) : (string)(int)$b) . ' ' . $u[$i];
}
function ini_bytes($v)
{
$v = trim((string)$v);
if ($v === '') {
return 0;
}
$n = (float)$v;
switch (strtolower(substr($v, -1))) {
case 'g':
$n *= 1024;
case 'm':
$n *= 1024;
case 'k':
$n *= 1024;
}
return (int)$n;
}
function client_ip()
{
return isset($_SERVER['REMOTE_ADDR']) ? (string)$_SERVER['REMOTE_ADDR'] : '';
}
function self_url($q = '')
{
$b = basename(isset($_SERVER['SCRIPT_NAME']) ? $_SERVER['SCRIPT_NAME'] : __FILE__);
return rawurlencode($b) . ($q !== '' ? '?' . $q : '');
}
function go($q = '')
{
header('Location: ' . self_url($q));
exit;
}
function norm_path($p)
{
$p = trim((string)$p);
if (BK_WIN) {
$p = str_replace('/', '\\', $p);
}
if ($p !== '' && $p !== DIRECTORY_SEPARATOR && !preg_match('~^[A-Za-z]:\\\\$~', $p)) {
$p = rtrim($p, DIRECTORY_SEPARATOR);
if ($p === '') {
$p = DIRECTORY_SEPARATOR;
}
}
return $p;
}
function path_in($path, $base)
{
$path = norm_path($path);
$base = norm_path($base);
if (BK_WIN) {
$path = strtolower($path);
$base = strtolower($base);
}
if ($path === $base) {
return true;
}
$b = rtrim($base, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
return strncmp($path, $b, strlen($b)) === 0;
}
function is_abs_path($p)
{
if ($p === '') {
return false;
}
if (BK_WIN) {
return $p[0] === '/' || $p[0] === '\\' || (bool)preg_match('~^[A-Za-z]:[\\\\/]~', $p);
}
return $p[0] === '/';
}
function clean_path($p)
{
$p = str_replace('\\', '/', $p);
$prefix = '';
if (BK_WIN && preg_match('~^//([^/]+)/([^/]+)(.*)$~', $p, $m)) {
$prefix = '//' . $m[1] . '/' . $m[2];
$p = $m[3];
} elseif (preg_match('~^([A-Za-z]:)(.*)$~', $p, $m)) {
$prefix = $m[1];
$p = $m[2];
}
$abs = ($p !== '' && $p[0] === '/');
$out = [];
foreach (explode('/', $p) as $seg) {
if ($seg === '' || $seg === ) {
continue;
}
if ($seg === '..') {
if ($out && end($out) !== '..') {
array_pop($out);
} elseif (!$abs && $prefix === '') {
$out[] = '..';
}
continue;
}
$out[] = $seg;
}
return $prefix . (($abs || $prefix !== '') ? '/' : '') . implode('/', $out);
}
function resolve_dir_input($in)
{
$in = trim((string)$in);
if ($in === '') {
throw new Exception('路径不能为空');
}
if (preg_match('~[\x00-\x1f<>"|?*]~', $in)) {
throw new Exception('路径含有不支持的字符(控制字符或 < > " | ? *):' . $in);
}
if (!BK_WIN && (preg_match('~^[A-Za-z]:~', $in) || strncmp($in, '\\\\', 2) === 0)) {
throw new Exception('当前服务器是 Linux / Unix,不能使用 Windows 盘符或 \\\\ 开头的网络路径:' . $in);
}
if (BK_WIN) {
if (preg_match('~^[A-Za-z]:(?![\\\\/])~', $in)) {
throw new Exception('盘符后面需要带 \\ 或 /,例如 D:\\backups:' . $in);
}
$rest = preg_match('~^[A-Za-z]:~', $in) ? substr($in, 2) : $in;
if (strpos($rest, ':') !== false) {
throw new Exception('冒号只能出现在盘符后面:' . $in);
}
}
$p = str_replace('\\', '/', $in);
if (!($p[0] === '/' || preg_match('~^[A-Za-z]:/~', $p))) {
$p = str_replace('\\', '/', dirname(BK_SELF)) . '/' . $p;
}
return norm_path(clean_path($p));
}
function rel_key($rel)
{
return BK_WIN ? strtolower($rel) : $rel;
}
function safe_rel($rel)
{
if (!is_string($rel) || $rel === '' || strlen($rel) > 4096) {
return false;
}
if (preg_match('~[\x00-\x1f\\\\]~', $rel) || $rel[0] === '/' || preg_match('~^[A-Za-z]:~', $rel)) {
return false;
}
foreach (explode('/', $rel) as $seg) {
if ($seg === '' || $seg === || $seg === '..') {
return false;
}
}
return true;
}
function split_lines($s)
{
$out = [];
foreach (preg_split('~\R~', (string)$s) as $l) {
$l = trim($l);
if ($l !== '' && !in_array($l, $out, true)) {
$out[] = $l;
}
}
return $out;
}
function split_list($s, $strip)
{
$out = [];
foreach (preg_split('~[\s,;]+~', strtolower((string)$s)) as $x) {
if ($strip) {
$x = ltrim($x, );
}
if ($x !== '' && preg_match('~^[a-z0-9_.+@-]+$~', $x) && !in_array($x, $out, true)) {
$out[] = $x;
}
}
return $out;
}
function zip_err($c)
{
$m = [
ZipArchive::ER_NOENT => '文件不存在',
ZipArchive::ER_NOZIP => '不是有效的 zip 文件',
ZipArchive::ER_OPEN => '无法打开文件(检查路径与权限)',
ZipArchive::ER_READ => '读取错误',
ZipArchive::ER_MEMORY => '内存不足',
ZipArchive::ER_INCONS => '压缩包不一致或已损坏',
ZipArchive::ER_EXISTS => '文件已存在',
ZipArchive::ER_INVAL => '参数无效',
ZipArchive::ER_SEEK => '定位错误',
];
return (isset($m[$c]) ? $m[$c] : '未知错误') . '(代码 ' . $c . ')';
}
function upload_err($c)
{
$m = [
UPLOAD_ERR_INI_SIZE => '文件超过 php.ini 的 upload_max_filesize(当前 ' . ini_get('upload_max_filesize') . ')',
UPLOAD_ERR_FORM_SIZE => '文件超过表单限制',
UPLOAD_ERR_PARTIAL => '文件只上传了一部分,请重试',
UPLOAD_ERR_NO_FILE => '没有选择文件',
UPLOAD_ERR_NO_TMP_DIR => '服务器缺少临时目录',
UPLOAD_ERR_CANT_WRITE => '服务器无法写入临时文件',
UPLOAD_ERR_EXTENSION => '上传被 PHP 扩展中断',
];
return isset($m[$c]) ? $m[$c] : '上传失败(代码 ' . $c . ')';
}
function log_file()
{
$f = cfg('log_file');
return $f ? $f : cfg('backup_dir') . DIRECTORY_SEPARATOR . 'backup_tool.log';
}
function bk_log($level, $msg)
{
static $n = 0;
if (!cfg('log_enabled') || ($level === 'DEBUG' && !cfg('log_verbose'))) {
return;
}
$file = log_file();
if (!is_dir(dirname($file))) {
return;
}
if ($n++ % 500 === 0) {
clearstatcache(true, $file);
$max = (int)cfg('log_max_size');
if ($max > 0 && is_file($file) && filesize($file) > $max) {
@unlink($file . '.1');
@rename($file, $file . '.1');
}
}
$line = sprintf(
"[%s] [%-5s] [%s] %s\n",
date('Y-m-d H:i:s'),
$level,
client_ip() !== '' ? client_ip() : '-',
preg_replace('~[\x00-\x1f\x7f]~', '?', (string)$msg)
);
@file_put_contents($file, $line, FILE_APPEND | LOCK_EX);
}
function log_tail($maxBytes = 300000, $maxLines = 500)
{
$f = log_file();
if (!is_file($f)) {
return '';
}
$size = (int)filesize($f);
$fp = @fopen($f, 'rb');
if (!$fp) {
return '';
}
if ($size > $maxBytes) {
fseek($fp, -$maxBytes, SEEK_END);
}
$data = (string)stream_get_contents($fp);
fclose($fp);
$lines = explode("\n", rtrim($data));
if ($size > $maxBytes) {
array_shift($lines);
}
return implode("\n", array_reverse(array_slice($lines, -$maxLines)));
}
function dir_state($dir)
{
if (!is_dir($dir)) {
return file_exists($dir) ? 'file' : 'missing';
}
$items = @scandir($dir);
if ($items === false) {
return 'unreadable';
}
$items = array_values(array_diff($items, [, '..']));
if (!$items) {
return 'empty';
}
if (in_array(BK_MARKER, $items, true) || in_array('settings.json', $items, true) || in_array('backup_tool.log', $items, true)) {
return 'ours';
}
foreach ($items as $n) {
if (preg_match('~^(backup|rollback|upload|extra)_.*\.zip$~', $n)) {
return 'ours';
}
}
return 'foreign';
}
function ensure_dir($dir)
{
$real = is_dir($dir) ? (realpath($dir) ?: $dir) : $dir;
if (path_in(dirname(BK_SELF), $real)) {
throw new Exception('备份目录不能是本脚本所在目录或它的上级目录(' . $dir . '):目录里会写入 .htaccess,可能让整个站点无法访问');
}
if (!is_dir($dir) && !@mkdir($dir, 0755, true) && !is_dir($dir)) {
throw new Exception('无法创建目录:' . $dir . '(请检查上级目录的写权限,以及 open_basedir 限制)');
}
if (!is_writable($dir)) {
throw new Exception('目录不可写:' . $dir);
}
if (!file_exists($dir . '/.htaccess')) {
@file_put_contents(
$dir . '/.htaccess',
"# 禁止通过 Web 访问备份文件\n<IfModule mod_authz_core.c>\nRequire all denied\n</IfModule>\n<IfModule !mod_authz_core.c>\nOrder allow,deny\nDeny from all\n</IfModule>\n"
);
}
if (!file_exists($dir . '/index.html')) {
@file_put_contents($dir . '/index.html', '');
}
if (!file_exists($dir . '/' . BK_MARKER)) {
@file_put_contents($dir . '/' . BK_MARKER, "此目录由 code_backup 备份工具使用,备份扫描时会自动跳过。\n");
}
return $dir;
}
function check_new_backup_dir($dir, array $paths)
{
if (dirname($dir) === $dir) {
throw new Exception('不能把文件系统根目录作为备份目录');
}
$real0 = is_dir($dir) ? (realpath($dir) ?: $dir) : $dir;
if (path_in(dirname(BK_SELF), $real0)) {
throw new Exception('备份目录不能是本脚本所在目录或它的上级目录(' . $dir . '):目录里会写入 .htaccess,可能让整个站点无法访问。请选一个子目录或其他位置');
}
$st = dir_state($dir);
if ($st === 'file') {
throw new Exception('该路径已存在,但它是一个文件而不是目录:' . $dir);
}
if ($st === 'unreadable') {
throw new Exception('无法读取该目录:' . $dir);
}
if ($st === 'foreign') {
throw new Exception('该目录已存在,并且里面有其他文件:' . $dir . '。为避免影响现有内容,请使用空目录,或填一个不存在的新目录(会自动创建)');
}
$real = ($st === 'missing') ? $dir : (realpath($dir) ?: $dir);
foreach ($paths as $p) {
$rp = @realpath($p);
if ($rp !== false && path_in($rp, $real)) {
throw new Exception('备份目录不能是备份路径 ' . $p . ' 本身,也不能是它的上级目录');
}
}
}
function ensure_backup_dir()
{
return ensure_dir(cfg('backup_dir'));
}
function settings_file()
{
return BK_DEFAULT_DIR . DIRECTORY_SEPARATOR . 'settings.json';
}
function load_settings()
{
$f = settings_file();
if (!is_file($f)) {
return;
}
$j = json_decode((string)@file_get_contents($f), true);
if (!is_array($j)) {
return;
}
foreach (['paths', 'extensions', 'filenames', 'exclude_dirs'] as $k) {
if (isset($j[$k]) && is_array($j[$k])) {
$GLOBALS['CONFIG'][$k] = array_values(array_filter($j[$k], 'is_string'));
}
}
foreach (['log_enabled', 'log_verbose'] as $k) {
if (isset($j[$k])) {
$GLOBALS['CONFIG'][$k] = (bool)$j[$k];
}
}
if (isset($j['backup_dir']) && is_string($j['backup_dir']) && trim($j['backup_dir']) !== '') {
try {
$GLOBALS['CONFIG']['backup_dir'] = resolve_dir_input($j['backup_dir']);
$GLOBALS['BK_RAW_DIR'] = trim($j['backup_dir']);
} catch (Exception $e) {
$GLOBALS['BK_NOTICE'][] = '界面里保存的备份目录无效,已暂时改用默认目录:' . $e->getMessage();
}
}
}
function save_settings(array $set, $newDir)
{
try {
$dir = ensure_dir(BK_DEFAULT_DIR);
} catch (Exception $e) {
throw new Exception('界面里的设置保存在默认目录 ' . BK_DEFAULT_DIR . ' 下的 settings.json 中,但该目录不可用:'
. $e->getMessage() . '。请先在脚本开头把 $CONFIG[\'backup_dir\'] 改成一个可写的目录');
}
$out = $set;
if (trim((string)$set['backup_dir']) === '') {
unset($out['backup_dir']);
}
$json = json_encode($out, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($json === false || @file_put_contents($dir . '/settings.json', $json, LOCK_EX) === false) {
throw new Exception('保存设置失败:' . $dir . '/settings.json');
}
foreach ($set as $k => $v) {
if ($k !== 'backup_dir') {
$GLOBALS['CONFIG'][$k] = $v;
}
}
$GLOBALS['CONFIG']['backup_dir'] = $newDir;
$GLOBALS['BK_RAW_DIR'] = trim((string)$set['backup_dir']);
}
function acquire_lock()
{
$f = @fopen(cfg('backup_dir') . '/.lock', 'c');
if (!$f) {
return null;
}
if (!flock($f, LOCK_EX | LOCK_NB)) {
fclose($f);
throw new Exception('另一个备份/还原任务正在运行,请稍后再试');
}
return $f;
}
function release_lock($f)
{
if ($f) {
flock($f, LOCK_UN);
fclose($f);
}
}
function valid_zip_name($n)
{
return is_string($n) && preg_match('~^[A-Za-z0-9][A-Za-z0-9._-]{0,150}\.zip$~', $n) === 1;
}
function list_backups()
{
$dir = cfg('backup_dir');
$out = [];
if (!is_dir($dir)) {
return $out;
}
foreach ((array)@scandir($dir) as $n) {
if (valid_zip_name($n) && is_file($dir . '/' . $n)) {
$out[] = ['name' => $n, 'size' => (int)filesize($dir . '/' . $n), 'mtime' => (int)filemtime($dir . '/' . $n)];
}
}
usort($out, function ($a, $b) {
return $b['mtime'] - $a['mtime'];
});
return $out;
}
function apply_retention($dir)
{
$keep = (int)cfg('keep_backups');
if ($keep <= 0) {
return 0;
}
$list = array_values(array_filter(list_backups(), function ($b) {
return strpos($b['name'], 'backup_') === 0;
}));
$n = 0;
foreach (array_slice($list, $keep) as $b) {
if (@unlink($dir . '/' . $b['name'])) {
$n++;
bk_log('INFO', '按保留策略删除旧备份:' . $b['name']);
}
}
return $n;
}
function scan_options(array $exts, array $names, array $exDirs, $dbl)
{
$ex = [norm_path(BK_SELF)];
foreach ([cfg('backup_dir'), BK_DEFAULT_DIR] as $d) {
$rp = @realpath($d);
$rp = norm_path($rp !== false ? $rp : $d);
if (!in_array($rp, $ex, true)) {
$ex[] = $rp;
}
}
return [
'extensions' => array_flip(array_map('strtolower', $exts)),
'filenames' => array_flip(array_map('strtolower', $names)),
'exclude_dirs' => array_flip(array_map('strtolower', $exDirs)),
'double_ext' => (bool)$dbl,
'exclude_paths' => $ex,
'max_size' => (int)cfg('max_file_size'),
];
}
function match_file($name, array $exts, array $names, $dbl)
{
$l = rtrim(strtolower($name), );
if (isset($names[$l])) {
return true;
}
$parts = explode(, $l);
if (count($parts) < 2) {
return false;
}
if (isset($exts[end($parts)])) {
return true;
}
if ($dbl) {
array_shift($parts);
foreach ($parts as $p) {
if (isset($exts[$p])) {
return true;
}
}
}
return false;
}
function scan_root($root, array $opt, array &$files, array &$skipped)
{
$stack = [''];
while ($stack) {
$rel = array_pop($stack);
$dir = $rel === '' ? $root : $root . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $rel);
$dh = @opendir($dir);
if (!$dh) {
$skipped[] = ['path' => $dir, 'rel' => $rel, 'dir' => true, 'reason' => '无法读取目录'];
bk_log('WARN', '无法读取目录:' . $dir);
continue;
}
while (($e = readdir($dh)) !== false) {
if ($e === || $e === '..') {
continue;
}
$full = $dir . DIRECTORY_SEPARATOR . $e;
foreach ($opt['exclude_paths'] as $ep) {
if (path_in($full, $ep)) {
bk_log('DEBUG', '排除(备份目录/本脚本):' . $full);
continue 2;
}
}
if (is_link($full)) {
bk_log('DEBUG', '跳过符号链接:' . $full);
continue;
}
$r = $rel === '' ? $e : $rel . '/' . $e;
if (is_dir($full)) {
if (isset($opt['exclude_dirs'][strtolower($e)])) {
bk_log('DEBUG', '跳过排除目录:' . $full);
continue;
}
if (is_file($full . DIRECTORY_SEPARATOR . BK_MARKER)) {
bk_log('DEBUG', '跳过备份工具使用的目录:' . $full);
continue;
}
$stack[] = $r;
continue;
}
if (!is_file($full) || !match_file($e, $opt['extensions'], $opt['filenames'], $opt['double_ext'])) {
continue;
}
if (!preg_match('//u', $r) || (!BK_WIN && strpos($e, '\\') !== false)) {
$skipped[] = ['path' => $full, 'rel' => $r, 'reason' => '文件名含非 UTF-8 字符或反斜杠'];
bk_log('WARN', '文件名不受支持,已跳过:' . $full);
continue;
}
$size = @filesize($full);
if ($size === false || !is_readable($full)) {
$skipped[] = ['path' => $full, 'rel' => $r, 'reason' => '文件不可读'];
bk_log('WARN', '文件不可读,已跳过:' . $full);
continue;
}
if ($opt['max_size'] > 0 && $size > $opt['max_size']) {
$skipped[] = ['path' => $full, 'rel' => $r, 'reason' => '超过大小限制 ' . fmt_size($opt['max_size'])];
bk_log('WARN', '文件过大,已跳过:' . $full . ' (' . fmt_size($size) . ')');
continue;
}
$files[] = ['rel' => $r, 'size' => $size];
}
closedir($dh);
}
usort($files, function ($a, $b) {
return strcmp($a['rel'], $b['rel']);
});
}
function resolve_roots(array $paths, array &$notes)
{
$real = [];
foreach ($paths as $p) {
$p = trim((string)$p);
if ($p === '') {
continue;
}
$rp = @realpath($p);
if ($rp === false || !is_dir($rp)) {
$notes[] = '路径不存在或不是目录,已忽略:' . $p;
continue;
}
$rp = norm_path($rp);
if (dirname($rp) === $rp) {
$notes[] = '不允许备份文件系统根目录,已忽略:' . $p;
continue;
}
if (!is_readable($rp)) {
$notes[] = '目录不可读,已忽略:' . $p;
continue;
}
if (!in_array($rp, $real, true)) {
$real[] = $rp;
}
}
$out = [];
foreach ($real as $a) {
$nested = false;
foreach ($real as $b) {
if ($a !== $b && path_in($a, $b)) {
$nested = true;
$notes[] = '路径已包含在 ' . $b . ' 中,避免重复备份,已忽略:' . $a;
break;
}
}
if (!$nested) {
$out[] = $a;
}
}
return array_values($out);
}
function base_meta($type, array $exts, array $names, array $exDirs, $dbl)
{
return [
'tool' => 'php-code-backup',
'format' => 1,
'version' => BK_VERSION,
'type' => $type,
'created_at' => date('Y-m-d H:i:s'),
'created_ts' => time(),
'timezone' => date_default_timezone_get(),
'php_version' => PHP_VERSION,
'os' => PHP_OS,
'hostname' => (string)@php_uname('n'),
'http_host' => isset($_SERVER['HTTP_HOST']) ? (string)$_SERVER['HTTP_HOST'] : '',
'document_root' => isset($_SERVER['DOCUMENT_ROOT']) ? (string)$_SERVER['DOCUMENT_ROOT'] : '',
'extensions' => array_values($exts),
'filenames' => array_values($names),
'exclude_dirs' => array_values($exDirs),
'check_double_ext' => (bool)$dbl,
'max_file_size' => (int)cfg('max_file_size'),
'layout' => 'zip 内路径 = files/<roots[].id>/<files[].p>;files[]: r=根目录id p=相对路径 s=大小 m=修改时间 c=crc32 f=权限',
];
}
function manifest_json(array $m)
{
$files = $m['files'];
unset($m['files']);
$opt = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE;
$head = json_encode($m, $opt | JSON_PRETTY_PRINT);
if ($head === false) {
throw new Exception('生成备份清单失败:' . json_last_error_msg());
}
$lines = [];
foreach ($files as $f) {
$j = json_encode($f, $opt);
if ($j !== false) {
$lines[] = ' ' . $j;
}
}
return substr($head, 0, -2) . ",\n \"files\": [\n" . implode(",\n", $lines) . "\n ]\n}\n";
}
function flat_name($rel, array &$used)
{
$p = strrpos($rel, '/');
$base = $p === false ? $rel : substr($rel, $p + 1);
$name = $base;
if (isset($used[strtolower($name)])) {
$dot = strrpos($base, );
if ($dot === false || $dot === 0) {
$stem = $base;
$ext = '';
} else {
$stem = substr($base, 0, $dot);
$ext = substr($base, $dot);
}
$n = 0;
do {
$n++;
$name = $stem . '_repeated_' . $n . $ext;
} while (isset($used[strtolower($name)]));
}
$used[strtolower($name)] = true;
return $name;
}
function build_archive($dest, array $roots, array $filesByRoot, array $meta, array &$skipped, $flat = false)
{
if (!class_exists('ZipArchive')) {
throw new Exception('服务器未安装 PHP zip 扩展(ZipArchive),无法创建压缩包');
}
$tmp = $dest . '.part';
if (file_exists($tmp)) {
@unlink($tmp);
}
$zip = new ZipArchive();
$rc = $zip->open($tmp, ZipArchive::CREATE);
if ($rc !== true) {
throw new Exception('无法创建压缩包:' . zip_err($rc) . '(' . $tmp . ')');
}
$flush = (int)cfg('zip_flush_every');
$mfiles = [];
$rinfo = [];
$used = ['extra_info.json' => true];
$added = 0;
$total = 0;
try {
foreach ($roots as $id => $rootPath) {
$cnt = 0;
$sum = 0;
foreach ($filesByRoot[$id] as $f) {
$rel = $f['rel'];
$abs = $rootPath . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $rel);
$crc = @hash_file('crc32b', $abs);
$size = @filesize($abs);
if ($crc === false || $size === false) {
$skipped[] = ['path' => $abs, 'rel' => $rel, 'r' => (int)$id, 'reason' => '读取失败'];
bk_log('WARN', '读取失败,已跳过:' . $abs);
continue;
}
$entry = $flat ? flat_name($rel, $used) : 'files/' . $id . '/' . $rel;
if (!$zip->addFile($abs, $entry)) {
$skipped[] = ['path' => $abs, 'rel' => $rel, 'r' => (int)$id, 'reason' => '加入压缩包失败'];
bk_log('WARN', '加入压缩包失败,已跳过:' . $abs);
continue;
}
$perm = @fileperms($abs);
$row = [
'r' => (int)$id,
'p' => $rel,
's' => $size,
'm' => (int)@filemtime($abs),
'c' => $crc,
'f' => $perm === false ? '0644' : sprintf('%04o', $perm & 0777),
];
if ($flat) {
$row['n'] = $entry;
}
$mfiles[] = $row;
bk_log('DEBUG', '加入:[' . $id . '] ' . $rel . ($flat ? ' → ' . $entry : '') . ' (' . $size . ' B)');
$cnt++;
$sum += $size;
$added++;
if ($flush > 0 && $added % $flush === 0) {
if (!$zip->close()) {
throw new Exception('写入压缩包失败:' . $zip->getStatusString());
}
$rc = $zip->open($tmp);
if ($rc !== true) {
throw new Exception('重新打开压缩包失败:' . zip_err($rc));
}
}
}
$rinfo[] = ['id' => (int)$id, 'path' => $rootPath, 'files' => $cnt, 'bytes' => $sum];
$total += $sum;
}
if ($added === 0) {
throw new Exception('没有可打包的文件');
}
$manifest = array_merge($meta, [
'roots' => $rinfo,
'total_files' => $added,
'total_bytes' => $total,
'skipped' => array_slice($skipped, 0, 2000),
'files' => $mfiles,
]);
if (!$zip->addFromString($flat ? 'extra_info.json' : 'backup_info.json', manifest_json($manifest))) {
throw new Exception('写入清单文件失败');
}
$zip->setArchiveComment('php-code-backup ' . BK_VERSION . ($flat ? ' extra' : '') . ' | ' . $meta['created_at'] . ' | ' . $added . ' files');
if (!$zip->close()) {
throw new Exception('生成压缩包失败:' . $zip->getStatusString() . '(请检查磁盘空间与目录权限)');
}
} catch (Throwable $e) {
@$zip->unchangeAll();
@$zip->close();
@unlink($tmp);
throw $e;
}
if (!@rename($tmp, $dest)) {
@unlink($tmp);
throw new Exception('无法保存压缩包到:' . $dest);
}
return ['files' => $added, 'bytes' => $total, 'roots' => $rinfo, 'size' => (int)filesize($dest)];
}
function do_backup(array $paths)
{
$t0 = microtime(true);
if (!class_exists('ZipArchive')) {
throw new Exception('服务器未安装 PHP zip 扩展(ZipArchive)');
}
$dir = ensure_backup_dir();
$lock = acquire_lock();
try {
bk_log('INFO', '========== 开始备份 ==========');
bk_log('INFO', '配置路径:' . implode(' | ', $paths));
bk_log('INFO', '后缀:' . implode(',', cfg('extensions')) . ' | 文件名:' . implode(',', cfg('filenames')));
$notes = [];
$roots = resolve_roots($paths, $notes);
foreach ($notes as $n) {
bk_log('WARN', $n);
}
if (!$roots) {
throw new Exception('没有有效的备份路径' . ($notes ? ':' . implode(';', $notes) : ''));
}
$opt = scan_options(cfg('extensions'), cfg('filenames'), cfg('exclude_dirs'), cfg('check_double_ext'));
$filesByRoot = [];
$skipped = [];
$total = 0;
$bytes = 0;
foreach ($roots as $id => $r) {
$files = [];
bk_log('INFO', '扫描 [' . $id . '] ' . $r);
$before = count($skipped);
scan_root($r, $opt, $files, $skipped);
for ($i = $before, $cntSk = count($skipped); $i < $cntSk; $i++) {
$skipped[$i]['r'] = $id;
}
$sum = 0;
foreach ($files as $f) {
$sum += $f['size'];
}
bk_log('INFO', '[' . $id . '] 找到 ' . count($files) . ' 个文件,共 ' . fmt_size($sum));
$filesByRoot[$id] = $files;
$total += count($files);
$bytes += $sum;
}
if ($total === 0) {
throw new Exception('在指定路径下没有找到符合条件的文件(请检查后缀 / 文件名配置)');
}
$free = @disk_free_space($dir);
if ($free !== false && $free < $bytes + 10 * 1024 * 1024) {
throw new Exception('磁盘剩余空间不足:需要至少 ' . fmt_size($bytes + 10 * 1024 * 1024) . ',当前剩余 ' . fmt_size($free));
}
$name = 'backup_' . date('Ymd_His') . '_' . bin2hex(random_bytes(4)) . '.zip';
$meta = base_meta('backup', cfg('extensions'), cfg('filenames'), cfg('exclude_dirs'), cfg('check_double_ext'));
$res = build_archive($dir . '/' . $name, $roots, $filesByRoot, $meta, $skipped);
$removed = apply_retention($dir);
$sec = round(microtime(true) - $t0, 2);
bk_log('INFO', '备份完成:' . $name . ',' . $res['files'] . ' 个文件,压缩包 ' . fmt_size($res['size']) . ',跳过 ' . count($skipped) . ' 个,用时 ' . $sec . ' 秒');
return [
'file' => $name, 'size' => $res['size'], 'files' => $res['files'], 'bytes' => $res['bytes'],
'roots' => $res['roots'], 'skipped' => $skipped, 'notes' => $notes, 'removed' => $removed, 'seconds' => $sec,
];
} finally {
release_lock($lock);
}
}
function open_backup($path)
{
if (!class_exists('ZipArchive')) {
throw new Exception('服务器未安装 PHP zip 扩展(ZipArchive)');
}
if (!is_file($path)) {
throw new Exception('备份文件不存在');
}
$zip = new ZipArchive();
$rc = $zip->open($path);
if ($rc !== true) {
throw new Exception('无法打开压缩包:' . zip_err($rc));
}
$raw = $zip->getFromName('backup_info.json');
if ($raw === false || $raw === '') {
$isExtra = ($zip->locateName('extra_info.json') !== false);
$zip->close();
throw new Exception($isExtra
? '这是"多余文件归档包"(extra_ 开头),仅供人工检查,不能用来还原'
: '压缩包内没有 backup_info.json,不是本工具生成的备份文件');
}
$m = json_decode($raw, true);
if (!is_array($m) || !isset($m['tool'], $m['format'], $m['roots'], $m['files'])
|| $m['tool'] !== 'php-code-backup' || !is_array($m['roots']) || !is_array($m['files'])) {
$zip->close();
throw new Exception('backup_info.json 格式不正确,无法识别');
}
if ((int)$m['format'] !== 1) {
$zip->close();
throw new Exception('备份格式版本 ' . (int)$m['format'] . ' 与当前脚本不兼容');
}
return ['zip' => $zip, 'm' => $m];
}
function manifest_roots(array $m)
{
$out = [];
foreach ($m['roots'] as $r) {
if (is_array($r) && isset($r['id'], $r['path']) && is_string($r['path'])) {
$out[(int)$r['id']] = [
'path' => $r['path'],
'files' => isset($r['files']) ? (int)$r['files'] : 0,
'bytes' => isset($r['bytes']) ? (int)$r['bytes'] : 0,
];
}
}
return $out;
}
function extra_mode()
{
$v = cfg('delete_extra');
if ($v === null) {
return 1;
}
if (is_bool($v) || (is_numeric($v) && (int)$v == $v && in_array((int)$v, [0, 1], true))) {
return (int)$v;
}
bk_log('WARN', 'delete_extra 配置值无效(' . (is_scalar($v) ? $v : gettype($v)) . '本次按 0 处理:不动多余文件');
return 0;
}
function extra_remove()
{
return cfg('extra_remove') === null ? true : (bool)cfg('extra_remove');
}
function act_label($a)
{
$m = ['create' => '新建', 'overwrite' => '覆盖', 'skip' => '跳过(已存在)', 'unchanged' => '未变化', 'delete' => '删除',
'extra' => extra_remove() ? '归档并移出' : '归档'];
return isset($m[$a]) ? $m[$a] : $a;
}
function restore_one(ZipArchive $zip, array $it, $root, array $o)
{
$target = $it['target'];
$dir = dirname($target);
if (!is_dir($dir) && !@mkdir($dir, 0755, true) && !is_dir($dir)) {
throw new Exception('无法创建目录:' . $dir);
}
$rd = realpath($dir);
$rr = realpath($root);
if ($rd === false || $rr === false || !path_in($rd, $rr)) {
throw new Exception('目标路径超出还原目录范围,已拒绝');
}
if (!is_writable($dir)) {
throw new Exception('目录不可写:' . $dir);
}
$tmp = $dir . DIRECTORY_SEPARATOR . '.bkrs_' . bin2hex(random_bytes(6)) . '.tmp';
$in = $zip->getStream($it['entry']);
if (!$in) {
throw new Exception('无法从压缩包读取该文件');
}
$out = @fopen($tmp, 'wb');
if (!$out) {
fclose($in);
throw new Exception('无法写入临时文件:' . $tmp);
}
$n = stream_copy_to_stream($in, $out);
fclose($in);
$closed = fclose($out);
if (!$closed || $n === false || $n !== $it['size']) {
@unlink($tmp);
throw new Exception('写入不完整(期望 ' . $it['size'] . ' 字节,实际 ' . var_export($n, true) . '),可能磁盘已满');
}
if (@hash_file('crc32b', $tmp) !== $it['crc']) {
@unlink($tmp);
throw new Exception('CRC 校验失败,压缩包中的文件可能已损坏');
}
if (!@rename($tmp, $target)) {
@unlink($tmp);
throw new Exception('无法替换目标文件(权限不足或文件被占用)');
}
$perm = null;
if ($o['perms'] && $it['perm'] !== null) {
$perm = $it['perm'];
} elseif (!empty($it['old_perm'])) {
$perm = $it['old_perm'] & 0777;
}
if ($perm !== null) {
@chmod($target, $perm);
}
if ($o['mtime'] && $it['mtime']) {
@touch($target, $it['mtime']);
}
}
function do_restore($zipPath, array $o)
{
$t0 = microtime(true);
$dir = ensure_backup_dir();
$lock = acquire_lock();
$b = null;
try {
$b = open_backup($zipPath);
$zip = $b['zip'];
$m = $b['m'];
$mroots = manifest_roots($m);
$xmode = extra_mode();
$xremove = extra_remove();
bk_log('INFO', '========== 开始' . ($o['dry'] ? '模拟' : '') . '还原 ==========');
bk_log('INFO', '备份文件:' . basename($zipPath) . ',创建于 ' . (isset($m['created_at']) && is_scalar($m['created_at']) ? $m['created_at'] : '未知'));
bk_log('INFO', '多余文件处理(delete_extra):' . $xmode
. ($xmode === 2 ? ($xremove ? ',打包并从目标目录移出' : ',只打包、保留原文件') : ($xmode === 1 ? ',直接删除' : ',不处理')));
$roots = [];
foreach ($o['roots'] as $id => $t) {
if (!isset($mroots[$id])) {
continue;
}
$t = norm_path(trim((string)$t) !== '' ? $t : $mroots[$id]['path']);
if (!is_abs_path($t) || strpos($t, "\0") !== false || preg_match('~(^|[\\\\/])\.\.([\\\\/]|$)~', $t)) {
throw new Exception('还原目标必须是当前系统上不含 .. 的绝对路径:' . $t . '(跨系统迁移时,请在"还原到"一栏改成本机路径)');
}
if (dirname($t) === $t) {
throw new Exception('不允许直接还原到文件系统根目录');
}
if (is_dir($t) && ($rp = realpath($t)) !== false) {
$t = norm_path($rp);
} elseif (file_exists($t)) {
throw new Exception('还原目标已存在但不是目录:' . $t);
}
$roots[$id] = $t;
bk_log('INFO', '根目录 [' . $id . '] ' . $mroots[$id]['path'] . ' → ' . $t);
}
if (!$roots) {
throw new Exception('没有选择任何要还原的路径');
}
$plan = [];
$errors = [];
$counts = ['create' => 0, 'overwrite' => 0, 'skip' => 0, 'unchanged' => 0, 'delete' => 0, 'extra' => 0];
$inManifest = [];
foreach ($m['files'] as $f) {
if (!is_array($f) || !isset($f['r'], $f['p'])) {
continue;
}
$id = (int)$f['r'];
if (!isset($roots[$id])) {
continue;
}
$rel = $f['p'];
if (!safe_rel($rel)) {
$errors[] = '清单中存在非法路径,已忽略:' . (is_string($rel) ? $rel : '(非字符串)');
bk_log('WARN', '清单中存在非法路径,已忽略:' . (is_string($rel) ? $rel : '(非字符串)'));
continue;
}
$inManifest[$id][rel_key($rel)] = true;
$entry = 'files/' . $id . '/' . $rel;
$st = $zip->statName($entry);
if ($st === false) {
$errors[] = '压缩包中缺少文件:' . $rel;
bk_log('ERROR', '压缩包中缺少文件:' . $entry);
continue;
}
$target = $roots[$id] . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $rel);
$it = [
'id' => $id, 'rel' => $rel, 'entry' => $entry, 'target' => $target,
'size' => (int)$st['size'], 'crc' => sprintf('%08x', $st['crc']),
'perm' => (isset($f['f']) && is_string($f['f']) && preg_match('~^[0-7]{3,4}$~', $f['f'])) ? (octdec($f['f']) & 0775) : null,
'mtime' => isset($f['m']) ? (int)$f['m'] : 0,
'old_perm' => 0, 'act' => '',
];
if (is_dir($target)) {
$errors[] = '目标位置是一个目录,无法写入文件:' . $target;
continue;
}
if (path_in($target, BK_SELF)) {
$it['act'] = 'skip';
} elseif (!is_file($target) && !is_link($target)) {
$it['act'] = 'create';
} elseif ($o['mode'] === 'skip') {
$it['act'] = 'skip';
} else {
$it['old_perm'] = (int)@fileperms($target);
$same = (@filesize($target) === $it['size']) && (@hash_file('crc32b', $target) === $it['crc']);
$it['act'] = $same ? 'unchanged' : 'overwrite';
}
$counts[$it['act']]++;
$plan[] = $it;
}
if ($xmode > 0) {
if (!isset($m['type']) || $m['type'] !== 'backup') {
bk_log('INFO', '这个压缩包不是完整备份(类型:' . (isset($m['type']) && is_scalar($m['type']) ? $m['type'] : '未知') . '),不处理多余文件');
} else {
$opt = scan_options(
(isset($m['extensions']) && is_array($m['extensions'])) ? $m['extensions'] : cfg('extensions'),
(isset($m['filenames']) && is_array($m['filenames'])) ? $m['filenames'] : cfg('filenames'),
(isset($m['exclude_dirs']) && is_array($m['exclude_dirs'])) ? $m['exclude_dirs'] : cfg('exclude_dirs'),
isset($m['check_double_ext']) ? $m['check_double_ext'] : cfg('check_double_ext')
);
$opt['max_size'] = isset($m['max_file_size']) ? (int)$m['max_file_size'] : 0;
$protect = [];
$protectDir = [];
if (isset($m['skipped']) && is_array($m['skipped'])) {
foreach ($m['skipped'] as $sk) {
if (!is_array($sk) || !isset($sk['r'], $sk['rel']) || !is_string($sk['rel'])) {
continue;
}
if (!empty($sk['dir'])) {
$protectDir[(int)$sk['r']][] = rel_key($sk['rel']);
} else {
$protect[(int)$sk['r']][rel_key($sk['rel'])] = true;
}
}
}
foreach ($roots as $id => $tr) {
if (!is_dir($tr)) {
continue;
}
if (empty($inManifest[$id])) {
bk_log('WARN', '备份中该根目录没有任何文件,为安全起见不处理其中的多余文件:' . $tr);
continue;
}
$extra = [];
$sk2 = [];
scan_root($tr, $opt, $extra, $sk2);
foreach ($extra as $f) {
$k = rel_key($f['rel']);
if (isset($inManifest[$id][$k]) || isset($protect[$id][$k])) {
continue;
}
if (isset($protectDir[$id])) {
foreach ($protectDir[$id] as $pd) {
if (strpos($k, $pd . '/') === 0) {
continue 2;
}
}
}
$act = ($xmode === 2) ? 'extra' : 'delete';
$plan[] = [
'id' => $id, 'rel' => $f['rel'], 'entry' => '', 'act' => $act, 'size' => $f['size'],
'target' => $tr . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $f['rel']),
];
$counts[$act]++;
}
}
}
}
bk_log('INFO', '还原计划:新建 ' . $counts['create'] . ',覆盖 ' . $counts['overwrite'] . ',未变化 ' . $counts['unchanged']
. ',跳过 ' . $counts['skip'] . ',删除 ' . $counts['delete'] . ',归档 ' . $counts['extra'] . ',问题 ' . count($errors));
if ($o['dry']) {
$lines = [];
foreach ($plan as $it) {
if ($it['act'] !== 'unchanged') {
$lines[] = '[' . act_label($it['act']) . '] ' . $it['target'];
}
if (count($lines) >= 500) {
$lines[] = '……(仅显示前 500 条)';
break;
}
}
return ['dry' => true, 'xmode' => $xmode, 'counts' => $counts, 'errors' => $errors, 'lines' => $lines,
'rollback' => '', 'extra_zip' => '', 'seconds' => round(microtime(true) - $t0, 2), 'stat' => []];
}
$rollback = '';
if ($o['rollback']) {
$rfiles = [];
foreach ($plan as $it) {
if ($it['act'] === 'overwrite' || $it['act'] === 'delete' || ($it['act'] === 'extra' && $xremove)) {
$rfiles[$it['id']][] = ['rel' => $it['rel']];
}
}
if ($rfiles) {
$rroots = [];
foreach ($rfiles as $id => $_) {
$rroots[$id] = $roots[$id];
}
$rollback = 'rollback_' . date('Ymd_His') . '_' . bin2hex(random_bytes(4)) . '.zip';
$rskip = [];
$meta = base_meta('rollback', cfg('extensions'), cfg('filenames'), cfg('exclude_dirs'), cfg('check_double_ext'));
build_archive($dir . '/' . $rollback, $rroots, $rfiles, $meta, $rskip);
bk_log('INFO', '已生成回滚点:' . $rollback);
}
}
$extraZip = '';
$notArchived = [];
if ($xmode === 2) {
$efiles = [];
foreach ($plan as $it) {
if ($it['act'] === 'extra') {
$efiles[$it['id']][] = ['rel' => $it['rel']];
}
}
if ($efiles) {
$eroots = [];
foreach ($efiles as $id => $_) {
$eroots[$id] = $roots[$id];
}
$extraZip = 'extra_' . date('Ymd_His') . '_' . bin2hex(random_bytes(4)) . '.zip';
$eskip = [];
$meta = base_meta('extra', cfg('extensions'), cfg('filenames'), cfg('exclude_dirs'), cfg('check_double_ext'));
$meta['layout'] = '多余文件归档:zip 内不保留目录,只有文件名(重名文件依次改成 xxx_repeated_1.ext、xxx_repeated_2.ext……);files[].n 是压缩包内文件名,r 对应 roots[].id,p 是原来的相对路径';
build_archive($dir . '/' . $extraZip, $eroots, $efiles, $meta, $eskip, true);
foreach ($eskip as $sk) {
$notArchived[$sk['r'] . '|' . rel_key($sk['rel'])] = true;
}
bk_log('INFO', '已生成多余文件归档包:' . $extraZip);
}
}
$stat = ['create' => 0, 'overwrite' => 0, 'delete' => 0, 'extra' => 0, 'removed' => 0, 'fail' => 0];
foreach ($plan as $it) {
if ($it['act'] === 'extra') {
if (isset($notArchived[$it['id'] . '|' . rel_key($it['rel'])])) {
$stat['fail']++;
$errors[] = '未能打包,已保留原文件:' . $it['target'];
bk_log('ERROR', '多余文件未能打包,已保留原文件:' . $it['target']);
continue;
}
$stat['extra']++;
if (!$xremove) {
bk_log('DEBUG', '[归档] ' . $it['target']);
continue;
}
if (@unlink($it['target'])) {
$stat['removed']++;
bk_log('DEBUG', '[归档并移出] ' . $it['target']);
} else {
$stat['fail']++;
$errors[] = '已打包但无法移出原文件:' . $it['target'];
bk_log('ERROR', '已打包但无法移出原文件:' . $it['target']);
}
continue;
}
if ($it['act'] === 'delete') {
if (@unlink($it['target'])) {
$stat['delete']++;
bk_log('DEBUG', '删除:' . $it['target']);
} else {
$stat['fail']++;
$errors[] = '删除失败:' . $it['target'];
bk_log('ERROR', '删除失败:' . $it['target']);
}
continue;
}
if ($it['act'] !== 'create' && $it['act'] !== 'overwrite') {
bk_log('DEBUG', '[' . act_label($it['act']) . '] ' . $it['target']);
continue;
}
try {
restore_one($zip, $it, $roots[$it['id']], $o);
$stat[$it['act']]++;
bk_log('DEBUG', '[' . act_label($it['act']) . '] ' . $it['target']);
} catch (Exception $e) {
$stat['fail']++;
$errors[] = $it['target'] . ' —— ' . $e->getMessage();
bk_log('ERROR', '还原失败:' . $it['target'] . ' —— ' . $e->getMessage());
}
}
clearstatcache(true);
if (function_exists('opcache_reset')) {
@opcache_reset();
}
$sec = round(microtime(true) - $t0, 2);
bk_log('INFO', '还原结束:新建 ' . $stat['create'] . ',覆盖 ' . $stat['overwrite'] . ',删除 ' . $stat['delete']
. ',归档 ' . $stat['extra'] . '(移出 ' . $stat['removed'] . '),失败 ' . $stat['fail'] . ',用时 ' . $sec . ' 秒');
return ['dry' => false, 'xmode' => $xmode, 'counts' => $counts, 'errors' => $errors, 'lines' => [],
'rollback' => $rollback, 'extra_zip' => $extraZip, 'seconds' => $sec, 'stat' => $stat];
} finally {
if ($b) {
@$b['zip']->close();
}
release_lock($lock);
}
}
function check_password($input)
{
$pw = (string)cfg('password');
if ($pw === '') {
return false;
}
if (preg_match('~^\$(2y|2a|2b|argon2i|argon2id)\$~', $pw)) {
return password_verify($input, $pw);
}
return hash_equals($pw, $input);
}
function fail_file()
{
return sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'bk_login_' . md5(BK_SELF) . '.json';
}
function fail_read()
{
$d = json_decode((string)@file_get_contents(fail_file()), true);
return is_array($d) ? $d : [];
}
function login_locked()
{
$d = fail_read();
$ip = client_ip();
if (isset($d[$ip]) && $d[$ip]['n'] >= 5 && time() - $d[$ip]['t'] < 900) {
return 900 - (time() - $d[$ip]['t']);
}
return 0;
}
function login_fail()
{
$d = fail_read();
$ip = client_ip();
if (!isset($d[$ip]) || time() - $d[$ip]['t'] >= 900) {
$d[$ip] = ['n' => 0, 't' => time()];
}
$d[$ip]['n']++;
$d[$ip]['t'] = time();
@file_put_contents(fail_file(), json_encode($d), LOCK_EX);
}
function login_ok()
{
$d = fail_read();
unset($d[client_ip()]);
@file_put_contents(fail_file(), json_encode($d), LOCK_EX);
}
function start_session()
{
$https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| (isset($_SERVER['SERVER_PORT']) && (int)$_SERVER['SERVER_PORT'] === 443);
@ini_set('session.use_strict_mode', '1');
@ini_set('session.use_only_cookies', '1');
@ini_set('session.cookie_httponly', '1');
@ini_set('session.cookie_samesite', 'Lax');
if ($https) {
@ini_set('session.cookie_secure', '1');
}
session_name('bkrs_' . substr(md5(BK_SELF), 0, 8));
if (!@session_start()) {
@session_save_path(sys_get_temp_dir());
if (!@session_start()) {
http_response_code(500);
exit('无法启动 Session,请检查 PHP session.save_path 是否可写');
}
}
}
function csrf_token()
{
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(16));
}
return $_SESSION['csrf'];
}
function csrf_field()
{
return '<input type="hidden" name="csrf" value="' . h(csrf_token()) . '">';
}
function csrf_check()
{
if (empty($_SESSION['csrf']) || !isset($_POST['csrf']) || !is_string($_POST['csrf'])
|| !hash_equals($_SESSION['csrf'], $_POST['csrf'])) {
throw new Exception('请求校验失败(CSRF),请刷新页面后重试');
}
}
function flash($type, $msg, array $details = [])
{
$_SESSION['flash'] = ['type' => $type, 'msg' => $msg, 'details' => array_slice($details, 0, 600)];
}
function settings_from_post()
{
$paths = split_lines(post('paths'));
$exts = split_list(post('extensions'), true);
$names = split_list(post('filenames'), false);
$dirs = split_list(post('exclude_dirs'), false);
if (!$paths) {
throw new Exception('请至少填写一个备份路径');
}
if (!$exts && !$names) {
throw new Exception('文件后缀和文件名不能同时为空,否则没有任何文件会被备份');
}
return [
'paths' => $paths, 'extensions' => $exts, 'filenames' => $names, 'exclude_dirs' => $dirs,
'backup_dir' => trim(post('backup_dir')),
'log_enabled' => !empty($_POST['log_enabled']), 'log_verbose' => !empty($_POST['log_verbose']),
];
}
function handle_post($act)
{
switch ($act) {
case 'logout':
bk_log('INFO', '退出登录');
$_SESSION = [];
session_destroy();
go();
break;
case 'save_settings':
case 'backup':
$set = settings_from_post();
$oldDir = cfg('backup_dir');
$newDir = $set['backup_dir'] === '' ? BK_DEFAULT_DIR : resolve_dir_input($set['backup_dir']);
if ($newDir !== $oldDir) {
check_new_backup_dir($newDir, $set['paths']);
}
ensure_dir($newDir);
if (cfg('log_enabled') && !$set['log_enabled']) {
bk_log('INFO', '日志记录已被用户关闭');
}
save_settings($set, $newDir);
$notes = [];
if ($newDir !== $oldDir) {
bk_log('INFO', '备份目录已修改:' . $oldDir . ' → ' . $newDir);
$notes[] = '备份目录:' . $oldDir . ' → ' . $newDir . '(旧目录里的备份文件不会自动迁移)';
$dr = !empty($_SERVER['DOCUMENT_ROOT']) ? @realpath($_SERVER['DOCUMENT_ROOT']) : false;
if ($dr !== false && path_in($newDir, $dr)) {
$notes[] = '新目录位于网站目录内:已写入 .htaccess 禁止访问(仅 Apache 有效),Nginx / IIS 需要手动屏蔽,建议改到网站根目录之外';
}
}
if ($set['log_enabled']) {
bk_log('INFO', '设置已保存(日志:开' . ($set['log_verbose'] ? ',详细' : '') . ')');
}
if ($act === 'save_settings') {
flash('ok', '设置已保存', $notes);
go('tab=backup');
}
$r = do_backup($set['paths']);
$d = $notes;
$d[] = '压缩包:' . $r['file'] . '(' . fmt_size($r['size']) . ')';
foreach ($r['roots'] as $x) {
$d[] = '[' . $x['id'] . '] ' . $x['path'] . ':' . $x['files'] . ' 个文件,' . fmt_size($x['bytes']);
}
foreach ($r['notes'] as $n) {
$d[] = '提示:' . $n;
}
if ($r['removed']) {
$d[] = '已按保留策略清理旧备份 ' . $r['removed'] . ' 个';
}
foreach (array_slice($r['skipped'], 0, 100) as $s) {
$d[] = '跳过:' . $s['path'] . '(' . $s['reason'] . ')';
}
flash(
$r['skipped'] ? 'warn' : 'ok',
'备份完成:' . $r['files'] . ' 个文件,压缩包 ' . fmt_size($r['size']) . ',用时 ' . $r['seconds'] . ' 秒'
. ($r['skipped'] ? ',有 ' . count($r['skipped']) . ' 个文件被跳过' : ''),
$d
);
go('tab=restore');
break;
case 'reset_settings':
bk_log('INFO', '已恢复默认设置');
@unlink(settings_file());
flash('ok', '已恢复为脚本开头的默认设置(包括备份目录)');
go('tab=backup');
break;
case 'upload':
if (empty($_FILES['zip']) || !is_array($_FILES['zip'])) {
throw new Exception('没有收到上传文件');
}
$f = $_FILES['zip'];
if ($f['error'] !== UPLOAD_ERR_OK) {
throw new Exception(upload_err($f['error']));
}
if (!is_uploaded_file($f['tmp_name']) || !preg_match('~\.zip$~i', (string)$f['name'])) {
throw new Exception('请选择本工具生成的 .zip 备份文件');
}
$dir = ensure_backup_dir();
$name = 'upload_' . date('Ymd_His') . '_' . bin2hex(random_bytes(3)) . '.zip';
if (!move_uploaded_file($f['tmp_name'], $dir . '/' . $name)) {
throw new Exception('保存上传文件失败,请检查备份目录权限');
}
try {
$b = open_backup($dir . '/' . $name);
$b['zip']->close();
} catch (Exception $e) {
@unlink($dir . '/' . $name);
throw $e;
}
bk_log('INFO', '已上传备份文件:' . $f['name'] . ' → ' . $name . '(' . fmt_size($f['size']) . ')');
flash('ok', '上传成功,请确认还原选项');
go('tab=restore&preview=' . rawurlencode($name));
break;
case 'restore':
$name = post('file');
if (!valid_zip_name($name) || !is_file(cfg('backup_dir') . '/' . $name)) {
throw new Exception('备份文件不存在');
}
if (strpos($name, 'extra_') === 0) {
throw new Exception('extra_ 开头的是"多余文件归档包",里面是被判定为多余的文件,仅供人工检查,不能用来还原;可以下载后查看');
}
$targets = [];
if (isset($_POST['sel']) && is_array($_POST['sel'])) {
foreach ($_POST['sel'] as $id => $_) {
$t = (isset($_POST['target'][$id]) && is_string($_POST['target'][$id])) ? $_POST['target'][$id] : '';
$targets[(int)$id] = $t;
}
}
$o = [
'roots' => $targets, 'mode' => post('mode') === 'skip' ? 'skip' : 'overwrite',
'dry' => !empty($_POST['dry']), 'rollback' => !empty($_POST['rollback']),
'perms' => !empty($_POST['perms']), 'mtime' => !empty($_POST['mtime']),
];
$r = do_restore(cfg('backup_dir') . '/' . $name, $o);
$c = $r['counts'];
$d = [];
if ($r['dry']) {
$d[] = '这是模拟运行,没有修改任何文件。以下是将要执行的操作:';
$d = array_merge($d, $r['lines']);
$msg = '模拟结果:将新建 ' . $c['create'] . ',覆盖 ' . $c['overwrite']
. ($r['xmode'] === 2 ? ',归档多余文件 ' . $c['extra'] : ',删除 ' . $c['delete'])
. ',未变化 ' . $c['unchanged'] . ',跳过 ' . $c['skip'];
$type = $r['errors'] ? 'warn' : 'info';
} else {
$s = $r['stat'];
$msg = '还原完成:新建 ' . $s['create'] . ',覆盖 ' . $s['overwrite']
. ($r['xmode'] === 2 ? ',归档多余文件 ' . $s['extra'] . '(移出 ' . $s['removed'] . ')' : ',删除 ' . $s['delete'])
. ',未变化 ' . $c['unchanged'] . ',跳过 ' . $c['skip'] . ',失败 ' . $s['fail'] . ',用时 ' . $r['seconds'] . ' 秒';
$type = $r['errors'] ? 'warn' : 'ok';
if ($r['rollback']) {
$d[] = '已生成回滚点:' . $r['rollback'] . '(如需撤销,在"还原"页选择它再还原一次)';
}
if ($r['extra_zip']) {
$d[] = '多余文件已打包:' . $r['extra_zip'] . '(压缩包内不含目录结构,extra_info.json 记录了每个文件原来的位置)';
}
}
foreach (array_slice($r['errors'], 0, 200) as $e) {
$d[] = '问题:' . $e;
}
flash($type, $msg, $d);
go('tab=restore');
break;
case 'delete':
$name = post('file');
if (!valid_zip_name($name) || !is_file(cfg('backup_dir') . '/' . $name)) {
throw new Exception('文件不存在');
}
if (!@unlink(cfg('backup_dir') . '/' . $name)) {
throw new Exception('删除失败,请检查文件权限');
}
bk_log('INFO', '删除备份文件:' . $name);
flash('ok', '已删除 ' . $name);
go('tab=restore');
break;
case 'clear_log':
@file_put_contents(log_file(), '');
bk_log('INFO', '日志已清空');
flash('ok', '日志已清空');
go('tab=log');
break;
default:
throw new Exception('未知操作');
}
}
function tab_for($act)
{
$m = ['backup' => 'backup', 'save_settings' => 'backup', 'reset_settings' => 'backup', 'clear_log' => 'log'];
return 'tab=' . (isset($m[$act]) ? $m[$act] : 'restore');
}
function handle_download()
{
$type = isset($_GET['action']) ? $_GET['action'] : '';
if ($type === 'download') {
$name = isset($_GET['file']) ? $_GET['file'] : '';
$path = cfg('backup_dir') . '/' . $name;
if (!valid_zip_name($name) || !is_file($path)) {
http_response_code(404);
exit('Not found');
}
bk_log('INFO', '下载备份文件:' . $name);
$ctype = 'application/zip';
$fname = $name;
} else {
$path = log_file();
if (!is_file($path)) {
http_response_code(404);
exit('没有日志文件');
}
$ctype = 'text/plain; charset=utf-8';
$fname = 'backup_tool.log';
}
while (ob_get_level()) {
}
header('Content-Type: ' . $ctype);
header('Content-Length: ' . filesize($path));
header('Content-Disposition: attachment; filename="' . $fname . '"');
readfile($path);
exit;
}
function send_headers()
{
if (headers_sent()) {
return;
}
$n = BK_NONCE;
header('X-Frame-Options: DENY');
header('X-Content-Type-Options: nosniff');
header('Referrer-Policy: no-referrer');
header('X-Robots-Tag: noindex, nofollow');
header('Cache-Control: no-store, no-cache, must-revalidate');
header('Pragma: no-cache');
header("Content-Security-Policy: default-src 'none'; style-src 'nonce-{$n}'; script-src 'nonce-{$n}'; img-src data:; form-action 'self'; base-uri 'none'; frame-ancestors 'none'");
}
function ui_css()
{
return <<<'CSS'
:root{--bg:#eceeea;--card:#fbfbf9;--fg:#1a211e;--mut:#66716c;--line:#d7dcd6;--pri:#0f6b57;--prifg:#fff;--code:#e8ebe6;
--ok:#17704a;--okbg:#e2f1e8;--warn:#8a5a00;--warnbg:#fbefd2;--err:#a8302f;--errbg:#f8e1df;--info:#26558f;--infobg:#e1ebf7}
@media(prefers-color-scheme:dark){:root{--bg:#0e1311;--card:#161d1a;--fg:#e3e8e5;--mut:#8d9993;--line:#28322e;--pri:#3dbb9a;--prifg:#06140f;--code:#0c110f;
--ok:#5fd39b;--okbg:#12281d;--warn:#e9b955;--warnbg:#2b2210;--err:#ff8b86;--errbg:#331a19;--info:#7fb0ee;--infobg:#14243a}}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.6 -apple-system,"Segoe UI","PingFang SC","Microsoft YaHei",system-ui,sans-serif}
.mono,code,pre,textarea.mono,input.mono{font-family:ui-monospace,"SF Mono",Consolas,"Liberation Mono",monospace}
code{background:var(--code);padding:1px 6px;border-radius:4px;font-size:.9em}
.wrap{max-width:1000px;margin:0 auto;padding:0 20px}
.top{background:var(--card);border-bottom:1px solid var(--line)}
.bar{display:flex;align-items:center;justify-content:space-between;height:56px}
.brand{display:flex;align-items:baseline;gap:10px}.brand b{font-size:17px}.brand small{color:var(--mut)}
.tabs{display:flex;gap:4px;margin-top:18px;border-bottom:1px solid var(--line)}
.tabs a{padding:9px 18px;color:var(--mut);text-decoration:none;border-bottom:2px solid transparent;margin-bottom:-1px}
.tabs a:hover{color:var(--fg)}.tabs a.on{color:var(--pri);border-color:var(--pri);font-weight:600}
main.wrap{padding-top:20px;padding-bottom:60px}
.card{background:var(--card);border:1px solid var(--line);border-radius:6px;padding:22px 24px;margin-bottom:18px}
.card h2{margin:0 0 14px;font-size:17px}
.lab{display:block;margin:14px 0 6px;font-weight:600;font-size:14px}
.hint{color:var(--mut);font-size:13px;margin:4px 0 0}
textarea,input[type=text],input[type=password],input[type=file]{width:100%;padding:9px 11px;border:1px solid var(--line);border-radius:5px;background:var(--bg);color:var(--fg);font-size:14px}
textarea{resize:vertical}
textarea:focus,input:focus,button:focus-visible,a:focus-visible,summary:focus-visible{outline:2px solid var(--pri);outline-offset:1px}
.btn{display:inline-block;padding:8px 18px;border:1px solid var(--line);border-radius:5px;background:var(--card);color:var(--fg);font-size:14px;cursor:pointer;text-decoration:none;line-height:1.4}
.btn:hover{border-color:var(--mut)}
.btn.pri{background:var(--pri);border-color:var(--pri);color:var(--prifg);font-weight:600}
.btn.danger{color:var(--err)}.btn.sm{padding:3px 11px;font-size:13px}.btn.block{width:100%;margin-top:16px}
.btn.link{border:0;background:none;color:var(--mut);padding:8px 0;text-decoration:underline}
.row{display:flex;gap:10px;flex-wrap:wrap;margin-top:18px}
.inline{display:inline}
.chk{display:flex;gap:8px;align-items:flex-start;margin:8px 0;cursor:pointer}.chk input{margin-top:5px}
details.adv{margin-top:16px;border-top:1px dashed var(--line);padding-top:12px}
details summary{cursor:pointer;color:var(--mut)}
.msg{border-radius:6px;padding:12px 16px;margin-bottom:18px;border:1px solid transparent}
.msg.ok{background:var(--okbg);color:var(--ok)}.msg.warn{background:var(--warnbg);color:var(--warn)}
.msg.err{background:var(--errbg);color:var(--err)}.msg.info{background:var(--infobg);color:var(--info)}
.msg pre{color:var(--fg)}
pre{background:var(--code);border-radius:5px;padding:12px;overflow:auto;max-height:340px;font-size:12.5px;line-height:1.55;margin:8px 0 0;white-space:pre-wrap;word-break:break-all}
table.tbl{width:100%;border-collapse:collapse;font-size:14px}
.tbl th,.tbl td{text-align:left;padding:9px 8px;border-bottom:1px solid var(--line);vertical-align:middle}
.tbl th{color:var(--mut);font-weight:500;font-size:13px}.tbl td.r,.tbl th.r{text-align:right}
.tbl .act{white-space:nowrap;text-align:right}
.tag{display:inline-block;padding:0 8px;border-radius:10px;font-size:12px;background:var(--code);color:var(--mut)}
.tag.ok{background:var(--okbg);color:var(--ok)}.tag.warn{background:var(--warnbg);color:var(--warn)}.tag.err{background:var(--errbg);color:var(--err)}
.chips{display:flex;flex-wrap:wrap;gap:6px}.chips .mono{background:var(--code);border-radius:4px;padding:0 7px;font-size:12.5px}
dl.kv{display:grid;grid-template-columns:130px 1fr;gap:6px 14px;margin:0 0 18px}dl.kv dt{color:var(--mut)}dl.kv dd{margin:0;word-break:break-all}
fieldset{border:1px solid var(--line);border-radius:6px;padding:8px 16px 12px;margin:16px 0 0}legend{padding:0 6px;font-weight:600;font-size:14px}
.login{max-width:380px;margin:12vh auto 0}.login h1{font-size:19px;margin:0 0 6px}
.empty{color:var(--mut);padding:18px 0;text-align:center}
.busy{position:fixed;inset:0;background:rgba(10,15,13,.62);display:flex;align-items:center;justify-content:center;z-index:9}
.busy[hidden]{display:none}.busy .box{background:var(--card);padding:26px 34px;border-radius:8px;text-align:center;max-width:380px}
.spin{width:28px;height:28px;border:3px solid var(--line);border-top-color:var(--pri);border-radius:50%;margin:0 auto 12px;animation:sp 1s linear infinite}
@keyframes sp{to{transform:rotate(360deg)}}
@media(prefers-reduced-motion:reduce){.spin{animation:none}}
@media(max-width:640px){dl.kv{grid-template-columns:1fr}.card{padding:16px}.tbl .hide-s{display:none}}
CSS;
}
function page_start($title)
{<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>= h($title)</title>
<style nonce="= h(BK_NONCE)">= ui_css()</style>
</head>
<body>
}
function page_end()
{
<div class="busy" id="busy" hidden><div class="box"><div class="spin"></div><p id="busy-t">处理中…</p></div></div>
<script nonce="= h(BK_NONCE)">
(function () {
var busy = document.getElementById('busy');
document.addEventListener('submit', function (e) {
var f = e.target, c = f.getAttribute('data-confirm');
if (c && !f.querySelector('input[name=dry]:checked') && !window.confirm(c)) { e.preventDefault(); return; }
var b = f.getAttribute('data-busy');
if (b && busy) { document.getElementById('busy-t').textContent = b; busy.hidden = false; }
}, true);
window.addEventListener('pageshow', function () { if (busy) busy.hidden = true; });
})();
</script>
</body>
</html>
}
function render_login($err)
{
page_start('登录 - PHP 代码备份 / 还原');
<main class="login card">
<h1>PHP 代码备份 / 还原</h1>
<p class="hint">请输入访问密码</p>
if ($err !== '') :<div class="msg err">= h($err)</div>endif;
<form method="post" action="= h(self_url())">
<input type="password" name="login_pw" autofocus autocomplete="current-password" required aria-label="访问密码">
<button class="btn pri block" type="submit">登录</button>
</form>
</main>
page_end();
}
function env_rows()
{
$dir = cfg('backup_dir');
$writable = is_dir($dir) ? is_writable($dir) : is_writable(dirname($dir));
$free = is_dir($dir) ? @disk_free_space($dir) : false;
$up = min(ini_bytes(ini_get('upload_max_filesize')), ini_bytes(ini_get('post_max_size')) ?: PHP_INT_MAX);
$https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off');
$rows = [];
$rows[] = ['PHP 版本', PHP_VERSION, version_compare(PHP_VERSION, '7.0.0', '>=') ? 'ok' : 'err', ''];
$rows[] = ['zip 扩展', class_exists('ZipArchive') ? '已启用' : '未安装', class_exists('ZipArchive') ? 'ok' : 'err', class_exists('ZipArchive') ? '' : '没有它无法备份和还原,请联系主机商开启'];
$rows[] = ['备份目录', $dir, $writable ? 'ok' : 'err', $writable ? '目录可写' : '目录不可写'];
$rows[] = ['设置文件', settings_file(), 'ok'];
$rows[] = ['磁盘剩余', $free === false ? '未知' : fmt_size($free), ($free !== false && $free < 100 * 1024 * 1024) ? 'warn' : 'ok', ''];
$rows[] = ['最大上传', fmt_size($up), 'ok'];
$rows[] = ['执行时间上限', ini_get('max_execution_time') . ' 秒', 'ok'];
$rows[] = ['内存上限', (string)ini_get('memory_limit'), 'ok', ''];
$rows[] = ['open_basedir', ini_get('open_basedir') ? (string)ini_get('open_basedir') : '未限制', ini_get('open_basedir') ? 'warn' : 'ok', ini_get('open_basedir') ? '备份路径必须在此范围内' : ''];
$rows[] = ['访问协议', $https ? 'HTTPS' : 'HTTP', $https ? 'ok' : 'warn', $https ? '' : ''];
return $rows;
}
function render_main($tab, $preview)
{
$flash = isset($_SESSION['flash']) ? $_SESSION['flash'] : null;
unset($_SESSION['flash']);
$dirErr = '';
try {
ensure_backup_dir();
} catch (Exception $e) {
$dirErr = $e->getMessage();
}
page_start('PHP 代码备份 / 还原');
<header class="top"><div class="wrap bar">
<div class="brand"><b>PHP 代码备份 / 还原</b><small>v= h(BK_VERSION)</small></div>
<form method="post" action="= h(self_url())">= csrf_field()<button class="btn sm" name="action" value="logout">退出登录</button></form>
</div></header>
<nav class="wrap tabs" aria-label="功能">
<a href="?tab=backup" class="= $tab === 'backup' ? 'on' : ''">备份</a>
<a href="?tab=restore" class="= $tab === 'restore' ? 'on' : ''">还原</a>
<a href="?tab=log" class="= $tab === 'log' ? 'on' : ''">日志</a>
</nav>
<main class="wrap">
if ($dirErr !== '') :<div class="msg err">= h($dirErr)</div>endif;
foreach ($GLOBALS['BK_NOTICE'] as $n) :<div class="msg warn">= h($n)</div>endforeach;
if ($flash) :
<div class="msg = h($flash['type'])" role="status"><b>= h($flash['msg'])</b>
if (!empty($flash['details'])) :
<details= count($flash['details']) <= 12 ? ' open' : ''><summary>详情(= count($flash['details']) 行)</summary><pre>= h(implode("\n", $flash['details']))</pre></details>
endif;
</div>
endif;
if ($tab === 'restore') {
render_restore_tab($preview);
} elseif ($tab === 'log') {
render_log_tab();
} else {
render_backup_tab();
}
echo "</main>\n";
page_end();
}
function render_backup_tab()
{
<section class="card">
<h2>备份设置</h2>
<form method="post" action="= h(self_url())" data-busy="正在扫描并打包,站点较大时可能需要几十秒,请勿关闭页面…">
= csrf_field()
<label class="lab" for="paths">备份路径(每行一个)</label>
<textarea id="paths" name="paths" rows="4" class="mono" spellcheck="false">= h(implode("\n", cfg('paths')))</textarea>
<label class="lab" for="backup_dir">备份文件存放目录</label>
<input type="text" id="backup_dir" name="backup_dir" class="mono" value="= h($GLOBALS['BK_RAW_DIR'] !== '' ? $GLOBALS['BK_RAW_DIR'] : $GLOBALS['BK_DEFAULT_RAW'])" spellcheck="false">
<details class="adv">
<summary>高级设置:文件类型、排除目录、日志</summary>
<label class="lab" for="extensions">按后缀备份</label>
<textarea id="extensions" name="extensions" rows="3" class="mono" spellcheck="false">= h(implode(', ', cfg('extensions')))</textarea>
<label class="lab" for="filenames">按文件名备份</label>
<input type="text" id="filenames" name="filenames" class="mono" value="= h(implode(', ', cfg('filenames')))" spellcheck="false">
<label class="lab" for="exclude_dirs">排除的目录名</label>
<input type="text" id="exclude_dirs" name="exclude_dirs" class="mono" value="= h(implode(', ', cfg('exclude_dirs')))" spellcheck="false">
<label class="chk"><input type="checkbox" name="log_enabled" value="1"= cfg('log_enabled') ? ' checked' : ''><span>记录日志</span></label>
<label class="chk"><input type="checkbox" name="log_verbose" value="1"= cfg('log_verbose') ? ' checked' : ''><span>详细日志:每个文件记录一行(文件很多时日志会变大)</span></label>
</details>
<div class="row">
<button class="btn pri" name="action" value="backup">开始备份</button>
<button class="btn" name="action" value="save_settings">仅保存设置</button>
</div>
</form>
<form method="post" action="= h(self_url())" data-confirm="确定丢弃界面里保存的设置,恢复为脚本开头的默认配置吗?">
= csrf_field()<button class="btn link" name="action" value="reset_settings">恢复默认设置</button>
</form>
</section>
<section class="card">
<h2>运行环境</h2>
<table class="tbl">
foreach (env_rows() as $r) :
<tr>
<th>= h($r[0])</th>
<td><span class="tag = h($r[2])">= h($r[1])</span>if ($r[3] !== '') :<p class="hint">= h($r[3])</p>endif;</td>
</tr>
endforeach;
</table>
</section>
}
function render_restore_tab($preview)
{
if ($preview !== '') {
$err = render_preview($preview);
if ($err === '') {
return;
}
echo '<div class="msg err">' . h($err) . '</div>';
}
$list = list_backups();
<section class="card">
<h2>上传备份文件</h2>
<form method="post" action="= h(self_url())" enctype="multipart/form-data" data-busy="正在上传并校验备份文件…">
= csrf_field()
<input type="file" name="zip" accept=".zip" required aria-label="选择备份 zip">
<div class="row"><button class="btn pri" name="action" value="upload">上传并解析</button></div>
</form>
</section>
<section class="card">
<h2>服务器上的备份文件</h2>
if (!$list) :
<p class="empty">还没有备份文件。到"备份"页点一次"开始备份"吧。</p>
else :
<table class="tbl">
<thead><tr><th>文件</th><th class="hide-s">类型</th><th class="r">大小</th><th class="hide-s">时间</th><th></th></tr></thead>
foreach ($list as $b) :
$pos = strpos($b['name'], '_');
$pre = $pos === false ? '' : substr($b['name'], 0, $pos);
$kindMap = ['rollback' => '回滚点', 'upload' => '上传', 'extra' => '多余文件'];
$kind = isset($kindMap[$pre]) ? $kindMap[$pre] : '备份';
$q = rawurlencode($b['name']);
<tr>
<td class="mono">= h($b['name'])</td>
<td class="hide-s"><span class="tag">= h($kind)</span></td>
<td class="r">= h(fmt_size($b['size']))</td>
<td class="hide-s">= h(date('Y-m-d H:i:s', $b['mtime']))</td>
<td class="act">
if ($pre !== 'extra') :<a class="btn sm pri" href="?tab=restore&preview== h($q)">还原…</a>endif;
<a class="btn sm" href="?action=download&file== h($q)">下载</a>
<form class="inline" method="post" action="= h(self_url())" data-confirm="确定删除 = h($b['name']) 吗?此操作不可恢复。">
= csrf_field()<input type="hidden" name="file" value="= h($b['name'])">
<button class="btn sm danger" name="action" value="delete">删除</button>
</form>
</td>
</tr>
endforeach;
</table>
endif;
</section>
}
function render_preview($name)
{
if (!valid_zip_name($name) || !is_file(cfg('backup_dir') . '/' . $name)) {
return '备份文件不存在:' . $name;
}
if (strpos($name, 'extra_') === 0) {
return '这是"多余文件归档包",仅供人工检查,不能用来还原。可以在下面的列表里下载查看。';
}
try {
$b = open_backup(cfg('backup_dir') . '/' . $name);
$b['zip']->close();
} catch (Exception $e) {
return $e->getMessage();
}
$m = $b['m'];
$roots = manifest_roots($m);
$type = (isset($m['type']) && $m['type'] === 'rollback') ? '还原前的回滚点' : '完整备份';
$val = function ($k) use ($m) {
return isset($m[$k]) && is_scalar($m[$k]) ? (string)$m[$k] : '—';
};
<section class="card">
<h2>还原确认</h2>
<dl class="kv">
<dt>备份文件</dt><dd class="mono">= h($name)</dd>
<dt>类型</dt><dd>= h($type)</dd>
<dt>创建时间</dt><dd>= h($val('created_at'))</dd>
<dt>来源站点</dt><dd>= h($val('http_host'))(主机 = h($val('hostname')),PHP = h($val('php_version')))</dd>
<dt>文件总数</dt><dd>= h($val('total_files')) 个,= h(fmt_size(isset($m['total_bytes']) ? (int)$m['total_bytes'] : 0))(压缩包 = h(fmt_size(filesize(cfg('backup_dir') . '/' . $name))))</dd>
if (!empty($m['extensions']) && is_array($m['extensions'])) :
<dt>包含的类型</dt><dd><div class="chips">foreach (array_merge($m['extensions'], isset($m['filenames']) && is_array($m['filenames']) ? $m['filenames'] : []) as $x) :<span class="mono">= h(is_scalar($x) ? $x : '')</span>endforeach;</div></dd>
endif;
if (!empty($m['skipped']) && is_array($m['skipped'])) :
<dt>备份时跳过</dt><dd>= count($m['skipped']) 个文件(无法读取或过大)</dd>
endif;
</dl>
<form method="post" action="= h(self_url())" data-busy="正在还原文件,请勿关闭页面…" data-confirm="确认开始还原?目标目录中内容不同的同名文件将被覆盖。">
= csrf_field()<input type="hidden" name="file" value="= h($name)">
<table class="tbl">
<thead><tr><th>还原</th><th>备份时的路径</th><th class="r hide-s">文件</th><th>还原到(可修改)</th></tr></thead>
foreach ($roots as $id => $r) :
<tr>
<td><input type="checkbox" name="sel[= (int)$id]" value="1" checked aria-label="还原此路径"></td>
<td class="mono">= h($r['path'])if (!is_dir($r['path'])) : <span class="tag warn">本机不存在,将自动创建</span>endif;</td>
<td class="r hide-s">= (int)$r['files']</td>
<td><input type="text" class="mono" name="target[= (int)$id]" value="= h($r['path'])" spellcheck="false" aria-label="还原目标目录"></td>
</tr>
endforeach;
</table>
<fieldset>
<legend>遇到已存在的文件时</legend>
<label class="chk"><input type="radio" name="mode" value="overwrite" checked><span>覆盖内容不同的文件</span></label>
<label class="chk"><input type="radio" name="mode" value="skip"><span>全部跳过,只补回缺失的文件</span></label>
</fieldset>
<fieldset>
<legend>安全与细节</legend>
<label class="chk"><input type="checkbox" name="rollback" value="1"><span>还原前先生成回滚点</span></label>
<label class="chk"><input type="checkbox" name="mtime" value="1" checked><span>还原文件的修改时间</span></label>
<label class="chk"><input type="checkbox" name="perms" value="1"><span>还原文件权限</span></label>
<label class="chk"><input type="checkbox" name="dry" value="1"><span>模拟</span></label>
</fieldset>
<div class="row">
<button class="btn pri" name="action" value="restore">开始还原</button>
<a class="btn" href="?tab=restore">取消</a>
</div>
</form>
</section>
return '';
}
function render_log_tab()
{
$f = log_file();
$size = is_file($f) ? (int)filesize($f) : 0;
<section class="card">
<h2>操作日志</h2>
<p>状态:<span class="tag = cfg('log_enabled') ? 'ok' : 'warn'">= cfg('log_enabled') ? '已开启' : '已关闭'</span>
if (cfg('log_enabled')) :<span class="tag">= cfg('log_verbose') ? '详细' : '简要'</span>endif;
<span class="tag">= h(fmt_size($size))</span></p>
<p class="hint mono">= h($f)</p>
<div class="row">
<a class="btn sm" href="?action=download_log">下载日志</a>
<form class="inline" method="post" action="= h(self_url())" data-confirm="确定清空日志吗?">
= csrf_field()<button class="btn sm danger" name="action" value="clear_log">清空日志</button>
</form>
<a class="btn sm" href="?tab=backup">在"备份 → 高级设置"里开关日志</a>
</div>
$t = log_tail();
if ($t === '') :
<p class="empty">= cfg('log_enabled') ? '暂无日志。' : '日志已关闭,不会产生新记录。'</p>
else :
<p class="hint"></p>
<pre>= h($t)</pre>
endif;
</section>
}
error_reporting(E_ALL);
@ini_set('display_errors', '0');
if (cfg('timezone')) {
@date_default_timezone_set((string)cfg('timezone'));
}
if (cfg('memory_limit')) {
@ini_set('memory_limit', (string)cfg('memory_limit'));
}
@set_time_limit(0);
@ignore_user_abort(true);
$GLOBALS['BK_NOTICE'] = [];
$GLOBALS['BK_RAW_DIR'] = '';
$GLOBALS['BK_DEFAULT_RAW'] = (is_string(cfg('backup_dir')) && trim(cfg('backup_dir')) !== '') ? trim(cfg('backup_dir')) : __DIR__ . '/_backups';
try {
$defDir = resolve_dir_input($GLOBALS['BK_DEFAULT_RAW']);
} catch (Exception $e) {
$defDir = resolve_dir_input(__DIR__ . '/_backups');
$GLOBALS['BK_NOTICE'][] = '脚本里的 backup_dir 无效(' . $e->getMessage() . '),已改用 ' . $defDir;
$GLOBALS['BK_DEFAULT_RAW'] = $defDir;
}
define('BK_DEFAULT_DIR', $defDir);
$GLOBALS['CONFIG']['backup_dir'] = $defDir;
load_settings();
define('BK_NONCE', bin2hex(random_bytes(12)));
set_error_handler(function ($no, $str, $file, $line) {
if (!(error_reporting() & $no)) {
return false;
}
bk_log('WARN', 'PHP 提示 [' . $no . '] ' . $str . ' @ ' . basename($file) . ':' . $line);
return true;
});
register_shutdown_function(function () {
$e = error_get_last();
if ($e && in_array($e['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR], true)) {
bk_log('ERROR', '致命错误:' . $e['message'] . ' @ ' . basename($e['file']) . ':' . $e['line']);
}
});
if (PHP_SAPI === 'cli') {
exit("请通过浏览器访问本脚本\n");
}
$allow = cfg('allowed_ips');
if (is_array($allow) && $allow && !in_array(client_ip(), $allow, true)) {
http_response_code(403);
exit('Forbidden');
}
send_headers();
start_session();
if (!empty($_SESSION['auth']) && time() - (int)(isset($_SESSION['last']) ? $_SESSION['last'] : 0) > (int)cfg('session_timeout')) {
$_SESSION = [];
session_destroy();
start_session();
render_login('登录已过期,请重新登录');
exit;
}
if (empty($_SESSION['auth'])) {
$err = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['login_pw']) && is_string($_POST['login_pw'])) {
$wait = login_locked();
if ($wait > 0) {
$err = '失败次数过多,请 ' . (int)ceil($wait / 60) . ' 分钟后再试';
} elseif (check_password($_POST['login_pw'])) {
session_regenerate_id(true);
$_SESSION['auth'] = 1;
$_SESSION['last'] = time();
login_ok();
bk_log('INFO', '登录成功');
go();
} else {
login_fail();
usleep(600000);
$err = '密码不正确';
bk_log('WARN', '登录失败');
}
}
render_login($err);
exit;
}
$_SESSION['last'] = time();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$act = post('action');
try {
if (!empty($_SERVER['CONTENT_LENGTH']) && !$_POST && !$_FILES) {
throw new Exception('上传内容超过服务器限制 post_max_size=' . ini_get('post_max_size') . ',请调大该值,或用 FTP 把 zip 放进备份目录');
}
csrf_check();
handle_post($act);
} catch (Throwable $e) {
bk_log('ERROR', '操作 ' . $act . ' 失败:' . $e->getMessage());
flash('err', $e->getMessage());
go(tab_for($act));
}
exit;
}
if (isset($_GET['action']) && in_array($_GET['action'], ['download', 'download_log'], true)) {
handle_download();
}
$tab = (isset($_GET['tab']) && in_array($_GET['tab'], ['backup', 'restore', 'log'], true)) ? $_GET['tab'] : 'backup';
$preview = (isset($_GET['preview']) && is_string($_GET['preview'])) ? $_GET['preview'] : '';
if ($preview !== '') {
$tab = 'restore';
}
render_main($tab, $preview);
© 2023 Quttera Ltd. All rights reserved.