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:
+218
@@ -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 }]
|
||||
}
|
||||
Reference in New Issue
Block a user