- scan.acfun.cn QR flow (start -> long-poll scanResult -> acceptResult) with rotated-signature handling, background poller, durable session in the Host credential service, and cookie handoff to the provider - Client half: Settings -> AcFun page (status, QR code, poll, logout) over a same-origin /acfun-panel JSON API with cross-origin rejection - acfun_moment / acfun_moment_comments / acfun_topic read tools, moment_like / moment_unlike / moment_banana actions, and comment posting on moments - acfun_account status tool; docs for the QR contract, the 广场/动态 research verdict, and the client-composition restart requirement
255 lines
8.6 KiB
JavaScript
255 lines
8.6 KiB
JavaScript
/**
|
|
* QR-login state machine.
|
|
*
|
|
* The manager owns one pending login ticket, asks the injected `flow` for a QR
|
|
* code and for status updates, persists a confirmed cookie through the injected
|
|
* `store`, and points the provider at it. The AcFun endpoint contract lives in
|
|
* the flow (`lib/qr-flow.js`), so tests and the panel can exercise the state
|
|
* machine without touching the network.
|
|
*/
|
|
|
|
import { AcfunError } from './provider.js'
|
|
|
|
/** Credential record key used to persist a QR-obtained session. */
|
|
export const SESSION_RECORD_KEY = 'acfun-session'
|
|
|
|
/** How long a confirmed session is trusted before it is re-read from the store. */
|
|
const IDLE = 'idle'
|
|
|
|
/**
|
|
* Durable session store over the Host credential service.
|
|
*
|
|
* A `grant` record is the generic opaque-record kind, so the cookie round-trips
|
|
* without a schema while staying out of the plugin config. With no credential
|
|
* service the store degrades to memory-only for the lifetime of the plugin.
|
|
*
|
|
* @param {{readRecord: Function, modifyRecord: Function, deleteRecord: Function} | undefined} credentials
|
|
*/
|
|
export function createCredentialSessionStore(credentials) {
|
|
let memory
|
|
return {
|
|
/** @returns {Promise<{cookie: string, savedAt: number} | undefined>} */
|
|
async read() {
|
|
if (credentials === undefined) return memory
|
|
try {
|
|
const record = await credentials.readRecord(SESSION_RECORD_KEY)
|
|
if (record === undefined || record === null) return undefined
|
|
if (record.kind !== 'grant') return undefined
|
|
const payload = record.payload
|
|
if (payload === null || typeof payload !== 'object') return undefined
|
|
if (typeof payload.cookie !== 'string' || payload.cookie.length === 0) return undefined
|
|
return { cookie: payload.cookie, savedAt: Number(payload.savedAt) || 0 }
|
|
} catch {
|
|
return undefined
|
|
}
|
|
},
|
|
/** @param {string} cookie */
|
|
async write(cookie) {
|
|
memory = { cookie, savedAt: Date.now() }
|
|
if (credentials === undefined) return
|
|
await credentials.modifyRecord(SESSION_RECORD_KEY, async () => ({
|
|
kind: 'grant',
|
|
payload: { cookie, savedAt: Date.now(), plugin: 'dsh-plugin-acfun' },
|
|
}))
|
|
},
|
|
async clear() {
|
|
memory = undefined
|
|
if (credentials === undefined) return
|
|
await credentials.deleteRecord(SESSION_RECORD_KEY)
|
|
},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* One QR-login manager bound to a provider and a session store.
|
|
*/
|
|
export class AcfunLoginManager {
|
|
/**
|
|
* @param {object} options
|
|
* @param {import('./provider.js').AcfunProvider} options.provider
|
|
* @param {{read: Function, write: Function, clear: Function}} options.store
|
|
* @param {{start: Function, poll: Function, cancel?: Function} | null} [options.flow]
|
|
* @param {() => number} [options.now]
|
|
*/
|
|
constructor(options) {
|
|
this.provider = options.provider
|
|
this.store = options.store
|
|
this.flow = options.flow ?? null
|
|
this.now = options.now ?? (() => Date.now())
|
|
this.ticket = ''
|
|
this.qr = null
|
|
this.expiresAt = 0
|
|
this.state = IDLE
|
|
this.message = ''
|
|
this.authSource = 'none'
|
|
this.aborter = null
|
|
this.loop = null
|
|
}
|
|
|
|
/** Whether a QR flow is wired in this Host. */
|
|
get available() {
|
|
return this.flow !== null
|
|
}
|
|
|
|
/** Immutable view for the panel and tools. */
|
|
snapshot() {
|
|
return {
|
|
available: this.available,
|
|
state: this.state,
|
|
ticket: this.ticket,
|
|
qr: this.qr,
|
|
expiresAt: this.expiresAt,
|
|
message: this.message,
|
|
authSource: this.authSource,
|
|
}
|
|
}
|
|
|
|
/** Reset the pending ticket without touching the stored session. */
|
|
#reset(state = IDLE, message = '') {
|
|
this.ticket = ''
|
|
this.qr = null
|
|
this.expiresAt = 0
|
|
this.state = state
|
|
this.message = message
|
|
}
|
|
|
|
/**
|
|
* Load a persisted QR session at activation.
|
|
*
|
|
* @returns {Promise<{loaded: boolean, cookie?: string}>}
|
|
*/
|
|
async restore() {
|
|
const saved = await this.store.read()
|
|
if (saved === undefined) return { loaded: false }
|
|
// A cookie from the plugin config wins until the user explicitly logs in
|
|
// again; a stored QR session fills an otherwise anonymous provider.
|
|
if (!this.provider.loggedIn) {
|
|
this.provider.setCookie(saved.cookie)
|
|
this.authSource = 'qr'
|
|
}
|
|
return { loaded: true, cookie: saved.cookie }
|
|
}
|
|
|
|
/** Start a fresh QR login, replacing any pending ticket. */
|
|
async start() {
|
|
if (this.flow === null) {
|
|
throw new AcfunError(
|
|
'AcFun QR login is unavailable: this Host did not detect the login endpoint contract.',
|
|
{ code: 'QR_LOGIN_UNAVAILABLE' },
|
|
)
|
|
}
|
|
await this.cancel()
|
|
const started = await this.flow.start()
|
|
this.ticket = String(started?.ticket ?? '')
|
|
this.qr = started?.qr ?? null
|
|
this.expiresAt = Number(started?.expiresAt) || 0
|
|
this.state = 'waiting'
|
|
this.message = ''
|
|
// The QR service long-polls, so the exchange runs in the background and the
|
|
// panel reads this in-memory state instead of blocking an HTTP response.
|
|
this.#launchLoop()
|
|
return this.snapshot()
|
|
}
|
|
|
|
/** Run the waiting → scanned → confirmed loop in the background. */
|
|
#launchLoop() {
|
|
const aborter = new AbortController()
|
|
this.aborter = aborter
|
|
this.loop = (async () => {
|
|
const signal = aborter.signal
|
|
try {
|
|
while (!signal.aborted) {
|
|
if (this.expiresAt > 0 && this.now() > this.expiresAt) {
|
|
this.state = 'expired'
|
|
this.message = 'The QR code expired; start a new login.'
|
|
return
|
|
}
|
|
const scanned = await this.flow.waitForScan({ signal })
|
|
if (signal.aborted) return
|
|
if (scanned.state === 'waiting') {
|
|
if (this.expiresAt > 0 && this.now() > this.expiresAt) {
|
|
this.state = 'expired'
|
|
this.message = 'The QR code expired; start a new login.'
|
|
return
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 1500))
|
|
continue
|
|
}
|
|
if (scanned.state !== 'scanned') {
|
|
this.state = scanned.state === 'expired' ? 'expired' : 'error'
|
|
this.message =
|
|
scanned.message ||
|
|
(this.state === 'expired' ? 'The QR code expired; start a new login.' : 'AcFun rejected this QR login.')
|
|
return
|
|
}
|
|
this.state = 'scanned'
|
|
this.message = 'Scanned; confirm the login on your phone.'
|
|
const accepted = await this.flow.accept({ signal })
|
|
if (signal.aborted) return
|
|
if (accepted.state === 'confirmed') {
|
|
try {
|
|
await this.store.write(accepted.cookie)
|
|
} catch (error) {
|
|
this.state = 'error'
|
|
this.message = `Login succeeded but the session could not be saved: ${error instanceof Error ? error.message : String(error)}`
|
|
return
|
|
}
|
|
this.provider.setCookie(accepted.cookie)
|
|
this.authSource = 'qr'
|
|
this.qr = null
|
|
this.state = 'confirmed'
|
|
this.message = 'Signed in to AcFun; the session cookie is saved.'
|
|
return
|
|
}
|
|
this.state = accepted.state === 'expired' ? 'expired' : 'error'
|
|
this.message = accepted.message || 'AcFun login failed.'
|
|
return
|
|
}
|
|
} catch (error) {
|
|
if (signal.aborted) return
|
|
this.state = error?.name === 'TimeoutError' ? 'expired' : 'error'
|
|
this.message = error?.name === 'TimeoutError' ? 'The QR code expired; start a new login.' : String(error?.message ?? error)
|
|
} finally {
|
|
if (this.aborter === aborter) this.aborter = null
|
|
}
|
|
})()
|
|
}
|
|
|
|
/**
|
|
* Snapshot poll used by the panel and tools. The network work happens in the
|
|
* background loop, so this never blocks on AcFun.
|
|
*
|
|
* @param {string} ticket - ticket returned by `start`; a stale ticket is ignored.
|
|
*/
|
|
async poll(ticket) {
|
|
if (this.ticket !== '' && String(ticket ?? '') !== this.ticket) return this.snapshot()
|
|
return this.snapshot()
|
|
}
|
|
|
|
/** Cancel the pending ticket; the stored session is untouched. */
|
|
async cancel() {
|
|
if (this.aborter !== null) {
|
|
this.aborter.abort()
|
|
this.aborter = null
|
|
}
|
|
if (this.ticket !== '' && this.flow !== null && typeof this.flow.cancel === 'function') {
|
|
try {
|
|
await this.flow.cancel(this.ticket)
|
|
} catch {
|
|
// Cancelling is best-effort; the ticket expires on AcFun's side anyway.
|
|
}
|
|
}
|
|
this.#reset('idle', '')
|
|
return this.snapshot()
|
|
}
|
|
|
|
/** Forget the QR session and return the provider to anonymous/config mode. */
|
|
async logout() {
|
|
await this.cancel()
|
|
await this.store.clear()
|
|
this.provider.setCookie('')
|
|
this.authSource = 'none'
|
|
return this.snapshot()
|
|
}
|
|
}
|