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
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}`;
|
|
}
|