Files
makelore/electron/services/learning-project-download.ts

242 lines
9.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 {
LEARNING_ARCHIVE_MAX_BYTES,
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)
|| !Number.isSafeInteger(project.archiveBytes)
|| project.archiveBytes <= 0
|| project.archiveBytes > LEARNING_ARCHIVE_MAX_BYTES
|| !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 declaredLength = Number(input.response.headers.get('content-length'));
if (Number.isFinite(declaredLength) && declaredLength !== input.project.archiveBytes) {
await input.response.body.cancel().catch(() => undefined);
throw new LearningProjectDownloadError(502, 'LEARNING_ARCHIVE_SIZE_MISMATCH', '项目压缩包大小校验失败');
}
const handle = await open(input.temporaryPath, 'wx');
const hash = createHash('sha256');
const signature: number[] = [];
let bytes = 0;
const reader = input.response.body.getReader();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
assertCurrentAccount(input.binding, input.isCurrentAccountBinding);
bytes += value.byteLength;
if (bytes > input.project.archiveBytes || bytes > LEARNING_ARCHIVE_MAX_BYTES) {
throw new LearningProjectDownloadError(502, 'LEARNING_ARCHIVE_SIZE_MISMATCH', '项目压缩包大小校验失败');
}
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 (bytes !== input.project.archiveBytes) {
throw new LearningProjectDownloadError(502, 'LEARNING_ARCHIVE_SIZE_MISMATCH', '项目压缩包大小校验失败');
}
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', '项目保存失败,请重新选择位置后重试');
}
}