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:
tamakyi
2026-10-27 11:50:44 +08:00
parent f9bedde19e
commit 97825f020d
19 changed files with 3723 additions and 189 deletions
+218
View File
@@ -0,0 +1,218 @@
/**
* Host-side HTTP surface for the AcFun settings panel.
*
* Static Client plugins reach their Host half over plain same-origin HTTP: the
* Web carrier serves these routes, and `client.js` calls them with `fetch`.
* Responses are JSON and never contain the session cookie itself — only a
* description of it.
*
* The panel is a local-management surface, so every route rejects a request
* whose `Origin`/`Referer` host differs from the Host header, and nothing is
* cached.
*/
import { AcfunError } from './provider.js'
/** Mount point of the panel API under the Web origin. */
export const PANEL_PATH = '/acfun-panel'
/** Largest JSON request body the panel accepts. */
const MAX_BODY_BYTES = 64 * 1024
/** Write one JSON response. */
function sendJson(res, status, payload) {
const body = Buffer.from(JSON.stringify(payload), 'utf8')
res.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': body.length,
'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff',
})
res.end(body)
}
/** Send one structured failure. */
function sendError(res, error) {
const known = error instanceof AcfunError
sendJson(res, known ? 400 : 500, {
ok: false,
error: {
code: known ? error.code : 'PANEL_ERROR',
message: error instanceof Error ? error.message : String(error),
},
})
}
/** Reject a cross-origin browser request; absent headers (curl) pass. */
function trustedRequest(req) {
const host = req.headers.host
if (typeof host !== 'string' || host.length === 0) return false
for (const header of ['origin', 'referer']) {
const value = req.headers[header]
if (typeof value !== 'string' || value.length === 0) continue
try {
if (new URL(value).host !== host) return false
} catch {
return false
}
}
return true
}
/** Read a small JSON request body, treating an empty body as `{}`. */
async function readJsonBody(req) {
const chunks = []
let size = 0
for await (const chunk of req) {
size += chunk.length
if (size > MAX_BODY_BYTES) throw new AcfunError('Request body is too large.', { code: 'BODY_TOO_LARGE' })
chunks.push(chunk)
}
if (chunks.length === 0) return {}
try {
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'))
return parsed !== null && typeof parsed === 'object' ? parsed : {}
} catch {
throw new AcfunError('Request body is not valid JSON.', { code: 'BAD_JSON' })
}
}
/** Summarize the provider session for the panel. */
function authPayload(provider, context) {
const described = provider.describeSession()
return {
configured: described.configured,
length: described.length,
hasAuthKey: described.hasAuthKey,
hasDid: described.hasDid,
source: context.cookieSource(),
writesEnabled: context.allowWriteActions,
}
}
/**
* Build the panel routes for one plugin activation.
*
* @param {object} options
* @param {import('./provider.js').AcfunProvider} options.provider - live client whose cookie may change.
* @param {{snapshot: Function, start: Function, poll: Function, cancel: Function, logout: Function}} options.login - QR login manager.
* @param {string} options.version - plugin version reported to the panel.
* @param {boolean} options.allowWriteActions - whether write tools are enabled.
* @param {() => string} options.cookieSource - current cookie source label.
* @param {Array<{name: string, description: string}>} options.tools - registered tool metadata.
* @returns {Array<{kind: 'prefix', path: string, handler: Function}>}
*/
export function createPanelRoutes(options) {
const context = {
allowWriteActions: options.allowWriteActions === true,
cookieSource: typeof options.cookieSource === 'function' ? options.cookieSource : () => 'none',
}
/** Report whether the Web carrier registered this package's client bundle. */
const clientModule = () => {
const service = options.clientModules
if (service === undefined || typeof service.clientPath !== 'function') return { available: false, path: null }
try {
const path = service.clientPath('dsh-plugin-acfun')
return { available: true, path: typeof path === 'string' ? path : null }
} catch {
return { available: true, path: null }
}
}
/** Diagnostics: which client module rows the carrier currently knows. */
const clientDiagnostics = () => {
const service = options.clientModules
const out = { available: false, packageId: 'dsh-plugin-acfun', path: null, entries: [], error: '' }
if (service === undefined || typeof service.clientPath !== 'function') return out
out.available = true
try {
const path = service.clientPath('dsh-plugin-acfun')
out.path = typeof path === 'string' ? path : null
} catch (error) {
out.error = String(error?.message ?? error)
}
try {
const graph = typeof service.graph === 'function' ? service.graph() : undefined
const entries = graph?.entries
if (Array.isArray(entries)) {
out.entries = entries.map((entry) => ({
id: String(entry?.id ?? entry?.name ?? ''),
module: String(entry?.module ?? entry?.moduleName ?? ''),
platform: String(entry?.platform ?? ''),
}))
}
} catch (error) {
out.error = out.error.length > 0 ? out.error : String(error?.message ?? error)
}
return out
}
const state = async () => ({
ok: true,
plugin: { name: 'dsh-plugin-acfun', version: options.version },
auth: authPayload(options.provider, context),
login: options.login.snapshot(),
client: clientModule(),
tools: options.tools.map((tool) => ({
name: tool.name,
summary: String(tool.description).split('.')[0].slice(0, 140),
})),
})
const handler = async (req, res) => {
if (!trustedRequest(req)) {
sendJson(res, 403, { ok: false, error: { code: 'FORBIDDEN', message: 'Cross-origin panel request rejected.' } })
return
}
let path
try {
path = new URL(req.url ?? '/', 'http://localhost').pathname
} catch {
sendJson(res, 400, { ok: false, error: { code: 'BAD_URL', message: 'Malformed request URL.' } })
return
}
if (path !== PANEL_PATH && !path.startsWith(`${PANEL_PATH}/`)) {
sendJson(res, 404, { ok: false, error: { code: 'NOT_FOUND', message: `Unknown AcFun panel path: ${path}` } })
return
}
const route = path.slice(PANEL_PATH.length) || '/'
const method = req.method ?? 'GET'
try {
if (route === '/state' && method === 'GET') {
sendJson(res, 200, await state())
return
}
if (route === '/diagnose' && method === 'GET') {
sendJson(res, 200, { ok: true, client: clientDiagnostics() })
return
}
if (route === '/login/start' && method === 'POST') {
await readJsonBody(req)
sendJson(res, 200, { ok: true, login: await options.login.start() })
return
}
if (route === '/login/poll' && method === 'GET') {
const ticket = new URL(req.url ?? '/', 'http://localhost').searchParams.get('ticket') ?? ''
sendJson(res, 200, { ok: true, login: await options.login.poll(ticket) })
return
}
if (route === '/login/cancel' && method === 'POST') {
await readJsonBody(req)
sendJson(res, 200, { ok: true, login: await options.login.cancel() })
return
}
if (route === '/logout' && method === 'POST') {
await readJsonBody(req)
sendJson(res, 200, { ok: true, login: await options.login.logout(), auth: authPayload(options.provider, context) })
return
}
sendJson(res, 404, { ok: false, error: { code: 'NOT_FOUND', message: `Unknown AcFun panel route: ${method} ${route}` } })
} catch (error) {
sendError(res, error)
}
}
return [{ kind: 'prefix', path: PANEL_PATH, handler }]
}