Files
Sdf1_login/admin_2fa_setup.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

2935 lines
98 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
/**
* 后台安全配置页(独立页面)—— 第一层 TOTP 二次验证 + 第二层 IP 白名单 的统一配置入口。
*
* 访问方式(三选一):
* A. 已登录后台,且(2FA 未启用 或 已通过 2FA 验证)→ 直接访问
* B. 被白名单挡在外面:宝塔/SSH 查 config.php 的 SEC_BOOT_TOKEN,
* 访问 admin_2fa_setup.php?boot=<令牌>(引导模式,免密码)
* C. 服务器本机 127.0.0.1 永远可访问(仍需登录,或用 boot)
*
* 所有配置写入 config.php 的 SEC-UPDATE 标记区(写前自动备份到 db/config_bak/)。
*/
require_once __DIR__ . '/security.php';
secEnsureSession();
// IP 白名单闸门 + boot 引导令牌解锁
secGatePage(true);
$boot = secBootSessionActive();
// ★ 首跑顺序(2026-10-09):装库先于安全配置 —— install.php 还没跑完(文件在场、
// 无 install.lock)时,本页先让位给数据库安装页;装完它自删,再回来配 2FA/白名单/令牌。
// 引导模式下 secGatePage 已先行 302,这里兜住「已引导 / 本机直连」两种入口;
// 已引导站点带 ?token= 过 install.php 的第三层门禁(令牌为空时 secTokenUrl 原样返回)。
// ★ 0.0.0.0 应急时让位反向失效:install.php 属「其它任何页面」,会被 secEmergencyGate()
// 302 回本页 —— 若本页再 302 去 install.php 就成了两页互跳死循环。
// 应急的语义就是「直达引导配置页把白名单改回来」,故应急优先于装库让位。
if (secInstallPending() && !secIsEmergency()) {
header('Location: ' . secTokenUrl('install.php'));
exit;
}
// ★ 首次部署:管理密码还是分发占位符 → 走初始化模式。
// 旧行为在这里直接跳登录页,是个死胡同:登录要密码,而这时候根本没有可用密码。
$needInit = !secAdminPassConfigured();
// ===== 访问模式判定 =====
// 说明:本页只依赖 security.php(不加载 core.php),
// isAdminLoggedIn() 是 core 的函数,这里用等价的内联判定(admin_auth + 安全纪元)。
$loggedIn = !empty($_SESSION['admin_auth']) && secSessionEpochOk();
$isEmergency = false;
if ($boot) {
$mode = 'boot';
} elseif (secIsEmergency()) {
// ★ 0.0.0.0 应急(2026-10-09):boot / 访问令牌全丢时的最后通道 —— 按 boot
// 引导模式免密放行(本分支必须在 session 之前:应急时若 session 开着 2FA,
// 会被送去 admin_2fa.php,而那页又被全局应急路由 302 回本页 → 死循环)。
// 引导动作:删掉白名单里的 0.0.0.0 → 保存 → 重新生成令牌,即恢复常态。
$mode = 'boot';
$isEmergency = true;
secLog('emergency_setup_enter', 'ip=' . secClientIp());
} elseif ($loggedIn) {
// 已登录:若 2FA 已启用但本会话未验证 → 先去验证页
if (defined('SEC_2FA_ENABLED') && SEC_2FA_ENABLED && empty($_SESSION['admin_2fa_ok'])) {
header('Location: admin_2fa.php');
exit;
}
$mode = 'session';
} elseif ($needInit) {
$mode = 'init';
} else {
header('Location: admin_login.php');
exit;
}
// ===== 当前状态(操作后会就地更新,保证同请求内显示正确)=====
$whitelist = (defined('ADMIN_IP_WHITELIST') && is_array(ADMIN_IP_WHITELIST)) ? ADMIN_IP_WHITELIST : array();
$twoFaOn = defined('SEC_2FA_ENABLED') && SEC_2FA_ENABLED;
$twoFaSecret = defined('SEC_2FA_SECRET') ? (string)SEC_2FA_SECRET : '';
// ★ 第三层:后台入口令牌(config.php 的 SEC_ACCESS_TOKEN)。
// 若旧版 config 尚未有该键,首次任何写操作会顺手生成一个(自愈),本页会把地址显示出来。
$secToken = (defined('SEC_ACCESS_TOKEN') && SEC_ACCESS_TOKEN !== '') ? (string)SEC_ACCESS_TOKEN : secNewToken();
// ★ boot 引导令牌同理:config.php 分发版里 SEC-UPDATE 区是空的(隐私信息不入库),
// 首次保存时自动生成,之后一直沿用 —— 否则逃生舱永远是空令牌、谁都进不来也等于没锁。
$secBootTok = (defined('SEC_BOOT_TOKEN') && SEC_BOOT_TOKEN !== '') ? (string)SEC_BOOT_TOKEN : secNewToken();
$pending = isset($_SESSION['sec_pending_secret']) ? (string)$_SESSION['sec_pending_secret'] : '';
$myIp = secClientIp();
$err = '';
$ok = '';
// 初始化成功后 302 回本页刷新(令牌要落盘后重读才准),提示语靠 session 带过来
if (!empty($_SESSION['sec_flash_ok'])) {
$ok = (string)$_SESSION['sec_flash_ok'];
unset($_SESSION['sec_flash_ok']);
}
// ===== POST:所有写操作 =====
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $mode !== 'none') {
$act = isset($_POST['act']) ? (string)$_POST['act'] : '';
$allowed = false;
if ($mode === 'boot') {
$allowed = true; // 引导模式(已持有服务器文件级凭据)免密码
} elseif ($act === 'init_setup') {
// ★ 首次初始化:这一步要设的就是密码本身,没有密码可校验。
// 真正的闸门是 $needInit —— 密码一旦配置成功,这条分支立刻关死。
if (!$needInit) {
$err = '管理密码已配置,初始化只能进行一次';
} else {
$allowed = true;
}
} else {
// 第二层附加保护:改配置必须重输管理密码(防活会话被盗用者直接改安全配置)
$lock = secThrottleLocked('setup_pw', $myIp, 5, 600);
if ($lock > 0) {
$err = '密码尝试过多,请 ' . $lock . ' 秒后再试';
secLog('setup_throttled', 'lock=' . $lock . 's');
} else {
$pw = isset($_POST['admin_pass']) ? (string)$_POST['admin_pass'] : '';
// 顺带关死"占位符当密码"这条路:密码没配置时 session 模式一律不放行
if ($pw !== '' && secAdminPassConfigured() && $pw === ADMIN_PASS) {
$allowed = true;
secThrottleReset('setup_pw', $myIp);
} else {
$left = secThrottleFail('setup_pw', $myIp, 5, 600);
secLog('setup_pw_fail', $left > 0 ? 'LOCKED_' . $left . 's' : 'wrong_password');
$err = $left > 0
? '管理密码错误且尝试过多,已锁定 ' . $left . ' 秒'
: '管理密码错误,操作被拒绝';
}
}
}
if ($allowed) {
try {
// 全量写回(未提及的键保留旧值由 secWriteConfig 内部保证)
$full = function ($over) {
return array_merge(array(
// 用变量而不是常量:分发版 config.php 的 SEC-UPDATE 区是空的,
// 未定义时常量会触发 PHP8 undefined constant Error;这里取"当前值或本次生成的值",
// 首次保存时把随机 boot 令牌 / 访问令牌 / 纪元就地补进 config.php。
'SEC_BOOT_TOKEN' => $GLOBALS['secBootTok'],
'SEC_ACCESS_TOKEN' => $GLOBALS['secToken'],
'SEC_EPOCH' => defined('SEC_EPOCH') ? (int)SEC_EPOCH : time(),
'ADMIN_IP_WHITELIST' => $GLOBALS['whitelist'],
'SEC_2FA_ENABLED' => $GLOBALS['twoFaOn'],
'SEC_2FA_SECRET' => $GLOBALS['twoFaSecret'],
), $over);
};
if ($act === 'init_setup') {
// ===== 首次初始化:设置管理密码 + 写入白名单,一次做完 =====
if (!$needInit) throw new Exception('管理密码已配置,初始化只能进行一次');
$pw1 = isset($_POST['new_pass']) ? (string)$_POST['new_pass'] : '';
$ve = secValidateNewPassword($pw1, isset($_POST['new_pass2']) ? (string)$_POST['new_pass2'] : '');
if ($ve !== null) throw new Exception($ve);
$items = secParseWhitelistText(isset($_POST['whitelist']) ? (string)$_POST['whitelist'] : '');
// 自锁保护:初始化页还没展示过任何恢复地址,白名单里漏掉自己的 IP
// 就会保存完立刻 404、且不知道 boot 令牌是什么 → 这里直接拦下来。
if (!secIpInWhitelist($myIp, $items)) {
throw new Exception('白名单里没有你当前的 IP(<b>' . htmlspecialchars($myIp, ENT_QUOTES, 'UTF-8')
. '</b>),保存后你会立刻把自己挡在门外。请把它加进去,或改成覆盖你网段的 CIDR。');
}
$twoFaSnap = secReadUpdateBlock();
// ① 先改写 config 第 32 行那条 ADMIN_PASS,再写 SEC-UPDATE 区
// (白名单 + 自动生成的两个令牌 + 安全纪元,顺手打引导标记)。
// 任一步失败都抛给下面的 catch 显示,可直接重试、不会把人锁死:
// 密码没设成 → 还是初始化页;密码设成而区段没写成 → 用新密码登录回来接着配。
secWriteAdminPass($pw1);
secWriteConfig($full(array('ADMIN_IP_WHITELIST' => $items)));
secGuardTwoFaUnchanged($twoFaSnap, 'init_setup');
$whitelist = $items;
secLog('admin_init', 'whitelist=' . count($items) . ' ' . implode(',', $items));
// ② 自动登录,省得刚设完密码又敲一遍
secEnsureSession();
$_SESSION['admin_auth'] = true;
$_SESSION['admin_login_time'] = time();
unset($_SESSION['admin_2fa_ok'], $_SESSION['admin_2fa_ip']);
// ③ 回本页刷新:两个令牌是这次才落盘的,当前请求里的 $secToken 还是
// 刚生成、没保存过的随机值,直接渲染出来会骗人(保存后就对不上了)。
$_SESSION['sec_flash_ok'] = '初始化完成:管理密码已设置,IP 白名单已保存(' . count($items)
. ' 条),门禁已转为强制模式。请立刻收藏本页显示的后台入口地址。';
header('Location: admin_2fa_setup.php');
exit;
} elseif ($act === 'save_whitelist') {
$items = secParseWhitelistText(isset($_POST['whitelist']) ? (string)$_POST['whitelist'] : '');
// 改白名单同样不许碰 2FA:写前快照 + 写后护栏(同 rotate_token)
$twoFaSnap = secReadUpdateBlock();
secWriteConfig($full(array('ADMIN_IP_WHITELIST' => $items)));
secGuardTwoFaUnchanged($twoFaSnap, 'save_whitelist');
$whitelist = $items;
secLog('whitelist_save', 'count=' . count($items) . ' ' . implode(',', $items));
$ok = 'IP 白名单已保存(' . count($items) . ' 条)' . (count($items) === 0 ? '。注意:白名单为空 = 仅服务器本机可访问后台!' : '');
} elseif ($act === 'twofa_begin') {
// ★ 幂等:已有待绑定密钥就继续用它。
// 以前每次点都换新密钥 —— 用户手里的认证器可能刚扫/刚录完旧的,
// 再点一次就把那条作废了,之后所有动态码"永远无效",极难排查。
if ($pending === '') {
$_SESSION['sec_pending_secret'] = secNewSecret();
$pending = $_SESSION['sec_pending_secret'];
secLog('twofa_begin', 'pending secret generated');
$ok = '密钥已生成:请扫码或手动录入后,提交下方验证码完成绑定';
} else {
secLog('twofa_begin', 'reuse pending secret');
$ok = '沿用已生成的密钥(避免认证器里出现多条作废记录)。要换新请点「重新生成密钥」。';
}
} elseif ($act === 'twofa_regen') {
$_SESSION['sec_pending_secret'] = secNewSecret();
$pending = $_SESSION['sec_pending_secret'];
secLog('twofa_regen', 'pending secret regenerated, old one discarded');
$ok = '密钥已重新生成 —— 之前录入认证器的那条已作废,请用新二维码/新密钥重新添加';
} elseif ($act === 'twofa_finish') {
if ($pending === '') throw new Exception('没有待验证的密钥,请先点击"生成密钥"');
$code = isset($_POST['code']) ? (string)$_POST['code'] : '';
$lock2 = secThrottleLocked('twofa_bind', $myIp, 5, 600);
if ($lock2 > 0) throw new Exception('尝试过多,请 ' . $lock2 . ' 秒后再试');
if (!secTotpVerify($pending, $code)) {
$left2 = secThrottleFail('twofa_bind', $myIp, 5, 600);
// ★ 诊断:区分"认证器里根本不是这把密钥"与"设备时钟偏了"
$off = secTotpDiagnose($pending, $code);
secLog('twofa_bind_fail', $left2 > 0 ? 'LOCKED'
: ($off === null ? 'wrong_secret' : 'clock_skew ' . $off . 's'));
if ($left2 > 0) throw new Exception('尝试过多,请 ' . $left2 . ' 秒后再试');
throw new Exception('绑定未完成:' . secTotpDiagnoseMsg($off));
}
secWriteConfig($full(array('SEC_2FA_ENABLED' => true, 'SEC_2FA_SECRET' => $pending)));
$twoFaOn = true;
$twoFaSecret = $pending;
unset($_SESSION['sec_pending_secret']);
$pending = '';
secThrottleReset('twofa_bind', $myIp);
secLog('twofa_enabled', '');
$ok = '✅ 二次验证已启用!之后每次登录后台都必须输入 6 位动态码(当前会话也需重新验证)';
} elseif ($act === 'rotate_token') {
// ★ 防误触兜底(2026-09-30 事故):本操作曾是表单第一个 submit 按钮,
// 任何文本框按回车都会隐式提交命中它。现在只有点了「重新生成令牌」
// 按钮并确认对话框(rotate_confirm=1)才执行;回车等路径一律拒绝。
if (!isset($_POST['rotate_confirm']) || (string)$_POST['rotate_confirm'] !== '1') {
secLog('token_rotate_denied', '缺少显式确认标记(疑似回车隐式提交),已拒绝');
throw new Exception('已拦截一次非按钮发起的令牌重置(防误触)。请点「重新生成令牌」按钮并在确认框中点确定。');
}
// ★ 需求:重置访问密钥不得关闭二次验证。
// 1) 写前先拍一张 SEC-UPDATE 区快照;
// 2) 最小写入 —— 本次只提交"两个令牌键",绝不再走 $full() 把内存里的
// 2FA / 白名单 / 纪元一起带进去(secWriteConfig 对未提及的键一律按
// 磁盘旧值原样保留,所以那些键连一个字节都不会动)。
// 顺带写 SEC_BOOT_TOKEN 是为了:分发版 config.php 的区段是空的,
// 页面上算出来的引导令牌必须落盘一次,否则刷新就变、逃生舱永远用不了。
// 3) 写后由 secGuardTwoFaUnchanged 复核磁盘上的 2FA 状态,
// 只要被打开/关闭或密钥被清空就立刻回滚并报错。
$twoFaSnap = secReadUpdateBlock();
$newTok = secNewToken();
secWriteConfig(array(
'SEC_ACCESS_TOKEN' => $newTok,
'SEC_BOOT_TOKEN' => $GLOBALS['secBootTok'],
));
secGuardTwoFaUnchanged($twoFaSnap, 'rotate_token');
$secToken = $newTok;
secLog('token_rotate', 'admin.php 入口令牌已更换,2FA 保持' . ($twoFaOn ? '启用' : '原样'));
$ok = '后台入口令牌已重新生成,旧地址立即失效(返回 404)。二次验证状态不受影响(仍为'
. ($twoFaOn ? '已启用 ✅' : '未启用') . ')。请复制下方新地址并更新书签。';
} elseif ($act === 'twofa_disable') {
if ($mode !== 'boot') {
// 非引导模式:必须出示当前动态码(证明认证器在手)
$code = isset($_POST['code']) ? (string)$_POST['code'] : '';
if (!secTotpVerify($twoFaSecret, $code)) {
$offD = secTotpDiagnose($twoFaSecret, $code);
secLog('twofa_disable_fail', $offD === null ? 'wrong_secret' : 'clock_skew ' . $offD . 's');
throw new Exception('未执行解绑:' . secTotpDiagnoseMsg($offD));
}
}
secWriteConfig($full(array('SEC_2FA_ENABLED' => false, 'SEC_2FA_SECRET' => '')));
$twoFaOn = false;
$twoFaSecret = '';
unset($_SESSION['sec_pending_secret']);
$pending = '';
secLog('twofa_disabled', 'mode=' . $mode);
$ok = '二次验证已关闭' . ($mode === 'boot' ? '(引导模式强制关闭)' : '');
} else {
throw new Exception('未知操作');
}
} catch (Exception $e) {
$err = $e->getMessage();
}
}
}
// ===== 恢复访问地址(引导令牌入口,供状态卡片展示)=====
$reqPath = parse_url(isset($_SERVER['REQUEST_URI']) ? $_SERVER['REQUEST_URI'] : '/', PHP_URL_PATH);
$baseDir = rtrim(dirname($reqPath), '/');
$scheme = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on') ? 'https' : 'http';
$host = isset($_SERVER['HTTP_HOST']) ? $_SERVER['HTTP_HOST'] : '';
$recoverUrl = $scheme . '://' . $host . $baseDir . '/admin_2fa_setup.php?boot=' . $secBootTok;
// ★ 第三层:后台唯一正确入口(不带令牌访问 admin.php 一律 404)
$adminUrl = $scheme . '://' . $host . $baseDir . '/admin.php?token=' . rawurlencode($secToken);
// ===== 安全审计日志(最近 8 条)=====
$logLines = array();
$secLogPath = __DIR__ . '/db/security.log';
if (file_exists($secLogPath)) {
$all = @file($secLogPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
if (is_array($all)) $logLines = array_slice($all, -8);
}
// ===== boot 恢复视角下"我的 IP 是否已被白名单接受" =====
// secIpAllowed() 读的是请求开始时定义的 ADMIN_IP_WHITELIST 常量 —— 本页刚点过
// 「保存白名单」时它还是旧值(secWriteConfig 只改磁盘)。不补判一次的话:刚把 IP
// 加进去、页面上的恢复提示横幅却还在说"你不在白名单",运维会以为根本没保存成功。
$wlAllowsMe = secIpAllowed() || secIpInWhitelist($myIp, $whitelist);
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SDF1 - 后台安全配置</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #0d1117; color: #e6edf3; font-family: 'Segoe UI', system-ui, sans-serif; padding: 24px 16px; }
.wrap { max-width: 760px; margin: 0 auto; }
h1 { color: #58a6ff; font-size: 22px; margin-bottom: 6px; }
.sub { color: #8b949e; font-size: 13px; margin-bottom: 18px; }
.card { background: #161b22; border: 1px solid #30363d; border-radius: 10px; padding: 20px; margin-bottom: 16px; }
.card h2 { font-size: 15px; color: #58a6ff; margin-bottom: 12px; }
.badge { display: inline-block; padding: 3px 10px; border-radius: 999px; font-size: 11px; font-weight: 600; margin-left: 8px; vertical-align: middle; }
.b-on { background: rgba(63,185,80,0.15); color: #3fb950; }
.b-off { background: rgba(248,81,73,0.15); color: #f85149; }
.b-boot { background: rgba(210,153,34,0.2); color: #d29922; }
.row { display: flex; gap: 8px; margin-bottom: 10px; align-items: center; flex-wrap: wrap; }
label { font-size: 12px; color: #8b949e; min-width: 92px; }
textarea, input[type=password], input[type=text] {
width: 100%; padding: 10px 12px; background: #0d1117; border: 1px solid #30363d;
border-radius: 6px; color: #e6edf3; font-size: 13px; outline: none; font-family: monospace;
}
textarea:focus, input:focus { border-color: #58a6ff; }
textarea { min-height: 92px; resize: vertical; }
.btn { padding: 9px 18px; border: none; border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: 600; }
.btn-blue { background: #58a6ff; color: #fff; }
.btn-green { background: #3fb950; color: #fff; }
.btn-red { background: #f85149; color: #fff; }
.btn-gray { background: #30363d; color: #e6edf3; }
.btn:hover { opacity: 0.85; }
.hint { font-size: 12px; color: #8b949e; line-height: 1.8; margin-top: 8px; }
.hint code { color: #d29922; background: #0d1117; padding: 1px 5px; border-radius: 4px; font-size: 11px; }
.msg { padding: 10px 14px; border-radius: 6px; font-size: 13px; margin-bottom: 14px; display: block; word-break: break-all; }
.msg.err { background: rgba(248,81,73,0.1); border: 1px solid rgba(248,81,73,0.35); color: #f85149; }
.msg.ok { background: rgba(63,185,80,0.1); border: 1px solid rgba(63,185,80,0.35); color: #3fb950; }
.secret-box { background: #0d1117; border: 1px dashed #d29922; border-radius: 8px; padding: 14px; margin: 10px 0; }
.secret-box .lbl { font-size: 11px; color: #8b949e; margin-bottom: 4px; }
.secret-box .val { font-family: monospace; font-size: 15px; color: #d29922; word-break: break-all; }
.secret-box .uri { font-family: monospace; font-size: 11px; color: #8b949e; word-break: break-all; margin-top: 8px; }
.qrbox { background: #ffffff; border-radius: 8px; padding: 12px; display: inline-block; line-height: 0; }
.qrbox svg { width: 190px; height: 190px; display: block; shape-rendering: crispEdges; }
.qrbox .fail { color: #333; font-size: 12px; line-height: 1.7; padding: 6px; }
.copy { background: none; border: 1px solid #30363d; color: #58a6ff; border-radius: 4px; padding: 2px 8px; font-size: 11px; cursor: pointer; margin-left: 6px; }
.status-list { font-size: 13px; line-height: 2; }
.status-list b { color: #e6edf3; }
.wl-item { display: inline-block; background: #0d1117; border: 1px solid #30363d; border-radius: 999px; padding: 3px 12px; margin: 3px 6px 3px 0; font-size: 12px; font-family: monospace; color: #3fb950; }
.logbox { background: #0d1117; border: 1px solid #30363d; border-radius: 6px; padding: 10px 12px; font-family: monospace; font-size: 11px; color: #8b949e; line-height: 1.9; max-height: 180px; overflow-y: auto; white-space: pre-wrap; word-break: break-all; }
a { color: #58a6ff; text-decoration: none; font-size: 13px; }
.top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 4px; flex-wrap: wrap; gap: 8px; }
.pwd-row { background: #0d1117; border: 1px solid #30363d; border-radius: 8px; padding: 12px 14px; margin-bottom: 16px; }
</style>
</head>
<body>
<div class="wrap">
<div class="top">
<h1>🔐 后台安全配置</h1>
<div>
<?php if ($mode === 'boot' && !$isEmergency): ?><span class="badge b-boot">引导模式(boot 令牌)</span><?php endif; ?>
<?php if ($isEmergency): ?><span class="badge b-boot" style="background:#6e1c24;border-color:#ff4d5e;color:#fff">🚨 应急模式(0.0.0.0)</span><?php endif; ?>
<?php if ($mode === 'init'): ?><span class="badge b-boot">首次初始化</span><?php endif; ?>
<?php
// "← 返回"指向 admin_2fa.php / admin.php?token= —— 两者对非白名单 IP 都是 404。
// boot 恢复模式下你的 IP 正是不在白名单才进来的,藏掉这个会把人直接弹回死胡同。
if (!$needInit && ($mode !== 'boot' || $wlAllowsMe)):
?><a href="<?php echo $twoFaOn ? 'admin_2fa.php' : htmlspecialchars(secTokenUrl('admin.php'), ENT_QUOTES, 'UTF-8'); ?>">← 返回</a><?php endif; ?>
</div>
</div>
<div class="sub">第一层:TOTP 二次验证(6 位动态码) | 第二层:管理面 IP 白名单(未配置时仅本机可访问)</div>
<?php if ($err !== ''): ?><span class="msg err">❌ <?php echo $err; ?></span><?php endif; ?>
<?php if ($ok !== ''): ?><span class="msg ok">✅ <?php echo $ok; ?></span><?php endif; ?>
<?php if ($isEmergency): ?>
<div class="msg err" style="display:block;margin-bottom:14px;line-height:1.7">
🚨 <b>应急模式生效中:白名单里存在条目 <code>0.0.0.0</code>,全站其它页面已全部锁定并跳转到本页。</b><br>
恢复步骤(做完再离开):
① 在下方「IP 白名单」里<strong>删掉 <code>0.0.0.0</code></strong> 并点「保存白名单」(确保你的 IP 在列表里);
② 点「重新生成令牌」<strong>重置全部令牌</strong>(原令牌视为已泄露);
③ 刷新本页确认 🚨 徽标消失。
</div>
<?php endif; ?>
<?php if ($mode === 'boot' && !$wlAllowsMe): ?>
<!-- boot 恢复模式的唯一正事:把当前 IP 加进白名单。其它管理页对你一律 404,这是设计如此。 -->
<span class="msg" style="background:rgba(210,153,34,0.10);border:1px solid rgba(210,153,34,0.40);color:#d29922">
🛟 引导恢复模式:你的 IP <b style="font-family:monospace"><?php echo htmlspecialchars($myIp, ENT_QUOTES, 'UTF-8'); ?></b>
不在 IP 白名单里,所以后台其它页面对你一律返回 404(最小化信息透露,属正常表现)。<br>
请到下方「🚪 第二层 · IP 白名单」把它加进去并点 <b>保存白名单</b> ——
保存成功后立刻就能正常打开后台,不需要再去宝塔改 config.php。
</span>
<?php endif; ?>
<?php if ($needInit): ?>
<!-- ===== 首次初始化:管理密码还是占位符时,本页唯一的入口 ===== -->
<form method="post" autocomplete="off">
<!-- act 默认就是 init_setup:回车隐式提交也走对分支(靠 JS 置值的话回车会提交空 act) -->
<input type="hidden" name="act" id="act" value="init_setup">
<div class="card" style="border-color:#d29922">
<h2>🚀 首次初始化 · 设置你的管理密码</h2>
<div class="hint" style="margin-top:0;margin-bottom:14px;line-height:1.9">
这是全新部署的第一次访问。config.php 里的管理密码还是出厂占位符
<code>REPLACE_ME_ADMIN_PASSWORD</code>,它不是可用密码 ——
<b>请在这里设置你自己的后台管理密码</b>,并确认下方白名单里有你的 IP。<br>
保存后门禁立刻转为强制模式:不在白名单的请求一律 nginx 404,
此后登录、保存配置都用这个密码(不存在"默认密码"这回事)。
</div>
<div class="row"><label>新管理密码</label>
<input type="password" name="new_pass" id="new_pass" placeholder="至少 6 位" required></div>
<div class="row"><label>再次输入</label>
<input type="password" name="new_pass2" id="new_pass2" placeholder="再输一遍确认" required></div>
<div class="row"><label>IP 白名单</label>
<textarea name="whitelist" id="initWhitelist" placeholder="一行一个 IP 或 CIDR"><?php echo htmlspecialchars($myIp, ENT_QUOTES, 'UTF-8'); ?></textarea>
</div>
<div class="hint">
已预填你当前的访问 IP <code><?php echo htmlspecialchars($myIp, ENT_QUOTES, 'UTF-8'); ?></code>,
请确认无误;还有别的管理机就一行填一个。<br>
白名单留空 = 只有服务器本机 127.0.0.1 能进后台(等于把自己挡在外面,只能靠引导令牌回来)。
</div>
<div class="row" style="margin-top:14px;margin-bottom:0">
<button type="submit" class="btn btn-green" onclick="setAct('init_setup')">保存并进入后台</button>
</div>
</div>
</form>
<?php else: ?>
<form method="post" autocomplete="off" id="secForm">
<input type="hidden" name="act" id="act" value="">
<input type="hidden" name="rotate_confirm" id="rotateConfirmBox" value="">
<?php if ($mode === 'session'): ?>
<div class="pwd-row">
<div class="row" style="margin-bottom:0">
<label>管理密码</label>
<input type="password" name="admin_pass" placeholder="所有写操作都需重输管理密码(boot 引导模式免)">
</div>
</div>
<?php endif; ?>
<!-- ===== 当前状态 ===== -->
<div class="card">
<h2>📋 当前状态</h2>
<div class="status-list">
门禁状态:<b><?php echo secIsBootstrapped() ? '已引导 · 强制模式(不在白名单的请求一律 nginx 404)✅' : '引导模式 · 尚未保存过配置(首次访问先放行,保存任意一项即转强制)⚠️'; ?></b><br>
二次验证:<b><?php echo $twoFaOn ? '已启用 ✅' : '未启用 ⚠️'; ?></b>
<span class="badge <?php echo $twoFaOn ? 'b-on' : 'b-off'; ?>"><?php echo $twoFaOn ? 'ON' : 'OFF'; ?></span><br>
IP 白名单:<b><?php echo count($whitelist) === 0 ? '空(仅服务器本机可访问)⚠️' : count($whitelist) . ' 条'; ?></b><br>
你的 IP:<b style="font-family:monospace;color:#58a6ff"><?php echo htmlspecialchars($myIp, ENT_QUOTES, 'UTF-8'); ?></b><br>
安全纪元:<b><?php echo secEpoch() > 0 ? date('Y-m-d H:i:s', secEpoch()) : '未设置(首次保存时自动定格)'; ?></b>(此前的登录会话已全部作废)<br>
后台入口令牌:<b style="font-family:monospace;color:#3fb950"><?php echo htmlspecialchars($secToken, ENT_QUOTES, 'UTF-8'); ?></b>
<button type="button" class="copy" onclick="copyText('<?php echo htmlspecialchars($secToken, ENT_QUOTES, 'UTF-8'); ?>', this)">复制</button><br>
引导令牌:<b style="font-family:monospace;color:#d29922"><?php echo htmlspecialchars($secBootTok, ENT_QUOTES, 'UTF-8'); ?></b>
<button type="button" class="copy" onclick="copyText('<?php echo htmlspecialchars($secBootTok, ENT_QUOTES, 'UTF-8'); ?>', this)">复制</button>
<div class="hint">🔖 后台唯一入口(请收藏这一条,不带 token 访问 admin.php 一律返回 404):<br><code><?php echo htmlspecialchars($adminUrl, ENT_QUOTES, 'UTF-8'); ?></code>
<button type="button" class="copy" onclick="copyText('<?php echo htmlspecialchars($adminUrl, ENT_QUOTES, 'UTF-8'); ?>', this)">复制地址</button>
<button type="submit" class="btn btn-gray" style="margin-left:6px" onclick="return rotateConfirm()">重新生成令牌</button></div>
<?php if ($mode === 'boot' && !$wlAllowsMe): ?>
<div class="hint" style="color:#f85149">⚠️ 你的 IP 还没加进白名单,现在打开上面这个入口<b>会是 404</b> ——
请先在下方「IP 白名单」里保存,再访问它。</div>
<?php endif; ?>
<div class="hint">恢复访问地址:<code><?php echo htmlspecialchars($recoverUrl, ENT_QUOTES, 'UTF-8'); ?></code>
<button type="button" class="copy" onclick="copyText('<?php echo htmlspecialchars($recoverUrl, ENT_QUOTES, 'UTF-8'); ?>', this)">复制</button></div>
</div>
</div>
<!-- ===== 第二层:IP 白名单 ===== -->
<div class="card">
<h2>🚪 第二层 · IP 白名单</h2>
<?php if (count($whitelist) > 0): ?>
<div style="margin-bottom:10px"><?php foreach ($whitelist as $w): ?><span class="wl-item"><?php echo htmlspecialchars($w, ENT_QUOTES, 'UTF-8'); ?></span><?php endforeach; ?></div>
<?php endif; ?>
<div class="hint" style="margin-bottom:6px">当前访问 IP:<code id="myIpTxt"><?php echo htmlspecialchars($myIp, ENT_QUOTES, 'UTF-8'); ?></code>
—— 白名单里<strong>必须包含它</strong>,否则保存后你会把自己挡在门外(含 0.0.0.0 应急条目时本页是唯一入口,改完记得删掉它)。</div>
<textarea name="whitelist" id="wlBox" placeholder="每行一个 IP 或 CIDR 网段,例如:&#10;203.0.113.8&#10;192.168.1.0/24"><?php echo htmlspecialchars(implode("\n", $whitelist), ENT_QUOTES, 'UTF-8'); ?></textarea>
<div class="row" style="margin-top:10px">
<button type="button" class="btn btn-gray" onclick="addMyIp()">+ 把我的 IP 加入</button>
<button type="submit" class="btn btn-blue" onclick="setAct('save_whitelist')">保存白名单</button>
<button type="submit" class="btn btn-red" onclick="setAct('save_whitelist');">清空白名单(锁死到仅本机)</button>
</div>
<div class="hint">
· 白名单<b style="color:#f85149">为空 = 除服务器本机外任何人都无法访问后台</b>(包括登录页与本页)。<br>
· 支持 CIDR 网段(家用动态 IP 可填运营商段,如 <code>x.x.x.0/24</code>,慎用大段)。<br>
· 动态 IP 变更后进不来 → 用上方引导令牌恢复访问。
</div>
</div>
<!-- ===== 第一层:二次验证 ===== -->
<div class="card">
<h2>🛡️ 第一层 · 二次验证(TOTP 6 位动态码)<span class="badge <?php echo $twoFaOn ? 'b-on' : 'b-off'; ?>"><?php echo $twoFaOn ? '已启用' : '未启用'; ?></span></h2>
<?php if (!$twoFaOn && $pending === ''): ?>
<div class="hint" style="margin-top:0">未启用。启用后,每次登录后台都需在认证器 App 中输入 6 位动态码(无推送,纯本地校验)。</div>
<div class="row" style="margin-top:10px">
<button type="submit" class="btn btn-green" onclick="setAct('twofa_begin')">生成密钥并绑定</button>
</div>
<?php endif; ?>
<?php if (!$twoFaOn && $pending !== ''): ?>
<div class="secret-box">
<div class="lbl" style="font-size:13px;color:#e6edf3;margin-bottom:8px">📱 第 1 步:用认证器扫码添加(打开 App 的「扫描二维码 / Setup another account」)</div>
<div id="qrBox" class="qrbox"></div>
<div class="lbl" id="qrHint">扫码会自动填好密钥与参数,避免手工输入 32 位密钥时敲错字符</div>
<div class="lbl" style="margin-top:14px">Base32 密钥(认证器选择"无法扫描/手动输入密钥"时填这个)
<button type="button" class="copy" onclick="copyText('<?php echo htmlspecialchars($pending, ENT_QUOTES, 'UTF-8'); ?>', this)">复制</button></div>
<div class="val"><?php echo htmlspecialchars($pending, ENT_QUOTES, 'UTF-8'); ?></div>
<div class="lbl" style="margin-top:10px">otpauth 链接(部分 App 支持粘贴)
<button type="button" class="copy" onclick="copyText('<?php echo htmlspecialchars(secOtpauthUri($pending), ENT_QUOTES, 'UTF-8'); ?>', this)">复制</button></div>
<div class="uri"><?php echo htmlspecialchars(secOtpauthUri($pending), ENT_QUOTES, 'UTF-8'); ?></div>
</div>
<div class="row">
<label>第 2 步 · 验证码</label>
<input type="text" name="code" id="codeBox" inputmode="numeric" maxlength="6" placeholder="认证器上的 6 位数字" style="max-width:200px">
<button type="submit" class="btn btn-green" onclick="setAct('twofa_finish')">确认绑定</button>
<button type="submit" class="btn btn-gray" onclick="return regenConfirm()" formnovalidate>重新生成密钥</button>
</div>
<div class="hint">扫码(或手动输入密钥)→ 认证器上出现 6 位数字 → 回填并点「确认绑定」。
提交被拒时会明确告诉你原因:是<b>密钥没对上</b>还是<b>手机时钟偏了</b>多少秒。<br>
<b style="color:#f85149">⚠ 绑定完成前不要点「重新生成密钥」</b> —— 一旦换新,认证器里已录入的那条立即作废,
之后提交的动态码会全部被拒(这正是"验证码一直无效"最常见的原因)。</div>
<?php endif; ?>
<?php if ($twoFaOn): ?>
<div class="row">
<label>解绑验证码</label>
<input type="text" name="code" id="codeBox" inputmode="numeric" maxlength="6" placeholder="当前 6 位动态码<?php echo $mode === 'boot' ? '(引导模式可留空)' : ''; ?>" style="max-width:220px">
<button type="submit" class="btn btn-red" onclick="setAct('twofa_disable')">关闭二次验证</button>
</div>
<div class="hint">关闭需出示当前动态码(证明认证器在手);boot 引导模式可强制关闭(灾难恢复)。关闭后请尽快重新绑定。<br>
<b style="color:#d29922">动态码一直被拒?</b>先看报错提示是"密钥没对上"还是"时钟偏了多少秒":
前者用状态卡里的引导令牌地址进入本页强制关闭后重新扫码绑定;后者把手机时间改成自动同步即可。</div>
<?php endif; ?>
</div>
<!-- ===== 审计日志 ===== -->
<div class="card">
<h2>📜 安全审计(db/security.log 最近 8 条)</h2>
<div class="logbox"><?php echo $logLines ? htmlspecialchars(implode("\n", $logLines), ENT_QUOTES, 'UTF-8') : '(暂无记录)'; ?></div>
</div>
</form>
<?php endif; ?>
</div>
<!-- QR Code Generator for JavaScript v1.4.4 | MIT | Copyright (c) 2009 Kazuhiko Arase
离线内联,不外联任何 CDN:绑定 2FA 时就地生成 otpauth 二维码,扫码即可把密钥
写入 1Password / Microsoft Authenticator / Google Authenticator,免手工输入。 -->
<script>
//---------------------------------------------------------------------
//
// QR Code Generator for JavaScript
//
// Copyright (c) 2009 Kazuhiko Arase
//
// URL: http://www.d-project.com/
//
// Licensed under the MIT license:
// http://www.opensource.org/licenses/mit-license.php
//
// The word 'QR Code' is registered trademark of
// DENSO WAVE INCORPORATED
// http://www.denso-wave.com/qrcode/faqpatent-e.html
//
//---------------------------------------------------------------------
var qrcode = function() {
//---------------------------------------------------------------------
// qrcode
//---------------------------------------------------------------------
/**
* qrcode
* @param typeNumber 1 to 40
* @param errorCorrectionLevel 'L','M','Q','H'
*/
var qrcode = function(typeNumber, errorCorrectionLevel) {
var PAD0 = 0xEC;
var PAD1 = 0x11;
var _typeNumber = typeNumber;
var _errorCorrectionLevel = QRErrorCorrectionLevel[errorCorrectionLevel];
var _modules = null;
var _moduleCount = 0;
var _dataCache = null;
var _dataList = [];
var _this = {};
var makeImpl = function(test, maskPattern) {
_moduleCount = _typeNumber * 4 + 17;
_modules = function(moduleCount) {
var modules = new Array(moduleCount);
for (var row = 0; row < moduleCount; row += 1) {
modules[row] = new Array(moduleCount);
for (var col = 0; col < moduleCount; col += 1) {
modules[row][col] = null;
}
}
return modules;
}(_moduleCount);
setupPositionProbePattern(0, 0);
setupPositionProbePattern(_moduleCount - 7, 0);
setupPositionProbePattern(0, _moduleCount - 7);
setupPositionAdjustPattern();
setupTimingPattern();
setupTypeInfo(test, maskPattern);
if (_typeNumber >= 7) {
setupTypeNumber(test);
}
if (_dataCache == null) {
_dataCache = createData(_typeNumber, _errorCorrectionLevel, _dataList);
}
mapData(_dataCache, maskPattern);
};
var setupPositionProbePattern = function(row, col) {
for (var r = -1; r <= 7; r += 1) {
if (row + r <= -1 || _moduleCount <= row + r) continue;
for (var c = -1; c <= 7; c += 1) {
if (col + c <= -1 || _moduleCount <= col + c) continue;
if ( (0 <= r && r <= 6 && (c == 0 || c == 6) )
|| (0 <= c && c <= 6 && (r == 0 || r == 6) )
|| (2 <= r && r <= 4 && 2 <= c && c <= 4) ) {
_modules[row + r][col + c] = true;
} else {
_modules[row + r][col + c] = false;
}
}
}
};
var getBestMaskPattern = function() {
var minLostPoint = 0;
var pattern = 0;
for (var i = 0; i < 8; i += 1) {
makeImpl(true, i);
var lostPoint = QRUtil.getLostPoint(_this);
if (i == 0 || minLostPoint > lostPoint) {
minLostPoint = lostPoint;
pattern = i;
}
}
return pattern;
};
var setupTimingPattern = function() {
for (var r = 8; r < _moduleCount - 8; r += 1) {
if (_modules[r][6] != null) {
continue;
}
_modules[r][6] = (r % 2 == 0);
}
for (var c = 8; c < _moduleCount - 8; c += 1) {
if (_modules[6][c] != null) {
continue;
}
_modules[6][c] = (c % 2 == 0);
}
};
var setupPositionAdjustPattern = function() {
var pos = QRUtil.getPatternPosition(_typeNumber);
for (var i = 0; i < pos.length; i += 1) {
for (var j = 0; j < pos.length; j += 1) {
var row = pos[i];
var col = pos[j];
if (_modules[row][col] != null) {
continue;
}
for (var r = -2; r <= 2; r += 1) {
for (var c = -2; c <= 2; c += 1) {
if (r == -2 || r == 2 || c == -2 || c == 2
|| (r == 0 && c == 0) ) {
_modules[row + r][col + c] = true;
} else {
_modules[row + r][col + c] = false;
}
}
}
}
}
};
var setupTypeNumber = function(test) {
var bits = QRUtil.getBCHTypeNumber(_typeNumber);
for (var i = 0; i < 18; i += 1) {
var mod = (!test && ( (bits >> i) & 1) == 1);
_modules[Math.floor(i / 3)][i % 3 + _moduleCount - 8 - 3] = mod;
}
for (var i = 0; i < 18; i += 1) {
var mod = (!test && ( (bits >> i) & 1) == 1);
_modules[i % 3 + _moduleCount - 8 - 3][Math.floor(i / 3)] = mod;
}
};
var setupTypeInfo = function(test, maskPattern) {
var data = (_errorCorrectionLevel << 3) | maskPattern;
var bits = QRUtil.getBCHTypeInfo(data);
// vertical
for (var i = 0; i < 15; i += 1) {
var mod = (!test && ( (bits >> i) & 1) == 1);
if (i < 6) {
_modules[i][8] = mod;
} else if (i < 8) {
_modules[i + 1][8] = mod;
} else {
_modules[_moduleCount - 15 + i][8] = mod;
}
}
// horizontal
for (var i = 0; i < 15; i += 1) {
var mod = (!test && ( (bits >> i) & 1) == 1);
if (i < 8) {
_modules[8][_moduleCount - i - 1] = mod;
} else if (i < 9) {
_modules[8][15 - i - 1 + 1] = mod;
} else {
_modules[8][15 - i - 1] = mod;
}
}
// fixed module
_modules[_moduleCount - 8][8] = (!test);
};
var mapData = function(data, maskPattern) {
var inc = -1;
var row = _moduleCount - 1;
var bitIndex = 7;
var byteIndex = 0;
var maskFunc = QRUtil.getMaskFunction(maskPattern);
for (var col = _moduleCount - 1; col > 0; col -= 2) {
if (col == 6) col -= 1;
while (true) {
for (var c = 0; c < 2; c += 1) {
if (_modules[row][col - c] == null) {
var dark = false;
if (byteIndex < data.length) {
dark = ( ( (data[byteIndex] >>> bitIndex) & 1) == 1);
}
var mask = maskFunc(row, col - c);
if (mask) {
dark = !dark;
}
_modules[row][col - c] = dark;
bitIndex -= 1;
if (bitIndex == -1) {
byteIndex += 1;
bitIndex = 7;
}
}
}
row += inc;
if (row < 0 || _moduleCount <= row) {
row -= inc;
inc = -inc;
break;
}
}
}
};
var createBytes = function(buffer, rsBlocks) {
var offset = 0;
var maxDcCount = 0;
var maxEcCount = 0;
var dcdata = new Array(rsBlocks.length);
var ecdata = new Array(rsBlocks.length);
for (var r = 0; r < rsBlocks.length; r += 1) {
var dcCount = rsBlocks[r].dataCount;
var ecCount = rsBlocks[r].totalCount - dcCount;
maxDcCount = Math.max(maxDcCount, dcCount);
maxEcCount = Math.max(maxEcCount, ecCount);
dcdata[r] = new Array(dcCount);
for (var i = 0; i < dcdata[r].length; i += 1) {
dcdata[r][i] = 0xff & buffer.getBuffer()[i + offset];
}
offset += dcCount;
var rsPoly = QRUtil.getErrorCorrectPolynomial(ecCount);
var rawPoly = qrPolynomial(dcdata[r], rsPoly.getLength() - 1);
var modPoly = rawPoly.mod(rsPoly);
ecdata[r] = new Array(rsPoly.getLength() - 1);
for (var i = 0; i < ecdata[r].length; i += 1) {
var modIndex = i + modPoly.getLength() - ecdata[r].length;
ecdata[r][i] = (modIndex >= 0)? modPoly.getAt(modIndex) : 0;
}
}
var totalCodeCount = 0;
for (var i = 0; i < rsBlocks.length; i += 1) {
totalCodeCount += rsBlocks[i].totalCount;
}
var data = new Array(totalCodeCount);
var index = 0;
for (var i = 0; i < maxDcCount; i += 1) {
for (var r = 0; r < rsBlocks.length; r += 1) {
if (i < dcdata[r].length) {
data[index] = dcdata[r][i];
index += 1;
}
}
}
for (var i = 0; i < maxEcCount; i += 1) {
for (var r = 0; r < rsBlocks.length; r += 1) {
if (i < ecdata[r].length) {
data[index] = ecdata[r][i];
index += 1;
}
}
}
return data;
};
var createData = function(typeNumber, errorCorrectionLevel, dataList) {
var rsBlocks = QRRSBlock.getRSBlocks(typeNumber, errorCorrectionLevel);
var buffer = qrBitBuffer();
for (var i = 0; i < dataList.length; i += 1) {
var data = dataList[i];
buffer.put(data.getMode(), 4);
buffer.put(data.getLength(), QRUtil.getLengthInBits(data.getMode(), typeNumber) );
data.write(buffer);
}
// calc num max data.
var totalDataCount = 0;
for (var i = 0; i < rsBlocks.length; i += 1) {
totalDataCount += rsBlocks[i].dataCount;
}
if (buffer.getLengthInBits() > totalDataCount * 8) {
throw 'code length overflow. ('
+ buffer.getLengthInBits()
+ '>'
+ totalDataCount * 8
+ ')';
}
// end code
if (buffer.getLengthInBits() + 4 <= totalDataCount * 8) {
buffer.put(0, 4);
}
// padding
while (buffer.getLengthInBits() % 8 != 0) {
buffer.putBit(false);
}
// padding
while (true) {
if (buffer.getLengthInBits() >= totalDataCount * 8) {
break;
}
buffer.put(PAD0, 8);
if (buffer.getLengthInBits() >= totalDataCount * 8) {
break;
}
buffer.put(PAD1, 8);
}
return createBytes(buffer, rsBlocks);
};
_this.addData = function(data, mode) {
mode = mode || 'Byte';
var newData = null;
switch(mode) {
case 'Numeric' :
newData = qrNumber(data);
break;
case 'Alphanumeric' :
newData = qrAlphaNum(data);
break;
case 'Byte' :
newData = qr8BitByte(data);
break;
case 'Kanji' :
newData = qrKanji(data);
break;
default :
throw 'mode:' + mode;
}
_dataList.push(newData);
_dataCache = null;
};
_this.isDark = function(row, col) {
if (row < 0 || _moduleCount <= row || col < 0 || _moduleCount <= col) {
throw row + ',' + col;
}
return _modules[row][col];
};
_this.getModuleCount = function() {
return _moduleCount;
};
_this.make = function() {
if (_typeNumber < 1) {
var typeNumber = 1;
for (; typeNumber < 40; typeNumber++) {
var rsBlocks = QRRSBlock.getRSBlocks(typeNumber, _errorCorrectionLevel);
var buffer = qrBitBuffer();
for (var i = 0; i < _dataList.length; i++) {
var data = _dataList[i];
buffer.put(data.getMode(), 4);
buffer.put(data.getLength(), QRUtil.getLengthInBits(data.getMode(), typeNumber) );
data.write(buffer);
}
var totalDataCount = 0;
for (var i = 0; i < rsBlocks.length; i++) {
totalDataCount += rsBlocks[i].dataCount;
}
if (buffer.getLengthInBits() <= totalDataCount * 8) {
break;
}
}
_typeNumber = typeNumber;
}
makeImpl(false, getBestMaskPattern() );
};
_this.createTableTag = function(cellSize, margin) {
cellSize = cellSize || 2;
margin = (typeof margin == 'undefined')? cellSize * 4 : margin;
var qrHtml = '';
qrHtml += '<table style="';
qrHtml += ' border-width: 0px; border-style: none;';
qrHtml += ' border-collapse: collapse;';
qrHtml += ' padding: 0px; margin: ' + margin + 'px;';
qrHtml += '">';
qrHtml += '<tbody>';
for (var r = 0; r < _this.getModuleCount(); r += 1) {
qrHtml += '<tr>';
for (var c = 0; c < _this.getModuleCount(); c += 1) {
qrHtml += '<td style="';
qrHtml += ' border-width: 0px; border-style: none;';
qrHtml += ' border-collapse: collapse;';
qrHtml += ' padding: 0px; margin: 0px;';
qrHtml += ' width: ' + cellSize + 'px;';
qrHtml += ' height: ' + cellSize + 'px;';
qrHtml += ' background-color: ';
qrHtml += _this.isDark(r, c)? '#000000' : '#ffffff';
qrHtml += ';';
qrHtml += '"/>';
}
qrHtml += '</tr>';
}
qrHtml += '</tbody>';
qrHtml += '</table>';
return qrHtml;
};
_this.createSvgTag = function(cellSize, margin, alt, title) {
var opts = {};
if (typeof arguments[0] == 'object') {
// Called by options.
opts = arguments[0];
// overwrite cellSize and margin.
cellSize = opts.cellSize;
margin = opts.margin;
alt = opts.alt;
title = opts.title;
}
cellSize = cellSize || 2;
margin = (typeof margin == 'undefined')? cellSize * 4 : margin;
// Compose alt property surrogate
alt = (typeof alt === 'string') ? {text: alt} : alt || {};
alt.text = alt.text || null;
alt.id = (alt.text) ? alt.id || 'qrcode-description' : null;
// Compose title property surrogate
title = (typeof title === 'string') ? {text: title} : title || {};
title.text = title.text || null;
title.id = (title.text) ? title.id || 'qrcode-title' : null;
var size = _this.getModuleCount() * cellSize + margin * 2;
var c, mc, r, mr, qrSvg='', rect;
rect = 'l' + cellSize + ',0 0,' + cellSize +
' -' + cellSize + ',0 0,-' + cellSize + 'z ';
qrSvg += '<svg version="1.1" xmlns="http://www.w3.org/2000/svg"';
qrSvg += !opts.scalable ? ' width="' + size + 'px" height="' + size + 'px"' : '';
qrSvg += ' viewBox="0 0 ' + size + ' ' + size + '" ';
qrSvg += ' preserveAspectRatio="xMinYMin meet"';
qrSvg += (title.text || alt.text) ? ' role="img" aria-labelledby="' +
escapeXml([title.id, alt.id].join(' ').trim() ) + '"' : '';
qrSvg += '>';
qrSvg += (title.text) ? '<title id="' + escapeXml(title.id) + '">' +
escapeXml(title.text) + '</title>' : '';
qrSvg += (alt.text) ? '<description id="' + escapeXml(alt.id) + '">' +
escapeXml(alt.text) + '</description>' : '';
qrSvg += '<rect width="100%" height="100%" fill="white" cx="0" cy="0"/>';
qrSvg += '<path d="';
for (r = 0; r < _this.getModuleCount(); r += 1) {
mr = r * cellSize + margin;
for (c = 0; c < _this.getModuleCount(); c += 1) {
if (_this.isDark(r, c) ) {
mc = c*cellSize+margin;
qrSvg += 'M' + mc + ',' + mr + rect;
}
}
}
qrSvg += '" stroke="transparent" fill="black"/>';
qrSvg += '</svg>';
return qrSvg;
};
_this.createDataURL = function(cellSize, margin) {
cellSize = cellSize || 2;
margin = (typeof margin == 'undefined')? cellSize * 4 : margin;
var size = _this.getModuleCount() * cellSize + margin * 2;
var min = margin;
var max = size - margin;
return createDataURL(size, size, function(x, y) {
if (min <= x && x < max && min <= y && y < max) {
var c = Math.floor( (x - min) / cellSize);
var r = Math.floor( (y - min) / cellSize);
return _this.isDark(r, c)? 0 : 1;
} else {
return 1;
}
} );
};
_this.createImgTag = function(cellSize, margin, alt) {
cellSize = cellSize || 2;
margin = (typeof margin == 'undefined')? cellSize * 4 : margin;
var size = _this.getModuleCount() * cellSize + margin * 2;
var img = '';
img += '<img';
img += '\u0020src="';
img += _this.createDataURL(cellSize, margin);
img += '"';
img += '\u0020width="';
img += size;
img += '"';
img += '\u0020height="';
img += size;
img += '"';
if (alt) {
img += '\u0020alt="';
img += escapeXml(alt);
img += '"';
}
img += '/>';
return img;
};
var escapeXml = function(s) {
var escaped = '';
for (var i = 0; i < s.length; i += 1) {
var c = s.charAt(i);
switch(c) {
case '<': escaped += '&lt;'; break;
case '>': escaped += '&gt;'; break;
case '&': escaped += '&amp;'; break;
case '"': escaped += '&quot;'; break;
default : escaped += c; break;
}
}
return escaped;
};
var _createHalfASCII = function(margin) {
var cellSize = 1;
margin = (typeof margin == 'undefined')? cellSize * 2 : margin;
var size = _this.getModuleCount() * cellSize + margin * 2;
var min = margin;
var max = size - margin;
var y, x, r1, r2, p;
var blocks = {
'██': '█',
'█ ': '▀',
' █': '▄',
' ': ' '
};
var blocksLastLineNoMargin = {
'██': '▀',
'█ ': '▀',
' █': ' ',
' ': ' '
};
var ascii = '';
for (y = 0; y < size; y += 2) {
r1 = Math.floor((y - min) / cellSize);
r2 = Math.floor((y + 1 - min) / cellSize);
for (x = 0; x < size; x += 1) {
p = '█';
if (min <= x && x < max && min <= y && y < max && _this.isDark(r1, Math.floor((x - min) / cellSize))) {
p = ' ';
}
if (min <= x && x < max && min <= y+1 && y+1 < max && _this.isDark(r2, Math.floor((x - min) / cellSize))) {
p += ' ';
}
else {
p += '█';
}
// Output 2 characters per pixel, to create full square. 1 character per pixels gives only half width of square.
ascii += (margin < 1 && y+1 >= max) ? blocksLastLineNoMargin[p] : blocks[p];
}
ascii += '\n';
}
if (size % 2 && margin > 0) {
return ascii.substring(0, ascii.length - size - 1) + Array(size+1).join('▀');
}
return ascii.substring(0, ascii.length-1);
};
_this.createASCII = function(cellSize, margin) {
cellSize = cellSize || 1;
if (cellSize < 2) {
return _createHalfASCII(margin);
}
cellSize -= 1;
margin = (typeof margin == 'undefined')? cellSize * 2 : margin;
var size = _this.getModuleCount() * cellSize + margin * 2;
var min = margin;
var max = size - margin;
var y, x, r, p;
var white = Array(cellSize+1).join('██');
var black = Array(cellSize+1).join(' ');
var ascii = '';
var line = '';
for (y = 0; y < size; y += 1) {
r = Math.floor( (y - min) / cellSize);
line = '';
for (x = 0; x < size; x += 1) {
p = 1;
if (min <= x && x < max && min <= y && y < max && _this.isDark(r, Math.floor((x - min) / cellSize))) {
p = 0;
}
// Output 2 characters per pixel, to create full square. 1 character per pixels gives only half width of square.
line += p ? white : black;
}
for (r = 0; r < cellSize; r += 1) {
ascii += line + '\n';
}
}
return ascii.substring(0, ascii.length-1);
};
_this.renderTo2dContext = function(context, cellSize) {
cellSize = cellSize || 2;
var length = _this.getModuleCount();
for (var row = 0; row < length; row++) {
for (var col = 0; col < length; col++) {
context.fillStyle = _this.isDark(row, col) ? 'black' : 'white';
context.fillRect(row * cellSize, col * cellSize, cellSize, cellSize);
}
}
}
return _this;
};
//---------------------------------------------------------------------
// qrcode.stringToBytes
//---------------------------------------------------------------------
qrcode.stringToBytesFuncs = {
'default' : function(s) {
var bytes = [];
for (var i = 0; i < s.length; i += 1) {
var c = s.charCodeAt(i);
bytes.push(c & 0xff);
}
return bytes;
}
};
qrcode.stringToBytes = qrcode.stringToBytesFuncs['default'];
//---------------------------------------------------------------------
// qrcode.createStringToBytes
//---------------------------------------------------------------------
/**
* @param unicodeData base64 string of byte array.
* [16bit Unicode],[16bit Bytes], ...
* @param numChars
*/
qrcode.createStringToBytes = function(unicodeData, numChars) {
// create conversion map.
var unicodeMap = function() {
var bin = base64DecodeInputStream(unicodeData);
var read = function() {
var b = bin.read();
if (b == -1) throw 'eof';
return b;
};
var count = 0;
var unicodeMap = {};
while (true) {
var b0 = bin.read();
if (b0 == -1) break;
var b1 = read();
var b2 = read();
var b3 = read();
var k = String.fromCharCode( (b0 << 8) | b1);
var v = (b2 << 8) | b3;
unicodeMap[k] = v;
count += 1;
}
if (count != numChars) {
throw count + ' != ' + numChars;
}
return unicodeMap;
}();
var unknownChar = '?'.charCodeAt(0);
return function(s) {
var bytes = [];
for (var i = 0; i < s.length; i += 1) {
var c = s.charCodeAt(i);
if (c < 128) {
bytes.push(c);
} else {
var b = unicodeMap[s.charAt(i)];
if (typeof b == 'number') {
if ( (b & 0xff) == b) {
// 1byte
bytes.push(b);
} else {
// 2bytes
bytes.push(b >>> 8);
bytes.push(b & 0xff);
}
} else {
bytes.push(unknownChar);
}
}
}
return bytes;
};
};
//---------------------------------------------------------------------
// QRMode
//---------------------------------------------------------------------
var QRMode = {
MODE_NUMBER : 1 << 0,
MODE_ALPHA_NUM : 1 << 1,
MODE_8BIT_BYTE : 1 << 2,
MODE_KANJI : 1 << 3
};
//---------------------------------------------------------------------
// QRErrorCorrectionLevel
//---------------------------------------------------------------------
var QRErrorCorrectionLevel = {
L : 1,
M : 0,
Q : 3,
H : 2
};
//---------------------------------------------------------------------
// QRMaskPattern
//---------------------------------------------------------------------
var QRMaskPattern = {
PATTERN000 : 0,
PATTERN001 : 1,
PATTERN010 : 2,
PATTERN011 : 3,
PATTERN100 : 4,
PATTERN101 : 5,
PATTERN110 : 6,
PATTERN111 : 7
};
//---------------------------------------------------------------------
// QRUtil
//---------------------------------------------------------------------
var QRUtil = function() {
var PATTERN_POSITION_TABLE = [
[],
[6, 18],
[6, 22],
[6, 26],
[6, 30],
[6, 34],
[6, 22, 38],
[6, 24, 42],
[6, 26, 46],
[6, 28, 50],
[6, 30, 54],
[6, 32, 58],
[6, 34, 62],
[6, 26, 46, 66],
[6, 26, 48, 70],
[6, 26, 50, 74],
[6, 30, 54, 78],
[6, 30, 56, 82],
[6, 30, 58, 86],
[6, 34, 62, 90],
[6, 28, 50, 72, 94],
[6, 26, 50, 74, 98],
[6, 30, 54, 78, 102],
[6, 28, 54, 80, 106],
[6, 32, 58, 84, 110],
[6, 30, 58, 86, 114],
[6, 34, 62, 90, 118],
[6, 26, 50, 74, 98, 122],
[6, 30, 54, 78, 102, 126],
[6, 26, 52, 78, 104, 130],
[6, 30, 56, 82, 108, 134],
[6, 34, 60, 86, 112, 138],
[6, 30, 58, 86, 114, 142],
[6, 34, 62, 90, 118, 146],
[6, 30, 54, 78, 102, 126, 150],
[6, 24, 50, 76, 102, 128, 154],
[6, 28, 54, 80, 106, 132, 158],
[6, 32, 58, 84, 110, 136, 162],
[6, 26, 54, 82, 110, 138, 166],
[6, 30, 58, 86, 114, 142, 170]
];
var G15 = (1 << 10) | (1 << 8) | (1 << 5) | (1 << 4) | (1 << 2) | (1 << 1) | (1 << 0);
var G18 = (1 << 12) | (1 << 11) | (1 << 10) | (1 << 9) | (1 << 8) | (1 << 5) | (1 << 2) | (1 << 0);
var G15_MASK = (1 << 14) | (1 << 12) | (1 << 10) | (1 << 4) | (1 << 1);
var _this = {};
var getBCHDigit = function(data) {
var digit = 0;
while (data != 0) {
digit += 1;
data >>>= 1;
}
return digit;
};
_this.getBCHTypeInfo = function(data) {
var d = data << 10;
while (getBCHDigit(d) - getBCHDigit(G15) >= 0) {
d ^= (G15 << (getBCHDigit(d) - getBCHDigit(G15) ) );
}
return ( (data << 10) | d) ^ G15_MASK;
};
_this.getBCHTypeNumber = function(data) {
var d = data << 12;
while (getBCHDigit(d) - getBCHDigit(G18) >= 0) {
d ^= (G18 << (getBCHDigit(d) - getBCHDigit(G18) ) );
}
return (data << 12) | d;
};
_this.getPatternPosition = function(typeNumber) {
return PATTERN_POSITION_TABLE[typeNumber - 1];
};
_this.getMaskFunction = function(maskPattern) {
switch (maskPattern) {
case QRMaskPattern.PATTERN000 :
return function(i, j) { return (i + j) % 2 == 0; };
case QRMaskPattern.PATTERN001 :
return function(i, j) { return i % 2 == 0; };
case QRMaskPattern.PATTERN010 :
return function(i, j) { return j % 3 == 0; };
case QRMaskPattern.PATTERN011 :
return function(i, j) { return (i + j) % 3 == 0; };
case QRMaskPattern.PATTERN100 :
return function(i, j) { return (Math.floor(i / 2) + Math.floor(j / 3) ) % 2 == 0; };
case QRMaskPattern.PATTERN101 :
return function(i, j) { return (i * j) % 2 + (i * j) % 3 == 0; };
case QRMaskPattern.PATTERN110 :
return function(i, j) { return ( (i * j) % 2 + (i * j) % 3) % 2 == 0; };
case QRMaskPattern.PATTERN111 :
return function(i, j) { return ( (i * j) % 3 + (i + j) % 2) % 2 == 0; };
default :
throw 'bad maskPattern:' + maskPattern;
}
};
_this.getErrorCorrectPolynomial = function(errorCorrectLength) {
var a = qrPolynomial([1], 0);
for (var i = 0; i < errorCorrectLength; i += 1) {
a = a.multiply(qrPolynomial([1, QRMath.gexp(i)], 0) );
}
return a;
};
_this.getLengthInBits = function(mode, type) {
if (1 <= type && type < 10) {
// 1 - 9
switch(mode) {
case QRMode.MODE_NUMBER : return 10;
case QRMode.MODE_ALPHA_NUM : return 9;
case QRMode.MODE_8BIT_BYTE : return 8;
case QRMode.MODE_KANJI : return 8;
default :
throw 'mode:' + mode;
}
} else if (type < 27) {
// 10 - 26
switch(mode) {
case QRMode.MODE_NUMBER : return 12;
case QRMode.MODE_ALPHA_NUM : return 11;
case QRMode.MODE_8BIT_BYTE : return 16;
case QRMode.MODE_KANJI : return 10;
default :
throw 'mode:' + mode;
}
} else if (type < 41) {
// 27 - 40
switch(mode) {
case QRMode.MODE_NUMBER : return 14;
case QRMode.MODE_ALPHA_NUM : return 13;
case QRMode.MODE_8BIT_BYTE : return 16;
case QRMode.MODE_KANJI : return 12;
default :
throw 'mode:' + mode;
}
} else {
throw 'type:' + type;
}
};
_this.getLostPoint = function(qrcode) {
var moduleCount = qrcode.getModuleCount();
var lostPoint = 0;
// LEVEL1
for (var row = 0; row < moduleCount; row += 1) {
for (var col = 0; col < moduleCount; col += 1) {
var sameCount = 0;
var dark = qrcode.isDark(row, col);
for (var r = -1; r <= 1; r += 1) {
if (row + r < 0 || moduleCount <= row + r) {
continue;
}
for (var c = -1; c <= 1; c += 1) {
if (col + c < 0 || moduleCount <= col + c) {
continue;
}
if (r == 0 && c == 0) {
continue;
}
if (dark == qrcode.isDark(row + r, col + c) ) {
sameCount += 1;
}
}
}
if (sameCount > 5) {
lostPoint += (3 + sameCount - 5);
}
}
};
// LEVEL2
for (var row = 0; row < moduleCount - 1; row += 1) {
for (var col = 0; col < moduleCount - 1; col += 1) {
var count = 0;
if (qrcode.isDark(row, col) ) count += 1;
if (qrcode.isDark(row + 1, col) ) count += 1;
if (qrcode.isDark(row, col + 1) ) count += 1;
if (qrcode.isDark(row + 1, col + 1) ) count += 1;
if (count == 0 || count == 4) {
lostPoint += 3;
}
}
}
// LEVEL3
for (var row = 0; row < moduleCount; row += 1) {
for (var col = 0; col < moduleCount - 6; col += 1) {
if (qrcode.isDark(row, col)
&& !qrcode.isDark(row, col + 1)
&& qrcode.isDark(row, col + 2)
&& qrcode.isDark(row, col + 3)
&& qrcode.isDark(row, col + 4)
&& !qrcode.isDark(row, col + 5)
&& qrcode.isDark(row, col + 6) ) {
lostPoint += 40;
}
}
}
for (var col = 0; col < moduleCount; col += 1) {
for (var row = 0; row < moduleCount - 6; row += 1) {
if (qrcode.isDark(row, col)
&& !qrcode.isDark(row + 1, col)
&& qrcode.isDark(row + 2, col)
&& qrcode.isDark(row + 3, col)
&& qrcode.isDark(row + 4, col)
&& !qrcode.isDark(row + 5, col)
&& qrcode.isDark(row + 6, col) ) {
lostPoint += 40;
}
}
}
// LEVEL4
var darkCount = 0;
for (var col = 0; col < moduleCount; col += 1) {
for (var row = 0; row < moduleCount; row += 1) {
if (qrcode.isDark(row, col) ) {
darkCount += 1;
}
}
}
var ratio = Math.abs(100 * darkCount / moduleCount / moduleCount - 50) / 5;
lostPoint += ratio * 10;
return lostPoint;
};
return _this;
}();
//---------------------------------------------------------------------
// QRMath
//---------------------------------------------------------------------
var QRMath = function() {
var EXP_TABLE = new Array(256);
var LOG_TABLE = new Array(256);
// initialize tables
for (var i = 0; i < 8; i += 1) {
EXP_TABLE[i] = 1 << i;
}
for (var i = 8; i < 256; i += 1) {
EXP_TABLE[i] = EXP_TABLE[i - 4]
^ EXP_TABLE[i - 5]
^ EXP_TABLE[i - 6]
^ EXP_TABLE[i - 8];
}
for (var i = 0; i < 255; i += 1) {
LOG_TABLE[EXP_TABLE[i] ] = i;
}
var _this = {};
_this.glog = function(n) {
if (n < 1) {
throw 'glog(' + n + ')';
}
return LOG_TABLE[n];
};
_this.gexp = function(n) {
while (n < 0) {
n += 255;
}
while (n >= 256) {
n -= 255;
}
return EXP_TABLE[n];
};
return _this;
}();
//---------------------------------------------------------------------
// qrPolynomial
//---------------------------------------------------------------------
function qrPolynomial(num, shift) {
if (typeof num.length == 'undefined') {
throw num.length + '/' + shift;
}
var _num = function() {
var offset = 0;
while (offset < num.length && num[offset] == 0) {
offset += 1;
}
var _num = new Array(num.length - offset + shift);
for (var i = 0; i < num.length - offset; i += 1) {
_num[i] = num[i + offset];
}
return _num;
}();
var _this = {};
_this.getAt = function(index) {
return _num[index];
};
_this.getLength = function() {
return _num.length;
};
_this.multiply = function(e) {
var num = new Array(_this.getLength() + e.getLength() - 1);
for (var i = 0; i < _this.getLength(); i += 1) {
for (var j = 0; j < e.getLength(); j += 1) {
num[i + j] ^= QRMath.gexp(QRMath.glog(_this.getAt(i) ) + QRMath.glog(e.getAt(j) ) );
}
}
return qrPolynomial(num, 0);
};
_this.mod = function(e) {
if (_this.getLength() - e.getLength() < 0) {
return _this;
}
var ratio = QRMath.glog(_this.getAt(0) ) - QRMath.glog(e.getAt(0) );
var num = new Array(_this.getLength() );
for (var i = 0; i < _this.getLength(); i += 1) {
num[i] = _this.getAt(i);
}
for (var i = 0; i < e.getLength(); i += 1) {
num[i] ^= QRMath.gexp(QRMath.glog(e.getAt(i) ) + ratio);
}
// recursive call
return qrPolynomial(num, 0).mod(e);
};
return _this;
};
//---------------------------------------------------------------------
// QRRSBlock
//---------------------------------------------------------------------
var QRRSBlock = function() {
var RS_BLOCK_TABLE = [
// L
// M
// Q
// H
// 1
[1, 26, 19],
[1, 26, 16],
[1, 26, 13],
[1, 26, 9],
// 2
[1, 44, 34],
[1, 44, 28],
[1, 44, 22],
[1, 44, 16],
// 3
[1, 70, 55],
[1, 70, 44],
[2, 35, 17],
[2, 35, 13],
// 4
[1, 100, 80],
[2, 50, 32],
[2, 50, 24],
[4, 25, 9],
// 5
[1, 134, 108],
[2, 67, 43],
[2, 33, 15, 2, 34, 16],
[2, 33, 11, 2, 34, 12],
// 6
[2, 86, 68],
[4, 43, 27],
[4, 43, 19],
[4, 43, 15],
// 7
[2, 98, 78],
[4, 49, 31],
[2, 32, 14, 4, 33, 15],
[4, 39, 13, 1, 40, 14],
// 8
[2, 121, 97],
[2, 60, 38, 2, 61, 39],
[4, 40, 18, 2, 41, 19],
[4, 40, 14, 2, 41, 15],
// 9
[2, 146, 116],
[3, 58, 36, 2, 59, 37],
[4, 36, 16, 4, 37, 17],
[4, 36, 12, 4, 37, 13],
// 10
[2, 86, 68, 2, 87, 69],
[4, 69, 43, 1, 70, 44],
[6, 43, 19, 2, 44, 20],
[6, 43, 15, 2, 44, 16],
// 11
[4, 101, 81],
[1, 80, 50, 4, 81, 51],
[4, 50, 22, 4, 51, 23],
[3, 36, 12, 8, 37, 13],
// 12
[2, 116, 92, 2, 117, 93],
[6, 58, 36, 2, 59, 37],
[4, 46, 20, 6, 47, 21],
[7, 42, 14, 4, 43, 15],
// 13
[4, 133, 107],
[8, 59, 37, 1, 60, 38],
[8, 44, 20, 4, 45, 21],
[12, 33, 11, 4, 34, 12],
// 14
[3, 145, 115, 1, 146, 116],
[4, 64, 40, 5, 65, 41],
[11, 36, 16, 5, 37, 17],
[11, 36, 12, 5, 37, 13],
// 15
[5, 109, 87, 1, 110, 88],
[5, 65, 41, 5, 66, 42],
[5, 54, 24, 7, 55, 25],
[11, 36, 12, 7, 37, 13],
// 16
[5, 122, 98, 1, 123, 99],
[7, 73, 45, 3, 74, 46],
[15, 43, 19, 2, 44, 20],
[3, 45, 15, 13, 46, 16],
// 17
[1, 135, 107, 5, 136, 108],
[10, 74, 46, 1, 75, 47],
[1, 50, 22, 15, 51, 23],
[2, 42, 14, 17, 43, 15],
// 18
[5, 150, 120, 1, 151, 121],
[9, 69, 43, 4, 70, 44],
[17, 50, 22, 1, 51, 23],
[2, 42, 14, 19, 43, 15],
// 19
[3, 141, 113, 4, 142, 114],
[3, 70, 44, 11, 71, 45],
[17, 47, 21, 4, 48, 22],
[9, 39, 13, 16, 40, 14],
// 20
[3, 135, 107, 5, 136, 108],
[3, 67, 41, 13, 68, 42],
[15, 54, 24, 5, 55, 25],
[15, 43, 15, 10, 44, 16],
// 21
[4, 144, 116, 4, 145, 117],
[17, 68, 42],
[17, 50, 22, 6, 51, 23],
[19, 46, 16, 6, 47, 17],
// 22
[2, 139, 111, 7, 140, 112],
[17, 74, 46],
[7, 54, 24, 16, 55, 25],
[34, 37, 13],
// 23
[4, 151, 121, 5, 152, 122],
[4, 75, 47, 14, 76, 48],
[11, 54, 24, 14, 55, 25],
[16, 45, 15, 14, 46, 16],
// 24
[6, 147, 117, 4, 148, 118],
[6, 73, 45, 14, 74, 46],
[11, 54, 24, 16, 55, 25],
[30, 46, 16, 2, 47, 17],
// 25
[8, 132, 106, 4, 133, 107],
[8, 75, 47, 13, 76, 48],
[7, 54, 24, 22, 55, 25],
[22, 45, 15, 13, 46, 16],
// 26
[10, 142, 114, 2, 143, 115],
[19, 74, 46, 4, 75, 47],
[28, 50, 22, 6, 51, 23],
[33, 46, 16, 4, 47, 17],
// 27
[8, 152, 122, 4, 153, 123],
[22, 73, 45, 3, 74, 46],
[8, 53, 23, 26, 54, 24],
[12, 45, 15, 28, 46, 16],
// 28
[3, 147, 117, 10, 148, 118],
[3, 73, 45, 23, 74, 46],
[4, 54, 24, 31, 55, 25],
[11, 45, 15, 31, 46, 16],
// 29
[7, 146, 116, 7, 147, 117],
[21, 73, 45, 7, 74, 46],
[1, 53, 23, 37, 54, 24],
[19, 45, 15, 26, 46, 16],
// 30
[5, 145, 115, 10, 146, 116],
[19, 75, 47, 10, 76, 48],
[15, 54, 24, 25, 55, 25],
[23, 45, 15, 25, 46, 16],
// 31
[13, 145, 115, 3, 146, 116],
[2, 74, 46, 29, 75, 47],
[42, 54, 24, 1, 55, 25],
[23, 45, 15, 28, 46, 16],
// 32
[17, 145, 115],
[10, 74, 46, 23, 75, 47],
[10, 54, 24, 35, 55, 25],
[19, 45, 15, 35, 46, 16],
// 33
[17, 145, 115, 1, 146, 116],
[14, 74, 46, 21, 75, 47],
[29, 54, 24, 19, 55, 25],
[11, 45, 15, 46, 46, 16],
// 34
[13, 145, 115, 6, 146, 116],
[14, 74, 46, 23, 75, 47],
[44, 54, 24, 7, 55, 25],
[59, 46, 16, 1, 47, 17],
// 35
[12, 151, 121, 7, 152, 122],
[12, 75, 47, 26, 76, 48],
[39, 54, 24, 14, 55, 25],
[22, 45, 15, 41, 46, 16],
// 36
[6, 151, 121, 14, 152, 122],
[6, 75, 47, 34, 76, 48],
[46, 54, 24, 10, 55, 25],
[2, 45, 15, 64, 46, 16],
// 37
[17, 152, 122, 4, 153, 123],
[29, 74, 46, 14, 75, 47],
[49, 54, 24, 10, 55, 25],
[24, 45, 15, 46, 46, 16],
// 38
[4, 152, 122, 18, 153, 123],
[13, 74, 46, 32, 75, 47],
[48, 54, 24, 14, 55, 25],
[42, 45, 15, 32, 46, 16],
// 39
[20, 147, 117, 4, 148, 118],
[40, 75, 47, 7, 76, 48],
[43, 54, 24, 22, 55, 25],
[10, 45, 15, 67, 46, 16],
// 40
[19, 148, 118, 6, 149, 119],
[18, 75, 47, 31, 76, 48],
[34, 54, 24, 34, 55, 25],
[20, 45, 15, 61, 46, 16]
];
var qrRSBlock = function(totalCount, dataCount) {
var _this = {};
_this.totalCount = totalCount;
_this.dataCount = dataCount;
return _this;
};
var _this = {};
var getRsBlockTable = function(typeNumber, errorCorrectionLevel) {
switch(errorCorrectionLevel) {
case QRErrorCorrectionLevel.L :
return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 0];
case QRErrorCorrectionLevel.M :
return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 1];
case QRErrorCorrectionLevel.Q :
return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 2];
case QRErrorCorrectionLevel.H :
return RS_BLOCK_TABLE[(typeNumber - 1) * 4 + 3];
default :
return undefined;
}
};
_this.getRSBlocks = function(typeNumber, errorCorrectionLevel) {
var rsBlock = getRsBlockTable(typeNumber, errorCorrectionLevel);
if (typeof rsBlock == 'undefined') {
throw 'bad rs block @ typeNumber:' + typeNumber +
'/errorCorrectionLevel:' + errorCorrectionLevel;
}
var length = rsBlock.length / 3;
var list = [];
for (var i = 0; i < length; i += 1) {
var count = rsBlock[i * 3 + 0];
var totalCount = rsBlock[i * 3 + 1];
var dataCount = rsBlock[i * 3 + 2];
for (var j = 0; j < count; j += 1) {
list.push(qrRSBlock(totalCount, dataCount) );
}
}
return list;
};
return _this;
}();
//---------------------------------------------------------------------
// qrBitBuffer
//---------------------------------------------------------------------
var qrBitBuffer = function() {
var _buffer = [];
var _length = 0;
var _this = {};
_this.getBuffer = function() {
return _buffer;
};
_this.getAt = function(index) {
var bufIndex = Math.floor(index / 8);
return ( (_buffer[bufIndex] >>> (7 - index % 8) ) & 1) == 1;
};
_this.put = function(num, length) {
for (var i = 0; i < length; i += 1) {
_this.putBit( ( (num >>> (length - i - 1) ) & 1) == 1);
}
};
_this.getLengthInBits = function() {
return _length;
};
_this.putBit = function(bit) {
var bufIndex = Math.floor(_length / 8);
if (_buffer.length <= bufIndex) {
_buffer.push(0);
}
if (bit) {
_buffer[bufIndex] |= (0x80 >>> (_length % 8) );
}
_length += 1;
};
return _this;
};
//---------------------------------------------------------------------
// qrNumber
//---------------------------------------------------------------------
var qrNumber = function(data) {
var _mode = QRMode.MODE_NUMBER;
var _data = data;
var _this = {};
_this.getMode = function() {
return _mode;
};
_this.getLength = function(buffer) {
return _data.length;
};
_this.write = function(buffer) {
var data = _data;
var i = 0;
while (i + 2 < data.length) {
buffer.put(strToNum(data.substring(i, i + 3) ), 10);
i += 3;
}
if (i < data.length) {
if (data.length - i == 1) {
buffer.put(strToNum(data.substring(i, i + 1) ), 4);
} else if (data.length - i == 2) {
buffer.put(strToNum(data.substring(i, i + 2) ), 7);
}
}
};
var strToNum = function(s) {
var num = 0;
for (var i = 0; i < s.length; i += 1) {
num = num * 10 + chatToNum(s.charAt(i) );
}
return num;
};
var chatToNum = function(c) {
if ('0' <= c && c <= '9') {
return c.charCodeAt(0) - '0'.charCodeAt(0);
}
throw 'illegal char :' + c;
};
return _this;
};
//---------------------------------------------------------------------
// qrAlphaNum
//---------------------------------------------------------------------
var qrAlphaNum = function(data) {
var _mode = QRMode.MODE_ALPHA_NUM;
var _data = data;
var _this = {};
_this.getMode = function() {
return _mode;
};
_this.getLength = function(buffer) {
return _data.length;
};
_this.write = function(buffer) {
var s = _data;
var i = 0;
while (i + 1 < s.length) {
buffer.put(
getCode(s.charAt(i) ) * 45 +
getCode(s.charAt(i + 1) ), 11);
i += 2;
}
if (i < s.length) {
buffer.put(getCode(s.charAt(i) ), 6);
}
};
var getCode = function(c) {
if ('0' <= c && c <= '9') {
return c.charCodeAt(0) - '0'.charCodeAt(0);
} else if ('A' <= c && c <= 'Z') {
return c.charCodeAt(0) - 'A'.charCodeAt(0) + 10;
} else {
switch (c) {
case ' ' : return 36;
case '$' : return 37;
case '%' : return 38;
case '*' : return 39;
case '+' : return 40;
case '-' : return 41;
case '.' : return 42;
case '/' : return 43;
case ':' : return 44;
default :
throw 'illegal char :' + c;
}
}
};
return _this;
};
//---------------------------------------------------------------------
// qr8BitByte
//---------------------------------------------------------------------
var qr8BitByte = function(data) {
var _mode = QRMode.MODE_8BIT_BYTE;
var _data = data;
var _bytes = qrcode.stringToBytes(data);
var _this = {};
_this.getMode = function() {
return _mode;
};
_this.getLength = function(buffer) {
return _bytes.length;
};
_this.write = function(buffer) {
for (var i = 0; i < _bytes.length; i += 1) {
buffer.put(_bytes[i], 8);
}
};
return _this;
};
//---------------------------------------------------------------------
// qrKanji
//---------------------------------------------------------------------
var qrKanji = function(data) {
var _mode = QRMode.MODE_KANJI;
var _data = data;
var stringToBytes = qrcode.stringToBytesFuncs['SJIS'];
if (!stringToBytes) {
throw 'sjis not supported.';
}
!function(c, code) {
// self test for sjis support.
var test = stringToBytes(c);
if (test.length != 2 || ( (test[0] << 8) | test[1]) != code) {
throw 'sjis not supported.';
}
}('\u53cb', 0x9746);
var _bytes = stringToBytes(data);
var _this = {};
_this.getMode = function() {
return _mode;
};
_this.getLength = function(buffer) {
return ~~(_bytes.length / 2);
};
_this.write = function(buffer) {
var data = _bytes;
var i = 0;
while (i + 1 < data.length) {
var c = ( (0xff & data[i]) << 8) | (0xff & data[i + 1]);
if (0x8140 <= c && c <= 0x9FFC) {
c -= 0x8140;
} else if (0xE040 <= c && c <= 0xEBBF) {
c -= 0xC140;
} else {
throw 'illegal char at ' + (i + 1) + '/' + c;
}
c = ( (c >>> 8) & 0xff) * 0xC0 + (c & 0xff);
buffer.put(c, 13);
i += 2;
}
if (i < data.length) {
throw 'illegal char at ' + (i + 1);
}
};
return _this;
};
//=====================================================================
// GIF Support etc.
//
//---------------------------------------------------------------------
// byteArrayOutputStream
//---------------------------------------------------------------------
var byteArrayOutputStream = function() {
var _bytes = [];
var _this = {};
_this.writeByte = function(b) {
_bytes.push(b & 0xff);
};
_this.writeShort = function(i) {
_this.writeByte(i);
_this.writeByte(i >>> 8);
};
_this.writeBytes = function(b, off, len) {
off = off || 0;
len = len || b.length;
for (var i = 0; i < len; i += 1) {
_this.writeByte(b[i + off]);
}
};
_this.writeString = function(s) {
for (var i = 0; i < s.length; i += 1) {
_this.writeByte(s.charCodeAt(i) );
}
};
_this.toByteArray = function() {
return _bytes;
};
_this.toString = function() {
var s = '';
s += '[';
for (var i = 0; i < _bytes.length; i += 1) {
if (i > 0) {
s += ',';
}
s += _bytes[i];
}
s += ']';
return s;
};
return _this;
};
//---------------------------------------------------------------------
// base64EncodeOutputStream
//---------------------------------------------------------------------
var base64EncodeOutputStream = function() {
var _buffer = 0;
var _buflen = 0;
var _length = 0;
var _base64 = '';
var _this = {};
var writeEncoded = function(b) {
_base64 += String.fromCharCode(encode(b & 0x3f) );
};
var encode = function(n) {
if (n < 0) {
// error.
} else if (n < 26) {
return 0x41 + n;
} else if (n < 52) {
return 0x61 + (n - 26);
} else if (n < 62) {
return 0x30 + (n - 52);
} else if (n == 62) {
return 0x2b;
} else if (n == 63) {
return 0x2f;
}
throw 'n:' + n;
};
_this.writeByte = function(n) {
_buffer = (_buffer << 8) | (n & 0xff);
_buflen += 8;
_length += 1;
while (_buflen >= 6) {
writeEncoded(_buffer >>> (_buflen - 6) );
_buflen -= 6;
}
};
_this.flush = function() {
if (_buflen > 0) {
writeEncoded(_buffer << (6 - _buflen) );
_buffer = 0;
_buflen = 0;
}
if (_length % 3 != 0) {
// padding
var padlen = 3 - _length % 3;
for (var i = 0; i < padlen; i += 1) {
_base64 += '=';
}
}
};
_this.toString = function() {
return _base64;
};
return _this;
};
//---------------------------------------------------------------------
// base64DecodeInputStream
//---------------------------------------------------------------------
var base64DecodeInputStream = function(str) {
var _str = str;
var _pos = 0;
var _buffer = 0;
var _buflen = 0;
var _this = {};
_this.read = function() {
while (_buflen < 8) {
if (_pos >= _str.length) {
if (_buflen == 0) {
return -1;
}
throw 'unexpected end of file./' + _buflen;
}
var c = _str.charAt(_pos);
_pos += 1;
if (c == '=') {
_buflen = 0;
return -1;
} else if (c.match(/^\s$/) ) {
// ignore if whitespace.
continue;
}
_buffer = (_buffer << 6) | decode(c.charCodeAt(0) );
_buflen += 6;
}
var n = (_buffer >>> (_buflen - 8) ) & 0xff;
_buflen -= 8;
return n;
};
var decode = function(c) {
if (0x41 <= c && c <= 0x5a) {
return c - 0x41;
} else if (0x61 <= c && c <= 0x7a) {
return c - 0x61 + 26;
} else if (0x30 <= c && c <= 0x39) {
return c - 0x30 + 52;
} else if (c == 0x2b) {
return 62;
} else if (c == 0x2f) {
return 63;
} else {
throw 'c:' + c;
}
};
return _this;
};
//---------------------------------------------------------------------
// gifImage (B/W)
//---------------------------------------------------------------------
var gifImage = function(width, height) {
var _width = width;
var _height = height;
var _data = new Array(width * height);
var _this = {};
_this.setPixel = function(x, y, pixel) {
_data[y * _width + x] = pixel;
};
_this.write = function(out) {
//---------------------------------
// GIF Signature
out.writeString('GIF87a');
//---------------------------------
// Screen Descriptor
out.writeShort(_width);
out.writeShort(_height);
out.writeByte(0x80); // 2bit
out.writeByte(0);
out.writeByte(0);
//---------------------------------
// Global Color Map
// black
out.writeByte(0x00);
out.writeByte(0x00);
out.writeByte(0x00);
// white
out.writeByte(0xff);
out.writeByte(0xff);
out.writeByte(0xff);
//---------------------------------
// Image Descriptor
out.writeString(',');
out.writeShort(0);
out.writeShort(0);
out.writeShort(_width);
out.writeShort(_height);
out.writeByte(0);
//---------------------------------
// Local Color Map
//---------------------------------
// Raster Data
var lzwMinCodeSize = 2;
var raster = getLZWRaster(lzwMinCodeSize);
out.writeByte(lzwMinCodeSize);
var offset = 0;
while (raster.length - offset > 255) {
out.writeByte(255);
out.writeBytes(raster, offset, 255);
offset += 255;
}
out.writeByte(raster.length - offset);
out.writeBytes(raster, offset, raster.length - offset);
out.writeByte(0x00);
//---------------------------------
// GIF Terminator
out.writeString(';');
};
var bitOutputStream = function(out) {
var _out = out;
var _bitLength = 0;
var _bitBuffer = 0;
var _this = {};
_this.write = function(data, length) {
if ( (data >>> length) != 0) {
throw 'length over';
}
while (_bitLength + length >= 8) {
_out.writeByte(0xff & ( (data << _bitLength) | _bitBuffer) );
length -= (8 - _bitLength);
data >>>= (8 - _bitLength);
_bitBuffer = 0;
_bitLength = 0;
}
_bitBuffer = (data << _bitLength) | _bitBuffer;
_bitLength = _bitLength + length;
};
_this.flush = function() {
if (_bitLength > 0) {
_out.writeByte(_bitBuffer);
}
};
return _this;
};
var getLZWRaster = function(lzwMinCodeSize) {
var clearCode = 1 << lzwMinCodeSize;
var endCode = (1 << lzwMinCodeSize) + 1;
var bitLength = lzwMinCodeSize + 1;
// Setup LZWTable
var table = lzwTable();
for (var i = 0; i < clearCode; i += 1) {
table.add(String.fromCharCode(i) );
}
table.add(String.fromCharCode(clearCode) );
table.add(String.fromCharCode(endCode) );
var byteOut = byteArrayOutputStream();
var bitOut = bitOutputStream(byteOut);
// clear code
bitOut.write(clearCode, bitLength);
var dataIndex = 0;
var s = String.fromCharCode(_data[dataIndex]);
dataIndex += 1;
while (dataIndex < _data.length) {
var c = String.fromCharCode(_data[dataIndex]);
dataIndex += 1;
if (table.contains(s + c) ) {
s = s + c;
} else {
bitOut.write(table.indexOf(s), bitLength);
if (table.size() < 0xfff) {
if (table.size() == (1 << bitLength) ) {
bitLength += 1;
}
table.add(s + c);
}
s = c;
}
}
bitOut.write(table.indexOf(s), bitLength);
// end code
bitOut.write(endCode, bitLength);
bitOut.flush();
return byteOut.toByteArray();
};
var lzwTable = function() {
var _map = {};
var _size = 0;
var _this = {};
_this.add = function(key) {
if (_this.contains(key) ) {
throw 'dup key:' + key;
}
_map[key] = _size;
_size += 1;
};
_this.size = function() {
return _size;
};
_this.indexOf = function(key) {
return _map[key];
};
_this.contains = function(key) {
return typeof _map[key] != 'undefined';
};
return _this;
};
return _this;
};
var createDataURL = function(width, height, getPixel) {
var gif = gifImage(width, height);
for (var y = 0; y < height; y += 1) {
for (var x = 0; x < width; x += 1) {
gif.setPixel(x, y, getPixel(x, y) );
}
}
var b = byteArrayOutputStream();
gif.write(b);
var base64 = base64EncodeOutputStream();
var bytes = b.toByteArray();
for (var i = 0; i < bytes.length; i += 1) {
base64.writeByte(bytes[i]);
}
base64.flush();
return 'data:image/gif;base64,' + base64;
};
//---------------------------------------------------------------------
// returns qrcode function.
return qrcode;
}();
// multibyte support
!function() {
qrcode.stringToBytesFuncs['UTF-8'] = function(s) {
// http://stackoverflow.com/questions/18729405/how-to-convert-utf8-string-to-byte-array
function toUTF8Array(str) {
var utf8 = [];
for (var i=0; i < str.length; i++) {
var charcode = str.charCodeAt(i);
if (charcode < 0x80) utf8.push(charcode);
else if (charcode < 0x800) {
utf8.push(0xc0 | (charcode >> 6),
0x80 | (charcode & 0x3f));
}
else if (charcode < 0xd800 || charcode >= 0xe000) {
utf8.push(0xe0 | (charcode >> 12),
0x80 | ((charcode>>6) & 0x3f),
0x80 | (charcode & 0x3f));
}
// surrogate pair
else {
i++;
// UTF-16 encodes 0x10000-0x10FFFF by
// subtracting 0x10000 and splitting the
// 20 bits of 0x0-0xFFFFF into two halves
charcode = 0x10000 + (((charcode & 0x3ff)<<10)
| (str.charCodeAt(i) & 0x3ff));
utf8.push(0xf0 | (charcode >>18),
0x80 | ((charcode>>12) & 0x3f),
0x80 | ((charcode>>6) & 0x3f),
0x80 | (charcode & 0x3f));
}
}
return utf8;
}
return toUTF8Array(s);
};
}();
(function (factory) {
if (typeof define === 'function' && define.amd) {
define([], factory);
} else if (typeof exports === 'object') {
module.exports = factory();
}
}(function () {
return qrcode;
}));
</script>
<script>
(function () {
var uri = <?php echo json_encode($pending !== '' ? secOtpauthUri($pending) : ''); ?>;
var box = document.getElementById('qrBox');
if (!uri || !box || typeof qrcode === 'undefined') return;
for (var t = 1; t <= 12; t++) { // 二维码型号自适应:按数据长度逐级尝试
try {
var q = qrcode(t, 'M');
q.addData(uri);
q.make();
box.innerHTML = q.createSvgTag({ cellSize: 4, margin: 4, scalable: true, alt: '2FA 绑定二维码' });
return;
} catch (e) { /* 型号容量不够,换下一个 */ }
}
box.innerHTML = '<div class="fail">二维码生成失败,请改用下方 Base32 密钥手动录入。</div>';
})();
</script>
<script>
function setAct(v) { document.getElementById('act').value = v; }
// ★ 重新生成令牌:必须显式点击 + 二次确认,并置 rotate_confirm=1 供服务端兜底校验
function rotateConfirm() {
if (!confirm('确定重新生成后台入口令牌?\n旧地址将立即失效(404),请复制新地址并更新书签。\n(此操作不影响二次验证状态)')) { return false; }
document.getElementById('rotateConfirmBox').value = '1';
setAct('rotate_token');
return true;
}
// ★ 回车隐式提交防护(2026-09-30 事故根因):
// 表单里第一个 type=submit 是「重新生成令牌」,浏览器在任意文本框按回车都会
// 隐式提交并命中它,导致"只想绑定二次验证,结果重置了后台密钥"。这里统一拦截:
// - 绑定流程中验证码框回车 = 点「确认绑定」(等价操作,体验不变)
// - 其它输入框回车一律不提交(必须显式点按钮,防误触破坏性操作)
// 注:只拦 INPUT —— textarea 的 Enter 本就是换行、不会提交表单,拦了反而没法换行。
document.addEventListener('DOMContentLoaded', function () {
var fm = document.getElementById('secForm');
if (!fm) return;
fm.addEventListener('keydown', function (ev) {
if (ev.key !== 'Enter') return;
var t = ev.target;
if (!t || t.tagName !== 'INPUT') return;
if (t.type === 'submit' || t.type === 'button' || t.type === 'image') return;
ev.preventDefault();
if (t.id === 'codeBox' && <?php echo (!$twoFaOn && $pending !== '') ? 'true' : 'false'; ?>) {
document.getElementById('rotateConfirmBox').value = '';
setAct('twofa_finish');
fm.submit();
}
});
});
// 重新生成密钥前必须确认:旧密钥一作废,认证器里那条就永远对不上了
function regenConfirm() {
if (!confirm('重新生成会作废当前密钥——你认证器里已经添加的那条将立即失效(动态码会一直被拒)。确定继续?')) return false;
setAct('twofa_regen');
return true;
}
function addMyIp() {
var box = document.getElementById('wlBox');
var ip = <?php echo json_encode($myIp); ?>;
if (!ip) return;
var lines = box.value.split(/\r?\n/).map(function(s){return s.trim();}).filter(Boolean);
if (lines.indexOf(ip) === -1) lines.push(ip);
box.value = lines.join('\n');
}
function copyText(t, btn) {
var done = function() { var o = btn.textContent; btn.textContent = '已复制'; setTimeout(function(){ btn.textContent = o; }, 1200); };
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(t).then(done, function(){ fallback(t, done); });
} else { fallback(t, done); }
}
function fallback(t, done) {
var ta = document.createElement('textarea');
ta.value = t; document.body.appendChild(ta); ta.select();
try { document.execCommand('copy'); done(); } catch (e) {}
document.body.removeChild(ta);
}
// 验证码框只允许数字
document.addEventListener('input', function(e) {
if (e.target && e.target.id === 'codeBox') e.target.value = e.target.value.replace(/\D/g, '').slice(0, 6);
});
// 清空白名单按钮二次确认
document.addEventListener('click', function(e) {
if (e.target && e.target.textContent && e.target.textContent.indexOf('清空白名单') >= 0) {
if (!confirm('确认清空 IP 白名单?清空后除服务器本机外任何人都无法访问后台(含你自己,只能用引导令牌恢复)。')) {
e.preventDefault();
}
}
});
</script>
</body>
</html>