merge: integrate remote main
Some checks failed
Electron E2E / Electron E2E (macos-latest) (push) Has been cancelled
Electron E2E / Electron E2E (ubuntu-latest) (push) Has been cancelled
Electron E2E / Electron E2E (windows-latest) (push) Has been cancelled

This commit is contained in:
2026-09-07 13:43:27 +08:00
124 changed files with 4233 additions and 6599 deletions

View File

@@ -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,

View File

@@ -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();

View File

@@ -211,21 +211,7 @@ export class CodingProjectService {
);
}
}
const config = await this.readCurrentConfig(projectPath);
if (!config) {
throw new CodingProjectServiceError(
409,
'CODING_PROJECT_CONFIG_INVALID',
'Coding project configuration is unavailable',
);
}
if (!config.projectId) {
throw new CodingProjectServiceError(
409,
'CODING_PROJECT_IDENTITY_REQUIRED',
'A durable coding project identity is required',
);
}
const config = (await this.getConfig(project.id)).config;
return { project, path: projectPath, projectId: config.projectId };
}
@@ -335,19 +321,10 @@ export class CodingProjectService {
async getConfig(projectId?: string): Promise<CodingProjectConfigSnapshot> {
const project = projectId ? await this.getProject(projectId) : await this.requireActiveProject();
const config = await this.readCurrentConfig(project.path);
if (!config) {
throw new CodingProjectServiceError(
409,
'CODING_PROJECT_CONFIG_INVALID',
'Coding project configuration is unavailable',
);
}
return {
project,
config,
knowledgeFiles: await this.listKnowledgeFiles(project.path),
};
const config = await this.requireCurrentConfig(project);
return config.projectId
? await this.projectConfigSnapshot(project, config)
: await this.ensureProjectIdentity(project);
}
async saveConfig(projectId: string, value: unknown): Promise<CodingProjectConfigSnapshot> {
@@ -379,8 +356,8 @@ export class CodingProjectService {
): Promise<CodingProjectConfigSnapshot> {
const project = await this.getProject(localProjectId);
return await this.serializeIdentityTransition(project.path, async () => {
const current = await this.getConfig(project.id);
if (current.config.projectId !== undefined) {
const current = await this.requireCurrentConfig(project);
if (current.projectId !== undefined) {
throw new CodingProjectServiceError(
409,
'CODING_PROJECT_IDENTITY_IMMUTABLE',
@@ -397,7 +374,7 @@ export class CodingProjectService {
nextProjectId,
);
const next = {
...current.config,
...current,
projectId: nextProjectId,
updatedAt: this.options.now?.() ?? new Date().toISOString(),
};
@@ -407,11 +384,7 @@ export class CodingProjectService {
storageFailure(error);
}
await this.options.onResourcesChanged?.(project);
return {
project: current.project,
config: next,
knowledgeFiles: await this.listKnowledgeFiles(current.project.path),
};
return await this.projectConfigSnapshot(project, next);
});
}
@@ -428,8 +401,8 @@ export class CodingProjectService {
}
const project = await this.getProject(localProjectId);
return await this.serializeIdentityTransition(project.path, async () => {
const current = await this.getConfig(project.id);
if (current.config.projectId === undefined) {
const current = await this.requireCurrentConfig(project);
if (current.projectId === undefined) {
throw new CodingProjectServiceError(
409,
'CODING_PROJECT_IDENTITY_REQUIRED',
@@ -442,11 +415,11 @@ export class CodingProjectService {
);
await this.options.onProjectIdentityChanging?.(
project,
current.config.projectId,
current.projectId,
nextProjectId,
);
const next = {
...current.config,
...current,
projectId: nextProjectId,
updatedAt: this.options.now?.() ?? new Date().toISOString(),
};
@@ -456,11 +429,7 @@ export class CodingProjectService {
storageFailure(error);
}
await this.options.onResourcesChanged?.(project);
return {
project: current.project,
config: next,
knowledgeFiles: await this.listKnowledgeFiles(current.project.path),
};
return await this.projectConfigSnapshot(project, next);
});
}
@@ -540,6 +509,61 @@ export class CodingProjectService {
});
}
private async ensureProjectIdentity(
project: CodingProject,
): Promise<CodingProjectConfigSnapshot> {
return await this.serializeIdentityTransition(project.path, async () => {
const current = await this.requireCurrentConfig(project);
if (current.projectId) {
return await this.projectConfigSnapshot(project, current);
}
const nextProjectId = resolveProjectIdentity(
{ kind: 'create' },
this.options.createProjectId ?? randomUUID,
);
await this.options.onProjectIdentityChanging?.(
project,
undefined,
nextProjectId,
);
const next = {
...current,
projectId: nextProjectId,
updatedAt: this.options.now?.() ?? new Date().toISOString(),
};
try {
await (this.options.writeConfig ?? writeCodingProjectConfigV2)(project.path, next);
} catch (error) {
storageFailure(error);
}
await this.options.onResourcesChanged?.(project);
return await this.projectConfigSnapshot(project, next);
});
}
private async requireCurrentConfig(project: CodingProject): Promise<CodingProjectConfigV2> {
const config = await this.readCurrentConfig(project.path);
if (!config) {
throw new CodingProjectServiceError(
409,
'CODING_PROJECT_CONFIG_INVALID',
'Coding project configuration is unavailable',
);
}
return config;
}
private async projectConfigSnapshot(
project: CodingProject,
config: CodingProjectConfigV2,
): Promise<CodingProjectConfigSnapshot> {
return {
project,
config,
knowledgeFiles: await this.listKnowledgeFiles(project.path),
};
}
private async listKnowledgeFiles(projectPath: string): Promise<string[]> {
try {
const entries = await readdir(path.join(projectPath, 'knowledge'), { withFileTypes: true });

View File

@@ -1,6 +1,6 @@
import { EventEmitter } from 'node:events';
export type DesktopModule = 'programming' | 'painting' | 'learning' | 'robot' | 'other';
export type DesktopModule = 'programming' | 'painting' | 'robot' | 'other';
export type DesktopActivity = {
visible: boolean;

View File

@@ -675,7 +675,7 @@ async function initialize(): Promise<void> {
});
// Coding workers are started on demand. Keeping them cold for
// Canvas/Learning/Robot avoids a resident child process on every launch.
// Canvas/Robot avoids a resident child process on every launch.
void initializeBackgroundServices().catch((error) => {
logger.warn('Deferred background initialization failed:', error);
});

View File

@@ -1,221 +0,0 @@
import { createHash, randomUUID } from 'node:crypto';
import { open, rename, rm } from 'node:fs/promises';
import { basename, dirname, extname, join } from 'node:path';
import type { LearningProjectDetail } from '../../shared/learning';
import type { WorksSquareAccountBinding } from './works-square-session';
const PROJECT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
const SHA256_PATTERN = /^[0-9a-f]{64}$/;
const ARCHIVE_MIME_TYPES = new Set([
'application/octet-stream',
'application/x-zip-compressed',
'application/zip',
]);
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
export const LEARNING_PROJECT_DOWNLOAD_MAX_REDIRECTS = 5;
export class LearningProjectDownloadError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
) {
super(message);
this.name = 'LearningProjectDownloadError';
}
}
export function safeLearningProjectArchiveFileName(value: string, projectId: string): string {
const fallbackId = projectId.replace(/[^A-Za-z0-9_-]+/g, '-').slice(0, 48) || 'project';
const normalized = [...basename(value.trim().replace(/\\/g, '/'))]
.map((character) => character.charCodeAt(0) < 32 ? '-' : character)
.join('')
.replace(/[<>:"/\\|?*]/g, '-')
.replace(/[. ]+$/g, '')
.slice(0, 120);
if (!normalized || extname(normalized).toLowerCase() !== '.zip') {
return `Makelore-${fallbackId}.zip`;
}
return normalized;
}
function assertCurrentAccount(
binding: WorksSquareAccountBinding,
isCurrentAccountBinding: (value: WorksSquareAccountBinding) => boolean,
): void {
if (!isCurrentAccountBinding(binding)) {
throw new LearningProjectDownloadError(409, 'LEARNING_ACCOUNT_CHANGED', '登录账号已更改,请重新下载');
}
}
function validateProject(project: LearningProjectDetail): void {
if (!PROJECT_ID_PATTERN.test(project.id)
|| !SHA256_PATTERN.test(project.archiveSha256)) {
throw new LearningProjectDownloadError(502, 'LEARNING_PROJECT_INVALID', '项目下载信息无效');
}
}
async function followRedirects(input: {
response: Response;
initialUrl: string;
apiOrigin: string;
fetchImpl: typeof fetch;
binding: WorksSquareAccountBinding;
isCurrentAccountBinding: (value: WorksSquareAccountBinding) => boolean;
}): Promise<Response> {
let response = input.response;
let currentUrl = new URL(input.initialUrl).href;
const visited = new Set([currentUrl]);
for (let hop = 0; REDIRECT_STATUSES.has(response.status); hop += 1) {
if (hop >= LEARNING_PROJECT_DOWNLOAD_MAX_REDIRECTS) {
await response.body?.cancel().catch(() => undefined);
throw new LearningProjectDownloadError(502, 'LEARNING_DOWNLOAD_REDIRECT_LIMIT', '项目下载重定向次数过多');
}
const location = response.headers.get('location');
await response.body?.cancel().catch(() => undefined);
if (!location) {
throw new LearningProjectDownloadError(502, 'LEARNING_DOWNLOAD_REDIRECT_INVALID', '项目下载地址无效');
}
let target: URL;
try {
target = new URL(location, currentUrl);
} catch {
throw new LearningProjectDownloadError(502, 'LEARNING_DOWNLOAD_REDIRECT_INVALID', '项目下载地址无效');
}
if (!['http:', 'https:'].includes(target.protocol)
|| target.origin !== input.apiOrigin
|| Boolean(target.username || target.password)
|| visited.has(target.href)) {
throw new LearningProjectDownloadError(502, 'LEARNING_DOWNLOAD_REDIRECT_INVALID', '项目下载地址不安全');
}
visited.add(target.href);
assertCurrentAccount(input.binding, input.isCurrentAccountBinding);
response = await input.fetchImpl(target, {
method: 'GET',
headers: { Accept: 'application/zip' },
redirect: 'manual',
});
currentUrl = target.href;
}
return response;
}
async function writeVerifiedArchive(input: {
response: Response;
temporaryPath: string;
project: LearningProjectDetail;
binding: WorksSquareAccountBinding;
isCurrentAccountBinding: (value: WorksSquareAccountBinding) => boolean;
}): Promise<void> {
if (!input.response.body) {
throw new LearningProjectDownloadError(502, 'LEARNING_DOWNLOAD_EMPTY', '项目压缩包内容为空');
}
const contentType = input.response.headers.get('content-type')?.split(';', 1)[0]?.trim().toLowerCase();
if (!contentType || !ARCHIVE_MIME_TYPES.has(contentType)) {
await input.response.body.cancel().catch(() => undefined);
throw new LearningProjectDownloadError(502, 'LEARNING_ARCHIVE_MIME_INVALID', '项目压缩包类型无效');
}
const handle = await open(input.temporaryPath, 'wx');
const hash = createHash('sha256');
const signature: number[] = [];
const reader = input.response.body.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
assertCurrentAccount(input.binding, input.isCurrentAccountBinding);
for (const byte of value.subarray(0, Math.max(0, 4 - signature.length))) signature.push(byte);
hash.update(value);
await handle.write(value);
}
} finally {
reader.releaseLock();
await handle.close();
}
if (signature.length < 4 || signature[0] !== 0x50 || signature[1] !== 0x4b
|| !((signature[2] === 0x03 && signature[3] === 0x04)
|| (signature[2] === 0x05 && signature[3] === 0x06)
|| (signature[2] === 0x07 && signature[3] === 0x08))) {
throw new LearningProjectDownloadError(502, 'LEARNING_ARCHIVE_INVALID', '项目压缩包不是有效的 ZIP 文件');
}
if (hash.digest('hex') !== input.project.archiveSha256) {
throw new LearningProjectDownloadError(502, 'LEARNING_ARCHIVE_HASH_MISMATCH', '项目压缩包完整性校验失败');
}
}
export async function saveLearningProjectArchive(input: {
project: LearningProjectDetail;
destinationPath: string;
binding: WorksSquareAccountBinding;
fetchImpl: typeof fetch;
getAccessToken: (options?: { fetchImpl?: typeof fetch; forceRefresh?: boolean }) => Promise<string | null>;
isCurrentAccountBinding: (value: WorksSquareAccountBinding) => boolean;
apiBaseUrl: string;
}): Promise<void> {
validateProject(input.project);
assertCurrentAccount(input.binding, input.isCurrentAccountBinding);
const apiBaseUrl = input.apiBaseUrl.replace(/\/+$/, '');
const archiveUrl = `${apiBaseUrl}/api/learning/projects/${encodeURIComponent(input.project.id)}/archive`;
const request = (accessToken: string) => input.fetchImpl(archiveUrl, {
method: 'GET',
headers: {
Accept: 'application/zip',
Authorization: `Bearer ${accessToken}`,
},
redirect: 'manual',
});
const token = await input.getAccessToken({ fetchImpl: input.fetchImpl });
assertCurrentAccount(input.binding, input.isCurrentAccountBinding);
if (!token) {
throw new LearningProjectDownloadError(401, 'LEARNING_AUTH_REQUIRED', '请先登录');
}
let response = await request(token);
if (response.status === 401) {
await response.body?.cancel().catch(() => undefined);
const refreshed = await input.getAccessToken({ fetchImpl: input.fetchImpl, forceRefresh: true });
assertCurrentAccount(input.binding, input.isCurrentAccountBinding);
if (!refreshed) {
throw new LearningProjectDownloadError(401, 'LEARNING_AUTH_REQUIRED', '请先登录');
}
response = await request(refreshed);
}
response = await followRedirects({
response,
initialUrl: archiveUrl,
apiOrigin: new URL(apiBaseUrl).origin,
fetchImpl: input.fetchImpl,
binding: input.binding,
isCurrentAccountBinding: input.isCurrentAccountBinding,
});
if (!response.ok) {
await response.body?.cancel().catch(() => undefined);
throw new LearningProjectDownloadError(
response.status >= 400 && response.status <= 599 ? response.status : 502,
'LEARNING_DOWNLOAD_FAILED',
'项目下载失败,请稍后重试',
);
}
const temporaryPath = join(
dirname(input.destinationPath),
`.${basename(input.destinationPath)}.${randomUUID()}.download`,
);
try {
await writeVerifiedArchive({
response,
temporaryPath,
project: input.project,
binding: input.binding,
isCurrentAccountBinding: input.isCurrentAccountBinding,
});
assertCurrentAccount(input.binding, input.isCurrentAccountBinding);
await rename(temporaryPath, input.destinationPath);
} catch (error) {
await rm(temporaryPath, { force: true }).catch(() => undefined);
if (error instanceof LearningProjectDownloadError) throw error;
throw new LearningProjectDownloadError(500, 'LEARNING_SAVE_FAILED', '项目保存失败,请重新选择位置后重试');
}
}