diff --git a/CHANGELOG.md b/CHANGELOG.md index 12fabd1..04c1dfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,16 @@ ### 修复 - +## [2.20.0] - 2026-10-01 + +### 新增 +- 后台凭据自动续期:独立于签到流程,调度器每小时扫一遍账号,做两件事——① 有 renew() 的站点(如 Akile):快过期的凭据提前换新并回写(Akile 先接入:到期前 12 小时触发);② 纯 Cookie 的站点(没有刷新接口、面板手里也没密码,技术上续不了):凭据里写着到期时间的(WordPress 会话 / JWT),剩不到 24 小时就提前预警,提醒去重新抓(每天每个账号一次)。天翼云盘、V2Board 存的是账号密码、每次签到本来就是重新登录,天生不会"过期",不需要续。 +- 续期走「扩展在线先走用户本地网络、再直连兜底」两条路线;只有「网络失败」才换路线重试,「站点拒绝」直接记原因不再空试。 +- 续期状态可视化:账号站点名悬浮提示新增「自动续期:上次成功 …」/「自动续期失败(原因 + 详情 + 每小时重试说明)」;失败且凭据剩不到 6 小时就过期时推送提醒(每天一次),不等死了才让用户手动。 + +### 修复 +- Akile 续期失败原因不再「一律吞掉」:区分「网络没通」(值得重试)和「站点拒绝」(旧 token 已死),后台续期失败会记下具体原因(HTTP 状态 + 网站回馈),而不是第二天莫名其妙报「请重新获取」。签到主流程的报错文案保持不变。 + ## [2.19.1] - 2026-09-30 ### 新增 diff --git a/package.json b/package.json index 244e5d2..92d5ed2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "daily-checkin-panel", - "version": "2.19.1", + "version": "2.20.0", "private": true, "description": "跑在 Cloudflare Workers 上的自动签到面板:网页后台管账号 + D1 存储 + 定时签到 + 推送通知", "type": "module", diff --git a/public/index.html b/public/index.html index 721abac..2452867 100644 --- a/public/index.html +++ b/public/index.html @@ -2751,7 +2751,7 @@ const SITE_MODE_HINT_DOCKER = { // 两种都报出来,用户就不用猜「我这份 Cookie 还能撑几天」。 // 其它站点(Discuz、机场面板…)的会话有效期只在服务器那边知道 —— 这里**如实说「凭据里没写」**, // 不编一个时间出来:他看这句话是为了决定「要不要现在去重新登录」,猜错比不说更坏。 -function credExpTip(exp) { +function credExpTip(exp, metaStr) { const fmt = (ms) => new Date(ms).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); const left = (ms) => { @@ -2776,6 +2776,18 @@ function credExpTip(exp) { lines.push('这份 Cookie / 凭据里没有写到期时间 —— 只有网站自己知道它还算不算登录。'); lines.push('真失效了面板会报「需要重新登录」,也能用「Cookie 解析器」当场体检。'); } + // ---- 自动续期状态(后台每小时检查,到期前自动换新;Akile 这类短命 token 就靠它续命)---- + let rm = {}; + try { rm = JSON.parse(metaStr || '{}'); } catch { /* 忽略 */ } + if (rm.renew_ok_at) { + lines.push('自动续期:上次成功 ' + fmt(rm.renew_ok_at) + '(面板自己换的新凭据,你不用管)'); + } + if (rm.renew_fail_at && (!rm.renew_ok_at || rm.renew_fail_at > rm.renew_ok_at)) { + const reasonMap = { network: '网络没通', rejected: '站点拒绝(旧凭据已失效)', invalid: '凭据格式不对', error: '续期过程出错' }; + lines.push('自动续期失败(' + fmt(rm.renew_fail_at) + '):' + (reasonMap[rm.renew_fail_reason] || rm.renew_fail_reason || '未知原因')); + if (rm.renew_fail_detail) lines.push('失败详情:' + rm.renew_fail_detail); + lines.push('面板会每小时重试一次;如果一直失败,请手动重新获取凭据。'); + } return lines.join('\n'); } @@ -2833,7 +2845,7 @@ function execCellHTML(a, execOverride) { return { isLocalExec, siteHint: hint, - html: `
${esc(a.name)}${comBadge}` + html: `
${esc(a.name)}${comBadge}` + `` + `
${execBadge}
${bsign}${pills}
`, }; diff --git a/src/index.js b/src/index.js index 8a8c6b9..2357956 100644 --- a/src/index.js +++ b/src/index.js @@ -1688,6 +1688,21 @@ export default { } if (changed) await setSetting(env.DB, 'sched_last_map', JSON.stringify(lastMap)).catch(() => {}); + // ---- 后台凭据续期(独立于签到)---- + // 每小时最多跑一轮:把「站点声明了 renew() 的账号」里快过期的凭据提前换新。 + // 故意放在签到主循环之后、且不计入上面的 ranCount/日报 —— 这是保养, + // 不是签到;续期自己的成败写在账号 meta 里(renew_ok_at / renew_fail_*), + // 前端「凭据到期时间」悬浮提示会展示,失败且快过期时会推送提醒。 + try { + const { renewCredentials } = await import('./lib/renew.js'); + const renewRes = await renewCredentials(env, env.DB, await loadCustomSites(env).catch(() => [])); + if (renewRes && renewRes.ran && renewRes.details && renewRes.details.length) { + console.log('[cron] 凭据续期:' + renewRes.details.join(';')); + } + } catch (e) { + console.error('[cron] 凭据续期失败', e); + } + // 心跳:记下「这次自动检查是什么时候跑的、跑了几个」。 // 没到点的大多数分钟(ranCount=0)不必写库,所以最多半小时写一次 —— // 这样面板既能显示「自动执行还活着」,也几乎不增加 D1 写入。 diff --git a/src/lib/renew.js b/src/lib/renew.js new file mode 100644 index 0000000..ddf01be --- /dev/null +++ b/src/lib/renew.js @@ -0,0 +1,208 @@ +// 后台凭据续期:独立于签到流程,每小时扫一遍账号,做两件事: +// ① 有 renew() 的站点(如 Akile):快过期的凭据提前换新并回写; +// ② 纯 Cookie 的站点(没有刷新接口、手里也没密码,续不了):凭据里写着到期时间 +// 的,剩不到 24 小时就提前预警,提醒用户去重新抓(每天每个账号一次)。 +// +// 为什么需要(2026-10-01):Akile 的 token 只有 ~24h 寿命,原来续期只发生在 +// 签到那一刻;那一刻网络一抖(CF 到 Akile 本来就不稳定)或扩展离线,续期就被 +// 静默吞掉——旧 token 当时还能用,签到照样成功,用户毫无察觉;第二天 token +// 一死,就只能手动重新登录获取。这个独立任务治的就是这个:提前续、失败了 +// 提前说,不等死了才让用户手动。 +// +// 站点侧约定见 akile.js 的 renew() 注释;调度器只认那个形状,别的新站点 +// 照着实现就能接入。 + +import { getSite } from '../sites/index.js'; +import { decryptJSON } from '../crypto.js'; +import { getSetting, setSetting } from '../db.js'; +import { withAccountLock } from '../runner.js'; +import { credentialExpiry } from './cookie-info.js'; + +const RENEW_INTERVAL_MS = 60 * 60 * 1000; // 每小时最多跑一轮(12 小时窗口内约有 12 次机会) +const ALERT_WITHIN_MS = 6 * 3600 * 1000; // 剩不到 6 小时就死、且续期失败 → 推送提醒(每天一次) +const EXP_ALERT_WITHIN_MS = 24 * 3600 * 1000; // 纯 Cookie 站点:凭据剩不到 24 小时 → 到期预警(每天每个账号一次) + +const REASON_TEXT = { + network: '网络没通', + rejected: '站点拒绝续期(旧凭据已失效)', + invalid: '存的凭据格式不对', + error: '续期过程出错', +}; + +function fmtTime(ms) { + try { + return new Date(ms).toLocaleString('zh-CN', + { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); + } catch { return ''; } +} + +// 一次续期尝试:扩展在线先走用户本地网络(中继),再走直连兜底。 +// 只有「网络失败」值得换路线再试;not-due/已换新/被拒绝/代码异常都不用再试。 +async function renewWithRoutes(env, db, account, site, creds) { + const routes = []; + try { + const { isRelayAvailable } = await import('./relay.js'); + if (await isRelayAvailable(db)) routes.push('relay'); + } catch { /* 扩展状态查不到就当离线,直连兜底 */ } + routes.push('server'); + let last = null; + for (const route of routes) { + let restore = null; + if (route === 'relay') { + const { relayFetch } = await import('./relay.js'); + const prev = globalThis.fetch; + globalThis.fetch = (url, init) => relayFetch(db, url, init); + restore = () => { globalThis.fetch = prev; }; + } + try { + const r = await site.renew(creds, { env, db, account }); + last = { route, ...r }; + if (r.renewed || r.reason !== 'network') break; + } catch (e) { + last = { route, renewed: false, reason: 'error', exp: 0, detail: String((e && e.message) || e).slice(0, 200) }; + break; + } finally { + if (restore) restore(); + } + } + return last; +} + +function metaOf(acc) { + try { return JSON.parse(acc.meta || '{}'); } catch { return {}; } +} + +// 纯 Cookie 站点的「到期预警」:Cookie 没有刷新接口、面板手里也没有密码, +// 技术上续不了 —— 但能提前发现快死了,提前一天告诉用户去重新抓, +// 而不是等第二天签到失败才看到「登录已失效」。 +// 只认凭据里自己写着的到期时间(WordPress 会话 / JWT),没有的不猜。 +async function checkExpiryAlert(env, db, full, site, creds) { + const now = Date.now(); + let nearest = 0; + let anyExpired = false; + try { + const { sessions, tokens } = credentialExpiry(creds); + const all = [...(sessions || []), ...(tokens || [])].filter((x) => x && x.exp > 0); + if (!all.length) return 'no-info'; + const live = all.filter((x) => !x.expired).map((x) => x.exp).sort((a, b) => a - b); + nearest = live.length ? live[0] : 0; + anyExpired = all.some((x) => x.expired); + } catch { return 'no-info'; } + const dying = nearest > 0 && nearest - now < EXP_ALERT_WITHIN_MS; + if (!dying && !anyExpired) return 'fine'; + // 同一天同一个账号只提醒一次 + const m = metaOf(full); + try { + const tz = (await getSetting(db, 'schedule_tz').catch(() => '')) || 'Asia/Shanghai'; + const { dayInTz } = await import('../schedule.js').catch(() => ({})); + const day = typeof dayInTz === 'function' ? dayInTz(new Date(), tz) : new Date().toDateString(); + if (m.exp_alert_day === day) return 'already'; + const { sendNotify } = await import('../notify.js'); + const leftH = nearest > now ? Math.max(1, Math.round((nearest - now) / 3600000)) : 0; + const when = nearest > now + ? `将在 ${fmtTime(nearest)} 过期(还剩约 ${leftH} 小时)` + : '已经过期了'; + await sendNotify(env, db, + 'Cookie 即将过期', + `${full.name || site.name} 的登录 Cookie ${when}。Cookie 没有自动续期,` + + `请在浏览器重新登录该网站(确认页面上能看到自己的用户名),再用扩展「发送到面板」更新一次。`); + m.exp_alert_day = day; + await db.prepare('UPDATE accounts SET meta = ?, updated_at = ? WHERE id = ?') + .bind(JSON.stringify(m), now, full.id).run().catch(() => {}); + return 'notified'; + } catch (e) { + console.error('[renew] 到期预警失败', e); + return 'error'; + } +} + +export async function renewCredentials(env, db, customSites = []) { + let lastRun = 0; + try { lastRun = Number(await getSetting(db, 'renew_last_at')) || 0; } catch { /* 忽略 */ } + if (Date.now() - lastRun < RENEW_INTERVAL_MS) return { ran: false, reason: 'too-soon' }; + await setSetting(db, 'renew_last_at', String(Date.now())).catch(() => {}); + + const { results } = await db.prepare( + 'SELECT id, site, name, meta, creds FROM accounts WHERE enabled = 1' + ).all().catch(() => ({ results: [] })); + + const summary = { ran: true, checked: 0, renewed: 0, failed: 0, details: [] }; + for (const acc of results || []) { + let site = null; + try { site = getSite(acc.site, customSites); } catch { /* 忽略 */ } + if (!site) continue; + // 和签到共用账号锁:同一账号的签到和续期不能同时跑(都会读写它的凭据) + try { + await withAccountLock(async () => { + const full = await db.prepare('SELECT * FROM accounts WHERE id = ?').bind(acc.id).first(); + if (!full || !full.enabled) return; + let creds = {}; + try { creds = await decryptJSON(env, db, full.creds); } catch { return; } + summary.checked++; + // 没有 renew() 的站点(纯 Cookie、手里没密码):续不了,但能做「到期预警」 + if (typeof site.renew !== 'function') { + const st = await checkExpiryAlert(env, db, full, site, creds); + if (st === 'notified') { + summary.details.push(`⚠️ ${full.name || site.name}:Cookie 快过期,已提醒重新获取`); + } + return; + } + const r = await renewWithRoutes(env, db, full, site, creds); + if (!r) return; + const m = metaOf(full); + const now = Date.now(); + let touched = false; + if (r.renewed) { + m.renew_ok_at = now; + m.renew_ok_exp = r.exp || 0; + delete m.renew_fail_at; + delete m.renew_fail_reason; + delete m.renew_fail_detail; + touched = true; + summary.renewed++; + summary.details.push(`✅ ${full.name || site.name}:token 已自动续期` + (r.exp ? `(新有效期至 ${fmtTime(r.exp * 1000)})` : '')); + } else if (r.reason === 'not-due' || r.reason === 'invalid') { + // 还没到时间 / 凭据格式不对(后者等签到流程去跟用户说清楚,这里不打扰) + } else { + m.renew_fail_at = now; + m.renew_fail_reason = r.reason; + m.renew_fail_detail = String(r.detail || '').slice(0, 200); + touched = true; + summary.failed++; + summary.details.push(`❌ ${full.name || site.name}:自动续期失败(${REASON_TEXT[r.reason] || r.reason})${r.detail ? ':' + String(r.detail).slice(0, 120) : ''}`); + } + if (touched) { + await db.prepare('UPDATE accounts SET meta = ?, updated_at = ? WHERE id = ?') + .bind(JSON.stringify(m), now, full.id).run().catch(() => {}); + } + // 续期失败、且凭据剩不到 6 小时就死(或已经死了)→ 推送提醒(每天一次,免得每小时刷屏) + if (!r.renewed && (r.reason === 'network' || r.reason === 'rejected' || r.reason === 'error') + && r.exp && r.exp * 1000 - now < ALERT_WITHIN_MS) { + try { + const tz = (await getSetting(db, 'schedule_tz').catch(() => '')) || 'Asia/Shanghai'; + const { dayInTz } = await import('../schedule.js').catch(() => ({})); + const day = typeof dayInTz === 'function' ? dayInTz(new Date(), tz) : new Date().toDateString(); + if ((await getSetting(db, 'renew_alert_day').catch(() => '')) !== `renew:${day}`) { + const { sendNotify } = await import('../notify.js'); + const expStr = fmtTime(r.exp * 1000); + const when = r.exp * 1000 <= now + ? `已经过期(${expStr}),且自动续期失败了` + : `将在 ${expStr} 过期(还剩不到 6 小时),但自动续期失败了`; + await sendNotify(env, db, + '凭据自动续期失败', + `${full.name || site.name} 的登录凭据${when}:` + + `${REASON_TEXT[r.reason] || r.reason}${r.detail ? '(' + String(r.detail).slice(0, 120) + ')' : ''}。` + + `面板会继续每小时重试;若一直失败,请手动重新获取凭据。`); + await setSetting(db, 'renew_alert_day', `renew:${day}`).catch(() => {}); + } + } catch (e) { + console.error('[renew] 推送提醒失败', e); + } + } + }); + } catch (e) { + console.error('[renew] account', acc.id, e); + } + } + return summary; +} diff --git a/src/runner.js b/src/runner.js index 28cbbdb..d348533 100644 --- a/src/runner.js +++ b/src/runner.js @@ -44,6 +44,9 @@ function withAccountLock(fn) { export function accountLockIdle() { return ACCOUNT_LOCK; } +// 凭据续期(src/lib/renew.js)与签到共用同一把账号锁:同一账号的签到和续期 +// 不能同时跑(都会读写它的凭据)。 +export { withAccountLock }; // 仅供测试:Set-Cookie 静默回写(生产代码走 runAccount 内部调用) export { applyCookieRefresh }; diff --git a/src/sites/akile.js b/src/sites/akile.js index cf7f43d..ee6379d 100644 --- a/src/sites/akile.js +++ b/src/sites/akile.js @@ -13,6 +13,7 @@ import { encryptJSON } from '../crypto.js'; const UA = 'Mozilla/5.0 (Linux; Android 13; KB2000 Build/TKQ1.221114.001) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Mobile Safari/537.36'; const API = 'https://api.akile.ai/api'; const REFRESH_AHEAD_SEC = 12 * 3600; // 与网页一致:过期前 12 小时刷新 +const JWT_RE = /^[\w-]+\.[\w-]+\.[\w-]+$/; function okCode(code) { return code === 0 || code === 200 || code === '0' || code === '200'; @@ -52,16 +53,35 @@ async function apiGet(path, token) { return { httpStatus: res.status, body }; } -// 用旧 token 换新 token;失败返回 null -async function refreshToken(token) { +// 用旧 token 换新 token(结构化结果——调用方必须分清「被站点拒绝」和「网络没通」: +// 前者再试也没用,后者换条路线/过会儿重试可能有用。原来一律返回 null, +// 网络抖一下就会被当成「token 已死」,用户看到的就是莫名其妙的「请重新获取)。 +// { token } —— 换到了新 token +// { rejected, detail } —— 站点明确拒绝(401 / status_code 非成功码):旧 token 已死 +// { network, detail } —— 请求没发出去或没回来(超时/断网/被拦):值得重试 +async function tryRefresh(token) { + let res; try { - const { httpStatus, body } = await apiGet('/v1/user/refreshToken', token); - if (httpStatus === 401 || !okCode(body.status_code)) return null; - const nt = body && body.data && body.data.token; - return nt ? String(nt) : null; - } catch { - return null; + res = await apiGet('/v1/user/refreshToken', token); + } catch (e) { + return { network: true, detail: String((e && e.message) || e || '请求失败').slice(0, 160) }; } + const { httpStatus, body } = res; + if (httpStatus === 401 || !okCode(body.status_code)) { + const msg = String((body && body.status_msg) || '').slice(0, 120); + return { rejected: true, detail: `HTTP ${httpStatus} · ${msg || '无返回文案'}` }; + } + const nt = body && body.data && body.data.token; + if (!nt) { + return { rejected: true, detail: `HTTP ${httpStatus} · 接口成功了但没给新 token(返回里没有 data.token)` }; + } + return { token: String(nt) }; +} + +// 签到主流程用(行为不变:失败一律 null,由 run() 按原来的文案报错) +async function refreshToken(token) { + const r = await tryRefresh(token); + return r.token || null; } // 续期成功后回写 D1,下次直接用新 token(失败不影响本次签到) @@ -96,7 +116,7 @@ export const akile = { placeholder: '浏览器登录 akile.ai 后,从 localStorage 复制 akile-token(一段 eyJ... 开头的 JWT)粘贴到这里', }, ], - tips: '一句话记住:这个框要的是 akile-token,**不是 Cookie**。它们在两个完全不同的地方:Cookie 在 F12 → Application → Cookies,token 在 F12 → Application → Local Storage → akile-token(值以 eyJ 开头,是一段 JWT)。粘贴 Cookie 会被服务器拒绝,面板也只能回「登录已过期」,看着就像「我明明更新了」。最省事的取法:在浏览器登录 akile.ai → 点下方「复制取 token 小书签」→ 在 akile.ai 页面点该书签,token 会自动复制 → 回到面板粘贴保存。另外一个细节:Akile 的 token 大约只活一天(实测 exp 与签发时间相差 12~24 小时),到期后必须重新登录获取,面板会在即将过期时尝试自动续期。', + tips: '一句话记住:这个框要的是 akile-token,**不是 Cookie**。它们在两个完全不同的地方:Cookie 在 F12 → Application → Cookies,token 在 F12 → Application → Local Storage → akile-token(值以 eyJ 开头,是一段 JWT)。粘贴 Cookie 会被服务器拒绝,面板也只能回「登录已过期」,看着就像「我明明更新了」。最省事的取法:在浏览器登录 akile.ai → 点下方「复制取 token 小书签」→ 在 akile.ai 页面点该书签,token 会自动复制 → 回到面板粘贴保存。另外一个细节:Akile 的 token 大约只活一天(实测 exp 与签发时间相差 12~24 小时)。面板会在后台每小时检查一次,到期前 12 小时自动续期并回写,你一般不用管;万一续期失败(比如网络不通),面板会提前提醒你手动更新,而不是等 token 死了才报错。', async run(creds, ctx = {}) { let token = String(creds.token || '').trim(); @@ -108,7 +128,7 @@ export const akile = { // 「登录已过期,请重新从浏览器复制 akile-token」——因为他更新的是 **Cookie**, // 而 Akile 要的是 localStorage 里的 `akile-token`(一段 JWT),两者不是一个东西。 // 所以先分辨值形态,把话说清楚,别拿一个 Cookie 去当 Authorization 白打一次请求。 - const looksJwt = /^[\w-]+\.[\w-]+\.[\w-]+$/.test(token); + const looksJwt = JWT_RE.test(token); if (!looksJwt) { const looksCookie = token.includes('=') || token.includes(';'); throw new Error(looksCookie @@ -169,6 +189,29 @@ export const akile = { throw new Error('签到失败:' + (msg || `status_code=${chk.body.status_code}`)); }, + // 后台自动续期(独立于签到流程):调度器每小时调用一次,提前把快过期的 token 换新。 + // 约定(别的站点以后也可以照这个实现,调度器只认这个形状): + // { renewed: true, exp } —— 已换新并回写 D1(exp 为新 token 的到期秒数) + // { renewed: false, reason: 'not-due', exp } —— 还没到续期时间,不用管 + // { renewed: false, reason: 'invalid', exp: 0 } —— 存的值不是 JWT(等签到流程去跟用户说清楚) + // { renewed: false, reason: 'network', exp, detail } —— 网络没通:调度器会换路线再试/下小时再试 + // { renewed: false, reason: 'rejected', exp, detail } —— 站点拒绝:旧 token 已死,只能手动重新获取 + // 注意:这里用的 globalThis.fetch 由调用方按路线准备好(中继=用户本地网络 / 直连=机房网络)。 + async renew(creds, ctx = {}) { + const token = String(creds.token || '').trim(); + if (!JWT_RE.test(token)) return { renewed: false, reason: 'invalid', exp: 0 }; + const exp = jwtExp(token); + if (!exp) return { renewed: false, reason: 'not-due', exp: 0 }; + if (exp - Date.now() / 1000 > REFRESH_AHEAD_SEC) return { renewed: false, reason: 'not-due', exp }; + const r = await tryRefresh(token); + if (r.token) { + await saveToken(ctx, r.token); + return { renewed: true, exp: jwtExp(r.token) || 0 }; + } + if (r.rejected) return { renewed: false, reason: 'rejected', exp, detail: r.detail }; + return { renewed: false, reason: 'network', exp, detail: r.detail }; + }, + // 浏览器端签到脚本:在用户浏览器中运行,使用用户本地网络(绕过 CF IP 限制) execution: 'browser', // 改为默认浏览器执行(CF 到 akile 网络不稳定) domain: 'api.akile.ai', // 浏览器执行时的目标域名 diff --git a/src/version.js b/src/version.js index 08af77c..57acf08 100644 --- a/src/version.js +++ b/src/version.js @@ -7,4 +7,4 @@ // · 测试会断言 package.json 的 version 和它一致(改了这里忘了改那边会直接报红) // 扩展的版本在 public/ext-src/manifest.json 里(它有自己的发布节奏:改过扩展代码, // 用户就得重新下载 + 在 chrome://extensions 点一次「重新加载」,所以必须单独能看出来)。 -export const PANEL_VERSION = '2.19.1'; +export const PANEL_VERSION = '2.20.0'; diff --git a/test/renew.test.mjs b/test/renew.test.mjs new file mode 100644 index 0000000..c31c4c5 --- /dev/null +++ b/test/renew.test.mjs @@ -0,0 +1,272 @@ +// 后台自动续期测试:node test/renew.test.mjs(纯 mock,不依赖网络) +// 覆盖:akile.renew() 的五种返回形态 + renewCredentials() 的整轮调度 +import assert from 'node:assert/strict'; +import { akile, jwtExp } from '../src/sites/akile.js'; +import { encryptJSON } from '../src/crypto.js'; +import { renewCredentials } from '../src/lib/renew.js'; + +let n = 0; +const t = async (name, fn) => { await fn(); n++; console.log('ok -', name); }; + +function mockFetch(routes) { + const calls = []; + globalThis.fetch = async (url, init = {}) => { + calls.push({ url: String(url), init }); + globalThis.__calls = calls; + for (const [match, resp] of routes) { + if (String(url).includes(match)) { + const body = typeof resp === 'function' ? resp(calls.length) : resp; + const status = body && body.__http ? body.__http : 200; + return { status, json: async () => body, text: async () => JSON.stringify(body) }; + } + } + throw new Error('unexpected url: ' + url); + }; +} + +const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url'); +const fakeJwt = (expDeltaSec) => `${b64({ alg: 'HS256' })}.${b64({ exp: Math.floor(Date.now() / 1000) + expDeltaSec })}.sig`; +const LONG_TOKEN = fakeJwt(20 * 3600); +const SOON_TOKEN = fakeJwt(2 * 3600); +const NEW_TOKEN = fakeJwt(24 * 3600); + +const checkinOk = { status_code: 0, status_msg: '签到成功', data: { amount: 8 } }; +const refreshOk = (tok) => ({ status_code: 0, status_msg: 'ok', data: { token: tok } }); + +function fakeCtx() { + const writes = []; + const FIXED_KEY = Buffer.alloc(32, 7).toString('base64'); + const db = { + prepare: (sql) => ({ + bind: (...args) => ({ run: async () => { writes.push({ sql, args }); return {}; } }), + first: async () => ({ value: FIXED_KEY }), + }), + }; + return { ctx: { env: {}, db, account: { id: 'acc1' } }, writes }; +} + +await t('renew:长期 token 还没到时间,不发请求', async () => { + mockFetch([['any', checkinOk]]); + globalThis.__calls = []; + const r = await akile.renew({ token: LONG_TOKEN }, fakeCtx().ctx); + assert.equal(r.renewed, false); + assert.equal(r.reason, 'not-due'); + assert.equal(globalThis.__calls.length, 0); +}); + +await t('renew:临期 token 刷新并回写,新 exp 向后', async () => { + mockFetch([['/v1/user/refreshToken', refreshOk(NEW_TOKEN)]]); + const { ctx, writes } = fakeCtx(); + const oldExp = jwtExp(SOON_TOKEN); + const r = await akile.renew({ token: SOON_TOKEN }, ctx); + assert.equal(r.renewed, true); + assert.ok(r.exp > oldExp, '新 token 的 exp 必须向后'); + assert.equal(writes.length, 1); + assert.match(writes[0].sql, /UPDATE accounts/); +}); + +await t('renew:站点 401 拒绝 → rejected(不是 network)', async () => { + mockFetch([['/v1/user/refreshToken', { status_code: 401, status_msg: '无效token' }]]); + const r = await akile.renew({ token: SOON_TOKEN }, fakeCtx().ctx); + assert.equal(r.renewed, false); + assert.equal(r.reason, 'rejected'); + assert.match(r.detail, /无效token/); +}); + +await t('renew:请求抛错 → network(值得换路线重试)', async () => { + globalThis.fetch = async () => { throw new Error('fetch failed'); }; + const r = await akile.renew({ token: SOON_TOKEN }, fakeCtx().ctx); + assert.equal(r.renewed, false); + assert.equal(r.reason, 'network'); + assert.match(r.detail, /fetch failed/); +}); + +await t('renew:存的不是 JWT → invalid,不发请求', async () => { + mockFetch([['any', checkinOk]]); + globalThis.__calls = []; + const r = await akile.renew({ token: 'PHPSESSID=abc; foo=1' }, fakeCtx().ctx); + assert.equal(r.renewed, false); + assert.equal(r.reason, 'invalid'); + assert.equal(globalThis.__calls.length, 0); +}); + +await t('renew:接口成功但没给 data.token → rejected(结构变了要能看出来)', async () => { + mockFetch([['/v1/user/refreshToken', { status_code: 0, status_msg: 'ok', data: {} }]]); + const r = await akile.renew({ token: SOON_TOKEN }, fakeCtx().ctx); + assert.equal(r.renewed, false); + assert.equal(r.reason, 'rejected'); + assert.match(r.detail, /data\.token/); +}); + +// ---------- renewCredentials 整轮 ---------- +const ENC_KEY = Buffer.alloc(32, 9).toString('base64'); +const ENV = { ENCRYPT_KEY: ENC_KEY }; + +function fakeDb(accounts) { + const settings = {}; + const updates = []; + // 有状态:meta / creds 的回写要能被后面的 first() 读到(否则"同一天不重复提醒"测不起来) + const byId = new Map(accounts.map((a) => [String(a.id), { ...a }])); + const list = () => [...byId.values()].filter((a) => a.enabled); + return { + settings, updates, + prepare: (sql) => ({ + bind: (...args) => ({ + run: async () => { + if (sql.startsWith('UPDATE accounts')) { + updates.push({ sql, args }); + const row = byId.get(String(args[args.length - 1])); + if (row && /SET meta/.test(sql)) row.meta = args[0]; + if (row && /SET creds/.test(sql)) row.creds = args[0]; + } + return {}; + }, + first: async () => { + if (sql.startsWith('SELECT value FROM settings')) { + const k = args[0]; + return settings[k] !== undefined ? { value: settings[k] } : null; + } + if (sql.startsWith('SELECT * FROM accounts')) { + return byId.get(String(args[0])) || null; + } + return null; + }, + all: async () => { + if (sql.includes('FROM accounts WHERE enabled')) return { results: list() }; + return { results: [] }; + }, + }), + first: async () => null, + all: async () => { + if (sql.includes('FROM accounts WHERE enabled')) return { results: list() }; + return { results: [] }; + }, + run: async () => { + return {}; + }, + }), + }; +} + +// fakeDb 的 prepare().bind().run() 要能处理 setSetting 的 INSERT…ON CONFLICT: +// 上面 bind().run() 没记 settings,这里单独包一层 +function settingsAwareDb(accounts) { + const db = fakeDb(accounts); + const origPrepare = db.prepare; + db.prepare = (sql) => { + const stmt = origPrepare(sql); + if (sql.startsWith('INSERT INTO settings')) { + const origBind = stmt.bind; + stmt.bind = (...args) => { + const r = origBind(...args); + const origRun = r.run; + r.run = async () => { db.settings[args[0]] = args[1]; return origRun(); }; + return r; + }; + } + return stmt; + }; + return db; +} + +async function makeAccounts() { + const db0 = settingsAwareDb([]); + const soonCreds = await encryptJSON(ENV, db0, { token: SOON_TOKEN }); + const longCreds = await encryptJSON(ENV, db0, { token: LONG_TOKEN }); + const plainCookie = await encryptJSON(ENV, db0, { cookie: 'sessionid=abc123' }); + return [ + { id: 1, site: 'akile', name: 'Akile', enabled: 1, meta: '{}', creds: soonCreds }, + { id: 2, site: 'akile', name: 'Akile长期', enabled: 1, meta: '{}', creds: longCreds }, + { id: 3, site: 'v2ex', name: 'V2EX', enabled: 1, meta: '{}', creds: plainCookie }, // 没有 renew → 只做到期预警;这份 Cookie 里没写到期时间,不该被碰 + ]; +} + +await t('renewCredentials:临期账号被续期并写 meta;长期/无 renew 的不动', async () => { + mockFetch([['/v1/user/refreshToken', refreshOk(NEW_TOKEN)]]); + const db = settingsAwareDb(await makeAccounts()); + const res = await renewCredentials(ENV, db, []); + assert.equal(res.ran, true); + assert.equal(res.checked, 3, '三个账号都会被扫(akile 走续期,v2ex 走到期预警)'); + assert.equal(res.renewed, 1); + const upd = db.updates.find((u) => /SET meta/.test(u.sql) && String(u.args[2]) === '1'); + assert.ok(upd, '账号 1 写了 meta'); + const m = JSON.parse(upd.args[0]); + assert.ok(m.renew_ok_at > 0); + // 凭据本身也被换了(saveToken 走的 UPDATE accounts SET creds=…) + const credUpd = db.updates.find((u) => /creds/.test(u.sql) && String(u.args[2]) === '1'); + assert.ok(credUpd, '账号 1 的 creds 被回写'); + assert.ok(!db.updates.some((u) => /SET meta/.test(u.sql) && String(u.args[u.args.length - 1]) === '3'), 'v2ex 的 meta 不该被碰(它的 Cookie 里没写到期时间)'); +}); + +await t('renewCredentials:一小时内不重复跑', async () => { + mockFetch([['any', refreshOk(NEW_TOKEN)]]); + globalThis.__calls = []; + const db = settingsAwareDb(await makeAccounts()); + db.settings['renew_last_at'] = String(Date.now()); + const res = await renewCredentials(ENV, db, []); + assert.equal(res.ran, false); + assert.equal(globalThis.__calls.length, 0); +}); + +await t('renewCredentials:网络失败记 fail 原因,不抛错', async () => { + globalThis.fetch = async () => { throw new Error('socket hang up'); }; + const db = settingsAwareDb(await makeAccounts()); + const res = await renewCredentials(ENV, db, []); + assert.equal(res.ran, true); + assert.equal(res.failed, 1); + const upd = db.updates.find((u) => /SET meta/.test(u.sql) && String(u.args[u.args.length - 1]) === '1'); + const m = JSON.parse(upd.args[0]); + assert.equal(m.renew_fail_reason, 'network'); + assert.match(m.renew_fail_detail, /socket hang up/); +}); + +console.log(`\n${n} passed`); + +// ---------- 纯 Cookie 站点的到期预警 ---------- +const wpCookie = (expDeltaSec) => + `wordpress_logged_in_abc=testuser|${Math.floor(Date.now() / 1000) + expDeltaSec}|sessiontoken`; + +async function cookieAccounts(cookieStr, meta = '{}') { + const db0 = settingsAwareDb([]); + const creds = await encryptJSON(ENV, db0, { cookie: cookieStr }); + return [{ id: 7, site: 'hutue', name: '糊涂鳄', enabled: 1, meta, creds }]; +} + +await t('到期预警:Cookie 剩 2 小时 → 提醒一次并记 exp_alert_day', async () => { + mockFetch([]); + const db = settingsAwareDb(await cookieAccounts(wpCookie(2 * 3600))); + const res = await renewCredentials(ENV, db, []); + assert.equal(res.ran, true); + assert.ok(res.details.some((d) => d.includes('Cookie 快过期')), '详情里要有预警:' + res.details.join('|')); + const upd = db.updates.find((u) => /SET meta/.test(u.sql)); + assert.ok(upd, '要写 exp_alert_day'); + assert.ok(JSON.parse(upd.args[0]).exp_alert_day, 'exp_alert_day 已记'); +}); + +await t('到期预警:同一天不重复提醒', async () => { + mockFetch([]); + const db = settingsAwareDb(await cookieAccounts(wpCookie(2 * 3600))); + const r1 = await renewCredentials(ENV, db, []); + assert.ok(r1.details.some((d) => d.includes('Cookie 快过期')), '第一轮要提醒'); + db.settings['renew_last_at'] = '0'; // 放行第二轮 + db.updates.length = 0; + const r2 = await renewCredentials(ENV, db, []); + assert.equal(r2.details.length, 0, '第二轮不该再提醒:' + r2.details.join('|')); + assert.ok(!db.updates.some((u) => /SET meta/.test(u.sql)), '第二轮不该再写 meta'); +}); + +await t('到期预警:Cookie 还有 10 天 → 不打扰', async () => { + mockFetch([]); + globalThis.__calls = []; + const db = settingsAwareDb(await cookieAccounts(wpCookie(10 * 86400))); + const res = await renewCredentials(ENV, db, []); + assert.equal(res.details.length, 0); + assert.ok(!db.updates.some((u) => /SET meta/.test(u.sql)), '不该写 meta'); +}); + +await t('到期预警:已经过期的 Cookie → 也提醒(文案是"已经过期")', async () => { + mockFetch([]); + const db = settingsAwareDb(await cookieAccounts(wpCookie(-3600))); + const res = await renewCredentials(ENV, db, []); + assert.ok(res.details.some((d) => d.includes('Cookie 快过期'))); +}); diff --git a/test/ui.test.mjs b/test/ui.test.mjs index efa5131..aa846ad 100644 --- a/test/ui.test.mjs +++ b/test/ui.test.mjs @@ -447,13 +447,15 @@ t('鼠标悬浮站点名 → 弹出凭据到期时间', () => { // 用户最容易卡在「我明明更新了 Cookie,怎么还说失效」——而很多站点把到期时间就写在凭据里 // (WordPress 会话写在 cookie 值里, Akile 的 akile-token 是 JWT)。 // 悬浮站点名直接告诉他「这份还能撑到什么时候」,比等签到失败再查快得多。 - assert.match(html, /\$\{esc\(a\.name\)\}<\/b>/, - '站点名要挂气泡'); - assert.match(html, /function credExpTip\(exp\)/, '要有 credExpTip'); + assert.match(html, /\$\{esc\(a\.name\)\}<\/b>/, + '站点名要挂气泡(带账号 meta,好展示自动续期状态)'); + assert.match(html, /function credExpTip\(exp, metaStr\)/, '要有 credExpTip'); assert.match(html, /const s = \(exp && exp\.sessions\) \|\| \[\];/, '要读服务端解出的 sessions'); assert.match(html, /const t = \(exp && exp\.tokens\) \|\| \[\];/, '也要读 tokens(如 akile-token)'); assert.match(html, /const lines = \[s\.length \? 'Cookie 有效期' : '凭据到期时间'\];/, '有 Cookie 会话就叫「Cookie 有效期」,只有 token 时才叫「凭据到期时间」'); assert.match(html, /这份 Cookie \/ 凭据里没有写到期时间/, '凭据里确实没写时要如实说(不能猜一个时间出来)'); + assert.match(html, /自动续期:上次成功/, '自动续期成功要在悬浮提示里写出来'); + assert.match(html, /自动续期失败/, '自动续期失败要在悬浮提示里写出来(带原因和重试说明)'); // 不依赖后端:这一格是 a.cred_exp(服务端只回结论、不回凭据); // 拖不动的时候连提示都没了,所以要有一条点线底线作暗示 assert.match(html, /\.site-head b\.site-name \{ cursor:help; border-bottom:1px dotted/, '点线底线是可悬浮的唯一暗示');