1970 lines
100 KiB
PHP
1970 lines
100 KiB
PHP
<?php
|
||
/**
|
||
* install.php —— 自毁式四步安装向导(2026-10-09 由单表单重构为四步)
|
||
*
|
||
* 四关:
|
||
* 第一关 运行环境:sqlite3 / pdo_sqlite / mysqli 三项亮状态。
|
||
* 缺哪个,第二关就锁死对应模式 —— 让扩展缺失只表现为「选项灰掉」,
|
||
* 而不是把 mysqli/SQLite3 的原始报错甩到用户脸上(会吓到刚入坑的小白)。
|
||
* 第二关 数据库:db 或 mysql(按第一关锁死)。mysql 侧地址/端口预填 127.0.0.1 / 3306,
|
||
* 选本机 MySQL 时只需填账号、密码、库名。提交即扫库:
|
||
* 空 → 静默过关;
|
||
* 非空 → 立即刹车,列出表清单,取消则退回重填,
|
||
* 继续则要求手输「确认清空」二次确认后才 DROP 全部表。
|
||
* 第三关 邮箱:要么全空(直接过关),要么全不空 —— 发验证码到测试邮箱,
|
||
* 输对才能过关(不允许跳过)。测试邮箱与验证码不落盘。
|
||
* 第四关 安全:IP 白名单必填(可自动填充当前访问 IP);CF/VA 验证码密钥选填,
|
||
* 留空即关闭;2FA 跳转 admin_2fa_setup.php 复用其完整流程。
|
||
*
|
||
* 点「确认安装」统一写盘:sql.md 配置区 + config.php 的 SMTP_* / CAPTCHA_PROVIDER /
|
||
* ADMIN_IP_WHITELIST + captcha_keys.php → 目标库落 _install_lock 安装锁 + 写 install.lock
|
||
* → unlink 自毁 → 停留完成页回显全部配置,供截图手抄。
|
||
*
|
||
* 设计要点:
|
||
* 1) 只依赖 security.php,**不加载 core.php** —— 初始化阶段 core.php 未必能跑。
|
||
* 2) 门禁:见下方「数据库自身持锁」四态分流;secTokenGate(true) 把关已引导站点的令牌。
|
||
* 不做 IP 白名单验证(全新站根本没有白名单可言)。
|
||
* 3) 每关数据存 session,最后一关才落盘 —— 中途任何失败都不留半份配置。
|
||
* 4) 写盘一律「备份 → 原子写 → 回读校验」,失败还原。
|
||
* 5) 安装锁 = 文件 + 数据库双份(2026-10-09 需求:install.lock 不在了 ≠ 首次安装):
|
||
* 文件锁在 + 库在 ⇒ nginx 404(绝大多数)
|
||
* 文件锁在 + 库明确没了 ⇒ 数据丢失警告页(提示需额外关注,可确认后重装)
|
||
* 文件锁没了 + 库里锁在 ⇒ 自动补写 install.lock 再 404
|
||
* 两者都没 ⇒ 全新未安装,正常进向导
|
||
* 探测失败(MySQL 连不上、配置读不出)一律按「库在」从严:宁可 404 也不乱开门。
|
||
* 6) 全程 PHP 7.3 兼容(无箭头函数 / match / str_contains)。
|
||
*
|
||
* 与 admin_2fa_setup.php 的分工:那个页管 2FA 与令牌的完整流程,本页管四步安装配置。
|
||
*/
|
||
require_once __DIR__ . '/security.php';
|
||
|
||
// ============================ 门禁 ============================
|
||
define('INSTALL_LOCK', __DIR__ . '/install.lock');
|
||
define('INSTALL_MD', defined('SQL_MD_PATH') ? SQL_MD_PATH : __DIR__ . '/sql.md');
|
||
define('INST_PURGE_PHRASE', '确认清空'); // 二次确认口令(用户手输,防误触)
|
||
|
||
// ★ 数据库自身持锁(2026-10-09 需求):install.lock 不在了 ≠ 首次安装。
|
||
// 安装成功时除写文件锁外,还会在目标库里落 _install_lock 标记(db → web.db,
|
||
// mysql → web 侧库;Java 侧的库只读探测、绝不写入)。门禁按两把锁的组合分流:
|
||
// A 文件锁在 + 库在 → 正常 nginx 404(本页也可能还没来得及自删)
|
||
// B 文件锁在 + 库明确不存在 → 数据丢失警告页(提示数据丢失需额外关注,可确认后重装)
|
||
// C 文件锁不在 + 库里锁在 → 自动补写 install.lock 后 404(防误删文件锁后朝有数据的库重装)
|
||
// D 两者都不在 → 全新未安装,正常进向导
|
||
$instLockIn = is_file(INSTALL_LOCK);
|
||
$instProbe = instDbLockProbe();
|
||
$instPass = false;
|
||
if ($instLockIn) {
|
||
if ($instProbe['state'] === 'missing') {
|
||
$instPass = instDataLostPage($instProbe); // 只有「确认数据丢失并重装」才返回 true
|
||
}
|
||
if (!$instPass) {
|
||
// 老站迁移:文件锁在 + 库在 + 库里还没标记 → 顺手补写库内锁(幂等,失败不影响 404)
|
||
if ($instProbe['state'] === 'exists' && !$instProbe['lock']) instSeedDbLock();
|
||
secNginx404(); // A / B(未确认)
|
||
}
|
||
} elseif ($instProbe['lock']) {
|
||
// C:库里的安装锁还在、文件锁被误删 → 自动补回,表现与已安装完全一致
|
||
instWriteLockFile('auto-repair');
|
||
if (function_exists('secLog')) secLog('install_lock_repaired', 'ip=' . secClientIp());
|
||
secNginx404();
|
||
}
|
||
// D:全新未安装(或探测不了)→ 继续
|
||
|
||
// ② 不做 IP 白名单验证(2026-10-09 需求):全新站根本没有白名单可言,拿白名单
|
||
// 挡安装页只会把部署者锁在门外;已引导站点由下面 ③ 的令牌层把关(?token= / ?boot=)。
|
||
// ③ 动态访问令牌(config 里有 SEC_ACCESS_TOKEN 时才生效;全新部署为空 → 放行)
|
||
secTokenGate(true);
|
||
secEnsureSession();
|
||
|
||
$cur = sqlMdDbConfig(); // static 缓存,此刻读的是「写入前」的旧配置,仅用于回显
|
||
$err = '';
|
||
$ok = '';
|
||
$notice = ''; // 蓝色提示(验证码已发送等)
|
||
$saved = null; // 本次写入的配置 → 完成页回显供截图/手抄
|
||
|
||
// CSRF:会话级随机数(不依赖 SEC_ACCESS_TOKEN,全新部署时它还是空的)
|
||
if (empty($_SESSION['inst_csrf'])) {
|
||
$_SESSION['inst_csrf'] = bin2hex(random_bytes(16));
|
||
}
|
||
$csrf = (string)$_SESSION['inst_csrf'];
|
||
|
||
// 扩展检测(每次请求重算:同一 PHP 进程组结果稳定,且能反映运行期真实状态)
|
||
$ext = instExtCheck();
|
||
|
||
// 当前关卡
|
||
$step = isset($_SESSION['inst_step']) ? (int)$_SESSION['inst_step'] : 1;
|
||
if ($step < 1 || $step > 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 '<!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:36px auto;padding:0 16px}'
|
||
. '.card{background:#171a21;border:1px solid #262a33;border-radius:10px;padding:22px 24px;margin-bottom:16px}'
|
||
. 'h1{font-size:20px;margin:0 0 8px;color:#ffd9a0}'
|
||
. 'h2{font-size:15px;margin:18px 0 8px;color:#cdd3dd}'
|
||
. '.warn{background:#2b1f10;border:1px solid #6b4a17;color:#ffd9a0;padding:12px 14px;border-radius:6px;font-size:13.5px}'
|
||
. '.muted{color:#98a0ad;font-size:13px;margin-top:6px}'
|
||
. 'pre{background:#0d0f14;border:1px solid #262a33;border-radius:6px;padding:10px 12px;'
|
||
. 'font-size:12.5px;color:#e6b673;white-space:pre-wrap;word-break:break-all;margin:8px 0}'
|
||
. 'code{background:#0d0f14;padding:2px 6px;border-radius:4px;font-size:13px;color:#e6b673}'
|
||
. '.btn{display:inline-block;padding:11px 22px;background:#b62324;color:#fff;border:0;'
|
||
. 'border-radius:6px;font-size:14px;cursor:pointer;margin-top:14px}'
|
||
. '.btn:hover{background:#cf3132}'
|
||
. 'ul{margin:6px 0;padding-left:20px}'
|
||
. '</style></head><body><div class="wrap">'
|
||
|
||
. '<div class="card"><h1>⚠ 检测到数据库丢失 —— 请额外关注</h1>'
|
||
. '<div class="warn">本站已生成 <code>install.lock</code>(说明<strong>安装过</strong>),'
|
||
. '但按当前配置<strong>找不到目标数据库</strong>。这通常意味着数据已被删除、迁移到了别处,'
|
||
. '或磁盘/数据库损坏 —— <strong>请先确认数据是否需要从备份恢复,不要急着重装。</strong></div>'
|
||
. '<h2>探测详情</h2>'
|
||
. '<div class="muted">运行模式:<code>' . $e($probe['mode']) . '</code> '
|
||
. '数据库状态:<code>missing(不存在)</code> 库内安装锁:<code>'
|
||
. (!empty($probe['lock']) ? '有' : '无(随数据库一起消失)') . '</code></div>'
|
||
. '<div class="muted">定位:' . $e($probe['detail']) . '</div>'
|
||
. ($probe['err'] !== '' ? '<div class="muted">探测错误:' . $e($probe['err']) . '</div>' : '')
|
||
. '<div class="muted" style="margin-top:8px">install.lock 内容:</div>'
|
||
. '<pre>' . $e($lockTxt) . '</pre>'
|
||
. '</div>'
|
||
|
||
. '<div class="card"><h2>接下来怎么办</h2><ul>'
|
||
. '<li><strong>数据还在别处</strong>(迁移过 / 有备份)→ 把数据库恢复回原位置,或修改 sql.md '
|
||
. '指回正确位置,然后刷新本页:安装锁继续生效,一切照旧。</li>'
|
||
. '<li><strong>确认数据已丢失、要重新安装</strong> → 点下面的按钮:删除 install.lock 并进入安装向导。'
|
||
. '<strong>这一步不可撤销,请确认旧数据已妥善处理。</strong></li>'
|
||
. '</ul>';
|
||
if ($tokenOk) {
|
||
echo '<form method="post">'
|
||
. '<input type="hidden" name="csrf" value="' . $e($csrf) . '">'
|
||
. '<input type="hidden" name="act" value="reinstall">'
|
||
. '<button class="btn" type="submit">我已确认数据丢失,继续重新安装</button>'
|
||
. '</form>';
|
||
} else {
|
||
echo '<div class="warn" style="margin-top:14px">本站点已配置访问令牌 —— 请先在地址栏给本页地址'
|
||
. '追加 <code>?token=<SEC_ACCESS_TOKEN></code>(值见 config.php)刷新后,再继续重装操作。</div>';
|
||
}
|
||
echo '<p class="muted">本页只做提示,不改动任何文件。</p>'
|
||
. '</div></div></body></html>';
|
||
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不可用<br>\n答: 检查php扩展-sqllite是否安装<br>\n"
|
||
. "问: mysql连不上<br>\n答: 检查mysql服务是否启动、账密端口是否正确<br>\n"
|
||
. "问: 解析配置失败<br>\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 = "<?php\n"
|
||
. "/**\n"
|
||
. " * 人机验证码密钥 —— 由 install.php 四步向导第四关自动写入。\n"
|
||
. " * ⚠️ 本文件已在 .gitignore 中排除,真实密钥不要提交进 git。\n"
|
||
. " * 改完可用 captcha_test.php 自测连通性。\n"
|
||
. " */\n\n";
|
||
if (is_file($path)) {
|
||
$bak = $path . '.bak.' . date('YmdHis');
|
||
if (!@copy($path, $bak)) $bak = '';
|
||
}
|
||
if ($provider === 'CF') {
|
||
$body = "// ===== Cloudflare Turnstile =====\n"
|
||
. "\$CF_SITEKEY = '" . str_replace(array('\\', "'"), array('\\\\', "\\'"), (string)$k1) . "';\n"
|
||
. "\$CF_SECRET = '" . str_replace(array('\\', "'"), array('\\\\', "\\'"), (string)$k2) . "';\n"
|
||
. "\n// ===== VAPTCHA (V4) =====\n"
|
||
. "\$VAPTCHA_VID = '';\n\$VAPTCHA_VKEY = '';\n\$VAPTCHA_SCENE = 0;\n";
|
||
} else {
|
||
$body = "// ===== Cloudflare Turnstile =====\n"
|
||
. "\$CF_SITEKEY = '';\n\$CF_SECRET = '';\n"
|
||
. "\n// ===== VAPTCHA (V4) =====\n"
|
||
. "\$VAPTCHA_VID = '" . str_replace(array('\\', "'"), array('\\\\', "\\'"), (string)$k1) . "';\n"
|
||
. "\$VAPTCHA_VKEY = '" . str_replace(array('\\', "'"), array('\\\\', "\\'"), (string)$k2) . "';\n"
|
||
. "\$VAPTCHA_SCENE = 0;\n";
|
||
}
|
||
$content = $tplHead . $body;
|
||
$tmp = $path . '.insttmp';
|
||
if (@file_put_contents($tmp, $content, LOCK_EX) === false) {
|
||
throw new Exception('写入 captcha_keys.php 临时文件失败(目录不可写?)');
|
||
}
|
||
if (!@rename($tmp, $path)) {
|
||
@unlink($tmp);
|
||
if (@file_put_contents($path, $content, LOCK_EX) === false) {
|
||
throw new Exception('写入 captcha_keys.php 失败。');
|
||
}
|
||
}
|
||
$back = @file_get_contents($path);
|
||
if ($back === false || strpos($back, $body) === false) {
|
||
throw new Exception('captcha_keys.php 回读校验失败。');
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// ============================ 第一关回显:字段取值 ============================
|
||
/**
|
||
* 取第二关输入框的回显值。
|
||
* 优先级:本次 POST 出错时的原始输入 > 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 = '验证码已发送到 <strong>' . htmlspecialchars($s['to'], ENT_QUOTES, 'UTF-8')
|
||
. '</strong>,请查收后填入下方(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)
|
||
. '。<br><span class="muted">不想配置邮箱(找回密码、订单通知将发不出去)就把上面四项全部清空,'
|
||
. '再点「下一步」。</span>');
|
||
}
|
||
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 "<div style=\"font:14px/1.7 -apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei',sans-serif;"
|
||
. "color:#1f2328;max-width:520px\">"
|
||
. "<h2 style=\"margin:0 0 12px;font-size:18px\">邮箱验证验证码</h2>"
|
||
. "<p style=\"margin:0 0 14px;color:#57606a\">你正在配置 Sdf1_login 站点的 SMTP 邮箱。"
|
||
. "下面这串验证码用于确认这个邮箱确实能发信:</p>"
|
||
. "<div style=\"font-size:32px;letter-spacing:8px;font-weight:700;color:#1a7f37;"
|
||
. "background:#f6f8fa;border:1px solid #d0d7de;border-radius:8px;padding:14px 18px;text-align:center\">"
|
||
. htmlspecialchars($code, ENT_QUOTES, 'UTF-8') . "</div>"
|
||
. "<p style=\"margin:14px 0 0;color:#8b949e;font-size:12.5px\">验证码 5 分钟内有效。"
|
||
. "不是你本人操作请忽略本邮件。</p></div>";
|
||
}
|
||
|
||
// ============================ 渲染 ============================
|
||
// 第二关的刹车状态
|
||
$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'); };
|
||
?>
|
||
<!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:820px;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],input[type=email],select,textarea{width:100%;padding:9px 11px;background:#0f1115;
|
||
border:1px solid #2e333d;border-radius:6px;color:#e6e8ee;font-size:14px;outline:none;font-family:inherit}
|
||
input:focus,select:focus,textarea:focus{border-color:#4c8dff}
|
||
textarea{min-height:96px;resize:vertical}
|
||
.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}
|
||
.modes label.dis{opacity:.42;cursor:not-allowed}
|
||
.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}
|
||
.btn.gr{background:#238636}.btn.gr:hover{background:#2ea043}
|
||
.btn.gh{background:#21262d;border:1px solid #343a45;color:#c9d1d9}
|
||
.btn.gh:hover{background:#2a3038}
|
||
.btn.rd{background:#b62324}.btn.rd:hover{background:#cf3132}
|
||
.btn.sm{padding:7px 14px;font-size:13px;margin-top:0}
|
||
.acts{display:flex;gap:10px;align-items:center;flex-wrap:wrap}
|
||
.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}
|
||
.warn{background:#2b1f10;border:1px solid #6b4a17;color:#ffd9a0}
|
||
.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}
|
||
.steps{display:flex;gap:8px;margin:0 0 18px}
|
||
.step{flex:1;padding:9px 4px;text-align:center;border-radius:6px;background:#12151b;border:1px solid #262a33;
|
||
color:#7d8794;font-size:12.5px;line-height:1.35}
|
||
.step b{display:block;font-size:13px;color:#98a0ad}
|
||
.step.on{border-color:#4c8dff;background:#16243b}
|
||
.step.on b{color:#fff}
|
||
.step.done{border-color:#26543a;background:#12261a}
|
||
.step.done b{color:#9ff0bb}
|
||
.ext{width:100%;border-collapse:collapse;font-size:13.5px;margin-top:6px}
|
||
.ext td{padding:9px 10px;border-bottom:1px solid #232732}
|
||
.ext tr:last-child td{border-bottom:0}
|
||
.pill{display:inline-block;padding:1px 9px;border-radius:999px;font-size:12px}
|
||
.pill.y{background:#1a7f37;color:#fff}.pill.n{background:#da3633;color:#fff}
|
||
.tbl{width:100%;border-collapse:collapse;font-size:13px;margin:8px 0}
|
||
.tbl th,.tbl td{padding:6px 9px;border-bottom:1px solid #232732;text-align:left}
|
||
.tbl th{color:#98a0ad;font-weight:600;font-size:12.5px}
|
||
.chk{display:flex;gap:8px;align-items:center;margin-top:8px;color:#aab2bf;font-size:13px}
|
||
.chk input{width:auto}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="wrap">
|
||
|
||
<div class="card">
|
||
<h1>安装向导</h1>
|
||
<p class="sub">四步完成站点初始化:运行环境 → 数据库 → 邮箱 → 安全。全部写入成功后本页会
|
||
<strong>自动删除</strong>并生成 <code>install.lock</code> 锁定。配置好前请勿关闭页面。</p>
|
||
|
||
<div class="steps" data-step="<?php echo (int)$step; ?>">
|
||
<?php
|
||
$titles = array(1 => array('第一关', '运行环境'), 2 => array('第二关', '数据库'),
|
||
3 => array('第三关', '邮箱'), 4 => array('第四关', '安全'));
|
||
foreach ($titles as $n => $t):
|
||
$cls = ($step === $n) ? 'on' : (($step > $n || $step === 5) ? 'done' : '');
|
||
?>
|
||
<div class="step <?php echo $cls; ?>"><b><?php echo $t[0]; ?></b><?php echo $t[1]; ?></div>
|
||
<?php endforeach; ?>
|
||
</div>
|
||
|
||
<?php if ($ok !== '' && $step === 5): ?>
|
||
<div class="note ok">
|
||
<strong><?php echo htmlspecialchars($ok, ENT_QUOTES, 'UTF-8'); ?></strong>
|
||
<ul>
|
||
<li>配置已写入并逐项回读校验通过。</li>
|
||
<li>已生成 <code>install.lock</code>,本页再次访问将返回 404。</li>
|
||
<?php if (isset($mk) && is_array($mk) && !$mk['ok']): ?>
|
||
<li style="color:#ffd9a0">⚠ 数据库安装锁写入失败(详见下方写入清单)——
|
||
<code>install.lock</code> 仍在,但它被误删后将无法从数据库自动补回。</li>
|
||
<?php else: ?>
|
||
<li>数据库里也写入了 <code>_install_lock</code> 安装锁 ——
|
||
就算 <code>install.lock</code> 被误删也会自动补回。</li>
|
||
<?php endif; ?>
|
||
<li><code>install.php</code> 已自我删除(后台静默完成,<strong>页面照常停留供你截图留档,请勿刷新</strong>)。</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;vertical-align:top">运行模式</td>
|
||
<td><code><?php echo htmlspecialchars($saved['db']['mode'] === 'mysql' ? 'mysql' : 'db(SQLite 文件)', ENT_QUOTES, 'UTF-8'); ?></code></td>
|
||
</tr>
|
||
<?php if ($saved['db']['mode'] !== 'mysql'): ?>
|
||
<tr>
|
||
<td style="padding:4px 8px;color:#9ff0bb;white-space:nowrap;vertical-align:top">存储位置</td>
|
||
<td><code><?php echo htmlspecialchars($saved['db']['dir'] === '' ? './db/(默认)' : $saved['db']['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 $se = $saved['db'][$slot]; ?>
|
||
<tr>
|
||
<td style="padding:4px 8px;color:#9ff0bb;vertical-align:top;white-space:nowrap"><?php echo $label; ?></td>
|
||
<td><?php if ($se['host'] === '' && $se['name'] === ''): ?><code>(空 → 自动回退 db 文件)</code><?php else: ?><code><?php echo htmlspecialchars($se['host'] . ':' . $se['port'] . ' · 库名 ' . $se['name'] . ' · 账号 ' . $se['user'] . ' · 密码 ' . $se['pass'], ENT_QUOTES, 'UTF-8'); ?></code><?php endif; ?></td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
<?php endif; ?>
|
||
<tr>
|
||
<td style="padding:4px 8px;color:#9ff0bb;white-space:nowrap;vertical-align:top">邮箱 SMTP</td>
|
||
<td><?php if (!empty($saved['smtp']['empty'])): ?><code>未配置(找回密码、订单通知发不出去)</code><?php else: ?><code><?php echo htmlspecialchars($saved['smtp']['cfg']['host'] . ':' . $saved['smtp']['cfg']['port'] . ' · 账号 ' . $saved['smtp']['cfg']['user'], ENT_QUOTES, 'UTF-8'); ?></code><?php endif; ?></td>
|
||
</tr>
|
||
<tr>
|
||
<td style="padding:4px 8px;color:#9ff0bb;white-space:nowrap;vertical-align:top">IP 白名单</td>
|
||
<td><code><?php echo htmlspecialchars(implode('、', $saved['whitelist']), ENT_QUOTES, 'UTF-8'); ?></code></td>
|
||
</tr>
|
||
<tr>
|
||
<td style="padding:4px 8px;color:#9ff0bb;white-space:nowrap;vertical-align:top">人机验证码</td>
|
||
<td><code><?php echo htmlspecialchars($saved['provider'] === 'off' ? '关闭' : strtoupper($saved['provider']), ENT_QUOTES, 'UTF-8'); ?></code></td>
|
||
</tr>
|
||
</table>
|
||
<div style="color:#9ff0bb;font-size:12.5px;margin-top:6px">本页已随安装完成而删除 —— 这是最后一处能整份查看该配置的页面;完整配置仍保存在 <code>sql.md</code> 与 <code>config.php</code>。</div>
|
||
</div>
|
||
|
||
<div style="margin-top:12px;border-top:1px solid #26543a;padding-top:10px">
|
||
<div style="font-weight:600;margin:0 0 6px">写入明细:</div>
|
||
<ul><?php foreach ($saved['written'] as $w): ?><li><?php echo htmlspecialchars($w, ENT_QUOTES, 'UTF-8'); ?></li><?php endforeach; ?></ul>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
下一步:
|
||
<ul>
|
||
<?php if (!empty($saved['go2fa'])): ?>
|
||
<li><strong>正在跳转到 <a href="admin_2fa_setup.php" style="color:#7fb0ff">安全配置引导页</a></strong>:配置 2FA 验证器与访问令牌。</li>
|
||
<?php else: ?>
|
||
<li>2FA 二次验证<strong>尚未配置</strong> —— 建议尽快去 <a href="admin_2fa_setup.php" style="color:#7fb0ff">安全配置引导页</a> 配好(不配则后台只靠 IP 白名单 + 访问令牌把关)。</li>
|
||
<?php endif; ?>
|
||
<li>mysql 模式 → 首次进后台会自动建表(已在第二关实连并验证过建表权限);db 模式 → 配完即可登录。</li>
|
||
<li>记得让 <strong>Java 端</strong>(api/sync.php 所在的独立站点)也用同一份配置:改 sync.php 时两处都要传。</li>
|
||
</ul>
|
||
<?php if (!empty($saved['go2fa'])): ?>
|
||
<script>setTimeout(function(){location.href='admin_2fa_setup.php';},1500);</script>
|
||
<?php endif; ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
|
||
<?php if ($err !== ''): ?>
|
||
<div class="note err"><strong>本步未通过:</strong><?php echo $err; ?></div>
|
||
<?php endif; ?>
|
||
<?php if ($notice !== ''): ?>
|
||
<div class="note info"><?php echo $notice; ?></div>
|
||
<?php endif; ?>
|
||
|
||
<?php if ($step === 5): ?>
|
||
<!-- 完成态:只剩上面的成功卡,不再渲染表单 -->
|
||
<p class="muted" style="border-top:1px solid #232732;padding-top:14px">
|
||
安装已完成。如果需要查看配置,请打开 <code>sql.md</code>(数据库)与 <code>config.php</code>(邮箱、验证码、白名单)。
|
||
</p>
|
||
|
||
<?php elseif ($step === 1): ?>
|
||
<!-- ==================== 第一关:运行环境 ==================== -->
|
||
<form method="post" action="install.php" autocomplete="off">
|
||
<input type="hidden" name="act" value="n1">
|
||
<input type="hidden" name="csrf" value="<?php echo htmlspecialchars($csrf, ENT_QUOTES, 'UTF-8'); ?>">
|
||
<?php if (secToken() !== ''): ?>
|
||
<input type="hidden" name="token" value="<?php echo htmlspecialchars(secToken(), ENT_QUOTES, 'UTF-8'); ?>">
|
||
<?php endif; ?>
|
||
|
||
<h2>第一关 · 运行环境检测</h2>
|
||
<p class="muted">先确认 PHP 扩展齐全。缺扩展在这里只是「选项灰掉」,不会把底层原始报错甩到你脸上。</p>
|
||
|
||
<table class="ext">
|
||
<tr>
|
||
<td style="width:130px"><code>sqlite3</code></td>
|
||
<td style="width:96px"><span class="pill <?php echo $ext['sqlite3'] ? 'y">已安装' : 'n">未安装'; ?></span></td>
|
||
<td class="muted">db(SQLite 文件)模式依赖它</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>pdo_sqlite</code></td>
|
||
<td><span class="pill <?php echo $ext['pdo_sqlite'] ? 'y">已安装' : 'n">未安装'; ?></span></td>
|
||
<td class="muted">正版验证接口依赖它(缺失只影响该功能,不影响安装)</td>
|
||
</tr>
|
||
<tr>
|
||
<td><code>mysqli</code></td>
|
||
<td><span class="pill <?php echo $ext['mysqli'] ? 'y">已安装' : 'n">未安装'; ?></span></td>
|
||
<td class="muted">mysql 模式依赖它</td>
|
||
</tr>
|
||
</table>
|
||
|
||
<?php if (!$ext['any_db'] && !$ext['any_mysql']): ?>
|
||
<div class="note err"><strong>两种模式都用不了:</strong>当前 PHP 既没有 <code>sqlite3</code> 也没有 <code>mysqli</code>。
|
||
请先在宝塔面板 → PHP 设置 → 安装扩展里装好其中之一,再刷新本页。</div>
|
||
<?php elseif (!$ext['any_db']): ?>
|
||
<div class="note warn">检测到缺少 <code>sqlite3</code> → 下一关的 <strong>db 模式会被锁定</strong>,只能用 mysql。</div>
|
||
<?php elseif (!$ext['any_mysql']): ?>
|
||
<div class="note warn">检测到缺少 <code>mysqli</code> → 下一关的 <strong>mysql 模式会被锁定</strong>,只能用 db。</div>
|
||
<?php else: ?>
|
||
<div class="note ok">扩展齐全,db 与 mysql 两种模式都可以选。</div>
|
||
<?php endif; ?>
|
||
|
||
<div class="acts"><button class="btn" type="submit">下一步:配置数据库</button></div>
|
||
</form>
|
||
|
||
<?php elseif ($step === 2): ?>
|
||
<!-- ==================== 第二关:数据库 ==================== -->
|
||
<form method="post" action="install.php" autocomplete="off">
|
||
<input type="hidden" name="csrf" value="<?php echo htmlspecialchars($csrf, ENT_QUOTES, 'UTF-8'); ?>">
|
||
<?php if (secToken() !== ''): ?>
|
||
<input type="hidden" name="token" value="<?php echo htmlspecialchars(secToken(), ENT_QUOTES, 'UTF-8'); ?>">
|
||
<?php endif; ?>
|
||
|
||
<h2>第二关 · 数据库</h2>
|
||
|
||
<?php if ($purgeStage === 'warn' || $purgeStage === 'ask'): ?>
|
||
<!-- ===== 非空刹车 ===== -->
|
||
<div class="note warn">
|
||
<strong>⚠️ 目标数据库不是空白的,已暂停安装。</strong>
|
||
<div class="muted" style="margin-top:4px">继续下去会把下面这些表当作已有数据处理,站点初始化可能报错或串数据。
|
||
先确认它们是不是你需要的。</div>
|
||
<table class="tbl">
|
||
<tr><th>库 / 文件</th><th>表</th><th style="width:110px">行数(约)</th></tr>
|
||
<?php foreach ($purge['scan']['tables'] as $t): ?>
|
||
<tr>
|
||
<td><code><?php echo htmlspecialchars(isset($t['db']) ? $t['db'] : (isset($t['file']) ? $t['file'] : '-'), ENT_QUOTES, 'UTF-8'); ?></code></td>
|
||
<td><code><?php echo htmlspecialchars($t['name'], ENT_QUOTES, 'UTF-8'); ?></code></td>
|
||
<td><?php echo ((int)$t['rows'] < 0) ? '—' : number_format((int)$t['rows']); ?></td>
|
||
</tr>
|
||
<?php endforeach; ?>
|
||
</table>
|
||
<?php if ($purgeStage === 'ask'): ?>
|
||
<div style="border-top:1px solid #6b4a17;margin-top:8px;padding-top:8px">
|
||
<strong>二次确认:</strong>要清空上述全部表,请在下面一字不差输入
|
||
<span class="kbd"><?php echo INST_PURGE_PHRASE; ?></span>
|
||
<input type="text" name="purge_phrase" value="" placeholder="<?php echo INST_PURGE_PHRASE; ?>"
|
||
style="margin-top:6px" autocomplete="off">
|
||
<?php if (!empty($purge['bad'])): ?>
|
||
<div class="muted" style="color:#ffb4bc;margin-top:4px">口令不对,未执行任何删除。</div>
|
||
<?php endif; ?>
|
||
</div>
|
||
<?php endif; ?>
|
||
</div>
|
||
|
||
<div class="acts">
|
||
<?php if ($purgeStage === 'warn'): ?>
|
||
<button class="btn rd" type="submit" name="act" value="pack">确认要清空,继续</button>
|
||
<button class="btn gh" type="submit" name="act" value="pcancel">取消,回去重新输入数据库</button>
|
||
<?php else: ?>
|
||
<button class="btn rd" type="submit" name="act" value="pdo">输入无误,清空并继续</button>
|
||
<button class="btn gh" type="submit" name="act" value="pcancel">取消,回去重新输入数据库</button>
|
||
<?php endif; ?>
|
||
</div>
|
||
</div>
|
||
<?php else: ?>
|
||
|
||
<div class="modes">
|
||
<label class="<?php echo ($modeSel === 'db' ? 'on ' : '') . ($ext['any_db'] ? '' : 'dis'); ?>">
|
||
<input type="radio" name="mode" value="db" <?php echo $ext['any_db'] ? 'onchange="mSwitch(this)"' : 'disabled'; ?>
|
||
<?php echo $modeSel === 'db' ? 'checked' : ''; ?>>
|
||
db(SQLite 文件,默认)<?php echo $ext['any_db'] ? '' : ' · 扩展缺失已锁定'; ?></label>
|
||
<label class="<?php echo ($modeSel === 'mysql' ? 'on ' : '') . ($ext['any_mysql'] ? '' : 'dis'); ?>">
|
||
<input type="radio" name="mode" value="mysql" <?php echo $ext['any_mysql'] ? 'onchange="mSwitch(this)"' : 'disabled'; ?>
|
||
<?php echo $modeSel === 'mysql' ? 'checked' : ''; ?>>
|
||
mysql<?php echo $ext['any_mysql'] ? '' : ' · 扩展缺失已锁定'; ?></label>
|
||
</div>
|
||
|
||
<div id="dirBox">
|
||
<label>数据库存储位置 <span class="muted">(仅 db 模式生效;留空 = 默认 <code>./db/</code>)</span></label>
|
||
<input type="text" name="dir" value="<?php echo $f('dir'); ?>" placeholder="./db/">
|
||
<p class="muted">/ 开头=绝对路径;./ 开头=相对本目录;../ 开头=相对上层目录。</p>
|
||
</div>
|
||
|
||
<div id="mysqlBox">
|
||
<div class="note info">地址与端口已按<strong>本机 MySQL 的常规配置</strong>预填(<code>127.0.0.1:3306</code>)——
|
||
用本机 MySQL 的话,你只需要填账号、密码、库名。<br>
|
||
每侧地址<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 $f('java_host'); ?>" placeholder="127.0.0.1"></div>
|
||
<div><label>端口</label><input type="text" name="java_port" value="<?php echo $f('java_port'); ?>" placeholder="3306"></div>
|
||
</div>
|
||
<div class="row">
|
||
<div><label>数据库名</label><input type="text" name="java_name" value="<?php echo $f('java_name'); ?>" placeholder="game_java"></div>
|
||
<div><label>账号</label><input type="text" name="java_user" value="<?php echo $f('java_user'); ?>" autocomplete="off"></div>
|
||
</div>
|
||
<label>密码</label>
|
||
<input type="password" name="java_pass" value="<?php echo $f('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 $f('web_host'); ?>" placeholder="127.0.0.1"></div>
|
||
<div><label>端口</label><input type="text" name="web_port" value="<?php echo $f('web_port'); ?>" placeholder="3306"></div>
|
||
</div>
|
||
<div class="row">
|
||
<div><label>数据库名</label><input type="text" name="web_name" value="<?php echo $f('web_name'); ?>" placeholder="game_web"></div>
|
||
<div><label>账号</label><input type="text" name="web_user" value="<?php echo $f('web_user'); ?>" autocomplete="off"></div>
|
||
</div>
|
||
<label>密码</label>
|
||
<input type="password" name="web_pass" value="<?php echo $f('web_pass'); ?>" autocomplete="new-password">
|
||
</div>
|
||
|
||
<div class="acts">
|
||
<button class="btn" type="submit" name="act" value="n2">下一步:检查数据库状态</button>
|
||
<button class="btn gh" type="submit" name="act" value="prev">上一步</button>
|
||
<input type="hidden" name="from" value="2">
|
||
</div>
|
||
<?php endif; ?>
|
||
</form>
|
||
|
||
<?php elseif ($step === 3): ?>
|
||
<!-- ==================== 第三关:邮箱 ==================== -->
|
||
<form method="post" action="install.php" autocomplete="off">
|
||
<input type="hidden" name="csrf" value="<?php echo htmlspecialchars($csrf, ENT_QUOTES, 'UTF-8'); ?>">
|
||
<?php if (secToken() !== ''): ?>
|
||
<input type="hidden" name="token" value="<?php echo htmlspecialchars(secToken(), ENT_QUOTES, 'UTF-8'); ?>">
|
||
<?php endif; ?>
|
||
|
||
<h2>第三关 · 邮箱(SMTP)</h2>
|
||
<div class="note info"><strong>要么全填,要么全空。</strong><br>
|
||
全空 → 直接进最后一关(站点不能发邮件:找回密码、订单通知都发不出去)。<br>
|
||
全不空 → 必须收一个测试验证码,确认这个邮箱真的能发信,才放你进下一关。</div>
|
||
|
||
<div class="row">
|
||
<div style="flex:2"><label>SMTP 服务器</label>
|
||
<input type="text" name="smtp_host" value="<?php echo htmlspecialchars(isset($raw3['smtp_host']) ? $raw3['smtp_host'] : '', ENT_QUOTES, 'UTF-8'); ?>" placeholder="smtp.qq.com"></div>
|
||
<div><label>端口</label>
|
||
<input type="text" name="smtp_port" value="<?php echo htmlspecialchars(isset($raw3['smtp_port']) ? $raw3['smtp_port'] : '465', ENT_QUOTES, 'UTF-8'); ?>" placeholder="465"></div>
|
||
</div>
|
||
<label>用户名 / 发信账号</label>
|
||
<input type="text" name="smtp_user" value="<?php echo htmlspecialchars(isset($raw3['smtp_user']) ? $raw3['smtp_user'] : '', ENT_QUOTES, 'UTF-8'); ?>" placeholder="you@example.com" autocomplete="off">
|
||
<label>密码 / 授权码 <span class="muted">(不是登录密码,是邮箱后台生成的授权码)</span></label>
|
||
<input type="password" name="smtp_pass" value="<?php echo htmlspecialchars(isset($raw3['smtp_pass']) ? $raw3['smtp_pass'] : '', ENT_QUOTES, 'UTF-8'); ?>" autocomplete="new-password">
|
||
<div class="chk">
|
||
<input type="checkbox" id="sslBox" name="smtp_ssl" value="1"
|
||
<?php echo (!isset($raw3['smtp_ssl']) || $raw3['smtp_ssl'] === '1') ? 'checked' : ''; ?>>
|
||
<label for="sslBox" style="margin:0">使用 SSL(465 端口勾选,25/587 明文端口取消勾选)</label>
|
||
</div>
|
||
|
||
<label>测试邮箱 <span class="muted">(验证码发到这里,仅用于本次测试,不写入配置)</span></label>
|
||
<input type="email" name="smtp_to" value="<?php echo htmlspecialchars(isset($raw3['smtp_to']) ? $raw3['smtp_to'] : '', ENT_QUOTES, 'UTF-8'); ?>" placeholder="you@example.com" autocomplete="off">
|
||
|
||
<label>验证码</label>
|
||
<div class="row">
|
||
<div style="flex:2"><input type="text" name="code" value="" placeholder="<?php echo $smtpSent ? '6 位数字,已发送' : '先点右侧发送'; ?>" autocomplete="one-time-code" inputmode="numeric" maxlength="6"></div>
|
||
<div style="flex:0 0 auto;display:flex;align-items:center">
|
||
<button class="btn gh sm" type="submit" name="act" value="scode">发送测试验证码</button>
|
||
</div>
|
||
</div>
|
||
<?php if ($smtpSent): ?>
|
||
<p class="muted" style="margin-top:6px">验证码已发出,5 分钟内有效;没收到请检查垃圾箱或确认授权码。</p>
|
||
<?php endif; ?>
|
||
|
||
<div class="acts">
|
||
<button class="btn" type="submit" name="act" value="n3">下一步</button>
|
||
<button class="btn gh" type="submit" name="act" value="prev">上一步</button>
|
||
<input type="hidden" name="from" value="3">
|
||
</div>
|
||
<p class="muted" style="margin-top:10px">不想配置邮箱?把上面四个字段全部清空,直接点「下一步」。</p>
|
||
</form>
|
||
|
||
<?php elseif ($step === 4): ?>
|
||
<!-- ==================== 第四关:安全 ==================== -->
|
||
<form method="post" action="install.php" autocomplete="off">
|
||
<input type="hidden" name="csrf" value="<?php echo htmlspecialchars($csrf, ENT_QUOTES, 'UTF-8'); ?>">
|
||
<?php if (secToken() !== ''): ?>
|
||
<input type="hidden" name="token" value="<?php echo htmlspecialchars(secToken(), ENT_QUOTES, 'UTF-8'); ?>">
|
||
<?php endif; ?>
|
||
|
||
<h2>第四关 · 安全</h2>
|
||
|
||
<label>IP 白名单 <span style="color:#ffb4bc">★ 必填</span> <span class="muted">(一行一个 IP 或 CIDR)</span></label>
|
||
<textarea name="whitelist" id="wlBox" placeholder="一行一个 IP 或 CIDR 网段,例如: 203.0.113.8 192.168.1.0/24"><?php echo htmlspecialchars($curWl, ENT_QUOTES, 'UTF-8'); ?></textarea>
|
||
<div class="acts" style="margin-top:8px">
|
||
<button class="btn gh sm" type="button" onclick="fillIp()">使用当前访问 IP(<?php echo htmlspecialchars($myIp, ENT_QUOTES, 'UTF-8'); ?>)</button>
|
||
</div>
|
||
<p class="muted">当前访问 IP:<span class="kbd"><?php echo htmlspecialchars($myIp, ENT_QUOTES, 'UTF-8'); ?></span>
|
||
—— 它必须在白名单里,否则保存后你自己也进不了后台(除了服务器本机 127.0.0.1)。</p>
|
||
|
||
<h2>人机验证码 <span class="muted" style="font-weight:400">(选填,留空即关闭)</span></h2>
|
||
<label>使用哪家</label>
|
||
<select name="cap_provider" onchange="capSwitch(this)">
|
||
<option value="off" <?php echo $curProvider === 'off' ? 'selected' : ''; ?>>关闭(不启用人机验证)</option>
|
||
<option value="cf" <?php echo $curProvider === 'cf' ? 'selected' : ''; ?>>Cloudflare Turnstile</option>
|
||
<option value="va" <?php echo $curProvider === 'va' ? 'selected' : ''; ?>>VAPTCHA V4</option>
|
||
</select>
|
||
|
||
<div id="capBox" style="<?php echo $curProvider === 'off' ? 'display:none' : ''; ?>">
|
||
<div class="row">
|
||
<div><label id="capL1">Sitekey / VID</label>
|
||
<input type="text" name="cap_k1" id="capK1" value="<?php echo htmlspecialchars($curK1, ENT_QUOTES, 'UTF-8'); ?>" autocomplete="off"></div>
|
||
<div><label id="capL2">Secret / VKey</label>
|
||
<input type="text" name="cap_k2" id="capK2" value="<?php echo htmlspecialchars($curK2, ENT_QUOTES, 'UTF-8'); ?>" autocomplete="off"></div>
|
||
</div>
|
||
<p class="muted">CF:控制台 → Workers & Pages → Turnstile → Add site,拿 Sitekey 与 Secret。<br>
|
||
VA:VAPTCHA 控制台 → 创建应用,拿 VID 与 VKey。</p>
|
||
</div>
|
||
|
||
<h2>2FA 二次验证 <span class="muted" style="font-weight:400">(选填)</span></h2>
|
||
<div class="note info">2FA 验证器(Google Authenticator / 1Password / Microsoft Authenticator)
|
||
在<strong>配置完成后的安全引导页</strong>里设置 —— 那里会生成密钥、显示二维码并要求你输一次动态码确认。<br>
|
||
下面两个按钮都会在安装完成后跳过去;选「稍后再配」则后台只靠 IP 白名单 + 访问令牌把关。</div>
|
||
|
||
<div class="acts">
|
||
<button class="btn gr" type="submit" name="act" value="n4">完成安装,去配置 2FA</button>
|
||
<input type="hidden" name="go2fa" id="go2fa" value="1">
|
||
<button class="btn gh" type="submit" name="act" value="n4" onclick="document.getElementById('go2fa').value='0';">完成安装,稍后再配 2FA</button>
|
||
<button class="btn gh" type="submit" name="act" value="prev">上一步</button>
|
||
<input type="hidden" name="from" value="4">
|
||
</div>
|
||
<p class="muted" style="margin-top:10px">点「确认安装」后会一次性写入全部配置、生成 <code>install.lock</code> 并删除本页。</p>
|
||
</form>
|
||
|
||
<?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">?token=访问令牌</span> 或已通过引导,否则返回 nginx 404。
|
||
<?php endif; ?>
|
||
<br>本页不加载 core.php;所有写入都会先备份再原子替换,失败自动还原。
|
||
</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').disabled ? 'dis' : '')
|
||
+ (ls[i].querySelector('input').checked ? ' on' : '');
|
||
}
|
||
function capSwitch(sel){
|
||
var off = (sel.value === 'off');
|
||
document.getElementById('capBox').style.display = off ? 'none' : '';
|
||
var l1 = document.getElementById('capL1'), l2 = document.getElementById('capL2');
|
||
if (sel.value === 'va'){ l1.textContent='VID'; l2.textContent='VKey'; }
|
||
else { l1.textContent='Sitekey'; l2.textContent='Secret'; }
|
||
}
|
||
function fillIp(){
|
||
var t = document.getElementById('wlBox');
|
||
t.value = <?php echo json_encode((string)$myIp, JSON_UNESCAPED_UNICODE); ?>;
|
||
t.focus();
|
||
}
|
||
(function(){
|
||
var sel = document.querySelector('.modes input:checked:not([disabled])');
|
||
if (sel) mSwitch(sel);
|
||
else {
|
||
var first = document.querySelector('.modes input:not([disabled])');
|
||
if (first) { first.checked = true; mSwitch(first); }
|
||
else { document.getElementById('dirBox').style.display='none';
|
||
document.getElementById('mysqlBox').style.display='none'; }
|
||
}
|
||
var ps = document.querySelector('select[name=cap_provider]');
|
||
if (ps) capSwitch(ps);
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|