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 { 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 { 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; isCurrentAccountBinding: (value: WorksSquareAccountBinding) => boolean; apiBaseUrl: string; }): Promise { 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', '项目保存失败,请重新选择位置后重试'); } }