Online PHP and Javascript Decoder decode hidden script to uncover its real functionality


$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&amp;preview== h($q)">还原…</a>endif;
        <a class="btn sm" href="?action=download&amp;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.