5) $step = 1; // ============================ 基础工具 ============================ /** 当前 ip 是否本地(本机访问给出更宽松的提示文案) */ function instIsLocal() { $ip = secClientIp(); return $ip === '127.0.0.1' || $ip === '::1'; } /** * 第一关:扩展检测。 * 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 权限,请检查账号授权。'); } } // ============================ 数据库自身持锁 ============================ // 2026-10-09 需求原话:「install.lock 不在了 ≠ 首次安装。安装成功后,不管是 db 数据库、 // MySQL 数据库都通过数据库去锁。数据库不存在、install.lock 不存在 = 全新未安装; // 数据库安装锁存在、install.lock 不存在,自动补 install.lock; // install.lock 存在、数据库不存在,提示用户数据丢失需额外关注。」 // // 载体 = 目标库里的 `_install_lock` 标记表:db 模式写 web.db,mysql 模式写 web 侧库。 // Java 侧的库归插件管 —— 门禁只读探测、绝不写入。 // 标记表在扫库时被忽略(不算「非空」),在清空时一并删掉(清完真的空)。 /** 库内安装锁标记表名。 */ function instLockTable() { return '_install_lock'; } /** * 探测一个 SQLite 库文件:存在性 + 库内锁标记。 * @return array{state:string,lock:bool,detail:string,err:string} */ function instProbeSqliteLock($f) { $out = array('state' => 'missing', 'lock' => false, 'detail' => (string)$f, 'err' => ''); if (!is_file($f)) return $out; $out['state'] = 'exists'; // 文件在 = 库在(打不开也按在处理,从严) if (!class_exists('SQLite3')) { $out['err'] = 'sqlite3 扩展不可用,读不了库内锁'; return $out; } $db = @new SQLite3($f, SQLITE3_OPEN_READONLY); if (!$db) { $out['err'] = '打不开 ' . $f; return $out; } try { $c = @$db->querySingle('SELECT COUNT(*) FROM "' . str_replace('"', '""', instLockTable()) . '"'); if ($c !== null && $c !== false && (int)$c > 0) $out['lock'] = true; } catch (Exception $ex) { $out['err'] = $ex->getMessage(); // 表不存在 = 这个库没写过安装锁 } $db->close(); return $out; } /** * 探测一个 MySQL 库:存在性 + 库内锁标记。 * 只有 #1049(Unknown database)算「库明确不存在」;连不上/账密错一律 unknown(门禁从严按「库在」)。 * @return array{state:string,lock:bool,detail:string,err:string} */ function instProbeMysqlLock(array $e) { $out = array('state' => 'unknown', 'lock' => false, 'detail' => $e['host'] . ':' . $e['port'] . '/' . $e['name'], 'err' => ''); if (!class_exists('mysqli')) { $out['err'] = 'mysqli 扩展不可用'; return $out; } $m = mysqli_init(); $ok = false; $errno = 0; try { @mysqli_options($m, MYSQLI_OPT_CONNECT_TIMEOUT, 3); $ok = @mysqli_real_connect($m, $e['host'], $e['user'], $e['pass'], $e['name'], (int)$e['port']); if (!$ok) $errno = (int)$m->connect_errno; } catch (Exception $ex) { // PHP 8.1+ mysqli 默认抛异常 $ok = false; $errno = (int)$ex->getCode(); if ($errno === 0 && preg_match('/#(\d+)/', $ex->getMessage(), $mm)) $errno = (int)$mm[1]; } if (!$ok) { $out['state'] = ($errno === 1049) ? 'missing' : 'unknown'; $out['err'] = '#' . $errno; return $out; } $out['state'] = 'exists'; try { $rs = @mysqli_query($m, 'SELECT COUNT(*) AS c FROM `' . str_replace('`', '``', instLockTable()) . '`'); if ($rs !== false) { $row = mysqli_fetch_assoc($rs); if (isset($row['c']) && (int)$row['c'] > 0) $out['lock'] = true; } } catch (Exception $ex) { $out['err'] = $ex->getMessage(); // 表不存在 = 这个库没写过安装锁 } @mysqli_close($m); return $out; } /** * 探测「数据库自身持锁」三态。门禁据此分流(见文件头设计要点 5)。 * @return array{state:string,lock:bool,mode:string,detail:string,err:string} * state: 'exists' 库在 / 'missing' 库明确不存在 / 'unknown' 探测不了 * lock : 库里是否存在 _install_lock 安装锁标记 */ function instDbLockProbe() { $p = array('state' => 'unknown', 'lock' => false, 'mode' => 'db', 'detail' => '', 'err' => ''); // 站点运行时真正读的是 sql.md → 以它为准;读不出配置时退回 install.lock 记录的 mode。 $cfg = sqlMdDbConfig(); $mode = $cfg['ok'] ? $cfg['mode'] : 'db'; if (!$cfg['ok'] && is_file(INSTALL_LOCK)) { $t = @file_get_contents(INSTALL_LOCK); if (is_string($t) && preg_match('/^mode=(mysql|db)/m', $t, $mm)) $mode = $mm[1]; } $p['mode'] = $mode; if ($mode === 'mysql') { // 每侧:配了 MySQL 就探 MySQL,全空则该侧回退 db → 探 SQLite 文件 $states = array(); foreach (array('java', 'web') as $slot) { $e = (isset($cfg[$slot]) && is_array($cfg[$slot])) ? $cfg[$slot] : null; if ($e !== null && isset($e['host']) && $e['host'] !== '' && isset($e['name']) && $e['name'] !== '') { $r = instProbeMysqlLock($e); } else { $r = instProbeSqliteLock(sqlDbFile($slot)); } $states[] = $r['state']; if (!empty($r['lock'])) $p['lock'] = true; if ($r['detail'] !== '') $p['detail'] .= ($p['detail'] === '' ? '' : ';') . $r['detail']; if ($r['err'] !== '' && $p['err'] === '') $p['err'] = $r['err']; } if (in_array('exists', $states, true)) $p['state'] = 'exists'; elseif (count(array_diff($states, array('missing'))) === 0) $p['state'] = 'missing'; else $p['state'] = 'unknown'; } else { // db 模式:目录里一个 .db 都没有才算「数据库不存在」(避免误报数据丢失) $dir = sqlDbDir(); $files = array(); foreach (array(sqlDbFile('web'), sqlDbFile('java')) as $f) { if (is_file($f)) $files[] = $f; } foreach ((array)@glob($dir . '/*.db') as $f) { if (is_file($f) && !in_array($f, $files, true)) $files[] = $f; } if (empty($files)) { $p['state'] = 'missing'; $p['detail'] = '空目录 ' . $dir; } else { $p['state'] = 'exists'; $p['detail'] = implode('、', $files); $r = instProbeSqliteLock(sqlDbFile('web')); $p['lock'] = !empty($r['lock']); if ($r['err'] !== '') $p['err'] = $r['err']; } } if ($p['lock']) $p['state'] = 'exists'; // 标记在 = 库必在(防御 state 误判) return $p; } /** * 在目标库里落/刷新 _install_lock 安装锁标记(n4 写盘与老站迁移补写都走这里)。 * $cfg 与 instValidate() 返回同形(mode/dir/java/web),也可直接传 sqlMdDbConfig()。 * @return array{ok:bool,where:string,err:string} */ function instWriteDbLock(array $cfg) { $out = array('ok' => false, 'where' => '', 'err' => ''); $stamp = date('c'); $ip = secClientIp(); $tbl = '`' . str_replace('`', '``', instLockTable()) . '`'; // mysql 模式且 web 侧配了库 → 标记落 MySQL(Java 侧的库不碰) if (isset($cfg['mode']) && $cfg['mode'] === 'mysql' && isset($cfg['web']) && is_array($cfg['web']) && isset($cfg['web']['host']) && $cfg['web']['host'] !== '' && isset($cfg['web']['name']) && $cfg['web']['name'] !== '') { $e = $cfg['web']; $m = mysqli_init(); try { @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; return $out; } $ddl = 'CREATE TABLE IF NOT EXISTS ' . $tbl . ' (' . '`id` BIGINT NOT NULL AUTO_INCREMENT,' . '`installed_at` VARCHAR(32) NOT NULL,' . "`ip` VARCHAR(45) NOT NULL DEFAULT ''," . "`mode` VARCHAR(16) NOT NULL DEFAULT 'mysql'," . 'PRIMARY KEY (`id`)) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4'; if (!@mysqli_query($m, $ddl)) { $out['err'] = mysqli_error($m); @mysqli_close($m); return $out; } @mysqli_query($m, 'DELETE FROM ' . $tbl); // 永远只留最新一行 @mysqli_query($m, 'INSERT INTO ' . $tbl . ' (`installed_at`,`ip`,`mode`) VALUES (' . "'" . $m->real_escape_string($stamp) . "','" . $m->real_escape_string($ip) . "','mysql')"); @mysqli_close($m); $out['ok'] = true; $out['where'] = 'MySQL ' . $e['name'] . '/' . instLockTable(); return $out; } catch (Exception $ex) { $out['err'] = $ex->getMessage(); return $out; } } // db 模式(或 mysql 模式 web 侧回退 db)→ web.db 落标记 if (!class_exists('SQLite3')) { $out['err'] = 'sqlite3 扩展不可用,写不了库内锁'; return $out; } $dir = (isset($cfg['dir']) && trim((string)$cfg['dir']) !== '') ? $cfg['dir'] : './db/'; $file = instResolveDir($dir) . '/web.db'; $dd = dirname($file); if (!is_dir($dd) && !@mkdir($dd, 0755, true) && !is_dir($dd)) { $out['err'] = '目录创建失败:' . $dd; return $out; } $qname = '"' . str_replace('"', '""', instLockTable()) . '"'; $db = null; try { $db = @new SQLite3($file, SQLITE3_OPEN_READWRITE | SQLITE3_OPEN_CREATE); if (!$db) { $out['err'] = '打不开 ' . $file; return $out; } $db->busyTimeout(3000); if (!@$db->exec('CREATE TABLE IF NOT EXISTS ' . $qname . " (" . 'id INTEGER PRIMARY KEY, installed_at TEXT NOT NULL,' . "ip TEXT NOT NULL DEFAULT '', mode TEXT NOT NULL DEFAULT 'db')")) { $out['err'] = $db->lastErrorMsg(); return $out; } @$db->exec('DELETE FROM ' . $qname); @$db->exec('INSERT INTO ' . $qname . " (installed_at, ip, mode) VALUES ('" . $db->escapeString($stamp) . "','" . $db->escapeString($ip) . "','db')"); $db->close(); $db = null; $out['ok'] = true; $out['where'] = $file; } catch (Exception $ex) { $out['err'] = $ex->getMessage(); } finally { if ($db instanceof SQLite3) { @$db->close(); } } return $out; } /** 老站迁移:文件锁在 + 库在 + 库里没标记 → 补写库内锁(幂等,失败不影响 404)。 */ function instSeedDbLock() { $r = instWriteDbLock(sqlMdDbConfig()); if ($r['ok'] && function_exists('secLog')) secLog('install_lock_seeded', $r['where']); return $r['ok']; } /** * 写 install.lock 文件锁(n4 安装完成 / 门禁自动补回都走这里)。 * @param string $reason install|auto-repair * @param array $extra mode/dir 覆盖值(n4 传本次配置;补锁时留空 = 读 sql.md 现值) * @return bool 是否写成功 */ function instWriteLockFile($reason, array $extra = array()) { $cfg = sqlMdDbConfig(); $mode = isset($extra['mode']) ? $extra['mode'] : $cfg['mode']; $dir = isset($extra['dir']) ? $extra['dir'] : $cfg['dir']; $body = date('c') . "\n" . 'ip=' . secClientIp() . "\n" . 'mode=' . $mode . "\n" . 'dir=' . $dir . "\n" . 'md=' . INSTALL_MD . "\n" . 'reason=' . $reason . "\n"; @file_put_contents(INSTALL_LOCK, $body, LOCK_EX); @chmod(INSTALL_LOCK, 0644); return is_file(INSTALL_LOCK); } /** 与 secTokenGate 同判据的非破坏版:当前请求过不过得了令牌层(数据丢失页用)。 */ function instTokenOk() { if (!secIsBootstrapped()) return true; if (secToken() === '') return true; if (isset($_GET['token']) && hash_equals(secToken(), (string)$_GET['token'])) return true; if (isset($_POST['token']) && hash_equals(secToken(), (string)$_POST['token'])) return true; return secBootAuthenticate(); } /** * 「install.lock 在、数据库没了」警告页(2026-10-09 需求:提示用户数据丢失需额外关注)。 * POST act=reinstall + csrf 确认 → 删文件锁并返回 true(继续走正常向导,令牌层仍把关); * 其余一律渲染警告页后 exit。 * @return bool true=已确认重装,调用方跳过 404 继续开门 */ function instDataLostPage(array $probe) { secEnsureSession(); if (empty($_SESSION['inst_csrf'])) $_SESSION['inst_csrf'] = bin2hex(random_bytes(16)); $csrf = (string)$_SESSION['inst_csrf']; $act = isset($_POST['act']) ? (string)$_POST['act'] : ''; $got = isset($_POST['csrf']) ? (string)$_POST['csrf'] : ''; if ($act === 'reinstall' && hash_equals($csrf, $got)) { @unlink(INSTALL_LOCK); if (function_exists('secLog')) secLog('install_reinstall_ack', 'ip=' . secClientIp()); $_SERVER['REQUEST_METHOD'] = 'GET'; // 这次 POST 已在门禁消化,别再进分发器 unset($_POST); return true; } $lockTxt = is_file(INSTALL_LOCK) ? (string)@file_get_contents(INSTALL_LOCK) : '(文件已不在)'; $tokenOk = instTokenOk(); $e = function ($s) { return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); }; while (ob_get_level() > 0) { @ob_end_clean(); } header('Content-Type: text/html; charset=utf-8'); http_response_code(200); echo '' . '' . '' . '数据丢失警告 · 安装锁
' . '

⚠ 检测到数据库丢失 —— 请额外关注

' . '
本站已生成 install.lock(说明安装过),' . '但按当前配置找不到目标数据库。这通常意味着数据已被删除、迁移到了别处,' . '或磁盘/数据库损坏 —— 请先确认数据是否需要从备份恢复,不要急着重装。
' . '

探测详情

' . '
运行模式:' . $e($probe['mode']) . ' ' . '数据库状态:missing(不存在) 库内安装锁:' . (!empty($probe['lock']) ? '有' : '无(随数据库一起消失)') . '
' . '
定位:' . $e($probe['detail']) . '
' . ($probe['err'] !== '' ? '
探测错误:' . $e($probe['err']) . '
' : '') . '
install.lock 内容:
' . '
' . $e($lockTxt) . '
' . '
' . '

接下来怎么办

'; if ($tokenOk) { echo '
' . '' . '' . '' . '
'; } else { echo '
本站点已配置访问令牌 —— 请先在地址栏给本页地址' . '追加 ?token=<SEC_ACCESS_TOKEN>(值见 config.php)刷新后,再继续重装操作。
'; } echo '

本页只做提示,不改动任何文件。

' . '
'; exit; } /** * 校验第二关提交。返回 array('mode'=>'db'|'mysql', 'dir'=>..., 'java'=>..., 'web'=>..., 'warn'=>[]) * 校验不过抛 Exception,异常消息给用户看。 */ 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) { $g = function ($f) use ($k, $post) { $key = $k . '_' . $f; return isset($post[$key]) ? trim((string)$post[$key]) : ''; }; return array('host' => $g('host'), 'port' => $g('port'), 'name' => $g('name'), 'user' => $g('user'), 'pass' => $g('pass')); }; $java = $side('java'); $web = $side('web'); $entries = array('java' => $java, 'web' => $web); if ($mode === 'db') { // db 模式下地址允许全空(也允许填 —— 保存但不生效,回显时给警告) foreach ($entries as $k => $e) { $filled = array_filter($e, function ($v) { return $v !== ''; }); if (!empty($filled)) { $warn[] = '当前是 db 模式,' . strtoupper($k) . ' 侧的 MySQL 地址不会生效,已原样保存。'; } } // 存储位置必须是合法相对/绝对路径,且目录可写 if ($dir !== '') { $dir = str_replace('\\', '/', $dir); if ($dir[0] !== '/' && strpos($dir, './') !== 0 && strpos($dir, '../') !== 0 && strpos($dir, '/') !== false && !preg_match('#^[A-Za-z]:/#', $dir)) { throw new Exception('数据库存储位置写法不合法。用 / 开头=绝对路径,./ 开头=相对本目录,../ 开头=相对上层。'); } $abs = instResolveDir($dir); if (!is_dir($abs) && !@mkdir($abs, 0755, true) && !is_dir($abs)) { throw new Exception('无法创建数据库目录:' . $abs . '(请检查 Web 目录权限)。'); } if (!is_writable($abs)) { throw new Exception('数据库目录不可写:' . $abs); } } } else { // 每侧地址要么全填、要么全空 $complete = 0; foreach ($entries as $k => $e) { $empty = 0; foreach ($e as $v) { if ($v === '') $empty++; } if ($empty === count($e)) continue; // 全空 = 该侧回退 db,允许 if ($empty > 0) { $throw = array(); foreach ($e as $fk => $fv) { if ($fv === '') $throw[] = $fk; } throw new Exception(strtoupper($k) . ' 侧 MySQL 地址信息不完整,缺少:' . implode('、', $throw) . '。每侧要么填全、要么全空。'); } if (!ctype_digit($e['port'])) { throw new Exception(strtoupper($k) . ' 侧端口必须是纯数字。'); } $p = (int)$e['port']; if ($p < 1 || $p > 65535) { throw new Exception(strtoupper($k) . ' 侧端口超出范围(1-65535)。'); } $complete++; } if ($complete === 0) { throw new Exception('mysql 模式下至少要有一侧地址填全,否则会静默回退 db 模式,等于白配。'); } // 实连 + 建表权限探测:MySQL 模式下建表失败是降级不抛异常的(站点会悄悄跑在空库上), // 所以必须在这里就替部署者确认「连得上、库在、能建表」。 foreach ($entries as $k => $e) { if (array_sum(array_map(function ($v) { return $v === '' ? 1 : 0; }, $e)) === count($e)) continue; instProbeMySql($k, $e); } } return array('mode' => $mode, 'dir' => $dir, 'java' => $java, 'web' => $web, 'warn' => $warn); } // ============================ 第二关:扫库 / 清空 ============================ /** * 扫一个 MySQL 侧的用户表(表名 + 估算行数)。 * $withLock=false(默认)时忽略 _install_lock —— 它是安装锁标记,不算「库非空」; * 清空(purge)时传 true,把标记也一并删掉,清完真的空。 * @return array{ok:bool,err:string,tables:array} */ function instScanMysqlSide(array $e, $withLock = false) { $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 不维护精确计数),标注「约」以免误导 // ★ 库名必须带单引号 —— real_escape_string 只转义不加引号, // 裸拼会被 MySQL 当成列名 → Unknown column 'xxx' in 'where clause' $sql = "SELECT TABLE_NAME, TABLE_ROWS FROM information_schema.TABLES" . " WHERE TABLE_SCHEMA = '" . $m->real_escape_string($e['name']) . "'" . " AND TABLE_TYPE = 'BASE TABLE'"; if (!$withLock) $sql .= " AND TABLE_NAME <> '" . instLockTable() . "'"; $sql .= " 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, true); // 清空要把 _install_lock 标记也删掉 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; } /** * 扫 db(SQLite)模式下已存在的 .db 文件里的用户表。 * @return array{ok:bool,err:string,tables:array,files:array} */ 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; } foreach ((array)glob($abs . '/*.db') as $f) { if (!in_array($f, $cands, true)) $cands[] = $f; } 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); // _install_lock 是安装锁标记,不算「用户表」(否则装过一次的库永远不空白) $rs = @$db->query("SELECT name FROM sqlite_master WHERE type='table'" . " AND name NOT LIKE 'sqlite_%'" . " AND name <> '" . instLockTable() . "' 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(); } $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) { return array( '归属' => $slot, '数据库地址' => $e['host'], '端口' => $e['port'], '数据库名' => $e['name'], '数据库账号' => $e['user'], '数据库密码' => $e['pass'], ); }; return array( array('text' => '配置区开始'), array('运行模式' => ($c['mode'] === 'mysql' ? 'mysql' : 'true'), '数据库存储位置' => $c['dir']), $mkEntry('java', $c['java']), $mkEntry('web', $c['web']), array('text' => '配置区域结束'), ); } /** * 只替换 sql.md 里含「配置区开始」的那个 ```json 代码块,其余文本原样保留。 * 找不到哨兵块(文件不存在 / 格式坏)→ 用模板整个重建(这时没有说明书内容可保留)。 * @return string 新的 sql.md 全文 */ function instSpliceMd($md, array $arr) { $json = json_encode($arr, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); if ($json === false) throw new Exception('配置序列化失败:' . json_last_error_msg()); if (!is_string($md) || trim($md) === '') { return instFallbackMd($json); } // 逐块匹配 ```json ... ```,只换含哨兵的那块 $out = ''; $last = 0; $re = '/```(?:json|JSON)?\s*(.*?)```/s'; if (!preg_match_all($re, $md, $mm, PREG_OFFSET_CAPTURE)) { return instFallbackMd($json); } $replaced = false; foreach ($mm[1] as $hit) { $body = $hit[0]; $off = $hit[1]; $a = json_decode(trim($body), true); if (!is_array($a)) continue; $has = false; foreach ($a as $el) { if (is_array($el) && isset($el['text']) && strpos((string)$el['text'], '配置区开始') !== false) { $has = true; break; } } if (!$has) continue; $s = $off; $e = $off + strlen($body); $out .= substr($md, $last, $s - $last) . "\n" . $json . "\n"; $last = $e; $replaced = true; } if (!$replaced) return instFallbackMd($json); $out .= substr($md, $last); return $out; } /** 无哨兵块时的完整模板(与仓库内 sql.md 分发版同结构)。 */ function instFallbackMd($json) { return "```json\n" . $json . "\n```\n" . "\n\n| 配置值 | 意义 | 示例 |\n| :---: | :---: | :---: |\n" . "| 运行模式 | 选择db/mysql运行。不区分大小写,`true和db`表示使用db,`false和mysql`表示mysql | true |\n" . "| 数据库位置 | db模式专用,`/xxx`绝对路径,`./xxx`相对当前目录,`../xxx`相对上层 | ./db/ |\n" . "| 数据库地址 | mysql专用,连接地址。一般填127.0.0.1或localhost | localhost |\n" . "| 端口 | mysql专用,默认3306 | 3306 |\n" . "| 数据库名 | mysql专用 | sqlname |\n" . "| 数据库账号 | mysql专用 | mysqluser |\n" . "| 数据库密码 | mysql专用,必须与账号成对 | mysqlpassword |\n" . "\n## 常见问题\n\n" . "问:db不可用
\n答: 检查php扩展-sqllite是否安装
\n" . "问: mysql连不上
\n答: 检查mysql服务是否启动、账密端口是否正确
\n" . "问: 解析配置失败
\n答: 把整个json配置区丢入json校验器校验一次json格式\n"; } /** * 回读 sql.md 确认写进去的就是我们提交的那份(绕开 sqlMdDbConfig 的 static 缓存)。 * @return true|string true=通过;否则返回失败原因 */ function instVerifyWritten(array $want) { $md = @file_get_contents(INSTALL_MD); if (!is_string($md)) return '读不回 sql.md'; if (!preg_match_all('/```(?:json|JSON)?\s*(.*?)```/s', $md, $mm) || empty($mm[1])) { return '读不回配置块'; } $arr = null; foreach ($mm[1] as $body) { $a = json_decode(trim($body), true); if (!is_array($a)) continue; foreach ($a as $el) { if (is_array($el) && isset($el['text']) && strpos((string)$el['text'], '配置区开始') !== false) { $arr = $a; break; } } if ($arr !== null) break; } if ($arr === null) return '找不到配置区开始哨兵'; $modeGot = null; $ents = array(); $seq = 0; foreach ($arr as $el) { if (!is_array($el)) continue; if (array_key_exists('运行模式', $el)) { $modeGot = strtolower(trim((string)$el['运行模式'])); continue; } if (!array_key_exists('数据库地址', $el) && !array_key_exists('数据库名', $el)) continue; $own = strtolower(trim(isset($el['归属']) ? (string)$el['归属'] : '')); $slot = ($own === '') ? (($seq++ === 0) ? 'java' : 'web') : $own; $slot = (strpos($slot, 'java') !== false || strpos($slot, 'plugin') !== false) ? 'java' : ((strpos($slot, 'web') !== false || strpos($slot, 'php') !== false) ? 'web' : $slot); $ents[$slot] = $el; } $wantMode = ($want['mode'] === 'mysql') ? 'mysql' : 'true'; if ($modeGot !== strtolower($wantMode)) { return '运行模式没写对(读回=' . var_export($modeGot, true) . ')'; } foreach (array('java', 'web') as $slot) { if (!isset($ents[$slot])) return $slot . ' 侧地址条目丢了'; $e = $ents[$slot]; foreach (array('数据库地址' => 'host', '端口' => 'port', '数据库名' => 'name', '数据库账号' => 'user', '数据库密码' => 'pass') as $jk => $wk) { $rv = array_key_exists($jk, $e) ? (string)$e[$jk] : ''; if ($rv !== (string)$want[$slot][$wk]) { return $slot . ' 侧 ' . $jk . ' 读回不一致'; } } } return true; } // ============================ 第三关: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 (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 (preg_match('/^(java|web)_(host|port|name|user|pass)$/', $k, $m)) { $e = (isset($cur[$m[1]]) && is_array($cur[$m[1]])) ? $cur[$m[1]] : array(); $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 ''; } 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'; // ⑤ 数据库自身持锁 + 文件锁 + 自毁 // 先在目标库里落 _install_lock 标记(2026-10-09 需求:安装成功后不管 // db 还是 MySQL 都通过数据库去锁),再写 install.lock —— 文件锁哪天 // 被误删,门禁也能靠库里的锁自动补回(见文件头设计要点 5)。 $mk = instWriteDbLock($d2); if ($mk['ok']) { $written[] = '数据库安装锁已写入 ' . $mk['where']; } else { $written[] = '数据库安装锁写入失败:' . $mk['err'] . '(install.lock 文件锁照常生效)'; } instWriteLockFile('install', array('mode' => $d2['mode'], 'dir' => $d2['dir'])); @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'); }; ?> 安装向导 · 初始化

安装向导

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

array('第一关', '运行环境'), 2 => array('第二关', '数据库'), 3 => array('第三关', '邮箱'), 4 => array('第四关', '安全')); foreach ($titles as $n => $t): $cls = ($step === $n) ? 'on' : (($step > $n || $step === 5) ? 'done' : ''); ?>
  • 配置已写入并逐项回读校验通过。
  • 已生成 install.lock,本页再次访问将返回 404。
  • ⚠ 数据库安装锁写入失败(详见下方写入清单)—— install.lock 仍在,但它被误删后将无法从数据库自动补回。
  • 数据库里也写入了 _install_lock 安装锁 —— 就算 install.lock 被误删也会自动补回。
  • install.php 已自我删除(后台静默完成,页面照常停留供你截图留档,请勿刷新)。
📋 本次写入的配置 —— 请截图或手抄留档:
'Java 侧(sync.php · Java.db)', 'web' => 'PHP 侧(web.db)') as $slot => $label): ?>
运行模式
存储位置
(空 → 自动回退 db 文件)
邮箱 SMTP 未配置(找回密码、订单通知发不出去)
IP 白名单
人机验证码
本页已随安装完成而删除 —— 这是最后一处能整份查看该配置的页面;完整配置仍保存在 sql.md 与 config.php。
写入明细:
下一步:
  • 正在跳转到 安全配置引导页:配置 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 两种模式都可以选。

第二关 · 数据库

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

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

地址与端口已按本机 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 并删除本页。

访问要求: 你当前来自 127.0.0.1/::1(本机),可直接配置。 需要 ?token=访问令牌 或已通过引导,否则返回 nginx 404。
本页不加载 core.php;所有写入都会先备份再原子替换,失败自动还原。