import fs from 'fs/promises'; import path from 'path'; import yauzl from 'yauzl'; import config from '../config/config'; const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB const MAX_ZIP_SIZE = 50 * 1024 * 1024; // 50MB // 允许的文件扩展名 const ALLOWED_EXTENSIONS = ['.html', '.zip']; const ALLOWED_HTML_EXTENSIONS = ['.html', '.htm']; // 获取上传目录的绝对路径 export function getUploadsDir(): string { return path.join(__dirname, '../../', config.uploadDir); } // 将绝对路径转换为相对路径(相对于上传目录) export function toRelativePath(absolutePath: string): string { const uploadsDir = getUploadsDir(); const relativePath = path.relative(uploadsDir, absolutePath); // 统一使用正斜杠,跨平台兼容 return relativePath.split(path.sep).join('/'); } // 将相对路径转换为绝对路径 export function toAbsolutePath(relativePath: string): string { const uploadsDir = getUploadsDir(); // 处理相对路径中的正斜杠和反斜杠 const normalizedPath = relativePath.replace(/\//g, path.sep); return path.join(uploadsDir, normalizedPath); } // 清理文件名,防止路径遍历攻击 export function sanitizeFileName(fileName: string): string { // 移除路径分隔符和危险字符 return fileName .replace(/[\/\\]/g, '') .replace(/\.\./g, '') .replace(/[<>:"|?*]/g, '') .trim(); } // 验证文件类型 export function isValidFileType(fileName: string, allowedTypes: string[] = ALLOWED_EXTENSIONS): boolean { const ext = path.extname(fileName).toLowerCase(); return allowedTypes.includes(ext); } // 验证文件大小 export function isValidFileSize(size: number, isZip: boolean = false): boolean { const maxSize = isZip ? MAX_ZIP_SIZE : MAX_FILE_SIZE; return size <= maxSize; } // 创建目录(如果不存在) export async function ensureDirectory(dirPath: string): Promise { try { await fs.access(dirPath); } catch { await fs.mkdir(dirPath, { recursive: true }); } } // 解压ZIP文件 export async function extractZip(zipPath: string, extractTo: string): Promise { return new Promise((resolve, reject) => { yauzl.open(zipPath, { lazyEntries: true }, (err: Error | null, zipfile: yauzl.ZipFile | undefined) => { if (err) { reject(err); return; } if (!zipfile) { reject(new Error('无法打开ZIP文件')); return; } let totalSize = 0; const MAX_EXTRACT_SIZE = 100 * 1024 * 1024; // 100MB 解压后总大小限制 const fsSync = require('fs') as typeof import('fs'); zipfile.readEntry(); zipfile.on('entry', (entry: yauzl.Entry) => { // 检查文件名安全性 // 检查路径遍历攻击(..)和危险字符,但允许正常的目录结构 if (entry.fileName.includes('..') || /[<>:"|?*\x00-\x1f]/.test(entry.fileName)) { reject(new Error('ZIP文件包含不安全的文件名')); return; } // 检查绝对路径(Windows: C:\ 或 Unix: /) if (/^([a-zA-Z]:|\\\\|\/)/.test(entry.fileName)) { reject(new Error('ZIP文件包含绝对路径')); return; } // 检查解压后总大小(防止zip炸弹) totalSize += entry.uncompressedSize; if (totalSize > MAX_EXTRACT_SIZE) { reject(new Error('ZIP文件解压后大小超过限制')); return; } if (/\/$/.test(entry.fileName)) { // 目录 zipfile.readEntry(); } else { // 文件 zipfile.openReadStream(entry, (err: Error | null, readStream: NodeJS.ReadableStream | undefined) => { if (err) { reject(err); return; } const filePath = path.join(extractTo, entry.fileName); ensureDirectory(path.dirname(filePath)).then(() => { const writeStream = fsSync.createWriteStream(filePath); readStream!.pipe(writeStream); writeStream.on('close', () => { zipfile.readEntry(); }); writeStream.on('error', (error: any) => { reject(error); }); }).catch((error: any) => { reject(error); }); }); } }); zipfile.on('end', () => { resolve(); }); zipfile.on('error', (err: any) => { reject(err); }); }); }); } // 查找HTML入口文件 export async function findHtmlEntry(dirPath: string): Promise { const files = await fs.readdir(dirPath, { withFileTypes: true }); // 优先查找index.html for (const file of files) { if (file.isFile()) { const ext = path.extname(file.name).toLowerCase(); if (file.name.toLowerCase() === 'index.html' && ALLOWED_HTML_EXTENSIONS.includes(ext)) { return path.join(dirPath, file.name); } } } // 查找第一个HTML文件 for (const file of files) { if (file.isFile()) { const ext = path.extname(file.name).toLowerCase(); if (ALLOWED_HTML_EXTENSIONS.includes(ext)) { return path.join(dirPath, file.name); } } } return null; } // 递归删除目录 export async function removeDirectory(dirPath: string): Promise { try { await fs.rm(dirPath, { recursive: true, force: true }); } catch (error) { // 忽略错误 } } // 重写HTML中的资源路径,将相对路径转换为API路径 export function rewriteHtmlResourcePaths( htmlContent: string, projectId: number, htmlFilePath: string, projectBasePath: string ): string { // 计算HTML文件相对于项目根目录的路径 const htmlDir = path.dirname(htmlFilePath); const relativeHtmlDir = path.relative(projectBasePath, htmlDir).replace(/\\/g, '/'); const basePath = relativeHtmlDir ? `${relativeHtmlDir}/` : ''; // 匹配各种资源引用 // 1. htmlContent = htmlContent.replace( /]*)\s+href=["']([^"']+)["']([^>]*)>/gi, (match, before, href, after) => { if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('//') || href.startsWith('data:')) { return match; // 跳过绝对URL和data URI } const newHref = rewritePath(href, basePath, projectId); return ``; } ); // 2.