Files
dsh-acfun/lib/login.js
T
tamakyi 97825f020d feat: QR login, AcFun settings panel, and 动态 (moment) tools
- 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
2026-10-27 11:50:44 +08:00

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()
}
}