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) . '
'
. '
'
. '
接下来怎么办
'
. '- 数据还在别处(迁移过 / 有备份)→ 把数据库恢复回原位置,或修改 sql.md '
. '指回正确位置,然后刷新本页:安装锁继续生效,一切照旧。
'
. '- 确认数据已丢失、要重新安装 → 点下面的按钮:删除 install.lock 并进入安装向导。'
. '这一步不可撤销,请确认旧数据已妥善处理。
'
. '
';
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(邮箱、验证码、白名单)。
访问要求:
你当前来自 127.0.0.1/::1(本机),可直接配置。
需要 ?token=访问令牌 或已通过引导,否则返回 nginx 404。
本页不加载 core.php;所有写入都会先备份再原子替换,失败自动还原。