You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

331 lines
11 KiB

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<void> {
try {
await fs.access(dirPath);
} catch {
await fs.mkdir(dirPath, { recursive: true });
}
}
// 解压ZIP文件
export async function extractZip(zipPath: string, extractTo: string): Promise<void> {
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<string | null> {
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<void> {
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. <link rel="stylesheet" href="...">
htmlContent = htmlContent.replace(
/<link([^>]*)\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 `<link${before} href="${newHref}"${after}>`;
}
);
// 2. <script src="...">
htmlContent = htmlContent.replace(
/<script([^>]*)\s+src=["']([^"']+)["']([^>]*)>/gi,
(match, before, src, after) => {
if (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//') || src.startsWith('data:')) {
return match; // 跳过绝对URL和data URI
}
const newSrc = rewritePath(src, basePath, projectId);
return `<script${before} src="${newSrc}"${after}>`;
}
);
// 3. <img src="...">
htmlContent = htmlContent.replace(
/<img([^>]*)\s+src=["']([^"']+)["']([^>]*)>/gi,
(match, before, src, after) => {
if (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//') || src.startsWith('data:')) {
return match; // 跳过绝对URL和data URI
}
const newSrc = rewritePath(src, basePath, projectId);
return `<img${before} src="${newSrc}"${after}>`;
}
);
// 4. <source src="..."> 或 <source srcset="...">
htmlContent = htmlContent.replace(
/<source([^>]*)\s+(src|srcset)=["']([^"']+)["']([^>]*)>/gi,
(match, before, attr, value, after) => {
if (value.startsWith('http://') || value.startsWith('https://') || value.startsWith('//') || value.startsWith('data:')) {
return match; // 跳过绝对URL和data URI
}
const newValue = rewritePath(value, basePath, projectId);
return `<source${before} ${attr}="${newValue}"${after}>`;
}
);
// 5. <video src="..."> 和 <video poster="...">
htmlContent = htmlContent.replace(
/<video([^>]*)\s+(src|poster)=["']([^"']+)["']([^>]*)>/gi,
(match, before, attr, value, after) => {
if (value.startsWith('http://') || value.startsWith('https://') || value.startsWith('//') || value.startsWith('data:')) {
return match;
}
const newValue = rewritePath(value, basePath, projectId);
return `<video${before} ${attr}="${newValue}"${after}>`;
}
);
// 6. <audio src="...">
htmlContent = htmlContent.replace(
/<audio([^>]*)\s+src=["']([^"']+)["']([^>]*)>/gi,
(match, before, src, after) => {
if (src.startsWith('http://') || src.startsWith('https://') || src.startsWith('//') || src.startsWith('data:')) {
return match;
}
const newSrc = rewritePath(src, basePath, projectId);
return `<audio${before} src="${newSrc}"${after}>`;
}
);
// 7. CSS中的url()引用
htmlContent = htmlContent.replace(
/url\(["']?([^"')]+)["']?\)/gi,
(match, url) => {
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('//') || url.startsWith('data:') || url.startsWith('/')) {
return match; // 跳过绝对URL和data URI
}
const newUrl = rewritePath(url, basePath, projectId);
return `url("${newUrl}")`;
}
);
// 8. CSS @import 语句
htmlContent = htmlContent.replace(
/@import\s+["']([^"']+)["']/gi,
(match, importPath) => {
if (importPath.startsWith('http://') || importPath.startsWith('https://') || importPath.startsWith('//')) {
return match;
}
const newImportPath = rewritePath(importPath, basePath, projectId);
return `@import "${newImportPath}"`;
}
);
return htmlContent;
}
// 重写单个路径
function rewritePath(originalPath: string, basePath: string, projectId: number): string {
// 如果路径以 / 开头,说明是绝对路径(相对于项目根目录),直接使用
if (originalPath.startsWith('/')) {
const cleanPath = originalPath.replace(/^\/+/, '');
return `/api/files/${projectId}/${cleanPath}`;
}
// 处理相对路径
// 移除开头的 ./
let cleanPath = originalPath.replace(/^\.\//, '');
// 处理 ../
if (cleanPath.startsWith('../')) {
// 计算需要向上几级目录
const baseParts = basePath ? basePath.split('/').filter(p => p) : [];
let pathParts = cleanPath.split('/').filter(p => p);
// 移除 .. 并向上级目录
while (pathParts.length > 0 && pathParts[0] === '..') {
pathParts.shift();
if (baseParts.length > 0) {
baseParts.pop();
}
}
// 组合路径
const finalParts = [...baseParts, ...pathParts];
const finalPath = finalParts.join('/');
return `/api/files/${projectId}/${finalPath}`;
}
// 普通相对路径,直接拼接
const fullPath = basePath ? `${basePath}${cleanPath}` : cleanPath;
const finalPath = fullPath.replace(/^\//, '');
return `/api/files/${projectId}/${finalPath}`;
}