222 lines
8.5 KiB
TypeScript
222 lines
8.5 KiB
TypeScript
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', '项目保存失败,请重新选择位置后重试');
|
|
}
|
|
}
|