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
This commit is contained in:
+254
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* 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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user