From 1e60d31ffd1092318b679e815c386d70c6c29d54 Mon Sep 17 00:00:00 2001 From: ypsdf1 Date: Fri, 9 Oct 2026 21:36:53 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=E5=AE=89=E8=A3=85=E9=A1=B5?= =?UTF-8?q?=E4=B8=BA=E5=9B=9B=E6=AD=A5=E5=90=91=E5=AF=BC=EF=BC=9A=E6=89=A9?= =?UTF-8?q?=E5=B1=95=E6=A3=80=E6=B5=8B/=E6=95=B0=E6=8D=AE=E5=BA=93?= =?UTF-8?q?=E5=88=B9=E8=BD=A6/SMTP=E5=BC=BA=E6=A0=A1=E9=AA=8C/=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 第一关扩展检测:sqlite3/mysqli 缺失时第二关锁死对应模式(前端 disabled + instValidate 兜底) - 第二关数据库:mysql 地址端口预填 127.0.0.1/3306;扫库非空立即刹车,列清单后手输「确认清空」二次确认,取消则退回重填 - 第三关 SMTP:四项要么全空要么全不空,全不空必须发送验证码且比对通过才放行 - 第四关安全配置:IP 白名单必填(可自动填充当前 IP),验证码留空即关闭,2FA 跳转 admin_2fa_setup.php - 统一写盘:最后一关才落 config.php/sql.md/captcha_keys.php,写完生成 install.lock 并自删 --- install.php | 1357 ++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 1171 insertions(+), 186 deletions(-) diff --git a/install.php b/install.php index df2d724..1c3568a 100644 --- a/install.php +++ b/install.php @@ -1,21 +1,35 @@ 5) $step = 1; + +// ============================ 基础工具 ============================ /** 当前 ip 是否本地(本机访问给出更宽松的提示文案) */ function instIsLocal() { $ip = secClientIp(); return $ip === '127.0.0.1' || $ip === '::1'; } -// ============================ 校验 ============================ /** - * 校验一次提交。返回 array('mode'=>'db'|'mysql', 'dir'=>..., 'java'=>..., 'web'=>..., 'warn'=>[]) + * 第一关:扩展检测。 + * sqlite3 —— db(SQLite 文件)模式的硬依赖(core.php / security.php 全用 new SQLite3) + * pdo_sqlite —— 正版验证接口 api/minecraft_auth.php 用 PDO('sqlite:') + * mysqli —— mysql 模式硬依赖 + * @return array{sqlite3:bool,pdo_sqlite:bool,mysqli:bool,any_db:bool,any_mysql:bool} + */ +function instExtCheck() { + $e = array( + 'sqlite3' => extension_loaded('sqlite3'), + 'pdo_sqlite' => extension_loaded('pdo_sqlite'), + 'mysqli' => extension_loaded('mysqli'), + ); + $e['any_db'] = $e['sqlite3']; // db 模式能不能选 + $e['any_mysql'] = $e['mysqli']; // mysql 模式能不能选 + return $e; +} + +/** 解析存储位置为绝对路径(与 security.php 的 sqlDbDir() 同一套规则,独立实现避免耦合)。 */ +function instResolveDir($p) { + $p = str_replace('\\', '/', $p); + $base = str_replace('\\', '/', __DIR__); + while (strpos($p, '../') === 0) { $base = dirname($base); $p = substr($p, 3); } + if (strpos($p, './') === 0) $p = substr($p, 2); + if ($p !== '' && $p[0] === '/') return rtrim($p, '/'); + if ($p === '') return $base; + return rtrim($base, '/') . '/' . trim($p, '/'); +} + +/** + * MySQL 实连探测:连得上 + 库存在 + 能建表(建完立刻删)。 + * 失败抛 Exception,消息带 PHP/mysqli 原始错误码,方便部署者定位。 + */ +function instProbeMySql($slot, array $e) { + $m = mysqli_init(); + if (!($m instanceof mysqli)) { + throw new Exception(strtoupper($slot) . ' 侧:mysqli_init 失败。'); + } + @mysqli_options($m, MYSQLI_OPT_CONNECT_TIMEOUT, 6); + $okc = @mysqli_real_connect($m, $e['host'], $e['user'], $e['pass'], $e['name'], (int)$e['port']); + if (!$okc) { + $errno = $m->connect_errno; + $msg = $m->connect_error; + throw new Exception(strtoupper($slot) . ' 侧连接失败 #' . $errno . ':' . $msg + . '(常见原因:MySQL 服务未启动、账密错误、库名不存在、端口不对)'); + } + // 建表权限探测(软依赖下建表失败只记日志,站点会悄悄跑在空库上,这里必须硬报) + $tbl = '_install_probe'; + $failed = ''; + if (!@mysqli_query($m, 'DROP TABLE IF EXISTS `' . str_replace('`', '``', $tbl) . '`')) { + $failed = 'DROP: ' . mysqli_error($m); + } elseif (!@mysqli_query($m, 'CREATE TABLE `' . str_replace('`', '``', $tbl) . "` (\n `id` BIGINT NOT NULL AUTO_INCREMENT, PRIMARY KEY (`id`)\n) ENGINE=InnoDB")) { + $failed = 'CREATE: ' . mysqli_error($m) . '(' . mysqli_errno($m) . ')'; + } else { + @mysqli_query($m, 'DROP TABLE IF EXISTS `' . str_replace('`', '``', $tbl) . '`'); + } + @mysqli_close($m); + if ($failed !== '') { + throw new Exception(strtoupper($slot) . ' 侧连得上,但建表失败:' . $failed + . ' —— 站点初始化需要 CREATE 权限,请检查账号授权。'); + } +} + +/** + * 校验第二关提交。返回 array('mode'=>'db'|'mysql', 'dir'=>..., 'java'=>..., 'web'=>..., 'warn'=>[]) * 校验不过抛 Exception,异常消息给用户看。 */ -function instValidate(array $post) { +function instValidate(array $post, array $ext) { $warn = array(); $mode = isset($post['mode']) ? strtolower(trim((string)$post['mode'])) : 'db'; if ($mode !== 'db' && $mode !== 'mysql') { throw new Exception('运行模式取值非法(只接受 db 或 mysql)。'); } + // ★ 第一关锁死:缺对应扩展时该模式不可选(前端已禁用,这里兜底防绕过) + if (empty($ext['any_db']) && empty($ext['any_mysql'])) { + throw new Exception('当前 PHP 既没有 sqlite3 也没有 mysqli 扩展,两种运行模式都用不了,' + . '请先让主机商安装扩展(宝塔面板 → PHP 设置 → 安装扩展),装好后刷新本页。'); + } + if ($mode === 'db' && empty($ext['any_db'])) { + throw new Exception('当前 PHP 没有安装 sqlite3 扩展,无法使用 db 模式,请改用 mysql 模式。'); + } + if ($mode === 'mysql' && empty($ext['any_mysql'])) { + throw new Exception('当前 PHP 没有安装 mysqli 扩展,无法使用 mysql 模式,请改用 db 模式。'); + } $dir = isset($post['dir']) ? trim((string)$post['dir']) : ''; $side = function ($k) use ($post) { @@ -100,11 +196,6 @@ function instValidate(array $post) { } } } else { - // mysql 模式:mysqli 扩展必须在 - if (!extension_loaded('mysqli')) { - throw new Exception('当前 PHP 没有安装 mysqli 扩展,无法使用 mysql 模式。' - . '请先安装扩展,或改用 db 模式。'); - } // 每侧地址要么全填、要么全空 $complete = 0; foreach ($entries as $k => $e) { @@ -139,55 +230,146 @@ function instValidate(array $post) { return array('mode' => $mode, 'dir' => $dir, 'java' => $java, 'web' => $web, 'warn' => $warn); } -/** 解析存储位置为绝对路径(与 security.php 的 sqlDbDir() 同一套规则,独立实现避免耦合)。 */ -function instResolveDir($p) { - $p = str_replace('\\', '/', $p); - $base = str_replace('\\', '/', __DIR__); - while (strpos($p, '../') === 0) { $base = dirname($base); $p = substr($p, 3); } - if (strpos($p, './') === 0) $p = substr($p, 2); - if ($p !== '' && $p[0] === '/') return rtrim($p, '/'); - if ($p === '') return $base; - return rtrim($base, '/') . '/' . trim($p, '/'); +// ============================ 第二关:扫库 / 清空 ============================ +/** + * 扫一个 MySQL 侧的用户表(表名 + 估算行数)。 + * @return array{ok:bool,err:string,tables:array} + */ +function instScanMysqlSide(array $e) { + $out = array('ok' => false, 'err' => '', 'tables' => array()); + $m = mysqli_init(); + if (!($m instanceof mysqli)) { $out['err'] = 'mysqli_init 失败'; return $out; } + @mysqli_options($m, MYSQLI_OPT_CONNECT_TIMEOUT, 6); + if (!@mysqli_real_connect($m, $e['host'], $e['user'], $e['pass'], $e['name'], (int)$e['port'])) { + $out['err'] = '#' . $m->connect_errno . ' ' . $m->connect_error; + @mysqli_close($m); + return $out; + } + @mysqli_set_charset($m, 'utf8mb4'); + // TABLE_ROWS 是估算值(InnoDB 不维护精确计数),标注「约」以免误导 + $sql = 'SELECT TABLE_NAME, TABLE_ROWS FROM information_schema.TABLES' + . ' WHERE TABLE_SCHEMA = ' . $m->real_escape_string($e['name']) + . " AND TABLE_TYPE = 'BASE TABLE' ORDER BY TABLE_NAME"; + $rs = @mysqli_query($m, $sql); + if ($rs === false) { + $out['err'] = mysqli_error($m); + @mysqli_close($m); + return $out; + } + while ($row = mysqli_fetch_assoc($rs)) { + $out['tables'][] = array( + 'name' => (string)$row['TABLE_NAME'], + 'rows' => ($row['TABLE_ROWS'] === null) ? -1 : (int)$row['TABLE_ROWS'], + ); + } + mysqli_free_result($rs); + @mysqli_close($m); + $out['ok'] = true; + return $out; +} + +/** 清空一个 MySQL 侧的全部用户表。@return array{ok:bool,err:string,dropped:int} */ +function instPurgeMysqlSide(array $e) { + $out = array('ok' => false, 'err' => '', 'dropped' => 0); + $m = mysqli_init(); + if (!($m instanceof mysqli)) { $out['err'] = 'mysqli_init 失败'; return $out; } + @mysqli_options($m, MYSQLI_OPT_CONNECT_TIMEOUT, 6); + if (!@mysqli_real_connect($m, $e['host'], $e['user'], $e['pass'], $e['name'], (int)$e['port'])) { + $out['err'] = '#' . $m->connect_errno . ' ' . $m->connect_error; + @mysqli_close($m); + return $out; + } + @mysqli_set_charset($m, 'utf8mb4'); + @mysqli_query($m, 'SET FOREIGN_KEY_CHECKS=0'); + $scan = instScanMysqlSide($e); + if (!$scan['ok']) { $out['err'] = $scan['err']; @mysqli_close($m); return $out; } + foreach ($scan['tables'] as $t) { + $q = 'DROP TABLE IF EXISTS `' . str_replace('`', '``', $t['name']) . '`'; + if (!@mysqli_query($m, $q)) { + $out['err'] = '删除 ' . $t['name'] . ' 失败:' . mysqli_error($m); + @mysqli_query($m, 'SET FOREIGN_KEY_CHECKS=1'); + @mysqli_close($m); + return $out; + } + $out['dropped']++; + } + @mysqli_query($m, 'SET FOREIGN_KEY_CHECKS=1'); + @mysqli_close($m); + $out['ok'] = true; + return $out; } /** - * MySQL 实连探测:连得上 + 库存在 + 能建表(建完立刻删)。 - * 失败抛 Exception,消息带 PHP/mysqli 原始错误码,方便部署者定位。 + * 扫 db(SQLite)模式下已存在的 .db 文件里的用户表。 + * @return array{ok:bool,err:string,tables:array,files:array} */ -function instProbeMySql($slot, array $e) { - $m = mysqli_init(); - if (!($m instanceof mysqli)) { - throw new Exception(strtoupper($slot) . ' 侧:mysqli_init 失败。'); +function instScanSqlite($dir) { + $out = array('ok' => false, 'err' => '', 'tables' => array(), 'files' => array()); + if (!extension_loaded('sqlite3')) { $out['err'] = 'sqlite3 扩展不可用'; return $out; } + $abs = instResolveDir($dir === '' ? './db/' : $dir); + if (!is_dir($abs)) { $out['ok'] = true; return $out; } // 目录都没有 = 空 + $cands = array(); + foreach (array('web.db', 'orders.db', 'login.db', 'Java.db') as $n) { + if (is_file($abs . '/' . $n)) $cands[] = $abs . '/' . $n; } - @mysqli_options($m, MYSQLI_OPT_CONNECT_TIMEOUT, 6); - $okc = @mysqli_real_connect($m, $e['host'], $e['user'], $e['pass'], $e['name'], (int)$e['port']); - if (!$okc) { - $errno = $m->connect_errno; - $msg = $m->connect_error; - throw new Exception(strtoupper($slot) . ' 侧连接失败 #' . $errno . ':' . $msg - . '(常见原因:MySQL 服务未启动、账密错误、库名不存在、端口不对)'); + foreach ((array)glob($abs . '/*.db') as $f) { + if (!in_array($f, $cands, true)) $cands[] = $f; } - // 建表权限探测(软依赖下建表失败只记日志,站点会悄悄跑在空库上,这里必须硬报) - $tbl = '_install_probe'; - $failed = ''; - if (!@mysqli_query($m, 'DROP TABLE IF EXISTS `' . $tbl . '`')) { - $failed = 'DROP: ' . mysqli_error($m); - } elseif (!@mysqli_query($m, 'CREATE TABLE `' . $tbl . "` (\n `id` BIGINT NOT NULL AUTO_INCREMENT, PRIMARY KEY (`id`)\n) ENGINE=InnoDB")) { - $failed = 'CREATE: ' . mysqli_error($m) . '(' . mysqli_errno($m) . ')'; - } else { - @mysqli_query($m, 'DROP TABLE IF EXISTS `' . $tbl . '`'); + if (empty($cands)) { $out['ok'] = true; return $out; } + foreach ($cands as $f) { + $db = @new SQLite3($f, SQLITE3_OPEN_READONLY); + if (!$db) continue; + $out['files'][] = basename($f); + $rs = @$db->query("SELECT name FROM sqlite_master WHERE type='table'" + . " AND name NOT LIKE 'sqlite_%' ORDER BY name"); + if ($rs) { + while ($row = $rs->fetchArray(SQLITE3_ASSOC)) { + $n = (string)$row['name']; + $cnt = -1; + $c = @$db->querySingle('SELECT COUNT(*) FROM "' . str_replace('"', '""', $n) . '"'); + if ($c !== null && $c !== false) $cnt = (int)$c; + $out['tables'][] = array('name' => $n, 'rows' => $cnt, 'file' => basename($f)); + } + } + $db->close(); } - @mysqli_close($m); - if ($failed !== '') { - throw new Exception(strtoupper($slot) . ' 侧连得上,但建表失败:' . $failed - . ' —— 站点初始化需要 CREATE 权限,请检查账号授权。'); + $out['ok'] = true; + return $out; +} + +/** 清空 db(SQLite)模式下已存在的用户表。@return array{ok:bool,err:string,dropped:int} */ +function instPurgeSqlite($dir) { + $out = array('ok' => false, 'err' => '', 'dropped' => 0); + if (!extension_loaded('sqlite3')) { $out['err'] = 'sqlite3 扩展不可用'; return $out; } + $abs = instResolveDir($dir === '' ? './db/' : $dir); + if (!is_dir($abs)) { $out['ok'] = true; return $out; } + $cands = array(); + foreach ((array)glob($abs . '/*.db') as $f) $cands[] = $f; + foreach ($cands as $f) { + $db = @new SQLite3($f); + if (!$db) continue; + $rs = @$db->query("SELECT name FROM sqlite_master WHERE type='table'" + . " AND name NOT LIKE 'sqlite_%' ORDER BY name"); + $names = array(); + if ($rs) { while ($row = $rs->fetchArray(SQLITE3_ASSOC)) $names[] = (string)$row['name']; } + foreach ($names as $n) { + if (!@$db->exec('DROP TABLE IF EXISTS "' . str_replace('"', '""', $n) . '"')) { + $out['err'] = '删除 ' . $n . ' 失败:' . $db->lastErrorMsg(); + $db->close(); + return $out; + } + $out['dropped']++; + } + $db->close(); } + $out['ok'] = true; + return $out; } // ============================ 生成 sql.md 配置区 ============================ /** 把本次配置序列化成带哨兵的 json 数组(与 sql.md 模板同形)。 */ function instBuildArray(array $c) { - $mkEntry = function ($slot, $e) use ($c) { + $mkEntry = function ($slot, $e) { return array( '归属' => $slot, '数据库地址' => $e['host'], @@ -265,61 +447,6 @@ function instFallbackMd($json) { . "问: 解析配置失败
\n答: 把整个json配置区丢入json校验器校验一次json格式\n"; } -// ============================ POST:写配置 ============================ -if ($_SERVER['REQUEST_METHOD'] === 'POST') { - $act = isset($_POST['act']) ? (string)$_POST['act'] : ''; - $got = isset($_POST['csrf']) ? (string)$_POST['csrf'] : ''; - if ($act !== 'save' || !hash_equals($csrf, $got)) { - $err = '请求校验失败,请刷新页面后重试。'; - } else { - try { - $cfg = instValidate($_POST); - $new = instSpliceMd(is_file(INSTALL_MD) ? @file_get_contents(INSTALL_MD) : '', - instBuildArray($cfg)); - - // --- 备份(*.bak* 在 gitignore 内,不会污染仓库) --- - $bak = ''; - if (is_file(INSTALL_MD)) { - $bak = INSTALL_MD . '.bak.' . date('YmdHis'); - if (!@copy(INSTALL_MD, $bak)) $bak = ''; - } - - // --- 原子写:先写 .tmp 再 rename,避免中途失败留下半份配置 --- - $tmp = INSTALL_MD . '.insttmp'; - if (@file_put_contents($tmp, $new, LOCK_EX) === false) { - throw new Exception('写入临时文件失败(目录不可写?):' . $tmp); - } - if (!@rename($tmp, INSTALL_MD)) { - @unlink($tmp); - // Windows 上 rename 覆盖已存在的目标可能失败 → 退化为直接写 - if (@file_put_contents(INSTALL_MD, $new, LOCK_EX) === false) { - throw new Exception('写入 sql.md 失败。'); - } - } - - // --- 回读校验:解出来必须与提交的一致,否则还原备份 --- - $chk = instVerifyWritten($cfg); - if ($chk !== true) { - if ($bak !== '') @copy($bak, INSTALL_MD); - @unlink($tmp); - throw new Exception('回读校验失败,已还原原配置。原因:' . $chk); - } - - // --- 锁定 + 自毁 --- - @file_put_contents(INSTALL_LOCK, date('c') . "\n" - . 'ip=' . secClientIp() . "\n" - . 'mode=' . $cfg['mode'] . "\n" - . 'md=' . INSTALL_MD . "\n", LOCK_EX); - @chmod(INSTALL_LOCK, 0644); - @unlink(__FILE__); - - $ok = $cfg['mode'] === 'mysql' ? 'MySQL 模式配置已写入' : 'db 模式配置已写入'; - } catch (Exception $e) { - $err = $e->getMessage(); - } - } -} - /** * 回读 sql.md 确认写进去的就是我们提交的那份(绕开 sqlMdDbConfig 的 static 缓存)。 * @return true|string true=通过;否则返回失败原因 @@ -376,29 +503,604 @@ function instVerifyWritten(array $want) { return true; } -// ============================ 渲染 ============================ -$modeNow = $cur['mode']; -$modeSel = ($_SERVER['REQUEST_METHOD'] === 'POST' && $err !== '') - ? (isset($_POST['mode']) ? strtolower(trim((string)$_POST['mode'])) : 'db') - : $modeNow; -$val = function ($k, $d = '') use ($cur, $err) { +// ============================ 第三关:SMTP ============================ +/** 生成 6 位数字验证码 */ +function instGenCode() { + return (string)random_int(100000, 999999); +} + +/** + * 极简 SMTP 客户端(不依赖 core.php —— 初始化阶段 core.php 未必能跑)。 + * 与 core.php 的 smtpSendEmail 同一套对话流程,失败抛 Exception。 + */ +function instSmtpSend($host, $port, $user, $pass, $ssl, $to, $subject, $body) { + $errno = 0; $errstr = ''; + $target = ($ssl ? 'ssl://' : '') . $host; + $socket = @fsockopen($target, (int)$port, $errno, $errstr, 20); + if (!$socket) throw new Exception('SMTP 连接失败:' . $errstr . ' (' . $errno . ')'); + stream_set_timeout($socket, 20); + + $response = fgets($socket); + if (strpos($response, '220') !== 0) { fclose($socket); throw new Exception('SMTP 服务器拒绝连接:' . trim($response)); } + + fwrite($socket, "EHLO localhost\r\n"); + fgets($socket); // 多行响应,忽略 + + fwrite($socket, "AUTH LOGIN\r\n"); + $response = fgets($socket); + if (strpos($response, '334') !== 0) { fclose($socket); throw new Exception('SMTP 认证被拒绝:' . trim($response)); } + + fwrite($socket, base64_encode($user) . "\r\n"); + $response = fgets($socket); + if (strpos($response, '334') !== 0) { fclose($socket); throw new Exception('SMTP 用户名错误:' . trim($response)); } + + fwrite($socket, base64_encode($pass) . "\r\n"); + $response = fgets($socket); + if (strpos($response, '235') !== 0) { fclose($socket); throw new Exception('SMTP 密码/授权码错误:' . trim($response)); } + + fwrite($socket, 'MAIL FROM:<' . $user . ">\r\n"); + fgets($socket); + fwrite($socket, 'RCPT TO:<' . $to . ">\r\n"); + $r = fgets($socket); + if (strpos($r, '250') !== 0 && strpos($r, '251') !== 0) { + fclose($socket); + throw new Exception('收件地址被拒:' . trim($r) . '(检查测试邮箱是否填对)'); + } + fwrite($socket, "DATA\r\n"); + fgets($socket); + + $hdr = 'From: ' . (defined('SMTP_SENDER_NAME') ? SMTP_SENDER_NAME : 'Sdf1_login') + . ' <' . $user . ">\r\n"; + $hdr .= 'To: <' . $to . ">\r\n"; + $hdr .= 'Subject: ' . $subject . "\r\n"; + $hdr .= "MIME-Version: 1.0\r\nContent-Type: text/html; charset=UTF-8\r\n"; + fwrite($socket, $hdr . "\r\n" . $body . "\r\n.\r\n"); + $r = fgets($socket); + fclose($socket); + if ($r !== false && strpos($r, '250') !== 0) { + throw new Exception('邮件投递被拒:' . trim($r)); + } + return true; +} + +// ============================ 第四关:写 config.php 主区 define ============================ +/** + * 精确替换 config.php 主区的单行 define(SMTP_* / CAPTCHA_PROVIDER 用)。 + * 只做「一行一 define」的确定性替换:找不到、或该行不是单行 define,一律报错不动手。 + * 走 security.php 的 secReplaceConfigFile(备份到 db/config_bak/ + 原子替换)。 + * + * @param array $kv key => PHP 字面量文本(如 "'smtp.qq.com'" / "true" / "'off'") + */ +function instConfigDefine(array $kv) { + $path = __DIR__ . '/config.php'; + $src = @file_get_contents($path); + if ($src === false) throw new Exception('读取 config.php 失败。'); + $srcLines = preg_split('/\r\n|\r|\n/', $src); + $lines = $srcLines; + + foreach ($kv as $key => $lit) { + if (!preg_match('/^[A-Z][A-Z0-9_]*$/', (string)$key)) { + throw new Exception('非法配置项名:' . $key); + } + $hit = -1; + for ($i = 0; $i < count($lines); $i++) { + if (preg_match('/^\s*define\s*\(\s*\'' . $key . '\'\s*,/', $lines[$i])) { $hit = $i; break; } + } + if ($hit < 0) throw new Exception('config.php 里找不到 ' . $key . ',已放弃整批写入(未改动任何文件)。'); + $orig = $lines[$hit]; + // 必须是「单行 define ... );」才敢动,值里含 // 也不会误伤(只认行尾 ); 之后的注释) + if (!preg_match('/^(\s*)define\s*\(\s*\'' . $key . '\'\s*,.*\)\s*;\s*(\/\/.*)?$/', $orig)) { + throw new Exception($key . ' 不是单行 define,无法安全改写,已放弃整批写入。'); + } + $indent = preg_match('/^\s*/', $orig, $mm) ? $mm[0] : ''; + $comment = preg_match('/\)\s*;\s*(\/\/.*)$/', $orig, $cm) ? ' ' . trim($cm[1]) : ''; + $lines[$hit] = $indent . "define('" . $key . "', " . $lit . ");" . $comment; + } + + $new = implode("\n", $lines); + if (count($lines) !== count($srcLines)) throw new Exception('config.php 替换后行数变化,已放弃。'); + foreach (array('SEC-UPDATE-BEGIN', 'SEC-UPDATE-END') as $mk) { + if (strpos($new, $mk) === false) throw new Exception('config.php 安全标记区被破坏,已放弃。'); + } + // 语法自检:token 化一遍确保没有截断成半个语句 + if (@token_get_all($new) === false) throw new Exception('config.php 改写后无法解析,已放弃。'); + + secReplaceConfigFile($src, $new, 'inst:define=' . implode(',', array_keys($kv))); + + $back = @file_get_contents($path); + if ($back === false) throw new Exception('写入后读不回 config.php。'); + foreach ($kv as $key => $lit) { + if (!preg_match('/define\s*\(\s*\'' . $key . '\'\s*,\s*' . preg_quote($lit, '/') . '\s*\)/', $back)) { + throw new Exception('config.php 回读校验失败:' . $key . ' 没写成预期值。'); + } + } + return true; +} + +/** + * 写 captcha_keys.php(第四关填了 CF/VA 密钥时)。 + * 保留模板的说明头,只改四个变量的值;写前备份。 + */ +function instWriteCaptchaKeys($provider, $k1, $k2) { + if ($provider === '' || $provider === 'off') return true; // 关闭 → 不动密钥文件 + $path = __DIR__ . '/captcha_keys.php'; + $tplHead = " session 暂存 > sql.md 已有配置 > 默认预填。 + */ +function instDbField($k) { + global $cur, $err; + // 1) 本次 POST 出错 → 回显用户刚填的(不清空他填的东西) if ($_SERVER['REQUEST_METHOD'] === 'POST' && $err !== '' && isset($_POST[$k])) { - return htmlspecialchars((string)$_POST[$k], ENT_QUOTES, 'UTF-8'); + return (string)$_POST[$k]; } + // 2) session 暂存(上一次成功校验过的) + if (isset($_SESSION['inst_raw2']) && is_array($_SESSION['inst_raw2']) + && isset($_SESSION['inst_raw2'][$k])) { + return (string)$_SESSION['inst_raw2'][$k]; + } + // 3) sql.md 已有配置(避免覆盖已填过的值) $cur = is_array($cur) ? $cur : array(); - if ($k === 'dir') return htmlspecialchars(isset($cur['dir']) ? (string)$cur['dir'] : '', ENT_QUOTES, 'UTF-8'); - if ($k === 'mode') return ''; if (preg_match('/^(java|web)_(host|port|name|user|pass)$/', $k, $m)) { - // ★ 全新站 sql.md 配置区账密全空 → sqlMdDbConfig 的 java/web 槽是 null - // ("字段不全 → null → 该侧兜底 db"),直接 $e[...] 在 PHP 8 会每个输入框 - // 报一条 "Trying to access array offset on null"(实录:test12 第 391 行)。 $e = (isset($cur[$m[1]]) && is_array($cur[$m[1]])) ? $cur[$m[1]] : array(); - $map = array('host' => 'host', 'port' => 'port', 'name' => 'name', 'user' => 'user', 'pass' => 'pass'); - $rv = isset($e[$map[$m[2]]]) ? (string)$e[$map[$m[2]]] : ''; - return htmlspecialchars($rv, ENT_QUOTES, 'UTF-8'); + $have = isset($e[$m[2]]) ? trim((string)$e[$m[2]]) : ''; + if ($have !== '') return $have; + // 4) 默认预填:本机 MySQL 的常规配置 —— 地址 127.0.0.1、端口 3306 + if ($m[2] === 'host') return '127.0.0.1'; + if ($m[2] === 'port') return '3306'; + return ''; } - return htmlspecialchars((string)$d, ENT_QUOTES, 'UTF-8'); -}; + if ($k === 'dir') { + $d = isset($cur['dir']) ? trim((string)$cur['dir']) : ''; + return ($d !== '') ? $d : ''; + } + return ''; +} + +// ============================ POST 分发 ============================ +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $act = isset($_POST['act']) ? (string)$_POST['act'] : ''; + $got = isset($_POST['csrf']) ? (string)$_POST['csrf'] : ''; + if (!hash_equals($csrf, $got)) { + $err = '请求校验失败,请刷新页面后重试。'; + } else { + try { + switch ($act) { + + // ---------- 第一关 → 第二关 ---------- + // 双扩展全缺也不在这里拦:锁死动作属于第二关(那里有「两种模式都用不了」 + // 的红条 + 两个单选的「扩展缺失已锁定」),instValidate 再兜底防提交。 + case 'n1': + $_SESSION['inst_step'] = 2; + $step = 2; + break; + + // ---------- 第二关:数据库 ---------- + case 'n2': + // 先原样存下用户输入,出错回显用 + $_SESSION['inst_raw2'] = $_POST; + $cfg = instValidate($_POST, $ext); + $_SESSION['inst_d2'] = $cfg; + unset($_SESSION['inst_purge']); + + // ★ 扫库:确认安装前先看目标库是不是空白 + $scan = instScanAll($cfg); + if (!$scan['ok']) { + throw new Exception('扫描数据库状态失败:' . $scan['err']); + } + if ($scan['empty']) { + // 静默继续 + $_SESSION['inst_step'] = 3; + $step = 3; + } else { + // 立即刹车 + $_SESSION['inst_purge'] = array('stage' => 'warn', 'scan' => $scan); + secLog('install_db_notempty', 'tables=' . count($scan['tables'])); + $step = 2; + } + break; + + // ---------- 第二关:刹车 → 取消(退回重新输入数据库) ---------- + case 'pcancel': + unset($_SESSION['inst_purge']); + $step = 2; + break; + + // ---------- 第二关:刹车 → 继续(展开二次确认输入框) ---------- + case 'pack': + $p = isset($_SESSION['inst_purge']) ? $_SESSION['inst_purge'] : null; + if (!is_array($p)) throw new Exception('状态已失效,请重新提交数据库配置。'); + $p['stage'] = 'ask'; + $_SESSION['inst_purge'] = $p; + $step = 2; + break; + + // ---------- 第二关:二次确认 → 真的清空 ---------- + case 'pdo': + $p = isset($_SESSION['inst_purge']) ? $_SESSION['inst_purge'] : null; + if (!is_array($p)) throw new Exception('状态已失效,请重新提交数据库配置。'); + $typed = isset($_POST['purge_phrase']) ? trim((string)$_POST['purge_phrase']) : ''; + if ($typed !== INST_PURGE_PHRASE) { + $p['stage'] = 'ask'; + $p['bad'] = true; + $_SESSION['inst_purge'] = $p; + throw new Exception('二次确认口令不匹配 —— 必须一字不差输入「' + . INST_PURGE_PHRASE . '」才会清空。'); + } + $cfg2 = isset($_SESSION['inst_d2']) ? $_SESSION['inst_d2'] : null; + if (!is_array($cfg2)) throw new Exception('数据库配置丢失,请重新填写。'); + $pg = instPurgeAll($cfg2); + if (!$pg['ok']) throw new Exception('清空失败:' . $pg['err']); + unset($_SESSION['inst_purge']); + secLog('install_db_purged', 'dropped=' . $pg['dropped'] . ' mode=' . $cfg2['mode']); + $_SESSION['inst_step'] = 3; + $step = 3; + $notice = '已清空 ' . $pg['dropped'] . ' 张表,数据库现在是空白状态,进入下一关。'; + break; + + // ---------- 第三关:发送测试验证码 ---------- + case 'scode': + $_SESSION['inst_raw3'] = $_POST; + $s = instSmtpCollect($_POST); // 抛异常 = 半空/非法 + if ($s['empty']) { + throw new Exception('SMTP 信息全空表示「不配置邮箱」—— 不用点发送,直接点「下一步」即可。'); + } + $code = instGenCode(); + $_SESSION['inst_smtp'] = $s; + $_SESSION['inst_code'] = $code; + $_SESSION['inst_code_at'] = time(); + $html = instMailBody($code); + instSmtpSend($s['host'], $s['port'], $s['user'], $s['pass'], $s['ssl'], + $s['to'], '【Sdf1_login】邮箱验证验证码', $html); + $notice = '验证码已发送到 ' . htmlspecialchars($s['to'], ENT_QUOTES, 'UTF-8') + . ',请查收后填入下方(5 分钟内有效)。没收到请检查垃圾箱。'; + secLog('install_smtp_sent', 'to=' . $s['to'] . ' host=' . $s['host']); + break; + + // ---------- 第三关:下一步(全空直接过 / 全不空要验证码对) ---------- + case 'n3': + $_SESSION['inst_raw3'] = $_POST; + $s = instSmtpCollect($_POST); + if ($s['empty']) { + // 全空 → 不配邮箱,直接过 + $_SESSION['inst_d3'] = array('empty' => true); + unset($_SESSION['inst_smtp'], $_SESSION['inst_code'], $_SESSION['inst_code_at']); + $_SESSION['inst_step'] = 4; + $step = 4; + break; + } + // 全不空 → 必须有验证码且比对通过 + $typed = isset($_POST['code']) ? trim((string)$_POST['code']) : ''; + // 注意:这里不能复用 $saved —— 那是完成页回显整份配置用的变量 + $expectCode = (isset($_SESSION['inst_code']) && $_SESSION['inst_code'] !== '') + ? (string)$_SESSION['inst_code'] : ''; + $at = isset($_SESSION['inst_code_at']) ? (int)$_SESSION['inst_code_at'] : 0; + if ($expectCode === '') { + throw new Exception('请先点「发送测试验证码」,收到码后再提交。'); + } + if (time() - $at > 300) { + unset($_SESSION['inst_code'], $_SESSION['inst_code_at']); + throw new Exception('验证码已过期(5 分钟),请重新发送。'); + } + if (!hash_equals($expectCode, $typed)) { + throw new Exception('验证码不正确,请核对测试邮箱里的 6 位数字。'); + } + $_SESSION['inst_d3'] = array('empty' => false, 'cfg' => $s); + unset($_SESSION['inst_code'], $_SESSION['inst_code_at']); + $_SESSION['inst_step'] = 4; + $step = 4; + break; + + // ---------- 第四关:确认安装(统一写盘 + 自毁) ---------- + case 'n4': + $d2 = isset($_SESSION['inst_d2']) && is_array($_SESSION['inst_d2']) ? $_SESSION['inst_d2'] : null; + if (!is_array($d2)) throw new Exception('数据库配置丢失,请回到第二关重新填写。'); + $d3 = (isset($_SESSION['inst_d3']) && is_array($_SESSION['inst_d3'])) ? $_SESSION['inst_d3'] + : array('empty' => true); + + // ---- 第四关输入 ---- + $wlText = isset($_POST['whitelist']) ? (string)$_POST['whitelist'] : ''; + $items = secParseWhitelistText($wlText); + $items = array_values(array_unique(array_filter($items, function ($v) { + $v = trim((string)$v); + return $v !== ''; + }))); + if (empty($items)) { + throw new Exception('IP 白名单是必填项 —— 至少要有一条,否则除了服务器本机谁也进不了后台。' + . '点「使用当前访问 IP」可以自动填入。'); + } + $provider = isset($_POST['cap_provider']) ? strtolower(trim((string)$_POST['cap_provider'])) : 'off'; + if (!in_array($provider, array('off', 'cf', 'va'), true)) $provider = 'off'; + $cap1 = isset($_POST['cap_k1']) ? trim((string)$_POST['cap_k1']) : ''; + $cap2 = isset($_POST['cap_k2']) ? trim((string)$_POST['cap_k2']) : ''; + if ($provider !== 'off' && ($cap1 === '' || $cap2 === '')) { + throw new Exception('选择了验证码厂商就必须把两个密钥都填上;不想配就选「关闭」。'); + } + $go2fa = !empty($_POST['go2fa']); + $_SESSION['inst_raw4'] = $_POST; + + // ================= 统一写盘 ================= + $written = array(); + + // ① config.php 主区:SMTP_*(第三关填了才写)+ CAPTCHA_PROVIDER + $kv = array(); + $kv['CAPTCHA_PROVIDER'] = ($provider === 'off') ? "'off'" + : (($provider === 'cf') ? "'CF'" : "'VA'"); + if (isset($d3['empty']) && !$d3['empty'] && is_array($d3['cfg'])) { + $sc = $d3['cfg']; + $kv['SMTP_HOST'] = "'" . addslashes($sc['host']) . "'"; + $kv['SMTP_PORT'] = "'" . (string)$sc['port'] . "'"; + $kv['SMTP_USER'] = "'" . addslashes($sc['user']) . "'"; + $kv['SMTP_PASS'] = "'" . addslashes($sc['pass']) . "'"; + $kv['SMTP_USE_SSL'] = $sc['ssl'] ? 'true' : 'false'; + $written[] = 'SMTP 已写入 config.php'; + } else { + $written[] = 'SMTP 未配置(保持 config.php 原值)'; + } + instConfigDefine($kv); + $written[] = '人机验证码 = ' . ($provider === 'off' ? '关闭' : strtoupper($provider)); + + // ② captcha_keys.php(选了厂商才有) + if ($provider !== 'off') { + instWriteCaptchaKeys($provider, $cap1, $cap2); + $written[] = '验证码密钥已写入 captcha_keys.php'; + } + + // ③ config.php SEC-UPDATE 区:IP 白名单 + secWriteConfig(array('ADMIN_IP_WHITELIST' => $items)); + $written[] = 'IP 白名单 ' . count($items) . ' 条已写入'; + + // ④ sql.md 配置区 + $new = instSpliceMd(is_file(INSTALL_MD) ? @file_get_contents(INSTALL_MD) : '', + instBuildArray($d2)); + $bak = ''; + if (is_file(INSTALL_MD)) { + $bak = INSTALL_MD . '.bak.' . date('YmdHis'); + if (!@copy(INSTALL_MD, $bak)) $bak = ''; + } + $tmp = INSTALL_MD . '.insttmp'; + if (@file_put_contents($tmp, $new, LOCK_EX) === false) { + throw new Exception('写入临时文件失败(目录不可写?):' . $tmp); + } + if (!@rename($tmp, INSTALL_MD)) { + @unlink($tmp); + if (@file_put_contents(INSTALL_MD, $new, LOCK_EX) === false) { + throw new Exception('写入 sql.md 失败。'); + } + } + $chk = instVerifyWritten($d2); + if ($chk !== true) { + if ($bak !== '') @copy($bak, INSTALL_MD); + @unlink($tmp); + throw new Exception('sql.md 回读校验失败,已还原原配置。原因:' . $chk); + } + $written[] = '运行模式与数据库地址已写入 sql.md'; + + // ⑤ 锁定 + 自毁 + @file_put_contents(INSTALL_LOCK, date('c') . "\n" + . 'ip=' . secClientIp() . "\n" + . 'mode=' . $d2['mode'] . "\n" + . 'md=' . INSTALL_MD . "\n", LOCK_EX); + @chmod(INSTALL_LOCK, 0644); + @unlink(__FILE__); + + $saved = array( + 'db' => $d2, 'smtp' => $d3, 'whitelist' => $items, + 'provider' => $provider, 'written' => $written, 'go2fa' => $go2fa, + ); + $ok = ($d2['mode'] === 'mysql') ? 'MySQL 模式配置已写入' : 'db 模式配置已写入'; + $_SESSION['inst_step'] = 5; + $step = 5; + break; + + // ---------- 上一步 ---------- + case 'prev': + $from = isset($_POST['from']) ? (int)$_POST['from'] : $step; + $step = max(1, $from - 1); + // 刹车状态只在第二关有意义 + if ($step !== 2) unset($_SESSION['inst_purge']); + $_SESSION['inst_step'] = $step; + break; + + default: + throw new Exception('未知操作:' . $act); + } + } catch (Exception $e) { + $err = $e->getMessage(); + $_SESSION['inst_step'] = $step; // 出错留在当前关 + } + } +} + +/** 汇总第二关配置的所有侧,扫一遍数据库状态。 */ +function instScanAll(array $cfg) { + $out = array('ok' => false, 'err' => '', 'empty' => true, 'tables' => array(), 'mode' => $cfg['mode']); + if ($cfg['mode'] === 'mysql') { + $any = false; + foreach (array('java', 'web') as $slot) { + $e = $cfg[$slot]; + if ($e['host'] === '' && $e['name'] === '') continue; // 该侧回退 db + $any = true; + $r = instScanMysqlSide($e); + if (!$r['ok']) { $out['err'] = strtoupper($slot) . ' 侧:' . $r['err']; return $out; } + foreach ($r['tables'] as $t) { + $t['slot'] = $slot; + $t['db'] = $e['name']; + $out['tables'][] = $t; + } + } + if (!$any) { $out['err'] = 'mysql 模式下没有任何一侧地址生效'; return $out; } + } else { + $r = instScanSqlite($cfg['dir']); + if (!$r['ok']) { $out['err'] = $r['err']; return $out; } + foreach ($r['tables'] as $t) $out['tables'][] = $t; + } + $out['ok'] = true; + $out['empty'] = empty($out['tables']); + return $out; +} + +/** 按第二关配置清空全部侧。 */ +function instPurgeAll(array $cfg) { + $out = array('ok' => false, 'err' => '', 'dropped' => 0); + if ($cfg['mode'] === 'mysql') { + foreach (array('java', 'web') as $slot) { + $e = $cfg[$slot]; + if ($e['host'] === '' && $e['name'] === '') continue; + $r = instPurgeMysqlSide($e); + if (!$r['ok']) { $out['err'] = strtoupper($slot) . ' 侧:' . $r['err']; return $out; } + $out['dropped'] += $r['dropped']; + } + } else { + $r = instPurgeSqlite($cfg['dir']); + if (!$r['ok']) { $out['err'] = $r['err']; return $out; } + $out['dropped'] += $r['dropped']; + } + $out['ok'] = true; + return $out; +} + +/** + * 收集第三关 SMTP 输入。全空返回 empty=true;半空抛异常;全不空返回配置。 + */ +function instSmtpCollect(array $post) { + $g = function ($k) use ($post) { + return isset($post[$k]) ? trim((string)$post[$k]) : ''; + }; + $host = $g('smtp_host'); + $user = $g('smtp_user'); + $pass = isset($post['smtp_pass']) ? trim((string)$post['smtp_pass']) : ''; + $to = $g('smtp_to'); + $port = $g('smtp_port'); + $ssl = !isset($post['smtp_ssl']) || $post['smtp_ssl'] !== '0'; + + $vals = array('SMTP 服务器' => $host, '用户名/发信账号' => $user, + '密码(授权码)' => $pass, '测试邮箱' => $to); + $filled = array(); + $blank = array(); + foreach ($vals as $label => $v) { + if ($v === '') $blank[] = $label; else $filled[] = $label; + } + if (empty($filled)) { + return array('empty' => true); // 全空 = 不配邮箱 + } + if (!empty($blank)) { + throw new Exception('SMTP 要么全填、要么全空。当前还缺:' . implode('、', $blank) + . '。
不想配置邮箱(找回密码、订单通知将发不出去)就把上面四项全部清空,' + . '再点「下一步」。'); + } + if ($port === '') $port = '465'; + if (!ctype_digit($port)) throw new Exception('SMTP 端口必须是纯数字。'); + $p = (int)$port; + if ($p < 1 || $p > 65535) throw new Exception('SMTP 端口超出范围(1-65535)。'); + if (strpos($to, '@') === false) throw new Exception('测试邮箱格式不正确。'); + return array('empty' => false, 'host' => $host, 'port' => $p, 'user' => $user, + 'pass' => $pass, 'to' => $to, 'ssl' => $ssl); +} + +/** 测试邮件正文。 */ +function instMailBody($code) { + return "
" + . "

邮箱验证验证码

" + . "

你正在配置 Sdf1_login 站点的 SMTP 邮箱。" + . "下面这串验证码用于确认这个邮箱确实能发信:

" + . "
" + . htmlspecialchars($code, ENT_QUOTES, 'UTF-8') . "
" + . "

验证码 5 分钟内有效。" + . "不是你本人操作请忽略本邮件。

"; +} + +// ============================ 渲染 ============================ +// 第二关的刹车状态 +$purge = (isset($_SESSION['inst_purge']) && is_array($_SESSION['inst_purge'])) ? $_SESSION['inst_purge'] : null; +$purgeStage = is_array($purge) && isset($purge['stage']) ? $purge['stage'] : ''; + +// 第三关回显源 +$raw3 = (isset($_SESSION['inst_raw3']) && is_array($_SESSION['inst_raw3'])) ? $_SESSION['inst_raw3'] : array(); +$smtpSent = (isset($_SESSION['inst_code']) && $_SESSION['inst_code'] !== ''); + +// 第四关回显源 +$raw4 = (isset($_SESSION['inst_raw4']) && is_array($_SESSION['inst_raw4'])) ? $_SESSION['inst_raw4'] : array(); +$myIp = secClientIp(); +$curProvider = 'off'; +if (isset($raw4['cap_provider'])) { + $curProvider = strtolower(trim((string)$raw4['cap_provider'])); +} elseif (defined('CAPTCHA_PROVIDER')) { + $v = strtolower(trim((string)CAPTCHA_PROVIDER)); + $curProvider = in_array($v, array('va', 'v', 'vaptcha'), true) ? 'va' + : (($v === '' || $v === 'off' || $v === 'none' || $v === 'false' || $v === '0') ? 'off' : 'cf'); +} +$curWl = ''; +if (isset($raw4['whitelist'])) { + $curWl = (string)$raw4['whitelist']; +} elseif (defined('ADMIN_IP_WHITELIST') && is_array(ADMIN_IP_WHITELIST) && !empty(ADMIN_IP_WHITELIST)) { + $curWl = implode("\n", ADMIN_IP_WHITELIST); +} else { + $curWl = $myIp; // 未配置时默认预填当前访问 IP +} +$curK1 = isset($raw4['cap_k1']) ? (string)$raw4['cap_k1'] : ''; +$curK2 = isset($raw4['cap_k2']) ? (string)$raw4['cap_k2'] : ''; + +$modeNow = is_array($cur) && isset($cur['mode']) ? $cur['mode'] : 'db'; +$modeSel = ''; +if ($_SERVER['REQUEST_METHOD'] === 'POST' && $err !== '' && isset($_POST['mode'])) { + $modeSel = strtolower(trim((string)$_POST['mode'])); +} elseif (isset($_SESSION['inst_raw2']['mode'])) { + $modeSel = strtolower(trim((string)$_SESSION['inst_raw2']['mode'])); +} elseif ($modeNow !== '') { + $modeSel = $modeNow; +} +// 第一关锁死后强制回到可用模式 +if ($modeSel === 'db' && empty($ext['any_db'])) $modeSel = $ext['any_mysql'] ? 'mysql' : 'db'; +if ($modeSel === 'mysql' && empty($ext['any_mysql'])) $modeSel = $ext['any_db'] ? 'db' : 'mysql'; +if ($modeSel !== 'db' && $modeSel !== 'mysql') $modeSel = 'db'; + +$f = function ($k) { return htmlspecialchars(instDbField($k), ENT_QUOTES, 'UTF-8'); }; ?> @@ -406,19 +1108,20 @@ $val = function ($k, $d = '') use ($cur, $err) { -数据库配置 · 初始化 +安装向导 · 初始化
-

数据库配置初始化

-

本页把运行模式与 MySQL 地址写进 sql.md 的配置区, - 写入成功后会自动删除本文件并生成 install.lock 锁定。 - 页面配置好前请勿关闭;只保留本机或持引导令牌可访问。

+

安装向导

+

四步完成站点初始化:运行环境 → 数据库 → 邮箱 → 安全。全部写入成功后本页会 + 自动删除并生成 install.lock 锁定。配置好前请勿关闭页面。

- +
+ array('第一关', '运行环境'), 2 => array('第二关', '数据库'), + 3 => array('第三关', '邮箱'), 4 => array('第四关', '安全')); + foreach ($titles as $n => $t): + $cls = ($step === $n) ? 'on' : (($step > $n || $step === 5) ? 'done' : ''); + ?> +
+ +
+ +
    -
  • 配置已写入 sql.md 并回读校验通过。
  • +
  • 配置已写入并逐项回读校验通过。
  • 已生成 install.lock,本页再次访问将返回 404。
  • -
  • install.php 已自我删除(后台静默完成,页面照常停留供你截图留档)。
  • +
  • install.php 已自我删除(后台静默完成,页面照常停留供你截图留档,请勿刷新)。
@@ -462,104 +1201,329 @@ ul{margin:6px 0;padding-left:20px}
📋 本次写入的配置 —— 请截图或手抄留档:
- - + + - + - - + + 'Java 侧(sync.php · Java.db)', 'web' => 'PHP 侧(web.db)') as $slot => $label): ?> - + - + + + + + + + + + + + + +
运行模式运行模式
存储位置存储位置
(空 → 自动回退 db 文件)(空 → 自动回退 db 文件)
邮箱 SMTP未配置(找回密码、订单通知发不出去)
IP 白名单
人机验证码
-
本页已随安装完成而删除 —— 这是最后一处能整份查看该配置的页面;完整配置仍保存在 sql.md。
+
本页已随安装完成而删除 —— 这是最后一处能整份查看该配置的页面;完整配置仍保存在 sql.md 与 config.php。
+
+ +
+
写入明细:
+
下一步:
    -
  • 先去 安全配置引导页:把你当前的 IP 加入白名单、保存访问令牌、设置 2FA。
  • -
  • mysql 模式 → 配完安全项首次进后台会自动建表(已在本页实连并验证过建表权限);db 模式 → 配完即可登录。
  • + +
  • 正在跳转到 安全配置引导页:配置 2FA 验证器与访问令牌。
  • + +
  • 2FA 二次验证尚未配置 —— 建议尽快去 安全配置引导页 配好(不配则后台只靠 IP 白名单 + 访问令牌把关)。
  • + +
  • mysql 模式 → 首次进后台会自动建表(已在第二关实连并验证过建表权限);db 模式 → 配完即可登录。
  • 记得让 Java 端(api/sync.php 所在的独立站点)也用同一份配置:改 sync.php 时两处都要传。
+ + +
-
写入失败:
+
本步未通过:
+ + +
+ + +

+ 安装已完成。如果需要查看配置,请打开 sql.md(数据库)与 config.php(邮箱、验证码、白名单)。 +

+ + +
- + - -

运行模式

+

第一关 · 运行环境检测

+

先确认 PHP 扩展齐全。缺扩展在这里只是「选项灰掉」,不会把底层原始报错甩到你脸上。

+ + + + + + + + + + + + + + + + + +
sqlite3已安装' : 'n">未安装'; ?>db(SQLite 文件)模式依赖它
pdo_sqlite已安装' : 'n">未安装'; ?>正版验证接口依赖它(缺失只影响该功能,不影响安装)
mysqli已安装' : 'n">未安装'; ?>mysql 模式依赖它
+ + +
两种模式都用不了:当前 PHP 既没有 sqlite3 也没有 mysqli。 + 请先在宝塔面板 → PHP 设置 → 安装扩展里装好其中之一,再刷新本页。
+ +
检测到缺少 sqlite3 → 下一关的 db 模式会被锁定,只能用 mysql。
+ +
检测到缺少 mysqli → 下一关的 mysql 模式会被锁定,只能用 db。
+ +
扩展齐全,db 与 mysql 两种模式都可以选。
+ + +
+
+ + + +
+ + + + + +

第二关 · 数据库

+ + + +
+ ⚠️ 目标数据库不是空白的,已暂停安装。 +
继续下去会把下面这些表当作已有数据处理,站点初始化可能报错或串数据。 + 先确认它们是不是你需要的。
+ + + + + + + + + +
库 / 文件表行数(约)
+ +
+ 二次确认:要清空上述全部表,请在下面一字不差输入 + + + +
口令不对,未执行任何删除。
+ +
+ +
+ +
+ + + + + + + +
+
+ +
- - + +
- +

/ 开头=绝对路径;./ 开头=相对本目录;../ 开头=相对上层目录。

-
每侧地址要么填全、要么全空;留空的一侧会自动回退 db 文件。 +
地址与端口已按本机 MySQL 的常规配置预填(127.0.0.1:3306)—— + 用本机 MySQL 的话,你只需要填账号、密码、库名。
+ 每侧地址要么填全、要么全空;留空的一侧会自动回退 db 文件。 本页会真实连接并尝试建表,失败会直接报错,不会悄悄写入一份跑不通的配置。

Java 侧(api/sync.php 通信组 · Java.db)

-
-
+
+
-
-
+
+
- +

PHP 侧(web.db)

-
-
+
+
-
-
+
+
- +
- +
+ + + +
+ + + + + +
+ + + + + +

第三关 · 邮箱(SMTP)

+
要么全填,要么全空。
+ 全空 → 直接进最后一关(站点不能发邮件:找回密码、订单通知都发不出去)。
+ 全不空 → 必须收一个测试验证码,确认这个邮箱真的能发信,才放你进下一关。
+ +
+
+
+
+
+
+ + + + +
+ > + +
+ + + + + +
+
+
+ +
+
+ +

验证码已发出,5 分钟内有效;没收到请检查垃圾箱或确认授权码。

+ + +
+ + + +
+

不想配置邮箱?把上面四个字段全部清空,直接点「下一步」。

+
+ + + +
+ + + + + +

第四关 · 安全

+ + + +
+ +
+

当前访问 IP: + —— 它必须在白名单里,否则保存后你自己也进不了后台(除了服务器本机 127.0.0.1)。

+ +

人机验证码 (选填,留空即关闭)

+ + + +
+
+
+
+
+
+
+

CF:控制台 → Workers & Pages → Turnstile → Add site,拿 Sitekey 与 Secret。
+ VA:VAPTCHA 控制台 → 创建应用,拿 VID 与 VKey。

+
+ +

2FA 二次验证 (选填)

+
2FA 验证器(Google Authenticator / 1Password / Microsoft Authenticator) + 在配置完成后的安全引导页里设置 —— 那里会生成密钥、显示二维码并要求你输一次动态码确认。
+ 下面两个按钮都会在安装完成后跳过去;选「稍后再配」则后台只靠 IP 白名单 + 访问令牌把关。
+ +
+ + + + + +
+

点「确认安装」后会一次性写入全部配置、生成 install.lock 并删除本页。

- -

- 当前 sql.md 已有配置 —— 模式 , - 存储位置 。 - 重新保存会覆盖它。 -

@@ -568,9 +1532,9 @@ ul{margin:6px 0;padding-left:20px} 你当前来自 127.0.0.1/::1(本机),可直接配置。 - 需要 ?boot=引导令牌 或你的 IP 已在白名单内,否则返回 nginx 404。 + 需要 ?token=访问令牌 或已通过引导,否则返回 nginx 404。 -
本页不加载 core.php;写入前会自动备份 sql.md.bak.时间戳,失败会自动还原。 +
本页不加载 core.php;所有写入都会先备份再原子替换,失败自动还原。 @@ -580,11 +1544,32 @@ function mSwitch(el){ document.getElementById('dirBox').style.display = db ? '' : 'none'; document.getElementById('mysqlBox').style.display = db ? 'none' : ''; var ls = document.querySelectorAll('.modes label'); - for (var i=0;i; + t.focus(); } (function(){ - var sel = document.querySelector('.modes input:checked'); + var sel = document.querySelector('.modes input:checked:not([disabled])'); if (sel) mSwitch(sel); + else { + var first = document.querySelector('.modes input:not([disabled])'); + if (first) { first.checked = true; mSwitch(first); } + else { document.getElementById('dirBox').style.display='none'; + document.getElementById('mysqlBox').style.display='none'; } + } + var ps = document.querySelector('select[name=cap_provider]'); + if (ps) capSwitch(ps); })();