317 行
13 KiB
PHP
317 行
13 KiB
PHP
<?php
|
||
if (!function_exists('up_h')) {
|
||
function up_h($s) {
|
||
return htmlspecialchars((string) $s, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||
}
|
||
}
|
||
$base = __DIR__;
|
||
if (!is_file($base . '/config.php')) {
|
||
http_response_code(500);
|
||
exit('未找到 config.php,请确认 update.php 位于商城根目录。');
|
||
}
|
||
require_once $base . '/config.php';
|
||
if (!is_file($base . '/includes/app_config.php')) {
|
||
http_response_code(500);
|
||
exit('未找到 includes/app_config.php,无法获取版本信息。');
|
||
}
|
||
require_once $base . '/includes/app_config.php';
|
||
if (!defined('SITE_KEY')) {
|
||
http_response_code(500);
|
||
exit('config.php 中未定义 SITE_KEY。');
|
||
}
|
||
$currentVersion = defined('MALL_VERSION') ? MALL_VERSION : 'unknown';
|
||
$apiUrl = defined('UPDATE_API_URL') ? rtrim(UPDATE_API_URL, '/') : '';
|
||
$apiHost = '';
|
||
if ($apiUrl !== '') {
|
||
$p = parse_url($apiUrl);
|
||
$apiHost = isset($p['host']) ? strtolower($p['host']) : '';
|
||
}
|
||
$step = 'form';
|
||
$errMsg = '';
|
||
$log = [];
|
||
function up_log(&$log, $msg, $ok = true) {
|
||
$log[] = ['msg' => $msg, 'ok' => $ok];
|
||
}
|
||
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||
$submitted = trim((string) ($_POST['site_key'] ?? ''));
|
||
if ($submitted === '') {
|
||
$step = 'error';
|
||
$errMsg = '请输入 SITE_KEY。';
|
||
} elseif (!hash_equals((string) SITE_KEY, $submitted)) {
|
||
$step = 'error';
|
||
$errMsg = 'SITE_KEY 校验失败,请确认后重试。';
|
||
} else {
|
||
$step = 'updating';
|
||
}
|
||
}
|
||
if ($step === 'updating') {
|
||
$ts = date('Ymd_His');
|
||
$tmpRoot = $base . '/storage/cache/update_' . $ts;
|
||
$zipPath = $base . '/storage/cache/update_' . $ts . '.zip';
|
||
$backupDir = $base . '/storage/backups/update_' . $ts;
|
||
$excludeRel = ['config.php', 'update.php'];
|
||
try {
|
||
if ($apiUrl === '') {
|
||
throw new RuntimeException('未配置 UPDATE_API_URL(请检查 includes/app_config.php)。');
|
||
}
|
||
$infoUrl = $apiUrl . '?v=' . urlencode($currentVersion);
|
||
$raw = up_fetch($infoUrl, 15);
|
||
if ($raw === false || $raw === null) {
|
||
throw new RuntimeException('无法连接更新服务器,请检查网络或 UPDATE_API_URL。');
|
||
}
|
||
$info = json_decode($raw, true);
|
||
if (!is_array($info)) {
|
||
throw new RuntimeException('更新服务器返回的数据格式异常。');
|
||
}
|
||
if (empty($info['update_available']) || empty($info['latest'])) {
|
||
up_log($log, '当前已是最新版本(v' . up_h($currentVersion) . '),无需更新。', true);
|
||
$step = 'done';
|
||
} else {
|
||
$latest = $info['latest'];
|
||
$newVersion = $latest['version'] ?? '?';
|
||
$dlUrl = $latest['download_url'] ?? '';
|
||
up_log($log, '检测到新版本:v' . up_h($newVersion)
|
||
. '(' . up_h($latest['type'] ?? 'patch') . ',发布于 '
|
||
. up_h($latest['release_date'] ?? '-') . ')', true);
|
||
if ($dlUrl === '') {
|
||
throw new RuntimeException('更新信息中缺少下载地址。');
|
||
}
|
||
$dlParts = parse_url($dlUrl);
|
||
$dlHost = isset($dlParts['host']) ? strtolower($dlParts['host']) : '';
|
||
if ($dlHost === '' || ($apiHost !== '' && $dlHost !== $apiHost)) {
|
||
throw new RuntimeException('下载地址域名不在信任列表(期望:' . up_h($apiHost) . ',实际:' . up_h($dlHost) . '),已拒绝以防供应链攻击。');
|
||
}
|
||
if (!is_dir(dirname($zipPath))) {
|
||
@mkdir(dirname($zipPath), 0755, true);
|
||
}
|
||
$ok = up_download($dlUrl, $zipPath, 60, 100 * 1024 * 1024);
|
||
if (!$ok || !is_file($zipPath)) {
|
||
throw new RuntimeException('安装包下载失败。');
|
||
}
|
||
up_log($log, '安装包已下载:' . up_h(basename($zipPath)) . '(' . round(filesize($zipPath) / 1024, 1) . ' KB)', true);
|
||
$sig = file_get_contents($zipPath, false, null, 0, 4);
|
||
if ($sig === false || substr($sig, 0, 2) !== "PK") {
|
||
@unlink($zipPath);
|
||
throw new RuntimeException('安装包不是有效的 ZIP 文件(签名校验失败),已删除。');
|
||
}
|
||
up_log($log, 'ZIP 签名校验通过。', true);
|
||
if (!class_exists('ZipArchive')) {
|
||
@unlink($zipPath);
|
||
throw new RuntimeException('服务器未启用 ZipArchive 扩展,无法解压更新包。');
|
||
}
|
||
if (!is_dir($tmpRoot)) {
|
||
@mkdir($tmpRoot, 0755, true);
|
||
}
|
||
$za = new ZipArchive();
|
||
if ($za->open($zipPath) !== TRUE) {
|
||
@unlink($zipPath);
|
||
throw new RuntimeException('无法打开 ZIP 安装包。');
|
||
}
|
||
if (!$za->extractTo($tmpRoot)) {
|
||
$za->close();
|
||
@unlink($zipPath);
|
||
throw new RuntimeException('解压安装包失败。');
|
||
}
|
||
$za->close();
|
||
up_log($log, '安装包已解压到临时目录。', true);
|
||
if (!is_dir($backupDir)) {
|
||
@mkdir($backupDir, 0755, true);
|
||
}
|
||
$copied = 0;
|
||
$skipped = 0;
|
||
up_apply($tmpRoot, $base, $backupDir, $excludeRel, $log, $copied, $skipped);
|
||
up_log($log, '更新完成:已覆盖 ' . $copied . ' 个文件,跳过 ' . $skipped . ' 个(含 config.php / storage / update.php)。', true);
|
||
up_log($log, '旧文件已备份至:storage/backups/update_' . $ts . '/', true);
|
||
@unlink($zipPath);
|
||
up_rrmdir($tmpRoot);
|
||
up_log($log, '已从 v' . up_h($currentVersion) . ' 升级到 v' . up_h($newVersion) . '。', true);
|
||
$step = 'done';
|
||
}
|
||
} catch (Throwable $e) {
|
||
$step = 'error';
|
||
$errMsg = '更新失败:' . $e->getMessage();
|
||
up_log($log, $errMsg, false);
|
||
}
|
||
}
|
||
function up_fetch($url, $timeout = 15) {
|
||
if (function_exists('curl_init')) {
|
||
$ch = curl_init($url);
|
||
curl_setopt_array($ch, [
|
||
CURLOPT_RETURNTRANSFER => true,
|
||
CURLOPT_TIMEOUT => $timeout,
|
||
CURLOPT_USERAGENT => 'FNW-Mall-Updater',
|
||
CURLOPT_SSL_VERIFYPEER => false,
|
||
CURLOPT_FOLLOWLOCATION => true,
|
||
CURLOPT_MAXREDIRS => 3,
|
||
]);
|
||
$r = curl_exec($ch);
|
||
curl_close($ch);
|
||
return $r;
|
||
}
|
||
if (function_exists('file_get_contents')) {
|
||
$ctx = stream_context_create([
|
||
'http' => ['timeout' => $timeout, 'ignore_errors' => true, 'method' => 'GET',
|
||
'header' => "User-Agent: FNW-Mall-Updater\r\n"]
|
||
]);
|
||
return @file_get_contents($url, false, $ctx);
|
||
}
|
||
return false;
|
||
}
|
||
function up_download($url, $dest, $timeout = 60, $maxBytes = 104857600) {
|
||
if (function_exists('curl_init')) {
|
||
$fp = @fopen($dest, 'wb');
|
||
if (!$fp) return false;
|
||
$ch = curl_init($url);
|
||
curl_setopt_array($ch, [
|
||
CURLOPT_FILE => $fp,
|
||
CURLOPT_TIMEOUT => $timeout,
|
||
CURLOPT_USERAGENT => 'FNW-Mall-Updater',
|
||
CURLOPT_SSL_VERIFYPEER => false,
|
||
CURLOPT_FOLLOWLOCATION => true,
|
||
CURLOPT_MAXREDIRS => 3,
|
||
CURLOPT_NOPROGRESS => false,
|
||
CURLOPT_BUFFERSIZE => 8192,
|
||
]);
|
||
$ok = curl_exec($ch);
|
||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||
curl_close($ch);
|
||
fclose($fp);
|
||
if (!$ok || $code >= 400 || !is_file($dest) || filesize($dest) > $maxBytes) {
|
||
@unlink($dest);
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
$data = up_fetch($url, $timeout);
|
||
if ($data === false || strlen($data) > $maxBytes) {
|
||
return false;
|
||
}
|
||
return file_put_contents($dest, $data) !== false;
|
||
}
|
||
function up_apply($src, $dstRoot, $backupDir, $excludeRel, &$log, &$copied, &$skipped) {
|
||
$iter = new RecursiveIteratorIterator(
|
||
new RecursiveDirectoryIterator($src, FilesystemIterator::SKIP_DOTS),
|
||
RecursiveIteratorIterator::SELF_FIRST
|
||
);
|
||
foreach ($iter as $fileInfo) {
|
||
$srcNorm = str_replace('\\', '/', $src);
|
||
$pathNorm = str_replace('\\', '/', $fileInfo->getPathname());
|
||
$rel = ltrim(substr($pathNorm, strlen($srcNorm)), '/');
|
||
if ($rel === '') continue;
|
||
if (in_array($rel, $excludeRel, true)) {
|
||
$skipped++;
|
||
continue;
|
||
}
|
||
if ($rel === 'storage' || strpos($rel, 'storage/') === 0) {
|
||
$skipped++;
|
||
continue;
|
||
}
|
||
if (basename($rel) === 'update.php') {
|
||
$skipped++;
|
||
continue;
|
||
}
|
||
$target = $dstRoot . '/' . $rel;
|
||
if ($fileInfo->isDir()) {
|
||
if (!is_dir($target)) @mkdir($target, 0755, true);
|
||
continue;
|
||
}
|
||
if (is_file($target)) {
|
||
$bk = $backupDir . '/' . $rel;
|
||
$bkDir = dirname($bk);
|
||
if (!is_dir($bkDir)) @mkdir($bkDir, 0755, true);
|
||
@copy($target, $bk);
|
||
} else {
|
||
$td = dirname($target);
|
||
if (!is_dir($td)) @mkdir($td, 0755, true);
|
||
}
|
||
if (@copy($fileInfo->getPathname(), $target)) {
|
||
$copied++;
|
||
} else {
|
||
up_log($log, '复制失败:' . up_h($rel), false);
|
||
}
|
||
}
|
||
}
|
||
function up_rrmdir($dir) {
|
||
if (!is_dir($dir)) return;
|
||
$items = array_diff(scandir($dir), ['.', '..']);
|
||
foreach ($items as $item) {
|
||
$p = $dir . '/' . $item;
|
||
if (is_dir($p)) up_rrmdir($p);
|
||
else @unlink($p);
|
||
}
|
||
@rmdir($dir);
|
||
}
|
||
?>
|
||
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>系统更新 - 自由云商城</title>
|
||
<style>
|
||
* { box-sizing: border-box; }
|
||
body { font-family: system-ui, "Microsoft YaHei", sans-serif; margin: 0;
|
||
background:
|
||
.wrap { max-width: 680px; margin: 48px auto; padding: 0 16px; }
|
||
.card { background:
|
||
padding: 28px; box-shadow: 0 2px 8px rgba(16,24,40,.08); }
|
||
h1 { font-size: 1.4rem; margin: 0 0 6px; color:
|
||
.muted { color:
|
||
label { display: block; font-weight: 600; margin-bottom: 8px; }
|
||
input[type=password] { width: 100%; padding: 12px 14px; border: 1px solid
|
||
border-radius: 10px; font-size: 1rem; outline: none; }
|
||
input[type=password]:focus { border-color:
|
||
.btn { display: inline-block; margin-top: 16px; background:
|
||
border: none; padding: 11px 22px; border-radius: 999px; font-size: .95rem;
|
||
font-weight: 600; cursor: pointer; }
|
||
.btn:hover { background:
|
||
.err { background:
|
||
padding: 12px 14px; border-radius: 10px; margin-bottom: 14px; }
|
||
.ok { background:
|
||
padding: 12px 14px; border-radius: 10px; margin-bottom: 14px; }
|
||
.log { list-style: none; padding: 0; margin: 14px 0 0; font-size: .9rem; }
|
||
.log li { padding: 6px 0; border-bottom: 1px dashed
|
||
.log li.bad { color:
|
||
.log li .tick { font-weight: 700; margin-right: 6px; }
|
||
code { background:
|
||
a.back { color:
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="wrap">
|
||
<div class="card">
|
||
<h1><i class="fas fa-cloud-arrow-up"></i> 系统更新</h1>
|
||
<p class="muted">当前版本:<strong><?= up_h($currentVersion) ?></strong></p>
|
||
<?php if (!empty($log)): ?>
|
||
<ul class="log">
|
||
<?php foreach ($log as $l): ?>
|
||
<li class="<?= $l['ok'] ? '' : 'bad' ?>">
|
||
<span class="tick"><?= $l['ok'] ? '✓' : '✗' ?></span><?= up_h($l['msg']) ?>
|
||
</li>
|
||
<?php endforeach; ?>
|
||
</ul>
|
||
<?php endif; ?>
|
||
<?php if ($step === 'form' || $step === 'error'): ?>
|
||
<?php if ($step === 'error' && $errMsg): ?>
|
||
<div class="err"><i class="fas fa-triangle-exclamation"></i> <?= up_h($errMsg) ?></div>
|
||
<?php endif; ?>
|
||
<form method="post" action="">
|
||
<label for="sk">请输入 SITE_KEY 以验证身份</label>
|
||
<input type="password" id="sk" name="site_key" autocomplete="off" placeholder="SITE_KEY" required>
|
||
<button type="submit" class="btn"><i class="fas fa-rocket"></i> 开始检查并更新</button>
|
||
</form>
|
||
<p class="muted" style="margin-top:14px">
|
||
SITE_KEY 与数据库凭据一同保存在 <code>config.php</code> 中,仅站点所有者可知。
|
||
更新前建议先备份数据库与 <code>storage/</code> 目录。
|
||
</p>
|
||
<?php elseif ($step === 'done'): ?>
|
||
<p class="ok" style="margin-top:16px">
|
||
<i class="fas fa-circle-check"></i> 更新流程已结束。
|
||
</p>
|
||
<p><a class="back" href="admin/update.php"><i class="fas fa-arrow-left"></i> 返回后台更新页</a></p>
|
||
<?php endif; ?>
|
||
</div>
|
||
</div>
|
||
</body>
|
||
</html>
|