/** * 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 }] }