<?php
/**
 * 炎酷CMS - 在线升级后端接口（JSON）
 * act=check   检查更新：拉取 manifest 并对比本地文件（需登录 + CSRF）
 * act=start   启动升级：创建异步任务并唤醒 worker（需登录 + CSRF）
 * act=run     后台执行：凭任务一次性令牌运行（无需登录，令牌随任务文件保存）
 * 任务进度查询见 api/tasks.php
 */
define('YKCMS', 1);

$act = isset($_REQUEST['act']) ? (string)$_REQUEST['act'] : '';

/* ================= 后台 worker：凭任务令牌执行（不经后台登录态） ================= */
if ($act === 'run') {
    ignore_user_abort(true);
    @set_time_limit(0);
    // auth.php 底部会强制校验登录并 exit，worker 只加载基础依赖
    require dirname(dirname(dirname(__DIR__))) . '/inc/functions.php';
    require dirname(__DIR__) . '/inc/upgradelib.php';
    // 与 auth.php 相同的常量推导
    $ykScriptDir = str_replace('\\', '/', dirname(isset($_SERVER['SCRIPT_NAME']) ? $_SERVER['SCRIPT_NAME'] : ''));
    $ykPos = strpos($ykScriptDir, '/admin');
    define('ADMIN_URL', ($ykPos !== false ? substr($ykScriptDir, 0, $ykPos) : '') . '/admin');
    define('SITE_URL', substr(ADMIN_URL, 0, -strlen('/admin')));
    define('SITE_PATH', dirname(dirname(dirname(__DIR__))));
    // worker 无后台会话，操作日志以 system 身份落库
    if (!function_exists('admin_log')) {
        function admin_log($action, $detail = '') {
            db_insert('INSERT INTO ' . DB_PREFIX . 'log (admin, action, detail, ip, add_time) VALUES (?, ?, ?, ?, ?)',
                array('system', $action, mb_substr((string)$detail, 0, 480),
                      isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '', time()));
        }
    }
    header('Content-Type: application/json; charset=utf-8');
    $id    = isset($_GET['id']) ? preg_replace('/[^a-zA-Z0-9_]/', '', $_GET['id']) : '';
    $token = isset($_GET['token']) ? (string)$_GET['token'] : '';
    $task  = $id !== '' ? yk_task_get($id) : null;
    if (!$task || !hash_equals($task['token'], $token)) {
        yk_json(array('ok' => 0, 'msg' => '任务不存在或令牌无效'));
    }
    if ($task['status'] === 'done' || $task['status'] === 'error') {
        yk_json(array('ok' => 1, 'msg' => '任务已结束'));
    }
    yk_upgrade_run($task);
    yk_json(array('ok' => 1));
}

/* ================= 以下操作需管理员登录 ================= */
require dirname(__DIR__) . '/inc/auth.php';
require dirname(__DIR__) . '/inc/upgradelib.php';

header('Content-Type: application/json; charset=utf-8');

function yk_json($arr) { echo json_encode($arr, JSON_UNESCAPED_UNICODE); exit; }

/** 拉取并校验 manifest，返回带文件对比的结果 */
function yk_manifest_fetch($url) {
    if (!yk_url_safe($url)) return array(null, '升级服务器地址不合法（仅允许 http/https）');
    $body = yk_http_get($url, 8);
    $m = $body !== false ? json_decode($body, true) : null;
    if (!is_array($m) || empty($m['version']) || !preg_match('/^\d+(\.\d+)*$/', (string)$m['version'])) {
        return array(null, '无法连接升级服务器或 manifest 格式错误');
    }
    $m['changelog'] = isset($m['changelog']) ? (string)$m['changelog'] : '';
    $m['files'] = isset($m['files']) && is_array($m['files']) ? $m['files'] : array();
    $m['sql']   = isset($m['sql']) && is_array($m['sql']) ? $m['sql'] : array();
    // 校验文件条目：路径防穿越、URL 协议白名单
    $files = array();
    foreach ($m['files'] as $f) {
        if (empty($f['path']) || empty($f['url'])) return array(null, 'manifest 存在非法文件条目');
        if (yk_upgrade_safe_path($f['path']) === '') return array(null, 'manifest 含危险路径：' . $f['path']);
        if (!yk_url_safe($f['url'])) return array(null, 'manifest 含非法下载地址：' . $f['url']);
        $files[] = array(
            'path' => (string)$f['path'],
            'url'  => (string)$f['url'],
            'md5'  => isset($f['md5']) ? strtolower(trim((string)$f['md5'])) : '',
        );
    }
    $m['files'] = $files;
    // 校验 SQL 白名单
    foreach ($m['sql'] as $s) {
        if (!yk_sql_allowed($s)) return array(null, 'manifest 含不允许的 SQL 语句，已拦截');
    }
    return array($m, '');
}

if ($act === 'check') {
    if ($_SERVER['REQUEST_METHOD'] !== 'POST') yk_json(array('ok' => 0, 'msg' => '非法请求'));
    csrf_check();
    unset($_SESSION['yk_upgrade_manifest']);
    $url = site_config('upgrade_url', 'https://api.dinyun.cn/upgrade/manifest.json');
    list($m, $err) = yk_manifest_fetch($url);
    if ($err !== '') yk_json(array('ok' => 0, 'msg' => $err));
    $cur = yk_version_get();
    // 对比本地文件 MD5
    $diffs = array();
    foreach ($m['files'] as $f) {
        $target = yk_upgrade_safe_path($f['path']);
        if (!is_file($target)) $st = '新增';
        elseif ($f['md5'] !== '' && md5_file($target) === $f['md5']) $st = '一致（跳过）';
        else $st = '更新';
        $diffs[] = array('path' => $f['path'], 'state' => $st);
    }
    $_SESSION['yk_upgrade_manifest'] = $m;
    admin_log('在线升级', '检查更新，远端版本:' . $m['version']);
    yk_json(array(
        'ok' => 1, 'current' => $cur, 'version' => $m['version'],
        'changelog' => $m['changelog'],
        'has_new' => version_compare($m['version'], $cur, '>'),
        'files' => $diffs, 'sql_count' => count($m['sql']),
    ));
}

if ($act === 'start') {
    if ($_SERVER['REQUEST_METHOD'] !== 'POST') yk_json(array('ok' => 0, 'msg' => '非法请求'));
    csrf_check();
    $m = isset($_SESSION['yk_upgrade_manifest']) ? $_SESSION['yk_upgrade_manifest'] : null;
    if (!is_array($m) || empty($m['version'])) yk_json(array('ok' => 0, 'msg' => '请先检查更新'));
    if (version_compare($m['version'], yk_version_get(), '<=')) yk_json(array('ok' => 0, 'msg' => '当前已是最新版本'));
    // 防并发：存在未结束任务时拒绝
    foreach (glob(yk_task_dir() . '/*.json') ?: array() as $tf) {
        $t = json_decode((string)file_get_contents($tf), true);
        if (is_array($t) && in_array($t['status'], array('pending', 'running'), true) && time() - $t['updated'] < 600) {
            yk_json(array('ok' => 0, 'msg' => '已有升级任务正在进行中'));
        }
    }
    $task = yk_task_new($m);
    $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
    $host   = isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : '127.0.0.1';
    $workerUrl = $scheme . '://' . $host . SITE_URL . '/admin/api/upgrade.php?act=run&id=' . $task['id'] . '&token=' . $task['token'];
    if (!yk_spawn_worker($workerUrl)) {
        $task['status'] = 'error';
        $task['log'][]  = '后台任务唤醒失败，请检查服务器网络';
        yk_task_save($task);
        yk_json(array('ok' => 0, 'msg' => '后台任务启动失败'));
    }
    admin_log('在线升级', '启动异步升级任务 ' . $task['id'] . ' -> ' . $m['version']);
    yk_json(array('ok' => 1, 'task_id' => $task['id']));
}

yk_json(array('ok' => 0, 'msg' => '未知操作'));

/* ================= 升级主流程（worker 内执行） ================= */
function yk_upgrade_run($task) {
    $m = $task['manifest'];
    $newVersion = $m['version'];
    $stamp = date('Ymd_His');
    $bakDir = SITE_PATH . '/data/backup';

    try {
        /* 1. 备份数据库 */
        yk_task_step($task, 5, '备份数据库', '开始备份数据库…');
        $dbFile = $bakDir . '/pre_upgrade_' . $stamp . '.sql';
        yk_db_backup($dbFile);
        yk_task_step($task, 15, '备份数据库', '数据库备份完成：data/backup/' . basename($dbFile));

        /* 2. 备份全站文件 */
        yk_task_step($task, 20, '备份全站文件', '开始打包全站文件…');
        $zipFile = $bakDir . '/pre_upgrade_' . $stamp . '_site.zip';
        yk_site_backup($zipFile);
        yk_task_step($task, 35, '备份全站文件', '全站文件备份完成：data/backup/' . basename($zipFile));

        /* 3. 下载、校验、覆盖文件（MD5 一致的跳过；失败自动回滚） */
        $files = $m['files'];
        $total = max(1, count($files));
        $written = array();
        $skipped = 0;
        $i = 0;
        foreach ($files as $f) {
            $i++;
            $target = yk_upgrade_safe_path($f['path']);
            if ($f['md5'] !== '' && is_file($target) && md5_file($target) === $f['md5']) {
                $skipped++;
            } else {
                $body = yk_http_get($f['url'], 30);
                if ($body === false) throw new Exception('下载失败：' . $f['path']);
                if ($f['md5'] !== '' && md5($body) !== $f['md5']) throw new Exception('MD5 校验失败：' . $f['path']);
                if (!is_dir(dirname($target))) @mkdir(dirname($target), 0755, true);
                // 先写临时文件再原子重命名，避免写一半损坏
                $tmp = $target . '.yktmp';
                if (@file_put_contents($tmp, $body) === false) throw new Exception('写入失败：' . $f['path'] . '（请检查目录权限）');
                if (!@rename($tmp, $target)) { @unlink($tmp); throw new Exception('覆盖失败：' . $f['path']); }
                $written[] = $target;
            }
            yk_task_step($task, 35 + (int)(50 * $i / $total), '更新文件', "文件 $i/$total：" . $f['path']);
        }

        /* 4. 顺序执行 SQL（白名单已在 check 阶段校验，此处再次拦截 + {pre} 占位替换） */
        yk_task_step($task, 88, '执行数据库升级', '开始执行 SQL…');
        $n = 0;
        foreach ($m['sql'] as $sql) {
            $sql = str_replace('{pre}', DB_PREFIX, trim((string)$sql));
            if (!yk_sql_allowed($sql)) throw new Exception('拦截到不允许的 SQL 语句');
            db()->exec($sql);
            $n++;
        }
        yk_task_step($task, 95, '执行数据库升级', "SQL 执行完成，共 $n 条");

        /* 5. 写入新版本号 */
        yk_version_set($newVersion);
        $task['status']   = 'done';
        $task['progress'] = 100;
        $task['step']     = '升级完成';
        $task['log'][]    = '[' . date('H:i:s') . "] 升级完成：{$newVersion}，更新文件 " . count($written) . " 个，跳过一致文件 $skipped 个，SQL $n 条";
        yk_task_save($task);
        admin_log('在线升级', '升级完成 -> ' . $newVersion);

    } catch (Exception $ex) {
        // 回滚已写入的文件（从全站备份压缩包还原）
        if (!empty($written) && isset($zipFile) && is_file($zipFile) && class_exists('ZipArchive')) {
            $zip = new ZipArchive();
            if ($zip->open($zipFile) === true) {
                $baseLen = strlen(SITE_PATH) + 1;
                foreach ($written as $w) {
                    $rel = substr(str_replace('\\', '/', $w), $baseLen);
                    $content = $zip->getFromName($rel);
                    if ($content !== false) @file_put_contents($w, $content);
                }
                $zip->close();
            }
        }
        $task['status'] = 'error';
        $task['step']   = '升级失败';
        $task['log'][]  = '[' . date('H:i:s') . '] 升级失败并已回滚文件：' . $ex->getMessage()
                        . '。备份位于 data/backup/pre_upgrade_' . $stamp . '*';
        yk_task_save($task);
        admin_log('在线升级', '升级失败：' . $ex->getMessage());
    }
}
