- 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
219 lines
7.8 KiB
JavaScript
219 lines
7.8 KiB
JavaScript
/**
|
|
* 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 }]
|
|
}
|