import { app, dialog, type SaveDialogOptions } from 'electron'; import type { IncomingMessage, ServerResponse } from 'node:http'; import { join } from 'node:path'; import { LEARNING_MEDIA_MAX_BYTES, type LearningProjectDetail, } from '../../../shared/learning'; import { LearningProjectDownloadError, safeLearningProjectArchiveFileName, saveLearningProjectArchive, } from '../../services/learning-project-download'; import { getValidWorksSquareAccessToken, getWorksSquareAccountBinding, isCurrentWorksSquareAccountBinding, type WorksSquareAccountBinding, } from '../../services/works-square-session'; import { proxyAwareFetch } from '../../utils/proxy-fetch'; import type { HostApiContext } from '../context'; import { sendJson } from '../route-utils'; import { WORKS_SQUARE_CONFIG } from '../works-config'; const LOCAL_ROOT = '/api/works/learning/projects'; const UPSTREAM_ROOT = '/api/learning/projects'; const PROJECT_ID = '[A-Za-z0-9][A-Za-z0-9._-]{0,127}'; const MEDIA_ID = '[A-Za-z0-9][A-Za-z0-9._-]{0,127}'; const PROJECT_ID_PATTERN = new RegExp(`^${PROJECT_ID}$`); const SHA256_PATTERN = /^[0-9a-f]{64}$/; const MEDIA_URL_PATTERN = new RegExp(`^/api/learning/projects/${PROJECT_ID}/media/${MEDIA_ID}$`); const LOCAL_MEDIA_PATH_PATTERN = new RegExp(`^${LOCAL_ROOT}/(${PROJECT_ID})/media/(${MEDIA_ID})$`); const LOCAL_DOWNLOAD_PATH_PATTERN = new RegExp(`^${LOCAL_ROOT}/(${PROJECT_ID})/download$`); const LOCAL_DETAIL_PATH_PATTERN = new RegExp(`^${LOCAL_ROOT}/(${PROJECT_ID})$`); const MAX_LIST_ITEMS = 48; const MAX_TAGS = 16; const MAX_RESPONSE_BYTES = 2 * 1024 * 1024; const TRUSTED_MEDIA_MIME_TYPES = new Set([ 'image/avif', 'image/gif', 'image/jpeg', 'image/png', 'image/webp', ]); type LearningRoute = | { kind: 'list'; upstreamPath: string } | { kind: 'detail'; upstreamPath: string; projectId: string } | { kind: 'media'; upstreamPath: string; projectId: string } | { kind: 'download'; upstreamPath: string; projectId: string }; type Dependencies = { fetchImpl?: typeof fetch; getAccessToken?: typeof getValidWorksSquareAccessToken; getAccountBinding?: () => WorksSquareAccountBinding | null; isCurrentAccountBinding?: (value: WorksSquareAccountBinding) => boolean; apiBaseUrl?: string; chooseDestination?: (ctx: HostApiContext, fileName: string) => Promise; saveArchive?: typeof saveLearningProjectArchive; }; class InvalidLearningDataError extends Error {} function matchRoute(pathname: string): LearningRoute | null { if (pathname === LOCAL_ROOT) return { kind: 'list', upstreamPath: UPSTREAM_ROOT }; const media = LOCAL_MEDIA_PATH_PATTERN.exec(pathname); if (media) { return { kind: 'media', projectId: media[1], upstreamPath: `${UPSTREAM_ROOT}/${media[1]}/media/${media[2]}`, }; } const download = LOCAL_DOWNLOAD_PATH_PATTERN.exec(pathname); if (download) { return { kind: 'download', projectId: download[1], upstreamPath: `${UPSTREAM_ROOT}/${download[1]}`, }; } const detail = LOCAL_DETAIL_PATH_PATTERN.exec(pathname); return detail ? { kind: 'detail', projectId: detail[1], upstreamPath: `${UPSTREAM_ROOT}/${detail[1]}` } : null; } function listQuery(url: URL): string { const query = new URLSearchParams(); const cursor = url.searchParams.get('cursor')?.trim(); if (cursor && cursor.length <= 1024) query.set('cursor', cursor); const limit = url.searchParams.get('limit')?.trim(); if (limit && /^\d{1,2}$/.test(limit) && Number(limit) >= 1 && Number(limit) <= MAX_LIST_ITEMS) { query.set('limit', limit); } const encoded = query.toString(); return encoded ? `?${encoded}` : ''; } function asRecord(value: unknown): Record { if (!value || typeof value !== 'object' || Array.isArray(value)) throw new InvalidLearningDataError(); return value as Record; } function boundedString(value: unknown, maximum: number): string { if (typeof value !== 'string') throw new InvalidLearningDataError(); const normalized = value.trim(); if (!normalized || normalized.length > maximum) throw new InvalidLearningDataError(); return normalized; } function nullableString(value: unknown, maximum: number): string | null { return value === null ? null : boundedString(value, maximum); } function boundedInteger(value: unknown, minimum: number, maximum: number): number { if (!Number.isSafeInteger(value) || (value as number) < minimum || (value as number) > maximum) { throw new InvalidLearningDataError(); } return value as number; } function isoTimestamp(value: unknown): string { const timestamp = boundedString(value, 64); if (!Number.isFinite(Date.parse(timestamp))) throw new InvalidLearningDataError(); return timestamp; } function mediaUrl(value: unknown): string { const url = boundedString(value, 2048); if (MEDIA_URL_PATTERN.test(url)) return url; let parsed: URL; try { parsed = new URL(url); } catch { throw new InvalidLearningDataError(); } if (parsed.protocol !== 'https:' || parsed.username || parsed.password) throw new InvalidLearningDataError(); return parsed.toString(); } function projectImage(value: unknown): Record { const image = asRecord(value); const width = image.width === undefined ? undefined : boundedInteger(image.width, 1, 32_768); const height = image.height === undefined ? undefined : boundedInteger(image.height, 1, 32_768); return { url: mediaUrl(image.url), alt: boundedString(image.alt, 500), ...(width === undefined ? {} : { width }), ...(height === undefined ? {} : { height }), }; } function projectSummary(value: unknown): Record { const project = asRecord(value); const id = boundedString(project.id, 128); if (!PROJECT_ID_PATTERN.test(id)) throw new InvalidLearningDataError(); const tags = Array.isArray(project.tags) && project.tags.length <= MAX_TAGS ? project.tags.map((tag) => boundedString(tag, 64)) : (() => { throw new InvalidLearningDataError(); })(); if (new Set(tags).size !== tags.length) throw new InvalidLearningDataError(); return { id, name: boundedString(project.name, 200), summary: boundedString(project.summary, 2_000), cover: projectImage(project.cover), tags, version: nullableString(project.version, 64), archiveBytes: boundedInteger(project.archiveBytes, 1, Number.MAX_SAFE_INTEGER), publishedAt: isoTimestamp(project.publishedAt), updatedAt: isoTimestamp(project.updatedAt), }; } function projectDetail(value: unknown): LearningProjectDetail { const project = asRecord(value); const summary = projectSummary(project); const archiveSha256 = boundedString(project.archiveSha256, 64); if (!SHA256_PATTERN.test(archiveSha256)) throw new InvalidLearningDataError(); const archiveFileName = boundedString(project.archiveFileName, 160); if (!archiveFileName.toLowerCase().endsWith('.zip')) throw new InvalidLearningDataError(); return { ...summary, readmeMarkdown: boundedString(project.readmeMarkdown, 500_000), archiveFileName, archiveSha256, } as LearningProjectDetail; } function unwrapEnvelope(value: unknown): unknown { const envelope = asRecord(value); if (envelope.success !== true || envelope.data === undefined) throw new InvalidLearningDataError(); return envelope.data; } function projectPage(value: unknown): Record { const page = asRecord(unwrapEnvelope(value)); if (!Array.isArray(page.items) || page.items.length > MAX_LIST_ITEMS) throw new InvalidLearningDataError(); const nextCursor = page.nextCursor === null ? null : boundedString(page.nextCursor, 1024); const total = page.total === undefined ? undefined : boundedInteger(page.total, 0, Number.MAX_SAFE_INTEGER); return { items: page.items.map(projectSummary), nextCursor, ...(total === undefined ? {} : { total }), }; } async function readBoundedJson(response: Response): Promise { const declaredLength = Number(response.headers.get('content-length')); if (Number.isFinite(declaredLength) && declaredLength > MAX_RESPONSE_BYTES) { await response.body?.cancel().catch(() => undefined); throw new InvalidLearningDataError(); } if (!response.body) throw new InvalidLearningDataError(); const reader = response.body.getReader(); const chunks: Uint8Array[] = []; let size = 0; while (true) { const { done, value } = await reader.read(); if (done) break; size += value.byteLength; if (size > MAX_RESPONSE_BYTES) { await reader.cancel().catch(() => undefined); throw new InvalidLearningDataError(); } chunks.push(value); } try { return JSON.parse(Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))).toString('utf8')) as unknown; } catch { throw new InvalidLearningDataError(); } } function mediaMimeType(response: Response): string | null { const mimeType = response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase(); return mimeType && TRUSTED_MEDIA_MIME_TYPES.has(mimeType) ? mimeType : null; } async function readBoundedMedia(response: Response): Promise { const declaredLength = Number(response.headers.get('content-length')); if (Number.isFinite(declaredLength) && declaredLength > LEARNING_MEDIA_MAX_BYTES) { await response.body?.cancel().catch(() => undefined); return null; } if (!response.body) return null; const reader = response.body.getReader(); const chunks: Uint8Array[] = []; let size = 0; while (true) { const { done, value } = await reader.read(); if (done) break; size += value.byteLength; if (size > LEARNING_MEDIA_MAX_BYTES) { await reader.cancel().catch(() => undefined); return null; } chunks.push(value); } return size > 0 ? Buffer.concat(chunks.map((chunk) => Buffer.from(chunk))) : null; } function safeError(status: number): { status: number; code: string; error: string } { if (status === 400 || status === 422) return { status, code: 'LEARNING_INVALID_REQUEST', error: '学习项目请求无效' }; if (status === 401) return { status, code: 'LEARNING_AUTH_REQUIRED', error: '请先登录' }; if (status === 403) return { status, code: 'LEARNING_FORBIDDEN', error: '没有权限访问学习项目' }; if (status === 404) return { status, code: 'LEARNING_PROJECT_NOT_FOUND', error: '学习项目不存在或已下架' }; if (status === 409) return { status, code: 'LEARNING_CONFLICT', error: '学习项目状态已变化,请刷新后重试' }; const safeStatus = status >= 400 && status <= 599 ? status : 502; return { status: safeStatus, code: 'LEARNING_UNAVAILABLE', error: safeStatus === 429 ? '请求过于频繁,请稍后再试' : '学习项目服务暂时不可用', }; } function sendSafeError(res: ServerResponse, status: number): void { const error = safeError(status); sendJson(res, error.status, { success: false, ...error }); } function sendInvalidResponse(res: ServerResponse): void { sendJson(res, 502, { success: false, status: 502, code: 'LEARNING_INVALID_RESPONSE', error: '学习项目服务返回了无效数据', }); } async function defaultChooseDestination(ctx: HostApiContext, fileName: string): Promise { const options: SaveDialogOptions = { defaultPath: join(app.getPath('downloads'), fileName), filters: [ { name: 'ZIP 项目压缩包', extensions: ['zip'] }, { name: '所有文件', extensions: ['*'] }, ], }; const mainWindow = ctx.mainWindow && !ctx.mainWindow.isDestroyed() ? ctx.mainWindow : null; const selection = mainWindow ? await dialog.showSaveDialog(mainWindow, options) : await dialog.showSaveDialog(options); return selection.canceled || !selection.filePath ? null : selection.filePath; } export function createLearningRouteHandler(dependencies: Dependencies = {}) { const fetchImpl = dependencies.fetchImpl ?? proxyAwareFetch; const getAccessToken = dependencies.getAccessToken ?? getValidWorksSquareAccessToken; const getAccountBinding = dependencies.getAccountBinding ?? getWorksSquareAccountBinding; const isCurrentAccountBinding = dependencies.isCurrentAccountBinding ?? isCurrentWorksSquareAccountBinding; const apiBaseUrl = (dependencies.apiBaseUrl ?? WORKS_SQUARE_CONFIG.apiBaseUrl).replace(/\/+$/, ''); const chooseDestination = dependencies.chooseDestination ?? defaultChooseDestination; const saveArchive = dependencies.saveArchive ?? saveLearningProjectArchive; async function authorizedRequest(path: string, accept: string): Promise { const token = await getAccessToken({ fetchImpl }); if (!token) throw new LearningProjectDownloadError(401, 'LEARNING_AUTH_REQUIRED', '请先登录'); const request = (accessToken: string) => fetchImpl(`${apiBaseUrl}${path}`, { method: 'GET', headers: { Accept: accept, Authorization: `Bearer ${accessToken}` }, redirect: 'manual', }); let response = await request(token); if (response.status === 401) { await response.body?.cancel().catch(() => undefined); const refreshed = await getAccessToken({ fetchImpl, forceRefresh: true }); if (!refreshed) throw new LearningProjectDownloadError(401, 'LEARNING_AUTH_REQUIRED', '请先登录'); response = await request(refreshed); } return response; } async function readDetail(route: Extract): Promise { const response = await authorizedRequest(route.upstreamPath, 'application/json'); if (!response.ok) { await response.body?.cancel().catch(() => undefined); throw new LearningProjectDownloadError(response.status, 'LEARNING_PROJECT_REQUEST_FAILED', '学习项目暂时无法读取'); } const detail = projectDetail(unwrapEnvelope(await readBoundedJson(response))); if (detail.id !== route.projectId) throw new InvalidLearningDataError(); return detail; } return async function handleLearningRoutes( req: IncomingMessage, res: ServerResponse, url: URL, ctx: HostApiContext, ): Promise { const route = matchRoute(url.pathname); if (!route) return false; const expectedMethod = route.kind === 'download' ? 'POST' : 'GET'; if (req.method !== expectedMethod) { sendJson(res, 405, { success: false, status: 405, code: 'LEARNING_METHOD_NOT_ALLOWED', error: '不支持的学习项目请求', }); return true; } try { if (route.kind === 'download') { const binding = getAccountBinding(); if (!binding) { sendSafeError(res, 401); return true; } const detail = await readDetail(route); if (!isCurrentAccountBinding(binding)) throw new LearningProjectDownloadError(409, 'LEARNING_ACCOUNT_CHANGED', '登录账号已更改,请重试'); const fileName = safeLearningProjectArchiveFileName(detail.archiveFileName, detail.id); const destinationPath = await chooseDestination(ctx, fileName); if (!destinationPath) { sendJson(res, 200, { success: true, data: { status: 'cancelled' } }); return true; } await saveArchive({ project: detail, destinationPath, binding, fetchImpl, getAccessToken, isCurrentAccountBinding, apiBaseUrl, }); sendJson(res, 200, { success: true, data: { status: 'saved' } }); return true; } const response = route.kind === 'list' ? await authorizedRequest(`${route.upstreamPath}${listQuery(url)}`, 'application/json') : await authorizedRequest(route.upstreamPath, route.kind === 'media' ? 'image/avif,image/webp,image/png,image/jpeg,image/gif' : 'application/json'); if (!response.ok) { await response.body?.cancel().catch(() => undefined); sendSafeError(res, response.status); return true; } if (route.kind === 'media') { const mimeType = mediaMimeType(response); if (!mimeType) { await response.body?.cancel().catch(() => undefined); sendInvalidResponse(res); return true; } const bytes = await readBoundedMedia(response); if (!bytes) { sendInvalidResponse(res); return true; } sendJson(res, 200, { dataBase64: bytes.toString('base64'), mimeType }); return true; } if (route.kind === 'list') { sendJson(res, 200, { success: true, data: projectPage(await readBoundedJson(response)) }); return true; } const detail = projectDetail(unwrapEnvelope(await readBoundedJson(response))); if (detail.id !== route.projectId) throw new InvalidLearningDataError(); sendJson(res, 200, { success: true, data: detail }); return true; } catch (error) { if (error instanceof InvalidLearningDataError) { sendInvalidResponse(res); } else if (error instanceof LearningProjectDownloadError) { sendJson(res, error.status, { success: false, status: error.status, code: error.code, error: error.message, }); } else { sendSafeError(res, 502); } return true; } }; } export const handleLearningRoutes = createLearningRouteHandler();