Files
Sdf1_login/install.php
T
ypsdf1 0b63a99719 install页去掉IP白名单门禁并修null告警, 新增0.0.0.0应急逃生通道
- install.php: sqlMdDbConfig 全新站 java/web 槽为 null, $val 闭包加 is_array 兜底,
  消除每个输入框的 array offset on null 告警; 门禁只留令牌层(全新站无白名单可言);
  安装成功页改为回显本次写入的完整配置供截图手抄, 背景自删
- admin_2fa_setup.php: 引导页白名单框上方显示当前访问IP; 应急模式免密进 boot 模式
  并挂红徽标; secInstallPending 让位改为 !secIsEmergency(), 避免与全局应急路由
  形成 install/引导页互跳死循环
- security.php: 新增 secIsEmergency() 判定白名单含 0.0.0.0; secIpAllowed 放行该条目;
  新增 secEmergencyGate() 在限流层前把非引导页/非本机请求 302 锁回引导页(用302防浏览器缓存)
- config.php: 补 0.0.0.0 应急用法与用完必删的说明注释
2026-10-09 19:56:34 +08:00

592 lines
29 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* install.php —— 自毁式数据库配置安装页(2026-10-09 大重构)
*
* 职责(只做一件事):把运行模式与 MySQL 连接地址写进 sql.md 的「配置区开始」哨兵块,
* 写 install.lock 锁定,然后把自己删掉(unlink(__FILE__))。
*
* 设计要点:
* 1) 只依赖 security.php,**不加载 core.php** —— 初始化阶段 core.php 未必能跑(配置还没写完)。
* 2) 门禁沿用 secGatePage(true)+secTokenGate(true):127.0.0.1/::1 恒通、?boot= 引导令牌通、
* IP 白名单通;被挡住就 nginx 原生 404(绝不 403 —— 403 等于承认文件存在)。
* 3) 只替换 sql.md 里含哨兵的那个 ```json 代码块,说明表格 / 常见问题原样保留。
* 4) 写盘是「备份 → 原子写 → 回读校验」三步;校验不过就还原备份、绝不留半份配置。
* 5) install.lock 存在 ⇒ 立刻 404(页面装完即失效,即使 __FILE__ 没删成也不会二次执行)。
* 6) 全程 PHP 7.3 兼容(无箭头函数 / match / str_contains)。
*
* 与 admin_2fa_setup.php 的分工:那个页管 config.php 的 SEC-UPDATE 区(安全配置),
* 本页管 sql.md 配置区(数据库配置)。两者互不引用。
*/
require_once __DIR__ . '/security.php';
// ============================ 门禁 ============================
// ① 已装过 → 与文件不存在完全一致的表现(本页也可能还没来得及自删)
if (is_file(__DIR__ . '/install.lock')) secNginx404();
// ② 不做 IP 白名单验证(2026-10-09 需求):全新站根本没有白名单可言,拿白名单
// 挡安装页只会把部署者锁在门外;已引导站点由下面 ③ 的令牌层把关(?token= / ?boot=)。
// 原 secGatePage(true) 在「已引导但没装库」时会把非白名单 IP 挡成 404。
// ③ 动态访问令牌(config 里有 SEC_ACCESS_TOKEN 时才生效;全新部署为空 → 放行)
secTokenGate(true);
secEnsureSession();
define('INSTALL_LOCK', __DIR__ . '/install.lock');
define('INSTALL_MD', defined('SQL_MD_PATH') ? SQL_MD_PATH : __DIR__ . '/sql.md');
$cur = sqlMdDbConfig(); // static 缓存,此刻读的是「写入前」的旧配置,仅用于回显
$err = '';
$ok = '';
$saved = null; // 本次 POST 成功写入的配置 → 完成页回显供截图/手抄
// CSRF:会话级随机数(不依赖 SEC_ACCESS_TOKEN,全新部署时它还是空的)
if (empty($_SESSION['inst_csrf'])) {
$_SESSION['inst_csrf'] = bin2hex(random_bytes(16));
}
$csrf = (string)$_SESSION['inst_csrf'];
/** 当前 ip 是否本地(本机访问给出更宽松的提示文案) */
function instIsLocal() {
$ip = secClientIp();
return $ip === '127.0.0.1' || $ip === '::1';
}
// ============================ 校验 ============================
/**
* 校验一次提交。返回 array('mode'=>'db'|'mysql', 'dir'=>..., 'java'=>..., 'web'=>..., 'warn'=>[])
* 校验不过抛 Exception,异常消息给用户看。
*/
function instValidate(array $post) {
$warn = array();
$mode = isset($post['mode']) ? strtolower(trim((string)$post['mode'])) : 'db';
if ($mode !== 'db' && $mode !== 'mysql') {
throw new Exception('运行模式取值非法(只接受 db 或 mysql)。');
}
$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 {
// mysql 模式:mysqli 扩展必须在
if (!extension_loaded('mysqli')) {
throw new Exception('当前 PHP 没有安装 mysqli 扩展,无法使用 mysql 模式。'
. '请先安装扩展,或改用 db 模式。');
}
// 每侧地址要么全填、要么全空
$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);
}
/** 解析存储位置为绝对路径(与 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 `' . $tbl . '`')) {
$failed = 'DROP: ' . mysqli_error($m);
} elseif (!@mysqli_query($m, 'CREATE TABLE `' . $tbl . "` (\n `id` BIGINT NOT NULL AUTO_INCREMENT, PRIMARY KEY (`id`)\n) ENGINE=InnoDB")) {
$failed = 'CREATE: ' . mysqli_error($m) . '(' . mysqli_errno($m) . ')';
} else {
@mysqli_query($m, 'DROP TABLE IF EXISTS `' . $tbl . '`');
}
@mysqli_close($m);
if ($failed !== '') {
throw new Exception(strtoupper($slot) . ' 侧连得上,但建表失败:' . $failed
. ' —— 站点初始化需要 CREATE 权限,请检查账号授权。');
}
}
// ============================ 生成 sql.md 配置区 ============================
/** 把本次配置序列化成带哨兵的 json 数组(与 sql.md 模板同形)。 */
function instBuildArray(array $c) {
$mkEntry = function ($slot, $e) use ($c) {
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不可用<br>\n答: 检查php扩展-sqllite是否安装<br>\n"
. "问: mysql连不上<br>\n答: 检查mysql服务是否启动、账密端口是否正确<br>\n"
. "问: 解析配置失败<br>\n答: 把整个json配置区丢入json校验器校验一次json格式\n";
}
// ============================ POST:写配置 ============================
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$act = isset($_POST['act']) ? (string)$_POST['act'] : '';
$got = isset($_POST['csrf']) ? (string)$_POST['csrf'] : '';
if ($act !== 'save' || !hash_equals($csrf, $got)) {
$err = '请求校验失败,请刷新页面后重试。';
} else {
try {
$cfg = instValidate($_POST);
$new = instSpliceMd(is_file(INSTALL_MD) ? @file_get_contents(INSTALL_MD) : '',
instBuildArray($cfg));
// --- 备份(*.bak* 在 gitignore 内,不会污染仓库) ---
$bak = '';
if (is_file(INSTALL_MD)) {
$bak = INSTALL_MD . '.bak.' . date('YmdHis');
if (!@copy(INSTALL_MD, $bak)) $bak = '';
}
// --- 原子写:先写 .tmp 再 rename,避免中途失败留下半份配置 ---
$tmp = INSTALL_MD . '.insttmp';
if (@file_put_contents($tmp, $new, LOCK_EX) === false) {
throw new Exception('写入临时文件失败(目录不可写?):' . $tmp);
}
if (!@rename($tmp, INSTALL_MD)) {
@unlink($tmp);
// Windows 上 rename 覆盖已存在的目标可能失败 → 退化为直接写
if (@file_put_contents(INSTALL_MD, $new, LOCK_EX) === false) {
throw new Exception('写入 sql.md 失败。');
}
}
// --- 回读校验:解出来必须与提交的一致,否则还原备份 ---
$chk = instVerifyWritten($cfg);
if ($chk !== true) {
if ($bak !== '') @copy($bak, INSTALL_MD);
@unlink($tmp);
throw new Exception('回读校验失败,已还原原配置。原因:' . $chk);
}
// --- 锁定 + 自毁 ---
@file_put_contents(INSTALL_LOCK, date('c') . "\n"
. 'ip=' . secClientIp() . "\n"
. 'mode=' . $cfg['mode'] . "\n"
. 'md=' . INSTALL_MD . "\n", LOCK_EX);
@chmod(INSTALL_LOCK, 0644);
@unlink(__FILE__);
$ok = $cfg['mode'] === 'mysql' ? 'MySQL 模式配置已写入' : 'db 模式配置已写入';
} catch (Exception $e) {
$err = $e->getMessage();
}
}
}
/**
* 回读 sql.md 确认写进去的就是我们提交的那份(绕开 sqlMdDbConfig 的 static 缓存)。
* @return true|string true=通过;否则返回失败原因
*/
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;
}
// ============================ 渲染 ============================
$modeNow = $cur['mode'];
$modeSel = ($_SERVER['REQUEST_METHOD'] === 'POST' && $err !== '')
? (isset($_POST['mode']) ? strtolower(trim((string)$_POST['mode'])) : 'db')
: $modeNow;
$val = function ($k, $d = '') use ($cur, $err) {
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $err !== '' && isset($_POST[$k])) {
return htmlspecialchars((string)$_POST[$k], ENT_QUOTES, 'UTF-8');
}
$cur = is_array($cur) ? $cur : array();
if ($k === 'dir') return htmlspecialchars(isset($cur['dir']) ? (string)$cur['dir'] : '', ENT_QUOTES, 'UTF-8');
if ($k === 'mode') return '';
if (preg_match('/^(java|web)_(host|port|name|user|pass)$/', $k, $m)) {
// ★ 全新站 sql.md 配置区账密全空 → sqlMdDbConfig 的 java/web 槽是 null
// ("字段不全 → null → 该侧兜底 db"),直接 $e[...] 在 PHP 8 会每个输入框
// 报一条 "Trying to access array offset on null"(实录:test12 第 391 行)。
$e = (isset($cur[$m[1]]) && is_array($cur[$m[1]])) ? $cur[$m[1]] : array();
$map = array('host' => 'host', 'port' => 'port', 'name' => 'name', 'user' => 'user', 'pass' => 'pass');
$rv = isset($e[$map[$m[2]]]) ? (string)$e[$map[$m[2]]] : '';
return htmlspecialchars($rv, ENT_QUOTES, 'UTF-8');
}
return htmlspecialchars((string)$d, ENT_QUOTES, 'UTF-8');
};
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<title>数据库配置 · 初始化</title>
<style>
*{box-sizing:border-box}
body{margin:0;background:#0f1115;color:#e6e8ee;font:14px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei",sans-serif}
.wrap{max-width:760px;margin:32px auto;padding:0 16px}
.card{background:#171a21;border:1px solid #262a33;border-radius:10px;padding:22px 24px;margin-bottom:18px}
h1{font-size:20px;margin:0 0 6px}
.sub{color:#98a0ad;font-size:13px;margin:0 0 18px}
h2{font-size:15px;margin:22px 0 10px;color:#cdd3dd}
label{display:block;font-size:13px;color:#aab2bf;margin:12px 0 5px}
input[type=text],input[type=password],select{width:100%;padding:9px 11px;background:#0f1115;border:1px solid #2e333d;
border-radius:6px;color:#e6e8ee;font-size:14px;outline:none}
input:focus,select:focus{border-color:#4c8dff}
.row{display:flex;gap:12px}
.row>div{flex:1}
.modes{display:flex;gap:10px;margin-top:6px}
.modes label{flex:1;margin:0;padding:11px;border:1px solid #2e333d;border-radius:6px;cursor:pointer;
background:#12151b;text-align:center;color:#c3cad4}
.modes input{display:none}
.modes label.on{border-color:#4c8dff;background:#16243b;color:#fff}
.btn{display:inline-block;padding:11px 22px;background:#4c8dff;color:#fff;border:0;border-radius:6px;
font-size:14px;cursor:pointer;margin-top:20px}
.btn:hover{background:#3f7ce8}
.note{padding:11px 14px;border-radius:6px;font-size:13.5px;margin:14px 0}
.err{background:#2a1519;border:1px solid #5a2730;color:#ffb4bc}
.ok{background:#12261a;border:1px solid #26543a;color:#9ff0bb}
.info{background:#141b2a;border:1px solid #26314a;color:#a8c1f0}
code{background:#0d0f14;padding:2px 6px;border-radius:4px;font-size:13px;color:#e6b673}
ul{margin:6px 0;padding-left:20px}
.muted{color:#7d8794;font-size:12.5px}
.kbd{background:#22262e;border:1px solid #343a45;padding:2px 7px;border-radius:4px;font-size:12.5px}
</style>
</head>
<body>
<div class="wrap">
<div class="card">
<h1>数据库配置初始化</h1>
<p class="sub">本页把运行模式与 MySQL 地址写进 <code>sql.md</code> 的配置区,
写入成功后会<strong>自动删除本文件</strong>并生成 <code>install.lock</code> 锁定。
页面配置好前请勿关闭;只保留本机或持引导令牌可访问。</p>
<?php if ($ok !== ''): ?>
<div class="note ok">
<strong><?php echo htmlspecialchars($ok, ENT_QUOTES, 'UTF-8'); ?></strong>
<ul>
<li>配置已写入 <code>sql.md</code> 并回读校验通过。</li>
<li>已生成 <code>install.lock</code>,本页再次访问将返回 404。</li>
<li><code>install.php</code> 已自我删除(后台静默完成,页面照常停留供你截图留档)。</li>
</ul>
<?php if (is_array($saved)): ?>
<div style="margin-top:10px;border-top:1px solid #26543a;padding-top:10px">
<div style="font-weight:600;margin:6px 0">📋 本次写入的配置 —— 请截图或手抄留档:</div>
<table style="width:100%;border-collapse:collapse;font-size:13px">
<tr>
<td style="padding:4px 8px;color:#9ff0bb;white-space:nowrap">运行模式</td>
<td><code><?php echo htmlspecialchars($saved['mode'] === 'mysql' ? 'mysql' : 'db(SQLite 文件)', ENT_QUOTES, 'UTF-8'); ?></code></td>
</tr>
<?php if ($saved['mode'] !== 'mysql'): ?>
<tr>
<td style="padding:4px 8px;color:#9ff0bb;white-space:nowrap">存储位置</td>
<td><code><?php echo htmlspecialchars($saved['dir'] === '' ? './db/(默认)' : $saved['dir'], ENT_QUOTES, 'UTF-8'); ?></code></td>
</tr>
<?php else: ?>
<?php foreach (array('java' => 'Java 侧(sync.php · Java.db)', 'web' => 'PHP 侧(web.db)') as $slot => $label): ?>
<?php $e = $saved[$slot]; ?>
<tr>
<td style="padding:4px 8px;color:#9ff0bb;vertical-align:top;white-space:nowrap"><?php echo $label; ?></td>
<td><?php if ($e['host'] === '' && $e['name'] === ''): ?><code>(空 → 自动回退 db 文件)</code><?php else: ?><code><?php echo htmlspecialchars($e['host'] . ':' . $e['port'] . ' · 库名 ' . $e['name'] . ' · 账号 ' . $e['user'] . ' · 密码 ' . $e['pass'], ENT_QUOTES, 'UTF-8'); ?></code><?php endif; ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</table>
<div style="color:#9ff0bb;font-size:12.5px;margin-top:6px">本页已随安装完成而删除 —— 这是最后一处能整份查看该配置的页面;完整配置仍保存在 <code>sql.md</code>。</div>
</div>
<?php endif; ?>
下一步:
<ul>
<li><strong>先去 <a href="admin_2fa_setup.php" style="color:#7fb0ff">安全配置引导页</a></strong>:把你当前的 IP 加入白名单、保存访问令牌、设置 2FA。</li>
<li>mysql 模式 → 配完安全项首次进后台会自动建表(已在本页实连并验证过建表权限);db 模式 → 配完即可登录。</li>
<li>记得让 <strong>Java 端</strong>(api/sync.php 所在的独立站点)也用同一份配置:改 sync.php 时两处都要传。</li>
</ul>
</div>
<?php endif; ?>
<?php if ($err !== ''): ?>
<div class="note err"><strong>写入失败:</strong><?php echo htmlspecialchars($err, ENT_QUOTES, 'UTF-8'); ?></div>
<?php endif; ?>
<form method="post" action="install.php" autocomplete="off">
<input type="hidden" name="act" value="save">
<input type="hidden" name="csrf" value="<?php echo htmlspecialchars($csrf, ENT_QUOTES, 'UTF-8'); ?>">
<?php if (secToken() !== ''): ?>
<!-- 已引导站点(config 有 SEC_ACCESS_TOKEN):POST 也要过第三层令牌门禁;
本页只会渲染给已经过了门禁的访问者,令牌不出圈。全新站点为空 → 不输出。 -->
<input type="hidden" name="token" value="<?php echo htmlspecialchars(secToken(), ENT_QUOTES, 'UTF-8'); ?>">
<?php endif; ?>
<h2>运行模式</h2>
<div class="modes">
<label class="<?php echo $modeSel === 'db' ? 'on' : ''; ?>">
<input type="radio" name="mode" value="db" onchange="mSwitch(this)"
<?php echo $modeSel === 'db' ? 'checked' : ''; ?>> db(SQLite 文件,默认)</label>
<label class="<?php echo $modeSel === 'mysql' ? 'on' : ''; ?>">
<input type="radio" name="mode" value="mysql" onchange="mSwitch(this)"
<?php echo $modeSel === 'mysql' ? 'checked' : ''; ?>> mysql</label>
</div>
<div id="dirBox">
<label>数据库存储位置 <span class="muted">(仅 db 模式生效;留空 = 默认 <code>./db/</code>)</span></label>
<input type="text" name="dir" value="<?php echo $val('dir'); ?>" placeholder="./db/">
<p class="muted">/ 开头=绝对路径;./ 开头=相对本目录;../ 开头=相对上层目录。</p>
</div>
<div id="mysqlBox">
<div class="note info">每侧地址<strong>要么填全、要么全空</strong>;留空的一侧会自动回退 db 文件。
本页会真实连接并尝试建表,失败会直接报错,不会悄悄写入一份跑不通的配置。</div>
<h2>Java 侧(api/sync.php 通信组 · Java.db)</h2>
<div class="row">
<div><label>连接地址</label><input type="text" name="java_host" value="<?php echo $val('java_host'); ?>" placeholder="127.0.0.1"></div>
<div><label>端口</label><input type="text" name="java_port" value="<?php echo $val('java_port'); ?>" placeholder="3306"></div>
</div>
<div class="row">
<div><label>数据库名</label><input type="text" name="java_name" value="<?php echo $val('java_name'); ?>" placeholder="game_java"></div>
<div><label>账号</label><input type="text" name="java_user" value="<?php echo $val('java_user'); ?>" autocomplete="off"></div>
</div>
<label>密码</label>
<input type="password" name="java_pass" value="<?php echo $val('java_pass'); ?>" autocomplete="new-password">
<h2>PHP 侧(web.db)</h2>
<div class="row">
<div><label>连接地址</label><input type="text" name="web_host" value="<?php echo $val('web_host'); ?>" placeholder="127.0.0.1"></div>
<div><label>端口</label><input type="text" name="web_port" value="<?php echo $val('web_port'); ?>" placeholder="3306"></div>
</div>
<div class="row">
<div><label>数据库名</label><input type="text" name="web_name" value="<?php echo $val('web_name'); ?>" placeholder="game_web"></div>
<div><label>账号</label><input type="text" name="web_user" value="<?php echo $val('web_user'); ?>" autocomplete="off"></div>
</div>
<label>密码</label>
<input type="password" name="web_pass" value="<?php echo $val('web_pass'); ?>" autocomplete="new-password">
</div>
<button class="btn" type="submit">保存配置(并删除本页)</button>
</form>
<?php if (!empty($cur['ok'])): ?>
<p class="muted" style="margin-top:18px;border-top:1px solid #232732;padding-top:14px">
当前 sql.md 已有配置 —— 模式 <code><?php echo htmlspecialchars($cur['mode'], ENT_QUOTES, 'UTF-8'); ?></code>,
存储位置 <code><?php echo htmlspecialchars($cur['dir'] === '' ? '(默认 ./db/)' : $cur['dir'], ENT_QUOTES, 'UTF-8'); ?></code>。
重新保存会覆盖它。
</p>
<?php endif; ?>
</div>
<div class="card muted">
<strong style="color:#aab2bf">访问要求:</strong>
<?php if (instIsLocal()): ?>
你当前来自 <span class="kbd">127.0.0.1/::1</span>(本机),可直接配置。
<?php else: ?>
需要 <span class="kbd">?boot=引导令牌</span> 或你的 IP 已在白名单内,否则返回 nginx 404。
<?php endif; ?>
<br>本页不加载 core.php;写入前会自动备份 <code>sql.md.bak.时间戳</code>,失败会自动还原。
</div>
</div>
<script>
function mSwitch(el){
var db = (el.value === 'db');
document.getElementById('dirBox').style.display = db ? '' : 'none';
document.getElementById('mysqlBox').style.display = db ? 'none' : '';
var ls = document.querySelectorAll('.modes label');
for (var i=0;i<ls.length;i++) ls[i].className = (ls[i].querySelector('input').checked ? 'on' : '');
}
(function(){
var sel = document.querySelector('.modes input:checked');
if (sel) mSwitch(sel);
})();
</script>
</body>
</html>