/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. * * 基于 techccy/phantom(MPL-2.0)修改。说明见 NOTICE.md。 */ /* widget 主体 */ import { CONFIG, TUNE, effLoupeEnabled, effLoupeSize, effLoupeZoom, effPadEnabled, effPreviewSeconds, isMobileViewport, } from "./config"; import { Loupe } from "./loupe"; import { PadCursor } from "./pad"; import { requestChallenge, submitVerify, type VerifyResult, } from "./api"; import { decrypt, deriveSessionKey, encrypt, generateClientKeyPair, importServerPublic, type SessionKey, } from "./crypto"; import { installAntidebug } from "./antidebug"; import { PhantomRenderer, type BezierParams } from "./renderer"; import { TrajectoryTracker } from "./tracker"; import { injectStyles, type Theme } from "./styles"; import { deriveBezierPath } from "./prng"; export type { VerifyResult } from "./api"; export interface PhantomOptions { apiBase: string; onSuccess?: (result: VerifyResult) => void; onFail?: (result: VerifyResult) => void; onError?: (error: Error) => void; onChallenge?: (info: { server: { targetHalf: number; duration: number; canvas: [number, number] }; effective: { targetHalf: number; duration: number }; }) => void; theme?: Theme; antidebug?: boolean; } export interface PhantomHandle { destroy(): void; reset(): void; } const VERSION = "0.1.0"; const previewMs = (): number => effPreviewSeconds() * 1000; const holdLabel = (padMode: boolean): string => padMode ? "按住这里控制光标" : "按住并跟随方块"; const LOGO_SVG = '"; const CHECK_SVG = '"; const CLOSE_SVG = '"; const ALERT_SVG = '"; function resolveContainer(el: string | HTMLElement): HTMLElement { const node = typeof el === "string" ? document.querySelector(el) : el; if (!(node instanceof HTMLElement)) { throw new Error(`Phantom.mount: 容器未找到 (${el})`); } return node; } type HintStage = "loading" | "ready" | "preview" | "collect" | "stopped" | "done"; type BarState = "idle" | "verifying" | "verified" | "error"; class WidgetSession { private renderer: PhantomRenderer | null = null; private tracker: TrajectoryTracker | null = null; private sessionKey: SessionKey | null = null; private challengeId = ""; private collecting = false; private finished = false; private previewing = false; private previewTimer = 0; private duration = 3; private retryTimer = 0; private device: "pc" | "mobile" = "pc"; constructor( private canvas: HTMLCanvasElement, private apiBase: string, private status: HTMLElement, private overlay: HTMLElement, private hint: HTMLElement, private activateBtn: HTMLButtonElement, private onResult: (r: VerifyResult) => void, private onError: (e: Error) => void, private onRetry: () => void, private onChallenge: (info: { server: { targetHalf: number; duration: number; canvas: [number, number] }; effective: { targetHalf: number; duration: number }; }) => void = () => {}, private loupe: Loupe | null = null, private cursor: PadCursor | null = null, ) {} private setHint(stage: HintStage, text: string): void { this.hint.setAttribute("data-stage", stage); this.hint.textContent = text; } async start(): Promise { const mobile = isMobileViewport(); this.device = mobile ? "mobile" : "pc"; this.canvas.width = mobile ? CONFIG.canvasWidthMobile : CONFIG.canvasWidthPC; this.canvas.height = mobile ? CONFIG.canvasHeightMobile : CONFIG.canvasHeightPC; this.status.textContent = "正在准备验证题…"; this.overlay.classList.add("phantom-hidden"); this.setHint("loading", ""); this.activateBtn.disabled = true; try { const { privateKey, publicJwk } = await generateClientKeyPair(); const challenge = await requestChallenge(this.apiBase, publicJwk, this.device); const serverPub = await importServerPublic(challenge.serverPublicJwk); this.sessionKey = await deriveSessionKey( privateKey, serverPub, challenge.salt, ); this.challengeId = challenge.challengeId; const paramsJson = await decrypt( this.sessionKey, challenge.encryptedParams.iv, challenge.encryptedParams.ciphertext, ); const raw = JSON.parse(new TextDecoder().decode(paramsJson)) as { canvas: { w: number; h: number }; duration: number; fps: number; targetHalf: number; pathSeed: string; }; const controlPoints = deriveBezierPath( raw.pathSeed, raw.canvas.w, raw.canvas.h, ); const params: BezierParams = { controlPoints, duration: TUNE.duration ?? raw.duration, fps: raw.fps, targetHalf: TUNE.targetHalf ?? raw.targetHalf, }; this.duration = params.duration; this.onChallenge({ server: { targetHalf: raw.targetHalf, duration: raw.duration, canvas: [raw.canvas.w, raw.canvas.h], }, effective: { targetHalf: params.targetHalf, duration: params.duration }, }); this.renderer = new PhantomRenderer(this.canvas, params); this.tracker = new TrajectoryTracker( this.canvas, this.cursor ? this.activateBtn : this.canvas, ); this.activateBtn.style.setProperty("--ph-charge-duration", `${this.duration}s`); this.status.textContent = ""; this.setHint("ready", this.cursor ? "按住下方控制区" : "按住下方按钮"); this.activateBtn.disabled = false; this.renderer.drawStaticNoise(); this.overlay.classList.remove("phantom-hidden"); this.bindInteraction(); } catch (e) { this.onError(e as Error); this.status.textContent = `初始化失败: ${(e as Error).message}`; } } private bindInteraction(): void { const onDown = (e: PointerEvent): void => { if (e.button !== 0) return; e.preventDefault(); if (this.collecting || this.previewing || this.finished) return; this.previewing = true; this.overlay.classList.add("phantom-hidden"); this.setHint( "preview", this.cursor ? "把光标环拖到闪烁的方块上等待" : "手指/鼠标拖动到闪烁的方块等待", ); this.loupe?.start(); this.cursor?.start(); this.cursor?.place(e.clientX, e.clientY); this.renderer?.startPreview(); this.previewTimer = window.setTimeout(beginCollect, previewMs()); }; const beginCollect = (): void => { if (!this.previewing || this.finished) return; this.previewing = false; this.collecting = true; this.status.textContent = ""; this.setHint("collect", "按住跟随方块移动"); this.renderer?.stopPreview(); this.renderer?.start((_center, t) => { if (t >= 1) this.setHint("stopped", "请松手"); }); this.tracker?.start(); this.activateBtn.classList.add("phantom-holding"); }; const onUp = (): void => { if (this.finished) return; if (this.previewing) { window.clearTimeout(this.previewTimer); this.previewing = false; this.loupe?.stop(); this.cursor?.stop(); this.renderer?.stopPreview(); this.renderer?.drawStaticNoise(); this.status.textContent = ""; this.setHint( "ready", this.cursor ? "按住控制区并把光标环拖到方块" : "按住下方按钮并马上拖动到方块", ); return; } if (!this.collecting) return; this.collecting = false; this.loupe?.stop(); this.cursor?.stop(); this.activateBtn.classList.remove("phantom-holding"); this.renderer?.pause(); this.setHint("done", ""); const samples = this.tracker?.stop() ?? []; void this.verifyAndFinish(samples); }; this.activateBtn.addEventListener("pointerdown", onDown); window.addEventListener("pointerup", onUp); const onSelectStart = (e: Event): void => e.preventDefault(); document.addEventListener("selectstart", onSelectStart, { capture: true }); document.addEventListener("dragstart", onSelectStart, { capture: true }); this._unbind = () => { this.activateBtn.removeEventListener("pointerdown", onDown); window.removeEventListener("pointerup", onUp); document.removeEventListener("selectstart", onSelectStart, { capture: true } as EventListenerOptions); document.removeEventListener("dragstart", onSelectStart, { capture: true } as EventListenerOptions); }; } private _unbind: () => void = () => {}; private async verifyAndFinish(samples: [number, number, number][]): Promise { if (!this.sessionKey) return; const payload = { points: samples, lastPointT_ms: Date.now(), }; const plaintext = new TextEncoder().encode(JSON.stringify(payload)); const { iv, ciphertext } = await encrypt(this.sessionKey, plaintext); try { const result = await submitVerify(this.apiBase, this.challengeId, iv, ciphertext); this.renderer?.stop(); this.finished = true; this.status.textContent = ""; if (result.passed) { this.activateBtn.classList.add("phantom-success"); this.activateBtn.textContent = "验证通过"; } else { this.activateBtn.classList.add("phantom-fail"); this.activateBtn.textContent = "验证失败"; this.scheduleRetry(); } this.onResult(result); } catch (e) { this.renderer?.stop(); this.finished = true; this.status.textContent = "提交失败"; this.activateBtn.classList.add("phantom-fail"); this.activateBtn.textContent = "验证失败"; this.scheduleRetry(); this.onError(e as Error); } } private scheduleRetry(): void { window.clearTimeout(this.retryTimer); this.retryTimer = window.setTimeout(() => { this.turnIntoRetryButton(); }, 1000); } private turnIntoRetryButton(): void { this._unbind(); this._unbind = () => {}; this.activateBtn.classList.remove( "phantom-holding", "phantom-success", "phantom-fail", ); this.activateBtn.classList.add("phantom-retry"); this.activateBtn.textContent = "点击刷新重试"; this.activateBtn.disabled = false; const onClick = (): void => { this.activateBtn.removeEventListener("click", onClick); this.onRetry(); }; this.activateBtn.addEventListener("click", onClick); this._unbind = () => { this.activateBtn.removeEventListener("click", onClick); this.activateBtn.classList.remove("phantom-retry"); }; } destroy(): void { this._unbind(); window.clearTimeout(this.retryTimer); window.clearTimeout(this.previewTimer); this.previewing = false; this.loupe?.stop(); this.cursor?.stop(); this.renderer?.stopPreview(); this.renderer?.stop(); this.tracker?.stop(); } } export function mount( el: string | HTMLElement, opts: PhantomOptions, ): PhantomHandle { injectStyles(); if (opts.antidebug ?? import.meta.env.PROD) { installAntidebug(true); } const container = resolveContainer(el); container.innerHTML = ""; const root = document.createElement("div"); root.className = "phantom-widget"; root.setAttribute("data-theme", opts.theme ?? "dark"); const bar = document.createElement("div"); bar.className = "phantom-bar"; bar.setAttribute("data-state", "idle"); bar.setAttribute("role", "checkbox"); bar.setAttribute("aria-checked", "false"); bar.tabIndex = 0; bar.title = "点击进行人机验证"; const check = document.createElement("span"); check.className = "phantom-check"; check.innerHTML = ""; const barText = document.createElement("span"); barText.className = "phantom-bar-text"; barText.textContent = "我不是机器人"; const brand = document.createElement("span"); brand.className = "phantom-bar-brand"; const barLogo = document.createElement("span"); barLogo.className = "phantom-bar-logo"; barLogo.innerHTML = LOGO_SVG; const copyright = document.createElement("span"); copyright.className = "phantom-bar-copyright"; copyright.innerHTML = 'techccy/phantom'; brand.appendChild(barLogo); brand.appendChild(copyright); bar.appendChild(check); bar.appendChild(barText); bar.appendChild(brand); root.appendChild(bar); container.appendChild(root); let modal: { node: HTMLDivElement; session: WidgetSession; closing: boolean; } | null = null; const setBarState = (state: BarState): void => { bar.setAttribute("data-state", state); bar.setAttribute("aria-checked", state === "verified" ? "true" : "false"); if (state === "verified") { check.innerHTML = CHECK_SVG; barText.textContent = "已验证"; } else if (state === "error") { check.innerHTML = ALERT_SVG; barText.textContent = "验证失败,点击重试"; } else if (state === "verifying") { check.innerHTML = ""; barText.textContent = "验证中…"; } else { check.innerHTML = ""; barText.textContent = "我不是机器人"; } }; const buildModalBody = (modalCard: HTMLDivElement): { hint: HTMLDivElement; canvas: HTMLCanvasElement; overlay: HTMLDivElement; activateBtn: HTMLButtonElement; status: HTMLDivElement; progress: HTMLSpanElement; loupe: Loupe | null; cursor: PadCursor | null; } => { const head = document.createElement("div"); head.className = "phantom-modal-head"; const headLogo = document.createElement("span"); headLogo.className = "phantom-modal-logo"; headLogo.innerHTML = LOGO_SVG; const title = document.createElement("span"); title.className = "phantom-modal-title"; title.textContent = "Phantom 人机验证"; const closeBtn = document.createElement("button"); closeBtn.className = "phantom-modal-close"; closeBtn.type = "button"; closeBtn.title = "关闭"; closeBtn.setAttribute("aria-label", "关闭验证"); closeBtn.innerHTML = CLOSE_SVG; closeBtn.addEventListener("click", () => closeModal(false)); head.appendChild(headLogo); head.appendChild(title); head.appendChild(closeBtn); const body = document.createElement("div"); body.className = "phantom-modal-body"; const hint = document.createElement("div"); hint.className = "phantom-hint"; hint.setAttribute("data-stage", "loading"); const stageWrap = document.createElement("div"); stageWrap.className = "phantom-stage-wrap"; const canvas = document.createElement("canvas"); canvas.className = "phantom-stage"; const overlay = document.createElement("div"); overlay.className = "phantom-overlay phantom-hidden"; const padMode = effPadEnabled(); const overlayText = document.createElement("div"); overlayText.className = "phantom-overlay-text"; overlayText.innerHTML = padMode ? "手指按住下方控制区
画布里会出现光标环
把光标环拖到闪烁方块上
方块出发后跟着走,停了就松手" : "按住下方按钮
马上拖动到闪烁方块处
方块出发后跟随移动
方块停止则松手"; overlay.appendChild(overlayText); stageWrap.appendChild(canvas); stageWrap.appendChild(overlay); const activateBtn = document.createElement("button"); activateBtn.className = padMode ? "phantom-activate phantom-pad" : "phantom-activate"; activateBtn.type = "button"; activateBtn.textContent = holdLabel(padMode); activateBtn.disabled = true; const progress = document.createElement("span"); progress.className = "phantom-progress"; activateBtn.appendChild(progress); const cursor = padMode ? new PadCursor(canvas, activateBtn) : null; if (cursor) stageWrap.appendChild(cursor.element); const status = document.createElement("div"); status.className = "phantom-status"; status.textContent = "正在准备验证题…"; const loupe = effLoupeEnabled() ? new Loupe(canvas, effLoupeZoom(), effLoupeSize()) : null; if (loupe) modalCard.classList.add("phantom-has-loupe"); if (padMode) modalCard.classList.add("phantom-has-pad"); body.appendChild(hint); if (loupe) body.appendChild(loupe.element); body.appendChild(stageWrap); body.appendChild(activateBtn); body.appendChild(status); modalCard.appendChild(head); modalCard.appendChild(body); return { hint, canvas, overlay, activateBtn, status, progress, loupe, cursor }; }; const dispatch = (r: VerifyResult): void => { if (r.passed) opts.onSuccess?.(r); else opts.onFail?.(r); }; const openModal = (): void => { if (modal) return; const node = document.createElement("div"); node.className = "phantom-modal"; node.setAttribute("data-theme", opts.theme ?? "dark"); node.setAttribute("role", "dialog"); node.setAttribute("aria-modal", "true"); node.setAttribute("aria-label", "Phantom 人机验证"); const modalCard = document.createElement("div"); modalCard.className = "phantom-modal-card"; node.appendChild(modalCard); const { hint, canvas, overlay, activateBtn, status, progress, loupe, cursor } = buildModalBody(modalCard); node.addEventListener("click", (e) => { if (e.target === node) closeModal(false); }); modalCard.addEventListener("click", (e) => e.stopPropagation()); document.body.appendChild(node); const resetSession = (): void => { activateBtn.classList.remove( "phantom-holding", "phantom-success", "phantom-fail", "phantom-retry", ); activateBtn.textContent = holdLabel(cursor !== null); activateBtn.appendChild(progress); activateBtn.disabled = true; status.textContent = "正在准备验证题…"; session = new WidgetSession( canvas, opts.apiBase, status, overlay, hint, activateBtn, (r) => { dispatch(r); if (r.passed) onVerified(); }, (e) => opts.onError?.(e), resetSession, (info) => opts.onChallenge?.(info), loupe, cursor, ); void session.start(); }; let session = new WidgetSession( canvas, opts.apiBase, status, overlay, hint, activateBtn, (r) => { dispatch(r); if (r.passed) onVerified(); }, (e) => opts.onError?.(e), resetSession, (info) => opts.onChallenge?.(info), loupe, cursor, ); modal = { node, session, closing: false }; setBarState("verifying"); void session.start(); }; const closeModal = (verified: boolean): void => { if (!modal || modal.closing) return; modal.closing = true; modal.session.destroy(); const node = modal.node; const finalize = (): void => { node.remove(); }; node.classList.add("phantom-leaving"); window.setTimeout(finalize, 160); modal = null; setBarState(verified ? "verified" : "idle"); }; const onVerified = (): void => { window.setTimeout(() => closeModal(true), 700); }; const onBarClick = (): void => { if (bar.getAttribute("data-state") === "verified") return; if (bar.getAttribute("data-state") === "verifying") return; openModal(); }; bar.addEventListener("click", onBarClick); bar.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onBarClick(); } }); return { destroy(): void { if (modal) { modal.session.destroy(); modal.node.remove(); modal = null; } bar.removeEventListener("click", onBarClick); root.remove(); }, reset(): void { closeModal(false); setBarState("idle"); }, }; } export const Phantom = { mount, version: VERSION }; if (typeof window !== "undefined") { const w = window as unknown as { Phantom?: typeof Phantom }; if (!w.Phantom) w.Phantom = Phantom; } export default Phantom;