fix(auth): 修复登录后反复报未配置账号并跳回登录页的死循环
根因:ny_save_config 只写不校验,Db::settingsSet 内部吞异常且返回 void。 data/ 目录不可写时登录页照常显示登录成功,但下一请求读到的账号仍是空的 → 抛未配置卷王登录账号 → 前端 auth 打标 → 跳登录页 → 无限循环。 修复: - Db::settingsSet 改返回 bool 并回读校验;新增 settingsDelete - ny_save_config 双写后均做回读校验,失败写入 ny_config_save_error() - 新增 ny_config_diag()/ny_session_usable(),暴露来源/可写性/是否已存账号 - ny_load_config 把 null 账号强制成空字符串(脏数据不再伪装成未配置) - login.php 存凭据后回读确认,存不住/会话不可写直接报错而不是假成功 - login.php 账号为空时页面直接给出 data 目录写权限提示 - config_save 合并逻辑抽成 ny_apply_config_input:账号/密码不许被改空 - 鉴权失败响应带 extra.diag,前端 401 也算 auth - 前端防死循环:刚登录 120s 内或已跳 2 次则停止自动跳转,弹窗给出真实原因+诊断 测试:新增 tests/test-config-persist.php(29) 与 tools/test-login-loop.js(19); run-all.sh 纳入两者。全套 148+48=196 项通过。
This commit is contained in:
+193
-5
@@ -282,23 +282,48 @@ function ny_load_config(): array
|
||||
}
|
||||
$cfg['base_url'] = rtrim(trim((string)$cfg['base_url']), '/');
|
||||
$cfg['api_prefix'] = '/' . trim((string)$cfg['api_prefix'], '/');
|
||||
// 账号/密码一律转成字符串:历史数据里若存成 null,不改这里就会表现为"未配置卷王登录账号"
|
||||
$cfg['username'] = trim((string)($cfg['username'] ?? ''));
|
||||
$cfg['password'] = (string)($cfg['password'] ?? '');
|
||||
return $cfg;
|
||||
}
|
||||
|
||||
function ny_save_config(array $cfg): bool
|
||||
{
|
||||
// 双写:SQLite 为主(成功即算成功),文件为备份(DB 挂掉时还能读回来)
|
||||
// 关键:两处都必须做"回读校验"。以前只管写不管是否真落盘,data/ 目录不可写时
|
||||
// 登录页会照常提示"登录成功",但下一个请求读到的账号仍是空的 → 无限跳登录页。
|
||||
$GLOBALS['__ny_cfg_save_error'] = '';
|
||||
$notes = [];
|
||||
$json = json_encode($cfg, JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$ok = false;
|
||||
if (class_exists('Db') && Db::pdo() !== null) {
|
||||
try {
|
||||
Db::settingsSet('config', json_encode($cfg, JSON_UNESCAPED_UNICODE));
|
||||
$GLOBALS['__ny_cfg_source'] = 'db';
|
||||
$ok = true;
|
||||
if (Db::settingsSet('config', $json)) {
|
||||
$GLOBALS['__ny_cfg_source'] = 'db';
|
||||
$ok = true;
|
||||
} else {
|
||||
$notes[] = '数据库写入失败:' . (Db::lastError() ?: '未知原因');
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$ok = false;
|
||||
$notes[] = '数据库写入异常:' . $e->getMessage();
|
||||
}
|
||||
} else {
|
||||
$notes[] = '数据库不可用(' . (class_exists('Db') ? Db::lastError() : 'Db 类未加载') . ')';
|
||||
}
|
||||
|
||||
$fileOk = ny_write_json(ny_config_file(), $cfg);
|
||||
if ($fileOk) {
|
||||
// 回读一致才算真成功
|
||||
$back = ny_read_json(ny_config_file(), null);
|
||||
if (!is_array($back) || json_encode($back, JSON_UNESCAPED_UNICODE) !== $json) {
|
||||
$fileOk = false;
|
||||
$notes[] = '配置文件回读不一致:' . ny_config_file();
|
||||
}
|
||||
} else {
|
||||
$notes[] = '配置文件写入失败:' . ny_config_file();
|
||||
}
|
||||
// 尽量收紧配置文件权限(仅属主可读写),降低明文密码被其它本地用户读取的风险。
|
||||
// Windows 下 chmod 基本无效(真正的防护是 data/ 目录禁止 Web 访问,见 .htaccess / 部署文档);
|
||||
// 明文密码仍需保留以便卷王 token 失效时平台自动重新登录,无法改为只存哈希。
|
||||
@@ -306,11 +331,149 @@ function ny_save_config(array $cfg): bool
|
||||
@chmod(ny_config_file(), 0600);
|
||||
}
|
||||
if (!$ok) {
|
||||
$GLOBALS['__ny_cfg_source'] = 'file';
|
||||
$GLOBALS['__ny_cfg_source'] = $fileOk ? 'file' : 'default';
|
||||
}
|
||||
if (!$ok && !$fileOk) {
|
||||
$GLOBALS['__ny_cfg_save_error'] = '配置未落盘:' . implode(';', $notes);
|
||||
} elseif ($notes !== []) {
|
||||
// 有一处成功即不影响使用,仅记录降级信息
|
||||
$GLOBALS['__ny_cfg_save_error'] = implode(';', $notes);
|
||||
}
|
||||
return $ok || $fileOk;
|
||||
}
|
||||
|
||||
/** 上一次 ny_save_config 的问题描述(空 = 完全正常) */
|
||||
function ny_config_save_error(): string
|
||||
{
|
||||
return (string)($GLOBALS['__ny_cfg_save_error'] ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置落盘诊断:登录/鉴权失败时优先看这里,用来区分
|
||||
* 「账号没配」与「账号配了但写不进去 / 读不回来」这两类完全不同的故障。
|
||||
*/
|
||||
function ny_config_diag(): array
|
||||
{
|
||||
$dir = rtrim((string)NY_DATA, '/\\');
|
||||
$file = ny_config_file();
|
||||
$dbOk = class_exists('Db') && Db::pdo() !== null;
|
||||
|
||||
$dbWritable = false;
|
||||
if ($dbOk) {
|
||||
$probe = '__ny_probe_' . bin2hex(random_bytes(4));
|
||||
$dbWritable = Db::settingsSet($probe, '1');
|
||||
Db::settingsDelete($probe);
|
||||
}
|
||||
|
||||
$cfgWritable = false;
|
||||
$probeFile = $dir . '/.__ny_write_probe';
|
||||
$fp = @fopen($probeFile, 'wb');
|
||||
if ($fp) {
|
||||
$cfgWritable = (fwrite($fp, '1') !== false);
|
||||
fclose($fp);
|
||||
@unlink($probeFile);
|
||||
}
|
||||
|
||||
$c = ny_load_config();
|
||||
|
||||
return [
|
||||
'storage' => ny_config_source(),
|
||||
'data_dir' => $dir,
|
||||
'dir_writable' => is_dir($dir) && is_writable($dir),
|
||||
'cfg_file' => $file,
|
||||
'cfg_writable' => $cfgWritable,
|
||||
'db_connected' => $dbOk,
|
||||
'db_writable' => $dbWritable,
|
||||
'db_error' => (class_exists('Db') && !$dbOk) ? Db::lastError() : '',
|
||||
'has_username' => trim((string)($c['username'] ?? '')) !== '',
|
||||
'has_password' => ((string)($c['password'] ?? '')) !== '',
|
||||
'username' => (string)($c['username'] ?? ''),
|
||||
'save_error' => ny_config_save_error(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 把接口/表单传入的配置片段合并进已有配置(api.php config_save 用)。
|
||||
* 抽出来是为了可单测,同时在这里统一守住"账号/密码不能被误清空"。
|
||||
*
|
||||
* @return array{config:array,conn_changed:bool}
|
||||
*/
|
||||
function ny_apply_config_input(array $old, array $in): array
|
||||
{
|
||||
$allowed = ['base_url', 'api_prefix', 'username', 'timeout', 'refresh_interval', 'page_size', 'name_keywords', 'history_display', 'export_sort'];
|
||||
$new = $old;
|
||||
foreach ($allowed as $k) {
|
||||
if (array_key_exists($k, $in)) {
|
||||
$new[$k] = is_string($in[$k]) ? trim((string)$in[$k]) : $in[$k];
|
||||
}
|
||||
}
|
||||
// 账号不允许被改空:设置页留空视为"不改",否则一旦保存就会陷入"未配置账号"死循环
|
||||
if (array_key_exists('username', $in)) {
|
||||
$u = trim((string)($in['username'] ?? ''));
|
||||
if ($u === '') {
|
||||
$new['username'] = (string)($old['username'] ?? '');
|
||||
}
|
||||
}
|
||||
// 密码:保留首尾空格的原貌,空 / 占位符 = 不修改
|
||||
if (array_key_exists('password', $in)) {
|
||||
$pwdIn = (string)$in['password'];
|
||||
if ($pwdIn !== '' && $pwdIn !== '******') {
|
||||
$new['password'] = $pwdIn;
|
||||
}
|
||||
}
|
||||
foreach (['verify_ssl', 'include_temp_save', 'dedupe'] as $k) {
|
||||
if (array_key_exists($k, $in)) {
|
||||
$new[$k] = filter_var($in[$k], FILTER_VALIDATE_BOOL);
|
||||
}
|
||||
}
|
||||
// 布局配置
|
||||
if (array_key_exists('layout', $in)) {
|
||||
$new['layout'] = ($in['layout'] === 'side') ? 'side' : 'default';
|
||||
}
|
||||
// 历史成绩展示方式:click(点击成员弹窗,默认) / inline(明细内全部展开)
|
||||
if (array_key_exists('history_display', $in)) {
|
||||
$new['history_display'] = ($in['history_display'] === 'inline') ? 'inline' : 'click';
|
||||
}
|
||||
// 成绩导出排序
|
||||
if (array_key_exists('export_sort', $in)) {
|
||||
$v = (string)$in['export_sort'];
|
||||
$new['export_sort'] = in_array($v, ['time','sid','score_desc','score_asc'], true) ? $v : 'time';
|
||||
}
|
||||
if (array_key_exists('pass_rate', $in)) {
|
||||
$pr = (float)$in['pass_rate'];
|
||||
$new['pass_rate'] = ($pr >= 1 && $pr <= 100) ? $pr : 60;
|
||||
}
|
||||
$new['base_url'] = rtrim((string)$new['base_url'], '/');
|
||||
$new['api_prefix'] = '/' . trim((string)$new['api_prefix'], '/');
|
||||
$new['timeout'] = (int)$new['timeout'];
|
||||
$new['refresh_interval'] = (int)$new['refresh_interval'];
|
||||
if ($new['refresh_interval'] < 3) {
|
||||
$new['refresh_interval'] = 3;
|
||||
}
|
||||
// 默认满分
|
||||
$fs = (int)($in['full_score'] ?? $new['full_score'] ?? 100);
|
||||
if ($fs < 1) {
|
||||
$fs = 100;
|
||||
}
|
||||
$new['full_score'] = $fs;
|
||||
|
||||
// 账号/密码强制字符串(历史数据里可能存成 null,导致 (string) 后变成空 → 报"未配置账号")
|
||||
$new['username'] = (string)($new['username'] ?? '');
|
||||
$new['password'] = (string)($new['password'] ?? '');
|
||||
|
||||
// 仅当连接信息真的变了才清 token + 重连;保存满分/布局等不应触发登录卷王
|
||||
$connKeys = ['base_url', 'api_prefix', 'username', 'password', 'verify_ssl'];
|
||||
$connChanged = false;
|
||||
foreach ($connKeys as $k) {
|
||||
if ((string)($old[$k] ?? '') !== (string)($new[$k] ?? '')) {
|
||||
$connChanged = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return ['config' => $new, 'conn_changed' => $connChanged];
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析「本卷满分 + 合格率」:
|
||||
* 优先取该试卷的独立配置(试卷之间互相隔离),没有则用全局配置。
|
||||
@@ -341,6 +504,31 @@ function ny_resolve_score_cfg(string $projectId, array $c): array
|
||||
return [$fs, $pr];
|
||||
}
|
||||
|
||||
/**
|
||||
* PHP 会话目录是否可用(登录状态能不能保持住)。
|
||||
* 目录不可写时 session_start() 只会告警,登录态存不下来 → 表现为"登录完又被踢回登录页"。
|
||||
*/
|
||||
function ny_session_usable(?string &$why = null): bool
|
||||
{
|
||||
$dir = (string)@session_save_path();
|
||||
// save_path 可能是 "N;/path"(多级目录散列),取分号后的真实路径
|
||||
if (preg_match('/^\d+;(.*)$/', $dir, $m) === 1) {
|
||||
$dir = $m[1];
|
||||
}
|
||||
if ($dir === '') {
|
||||
$dir = sys_get_temp_dir();
|
||||
}
|
||||
if (!is_dir($dir)) {
|
||||
$why = '会话目录不存在:' . $dir;
|
||||
return false;
|
||||
}
|
||||
if (!is_writable($dir)) {
|
||||
$why = '会话目录不可写:' . $dir;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 安全的字符串化(避免数组/对象转字符串报错) */
|
||||
function ny_str(mixed $v): string
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user