diff --git a/packages/adapters/src/sites/definitions/schema.ts b/packages/adapters/src/sites/definitions/schema.ts index 7bdb052d..06740ca3 100644 --- a/packages/adapters/src/sites/definitions/schema.ts +++ b/packages/adapters/src/sites/definitions/schema.ts @@ -103,6 +103,8 @@ const UmtNexusSchema = z.strictObject({ }) .optional(), profile: z.record(z.string(), z.string()).optional(), + /** VIP 等级名(用户数据 vip 判定;profile.vip_css 之外的补充) */ + vipLevels: z.array(z.string()).optional(), detail: z .strictObject({ path: z.string(), diff --git a/packages/adapters/src/sites/index.ts b/packages/adapters/src/sites/index.ts index 57cccdc8..ff32bdcd 100644 --- a/packages/adapters/src/sites/index.ts +++ b/packages/adapters/src/sites/index.ts @@ -16,6 +16,17 @@ export { SpiderFilterSchema, } from "./definitions/index.ts"; export type { SiteAdapterDeps } from "./deps.ts"; +export { + classifyLoginError, + loginFailureText, + loginNexusPhp, + type NexusLoginForm, + type NexusLoginInput, + type NexusLoginResult, + SiteLoginError, + type SiteLoginFailure, + totpCode, +} from "./login/nexusphp.ts"; export { createMteamAdapter, mteamTorrentToRelease } from "./mteam/adapter.ts"; export { type MteamApiPath, mteamPost } from "./mteam/client.ts"; export { type MteamDefinition, mteamApiBase, mteamDefinition } from "./mteam/definition.ts"; diff --git a/packages/adapters/src/sites/login/nexusphp.ts b/packages/adapters/src/sites/login/nexusphp.ts new file mode 100644 index 00000000..99143939 --- /dev/null +++ b/packages/adapters/src/sites/login/nexusphp.ts @@ -0,0 +1,250 @@ +import { createHmac } from "node:crypto"; +import { load } from "cheerio"; +import { redactText } from "../../rss/redact.ts"; +import { underChallenge } from "../user-parsers/common.ts"; + +/** + * NexusPHP 账密登录(takelogin),流程对齐 MovieClaw `CredentialAuthProvider`(docs/design/site-center.md §4.4): + * 1. 清空 Cookie GET 登录页,记下 Set-Cookie; + * 2. 页面含 `imagecode.php` → 验证码,本期不做 OCR,直接失败(captcha); + * 3. 取登录表单隐藏字段,填用户名 / 密码、selectDefaults、extraFormData;有 TOTP 密钥且表单有二次验证字段时填当前 6 位码; + * 4. POST(不跟随 3xx),再 GET index.php;`loginSuccess` 命中 → 成功,拼 Cookie; + * 5. 失败按 errorCss 文本归类。 + * 凭据只在内存中使用;错误消息不含密码 / Cookie。**未经真实站点核验**(表单字段、二次验证字段名、错误文本)。 + */ + +export type SiteLoginFailure = + | "captcha" + | "two_factor" + | "bad_credentials" + | "account_blocked" + | "challenge" + | "auth_expired" + | "network" + | "unsupported" + | "unknown"; + +export class SiteLoginError extends Error { + readonly reason: SiteLoginFailure; + constructor(reason: SiteLoginFailure, message: string) { + super(message); + this.name = "SiteLoginError"; + this.reason = reason; + } +} + +export interface NexusLoginForm { + path: string; + usernameField: string; + passwordField: string; + errorCss: string; + extraFormData?: Array<[string, string]>; + selectDefaults?: Array<[string, string]>; +} + +export interface NexusLoginInput { + /** 站点前端地址(以 / 结尾或不结尾均可) */ + baseUrl: string; + login: NexusLoginForm; + /** 登录成功判定选择器(定义 umt.loginSuccess;缺省 a[href*="logout"]) */ + loginSuccess?: string; + username: string; + password: string; + /** base32 TOTP 密钥 */ + totpSecret?: string | null; + userAgent: string; + fetch?: typeof globalThis.fetch; + limiter?: { acquire(signal?: AbortSignal): Promise }; + signal?: AbortSignal; + timeoutMs?: number; + now?: () => Date; +} + +export interface NexusLoginResult { + cookie: string; + userAgent: string; +} + +const TWO_FACTOR_FIELDS = ["two_step_code", "otp", "2fa_code", "twostep", "totp", "securitycode"]; + +/** RFC 6238 TOTP(SHA1、30 s、6 位) */ +export function totpCode(secretBase32: string, at: Date = new Date()): string { + const clean = secretBase32.replace(/[\s=-]/g, "").toUpperCase(); + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + let bits = ""; + for (const ch of clean) { + const v = alphabet.indexOf(ch); + if (v < 0) throw new SiteLoginError("unknown", "TOTP 密钥不是合法的 base32"); + bits += v.toString(2).padStart(5, "0"); + } + const bytes = new Uint8Array(Math.floor(bits.length / 8)); + for (let i = 0; i < bytes.length; i++) bytes[i] = Number.parseInt(bits.slice(i * 8, i * 8 + 8), 2); + const counter = Math.floor(at.getTime() / 1000 / 30); + const msg = Buffer.alloc(8); + msg.writeBigUInt64BE(BigInt(counter)); + const h = createHmac("sha1", Buffer.from(bytes)).update(msg).digest(); + const off = h[h.length - 1]! & 0x0f; + const code = ((h[off]! & 0x7f) << 24) | (h[off + 1]! << 16) | (h[off + 2]! << 8) | h[off + 3]!; + return String(code % 1_000_000).padStart(6, "0"); +} + +/** 失败文本归类(errorCss 取到的站点原文) */ +export function classifyLoginError(text: string): SiteLoginFailure { + const t = text.toLowerCase(); + if (/两步|二步|二次验证|两次验证|动态码|two.?step|two.?factor|2fa|otp|authenticator/.test(t)) return "two_factor"; + if (/验证码|imagecode|captcha/.test(t)) return "captcha"; + if (/封禁|禁用|被禁|未确认|未激活|待审核|banned|disabled|not confirmed|pending|parked/.test(t)) + return "account_blocked"; + if (/密码|用户名|账号|帳號|password|username|incorrect|invalid|错误|錯誤/.test(t)) return "bad_credentials"; + return "unknown"; +} + +const REASON_TEXT: Record = { + captcha: "站点要求验证码", + two_factor: "站点要求二次验证", + bad_credentials: "用户名或密码错误", + account_blocked: "账号被封禁或未确认", + challenge: "站点返回 Cloudflare 挑战页", + auth_expired: "登录后仍未检测到登录态", + network: "无法连接站点", + unsupported: "站点不支持账密登录", + unknown: "登录失败", +}; + +function pairs(v: readonly unknown[] | undefined): Array<[string, string]> { + const out: Array<[string, string]> = []; + for (const p of v ?? []) { + if (Array.isArray(p) && p.length >= 2) out.push([String(p[0]), String(p[1])]); + else if (p && typeof p === "object" && "name" in p) { + const o = p as { name: unknown; value?: unknown }; + out.push([String(o.name), String(o.value ?? "")]); + } + } + return out; +} + +class Jar { + private readonly map = new Map(); + take(res: Response) { + const list = + typeof (res.headers as Headers & { getSetCookie?: () => string[] }).getSetCookie === "function" + ? (res.headers as Headers & { getSetCookie: () => string[] }).getSetCookie() + : (res.headers.get("set-cookie") ?? "").split(/,(?=\s*[^;,=\s]+=)/).filter(Boolean); + for (const line of list) { + const first = line.split(";")[0] ?? ""; + const eq = first.indexOf("="); + if (eq <= 0) continue; + const name = first.slice(0, eq).trim(); + const value = first.slice(eq + 1).trim(); + if (!value || value === "deleted" || /max-age=0\b/i.test(line)) this.map.delete(name); + else this.map.set(name, value); + } + } + get size() { + return this.map.size; + } + header(): string { + return [...this.map].map(([k, v]) => `${k}=${v}`).join("; "); + } +} + +/** 账密登录;失败抛 SiteLoginError */ +export async function loginNexusPhp(input: NexusLoginInput): Promise { + const fetchImpl = input.fetch ?? globalThis.fetch; + const base = input.baseUrl.endsWith("/") ? input.baseUrl : `${input.baseUrl}/`; + const jar = new Jar(); + const timeoutMs = input.timeoutMs ?? 20_000; + + const request = async (url: string, init: RequestInit = {}): Promise<{ res: Response; text: string }> => { + await input.limiter?.acquire(input.signal); + const timeout = AbortSignal.timeout(timeoutMs); + const signal = input.signal ? AbortSignal.any([input.signal, timeout]) : timeout; + const headers = new Headers(init.headers); + headers.set("user-agent", input.userAgent); + if (jar.size) headers.set("cookie", jar.header()); + let res: Response; + try { + res = await fetchImpl(url, { ...init, headers, redirect: "manual", signal }); + } catch (e) { + if (input.signal?.aborted) throw e; + throw new SiteLoginError( + "network", + redactText(`请求 ${url} 失败:${timeout.aborted ? "超时" : (e as Error).message}`), + ); + } + jar.take(res); + const text = await res.text().catch(() => ""); + if (res.status >= 500 && res.status !== 503) throw new SiteLoginError("network", `站点返回 HTTP ${res.status}`); + if ((res.status === 403 || res.status === 503) && underChallenge(text)) { + throw new SiteLoginError("challenge", REASON_TEXT.challenge); + } + if (res.status === 503) throw new SiteLoginError("network", "站点返回 HTTP 503"); + return { res, text }; + }; + + // 1. 登录页 + const loginUrl = new URL(input.login.path, base).toString(); + const page = await request(loginUrl); + if (underChallenge(page.text)) throw new SiteLoginError("challenge", REASON_TEXT.challenge); + // 2. 验证码 + if (/imagecode\.php/i.test(page.text)) throw new SiteLoginError("captcha", REASON_TEXT.captcha); + const $ = load(page.text); + const byAction = $("form[action*='takelogin']").first(); + const form = byAction.length ? byAction : $(`input[name='${input.login.passwordField}']`).closest("form").first(); + const action = new URL(form.attr("action") || "takelogin.php", loginUrl).toString(); + // 3. 表单 + const body = new URLSearchParams(); + const fieldNames = new Set(); + const inputs = form.length ? form.find("input") : $("input"); + inputs.each((_, el) => { + const name = $(el).attr("name"); + if (!name) return; + fieldNames.add(name.toLowerCase()); + const type = ($(el).attr("type") ?? "text").toLowerCase(); + if (type === "hidden") body.set(name, $(el).attr("value") ?? ""); + if (type === "checkbox" && $(el).is("[checked]")) body.set(name, $(el).attr("value") ?? "on"); + }); + for (const [k, v] of pairs(input.login.selectDefaults)) body.set(k, v); + for (const [k, v] of pairs(input.login.extraFormData)) body.set(k, v); + body.set(input.login.usernameField, input.username); + body.set(input.login.passwordField, input.password); + const tfField = [...fieldNames].find((n) => TWO_FACTOR_FIELDS.includes(n)); + if (tfField && input.totpSecret) body.set(tfField, totpCode(input.totpSecret, input.now?.() ?? new Date())); + + // 4. 提交 → 首页判定 + const post = await request(action, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded", referer: loginUrl }, + body: body.toString(), + }); + const location = post.res.headers.get("location") ?? ""; + const home = await request(new URL("index.php", base).toString()); + const successSel = input.loginSuccess || "a[href*='logout']"; + const home$ = load(home.text); + if (home.res.status < 300 && home$(successSel).length > 0 && jar.size) { + return { cookie: jar.header(), userAgent: input.userAgent }; + } + // 5. 失败归类 + if (underChallenge(post.text) || underChallenge(home.text)) + throw new SiteLoginError("challenge", REASON_TEXT.challenge); + const post$ = load(post.text); + const errText = post$(input.login.errorCss).text().replace(/\s+/g, " ").trim(); + const pageText = post$("body").text().replace(/\s+/g, " ").trim(); + if (/imagecode\.php/i.test(post.text)) throw new SiteLoginError("captcha", REASON_TEXT.captcha); + const nextHasTwoFactor = + TWO_FACTOR_FIELDS.some((f) => new RegExp(`name=["']?${f}["'\\s>]`, "i").test(post.text)) || + /two_step|2fa|otp/i.test(location); + if (nextHasTwoFactor && !input.totpSecret) throw new SiteLoginError("two_factor", REASON_TEXT.two_factor); + const reason = classifyLoginError(errText || pageText.slice(0, 400)); + if (reason !== "unknown") + throw new SiteLoginError(reason, `${REASON_TEXT[reason]}${errText ? `:${clip(errText)}` : ""}`); + throw new SiteLoginError("unknown", errText ? `登录失败:${clip(errText)}` : REASON_TEXT.auth_expired); +} + +function clip(s: string, n = 200): string { + return s.length > n ? `${s.slice(0, n)}…` : s; +} + +export function loginFailureText(reason: SiteLoginFailure): string { + return REASON_TEXT[reason]; +} diff --git a/packages/adapters/src/sites/spider/schema.ts b/packages/adapters/src/sites/spider/schema.ts index 4083081a..feb39ee3 100644 --- a/packages/adapters/src/sites/spider/schema.ts +++ b/packages/adapters/src/sites/spider/schema.ts @@ -145,6 +145,8 @@ export interface UmtSiteExtension { * 主路径仍是 MP 用户信息解析器(`schema`),此处为备用数据,M2 使用。 */ profile?: Record; + /** VIP 等级名(用户数据 vip 判定) */ + vipLevels?: string[]; displayName?: string; /** 站点时间时区偏移,如 "+08:00";pubdate/freedate 按此转 UTC */ timezone: string; diff --git a/packages/adapters/src/sites/user-parsers/base.ts b/packages/adapters/src/sites/user-parsers/base.ts index 4b36518e..9637bc9b 100644 --- a/packages/adapters/src/sites/user-parsers/base.ts +++ b/packages/adapters/src/sites/user-parsers/base.ts @@ -21,6 +21,7 @@ * 3. 网络异常与非 200/500/403 状态:与 MP 一样返回空串继续,另把脱敏后的原因记在 `httpErrors`(入 raw)。 * 4. 响应解码:按 Content-Type / `` 选 TextDecoder(MP `get_decoded_html_content` 用 chardet 探测)。 */ +import { load as loadCheerio } from "cheerio"; import { redactText } from "../../rss/redact.ts"; import { parseSizeMp } from "../spider/spider.ts"; import { extractDomain, isLoggedIn, underChallenge } from "./common.ts"; @@ -259,7 +260,8 @@ export abstract class SiteParserBase { ), ); let guard = 0; - while (nextPage !== null && nextPage !== undefined && nextPage !== false && guard++ < MAX_PAGES) { + const maxExtra = Math.max(0, (this._opts.maxSeedingPages ?? MAX_PAGES + 1) - 1); + while (nextPage !== null && nextPage !== undefined && nextPage !== false && guard++ < maxExtra) { nextPage = await this._parseUserTorrentSeedingInfo( await this._getPageContent( urljoin(urljoin(this._baseUrl, this._torrentSeedingPage), nextPage), @@ -407,6 +409,23 @@ export abstract class SiteParserBase { } /** MP `refresh_userdata` 里构造 `SiteUserData` 的部分 */ + /** VIP 判定(本项目扩展,见 SiteUserInfo.vip) */ + protected detectVip(): boolean | null { + const css = this._opts.vipCss?.replace(/::.*$/, "").trim(); + const levels = this._opts.vipLevels?.filter(Boolean) ?? []; + if (!css && !levels.length) return null; + if (css && typeof this._indexHtml === "string" && this._indexHtml) { + try { + if (loadCheerio(this._indexHtml)(css).length > 0) return true; + } catch { + // 选择器不合法按未命中处理 + } + } + const level = (this.userLevel ?? "").toLowerCase(); + if (level && levels.some((l) => level.includes(l.toLowerCase()))) return true; + return false; + } + toUserInfo(): SiteUserInfo { const num = (v: unknown) => { const n = typeof v === "number" ? v : Number(v); @@ -435,6 +454,7 @@ export abstract class SiteParserBase { seedingInfo: this.seedingInfo.map(([a, b]) => [num(a), num(b)] as [number, number]), messageUnread: num(this.messageUnread), messageUnreadContents: messages, + vip: this.detectVip(), errMsg: this.errMsg, raw: this.toDict(), }; diff --git a/packages/adapters/src/sites/user-parsers/mtorrent.ts b/packages/adapters/src/sites/user-parsers/mtorrent.ts index 80c90e6e..97d0a0e8 100644 --- a/packages/adapters/src/sites/user-parsers/mtorrent.ts +++ b/packages/adapters/src/sites/user-parsers/mtorrent.ts @@ -48,7 +48,8 @@ export class MTorrentSiteUserInfo extends SiteParserBase { protected _parseSitePage(_htmlText: string): void { // 更换 api 地址 - this._baseUrl = `https://api.${extractDomain(this._baseUrl)}`; + // 本项目扩展:站点配置了独立 API 地址(site.config.apiBaseUrl)时用它 + this._baseUrl = this._apiUrl ? this._apiUrl.replace(/\/+$/, "") : `https://api.${extractDomain(this._baseUrl)}`; this._userTrafficPage = null; this._userDetailPage = null; this._userBasicPage = "api/member/profile"; diff --git a/packages/adapters/src/sites/user-parsers/types.ts b/packages/adapters/src/sites/user-parsers/types.ts index b0958d4f..2a2fc1f9 100644 --- a/packages/adapters/src/sites/user-parsers/types.ts +++ b/packages/adapters/src/sites/user-parsers/types.ts @@ -69,6 +69,11 @@ export interface SiteUserInfo { messageUnreadContents: SiteUnreadMessage[]; /** H&R 未达标数:MP 解析器不提供,保留字段供站点扩展 */ hrCount?: number; + /** + * VIP(本项目扩展):定义 `umt.profile.vip_css` 在首页命中,或 `umt.vipLevels` 与等级匹配 → true; + * 两者都没配置为 null(不显示) + */ + vip: boolean | null; /** 解析错误说明(MP `err_msg`,如"未检测到已登陆") */ errMsg: string | null; raw: Record; @@ -93,4 +98,10 @@ export interface SiteUserParserOptions { /** 共享站点限流器(与 list/search 共用) */ limiter?: { acquire(signal?: AbortSignal): Promise }; signal?: AbortSignal; + /** 做种分页上限(含第一页);缺省 50 */ + maxSeedingPages?: number; + /** VIP 判定:首页选择器(定义 umt.profile.vip_css,可带 ::attr / ::text 后缀,判定时去掉) */ + vipCss?: string | null; + /** VIP 判定:等级名列表(定义 umt.vipLevels),不区分大小写包含匹配 */ + vipLevels?: readonly string[] | null; } diff --git a/packages/testkit/src/sites/index.ts b/packages/testkit/src/sites/index.ts index 10a9724d..b2d44270 100644 --- a/packages/testkit/src/sites/index.ts +++ b/packages/testkit/src/sites/index.ts @@ -14,7 +14,12 @@ export { type MockMteamFailMode, type MockMteamOptions, } from "./mock-mteam.ts"; -export { createMockNexusPhpSite, type MockNexusPhpOptions, type MockNexusPhpSite } from "./mock-nexusphp.ts"; +export { + createMockNexusPhpSite, + type MockLoginGuard, + type MockNexusPhpOptions, + type MockNexusPhpSite, +} from "./mock-nexusphp.ts"; export { type MockMteamTorrent, MTEAM_FIXTURE_TORRENTS } from "./mteam-fixtures.ts"; export { AUDIENCES_FIXTURE_RELEASES, type MockNexusPromo, type MockNexusRelease } from "./nexusphp-fixtures.ts"; export { buildFakeTorrent, type FakeTorrent } from "./torrent.ts"; diff --git a/packages/testkit/src/sites/mock-mteam.ts b/packages/testkit/src/sites/mock-mteam.ts index 4d054813..a929a9b2 100644 --- a/packages/testkit/src/sites/mock-mteam.ts +++ b/packages/testkit/src/sites/mock-mteam.ts @@ -125,6 +125,10 @@ export async function createMockMteamApi(opts: MockMteamOptions = {}): Promise/g, ">").replace(/"/g, """); } +/** 观众新版 userbar(NexusAudiences 用户数据解析器读 data-uploader-*;数值与 AUDIENCES_USER_INDEX 一致) */ +const AUDIENCES_USERBAR = `
+mockuser +分享率20.48 +信 +
`; + function shell(siteName: string, title: string, body: string, loggedIn: boolean): string { const userbar = loggedIn ? `
mockuser 12.5 TB 3.2 TB - 退出
` + 退出${AUDIENCES_USERBAR}` : ""; return `${esc(siteName)} :: ${esc(title)} ${userbar}
${body}
`; } -function loginPage(siteName: string): string { +function loginPage(siteName: string, guard: MockLoginGuard = "none"): string { + const captcha = + guard === "captcha" + ? `` + : ""; + const twoStep = guard === "two_factor" ? `` : ""; return `${esc(siteName)} :: 登录

你需要启用cookies才能登录

-
`; + +${captcha}${twoStep}`; } +function loginFailedPage(siteName: string, text: string): string { + return `${esc(siteName)} :: 登录失败 +
${esc(text)}
`; +} + +const USER_DETAILS = ` + +
加入日期2020-05-06 07:08:09 (5年前)
`; +const USER_SEEDING = ` + +
类型数量体积
电影202.00 TB
Total313.50 TB
`; + function promoImg(r: MockNexusRelease): string { if (!r.promo) return ""; const title = r.promoUntil ?? ""; @@ -125,6 +161,10 @@ export async function createMockNexusPhpSite(opts: MockNexusPhpOptions = {}): Pr const pageBase = opts.pageBase ?? 0; const siteName = opts.siteName ?? "观众"; let loggedIn = true; + let guard: MockLoginGuard = opts.loginGuard ?? "none"; + let loginAttempts = 0; + const username = opts.username ?? "mockuser"; + const password = opts.password ?? "mock-password"; let broken = false; const failures: Array<{ status: number; retryAfter?: string }> = []; const torrents = new Map(releases.map((r) => [r.id, buildFakeTorrent({ name: r.title, length: 4 * 1024 * 1024 })])); @@ -133,14 +173,34 @@ export async function createMockNexusPhpSite(opts: MockNexusPhpOptions = {}): Pr res.writeHead(status, { "content-type": "text/html; charset=utf-8" }).end(html); }; - const server = await startHttpServer((req, res) => { + const server = await startHttpServer((req, res, raw) => { const url = new URL(req.url ?? "/", "http://mock"); const failure = failures.shift(); if (failure) { res.writeHead(failure.status, failure.retryAfter ? { "retry-after": failure.retryAfter } : {}).end("busy"); return; } - if (url.pathname === "/login.php") return send(res, 200, loginPage(siteName)); + if (url.pathname === "/login.php") return send(res, 200, loginPage(siteName, guard)); + if (url.pathname === "/takelogin.php" && req.method === "POST") { + loginAttempts++; + const form = new URLSearchParams(raw); + if (guard === "captcha") return send(res, 200, loginFailedPage(siteName, "验证码错误!")); + if (form.get("username") !== username || form.get("password") !== password) { + return send(res, 200, loginFailedPage(siteName, "用户名或密码不正确!或者你还没有通过验证?")); + } + if (guard === "blocked") return send(res, 200, loginFailedPage(siteName, "该账号已被禁用")); + if (guard === "two_factor" && !/^\d{6}$/.test(form.get("two_step_code") ?? "")) { + return send(res, 200, loginFailedPage(siteName, "两步验证码错误")); + } + loggedIn = true; + res + .writeHead(302, { + location: "index.php", + "set-cookie": cookie.split(";").map((p) => `${p.trim()}; path=/; httponly`), + }) + .end(); + return; + } if (!loggedIn || !hasCookie(req, cookie)) { if (opts.loginMode === "redirect") { res.writeHead(302, { location: `login.php?returnto=${encodeURIComponent(url.pathname.slice(1))}` }).end(); @@ -153,6 +213,10 @@ export async function createMockNexusPhpSite(opts: MockNexusPhpOptions = {}): Pr case "/index.php": case "/": return send(res, 200, shell(siteName, "首页", "

最新公告

", true)); + case "/userdetails.php": + return send(res, 200, USER_DETAILS); + case "/getusertorrentlistajax.php": + return send(res, 200, USER_SEEDING); case "/torrents.php": { if (broken) return send(res, 200, shell(siteName, "种子", `
改版了
`, true)); const kw = (q.get("search") ?? "").toLowerCase().trim(); @@ -211,6 +275,12 @@ export async function createMockNexusPhpSite(opts: MockNexusPhpOptions = {}): Pr setBrokenLayout(v) { broken = v; }, + setLoginGuard(v) { + guard = v; + }, + get loginAttempts() { + return loginAttempts; + }, infoHashOf(id) { const t = torrents.get(id); if (!t) throw new Error(`no torrent ${id}`);