feat: remove Learning module
This commit is contained in:
@@ -6,7 +6,6 @@ import { handleAppRoutes } from './routes/app';
|
||||
import { handleAuthRoutes } from './routes/auth';
|
||||
import { handleImageWorkspaceRoutes } from './routes/image-workspace';
|
||||
import { handleImagePromptMuseumRoutes } from './routes/image-prompt-museum';
|
||||
import { handleLearningRoutes } from './routes/learning';
|
||||
import { handleDataServiceRoutes } from './routes/data-service';
|
||||
import { handleWorksRoutes } from './routes/works';
|
||||
import { handleUserSyncRoutes } from './routes/user-sync';
|
||||
@@ -44,7 +43,6 @@ export const hostApiRouteHandlers: readonly HostApiRouteHandler[] = [
|
||||
handleAuthRoutes,
|
||||
handleImageWorkspaceRoutes,
|
||||
handleImagePromptMuseumRoutes,
|
||||
handleLearningRoutes,
|
||||
handleDataServiceRoutes,
|
||||
handleWorksRoutes,
|
||||
handleAgentBrowserRoutes,
|
||||
|
||||
@@ -1,444 +0,0 @@
|
||||
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<string | null>;
|
||||
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<string, unknown> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new InvalidLearningDataError();
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown> {
|
||||
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<string, unknown> {
|
||||
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<string, unknown> {
|
||||
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<unknown> {
|
||||
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<Buffer | null> {
|
||||
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<string | null> {
|
||||
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<Response> {
|
||||
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<LearningRoute, { kind: 'detail' | 'download' }>): Promise<LearningProjectDetail> {
|
||||
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<boolean> {
|
||||
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();
|
||||
Reference in New Issue
Block a user