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 邮箱。" + . "下面这串验证码用于确认这个邮箱确实能发信:
" + . "验证码 5 分钟内有效。" + . "不是你本人操作请忽略本邮件。
本页把运行模式与 MySQL 地址写进 sql.md 的配置区,
- 写入成功后会自动删除本文件并生成 install.lock 锁定。
- 页面配置好前请勿关闭;只保留本机或持引导令牌可访问。
四步完成站点初始化:运行环境 → 数据库 → 邮箱 → 安全。全部写入成功后本页会
+ 自动删除并生成 install.lock 锁定。配置好前请勿关闭页面。
sql.md 并回读校验通过。install.lock,本页再次访问将返回 404。install.php 已自我删除(后台静默完成,页面照常停留供你截图留档)。install.php 已自我删除(后台静默完成,页面照常停留供你截图留档,请勿刷新)。| 运行模式 | - |
+ 运行模式 | + |
| 存储位置 | - |
+ 存储位置 | + |
| - | (空 → 自动回退 db 文件) |
+ (空 → 自动回退 db 文件) |
|
| 邮箱 SMTP | +未配置(找回密码、订单通知发不出去) |
+ ||
| IP 白名单 | + |
+ ||
| 人机验证码 | + |
+
sql.md。sql.md 与 config.php。
+ 安装已完成。如果需要查看配置,请打开 sql.md(数据库)与 config.php(邮箱、验证码、白名单)。
+
/ 开头=绝对路径;./ 开头=相对本目录;../ 开头=相对上层目录。
127.0.0.1:3306)——
+ 用本机 MySQL 的话,你只需要填账号、密码、库名。
- 当前 sql.md 已有配置 —— 模式 ,
- 存储位置 。
- 重新保存会覆盖它。
-
sql.md.bak.时间戳,失败会自动还原。
+