/** * 本地图床服务器 * 提供安全的图片存储和访问服务 */ import http from "node:http"; import fs from "node:fs"; import path from "node:path"; import crypto from "node:crypto"; export interface ImageServerConfig { /** 监听端口 */ port: number; /** 图片存储目录 */ storageDir: string; /** 外部访问的基础 URL(如 http://your-server:port),留空则自动生成 */ baseUrl?: string; /** 图片过期时间(秒),0 表示不过期 */ ttlSeconds?: number; /** 允许的图片格式 */ allowedFormats?: string[]; } interface StoredImage { id: string; filename: string; mimeType: string; createdAt: number; ttl: number; } const DEFAULT_CONFIG: Required = { port: 18765, storageDir: "./qqbot-images", baseUrl: "", ttlSeconds: 3600, // 默认 1 小时过期 allowedFormats: ["png", "jpg", "jpeg", "gif", "webp"], }; let serverInstance: http.Server | null = null; let currentConfig: Required = { ...DEFAULT_CONFIG }; let imageIndex = new Map(); /** * 生成安全的随机 ID */ function generateImageId(): string { return crypto.randomBytes(16).toString("hex"); } /** * 验证请求路径是否安全(防止目录遍历攻击) */ function isPathSafe(requestPath: string, baseDir: string): boolean { const normalizedBase = path.resolve(baseDir); const normalizedPath = path.resolve(baseDir, requestPath); return normalizedPath.startsWith(normalizedBase + path.sep) || normalizedPath === normalizedBase; } /** * 获取 MIME 类型 */ function getMimeType(ext: string): string { const mimeTypes: Record = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", }; return mimeTypes[ext.toLowerCase()] || "application/octet-stream"; } /** * 从 MIME 类型获取扩展名 */ function getExtFromMime(mimeType: string): string | null { const extMap: Record = { "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp", "application/pdf": "pdf", "application/json": "json", "text/plain": "txt", "text/csv": "csv", "application/msword": "doc", "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx", "application/vnd.ms-excel": "xls", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx", }; return extMap[mimeType] || null; } /** * 清理过期图片 */ function cleanupExpiredImages(): void { const now = Date.now(); const expiredIds: string[] = []; for (const [id, image] of imageIndex) { if (image.ttl > 0 && now - image.createdAt > image.ttl * 1000) { expiredIds.push(id); } } for (const id of expiredIds) { const image = imageIndex.get(id); if (image) { const filePath = path.join(currentConfig.storageDir, image.filename); try { if (fs.existsSync(filePath)) { fs.unlinkSync(filePath); } } catch { // 忽略删除错误 } imageIndex.delete(id); } } } /** * 加载已有的图片索引 */ function loadImageIndex(): void { const indexPath = path.join(currentConfig.storageDir, ".index.json"); try { if (fs.existsSync(indexPath)) { const data = JSON.parse(fs.readFileSync(indexPath, "utf-8")); imageIndex = new Map(Object.entries(data)); } } catch { imageIndex = new Map(); } } /** * 保存图片索引 */ function saveImageIndex(): void { const indexPath = path.join(currentConfig.storageDir, ".index.json"); try { const data = Object.fromEntries(imageIndex); fs.writeFileSync(indexPath, JSON.stringify(data, null, 2)); } catch { // 忽略保存错误 } } /** * 处理 HTTP 请求 */ function handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void { const url = new URL(req.url || "/", `http://localhost:${currentConfig.port}`); const pathname = url.pathname; // 设置 CORS 头(允许 QQ 服务器访问) res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS"); if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; } // 只允许 GET 请求访问图片 if (req.method !== "GET") { res.writeHead(405, { "Content-Type": "text/plain" }); res.end("Method Not Allowed"); return; } // 解析图片 ID(路径格式: /images/{id}.{ext}) const match = pathname.match(/^\/images\/([a-f0-9]{32})\.(\w+)$/); if (!match) { res.writeHead(404, { "Content-Type": "text/plain" }); res.end("Not Found"); return; } const [, imageId, requestedExt] = match; const image = imageIndex.get(imageId); if (!image) { res.writeHead(404, { "Content-Type": "text/plain" }); res.end("Image Not Found"); return; } // 检查是否过期 if (image.ttl > 0 && Date.now() - image.createdAt > image.ttl * 1000) { res.writeHead(410, { "Content-Type": "text/plain" }); res.end("Image Expired"); return; } // 安全检查:确保文件路径在存储目录内 const filePath = path.join(currentConfig.storageDir, image.filename); if (!isPathSafe(image.filename, currentConfig.storageDir)) { res.writeHead(403, { "Content-Type": "text/plain" }); res.end("Forbidden"); return; } // 读取并返回图片 try { if (!fs.existsSync(filePath)) { res.writeHead(404, { "Content-Type": "text/plain" }); res.end("File Not Found"); return; } const imageData = fs.readFileSync(filePath); res.writeHead(200, { "Content-Type": image.mimeType, "Content-Length": imageData.length, "Cache-Control": image.ttl > 0 ? `max-age=${image.ttl}` : "max-age=31536000", }); res.end(imageData); } catch (err) { res.writeHead(500, { "Content-Type": "text/plain" }); res.end("Internal Server Error"); } } /** * 启动图床服务器 */ export function startImageServer(config?: Partial): Promise { return new Promise((resolve, reject) => { if (serverInstance) { const baseUrl = currentConfig.baseUrl || `http://localhost:${currentConfig.port}`; resolve(baseUrl); return; } currentConfig = { ...DEFAULT_CONFIG, ...config }; // 确保存储目录存在 if (!fs.existsSync(currentConfig.storageDir)) { fs.mkdirSync(currentConfig.storageDir, { recursive: true }); } // 加载图片索引 loadImageIndex(); // 启动定期清理 const cleanupInterval = setInterval(cleanupExpiredImages, 60000); // 每分钟清理一次 serverInstance = http.createServer(handleRequest); serverInstance.on("error", (err) => { clearInterval(cleanupInterval); reject(err); }); serverInstance.listen(currentConfig.port, () => { const baseUrl = currentConfig.baseUrl || `http://localhost:${currentConfig.port}`; resolve(baseUrl); }); }); } /** * 停止图床服务器 */ export function stopImageServer(): Promise { return new Promise((resolve) => { if (serverInstance) { serverInstance.close(() => { serverInstance = null; saveImageIndex(); resolve(); }); } else { resolve(); } }); } /** * 保存图片并返回访问 URL * @param imageData 图片数据(Buffer 或 base64 字符串) * @param mimeType 图片 MIME 类型 * @param ttlSeconds 过期时间(秒),默认使用配置值 * @returns 图片访问 URL */ export function saveImage( imageData: Buffer | string, mimeType: string = "image/png", ttlSeconds?: number ): string { // 转换 base64 为 Buffer let buffer: Buffer; if (typeof imageData === "string") { // 处理 data URL 格式 const base64Match = imageData.match(/^data:([^;]+);base64,(.+)$/); if (base64Match) { mimeType = base64Match[1]; buffer = Buffer.from(base64Match[2], "base64"); } else { buffer = Buffer.from(imageData, "base64"); } } else { buffer = imageData; } // 生成唯一 ID 和文件名 const imageId = generateImageId(); const ext = getExtFromMime(mimeType) || "png"; const filename = `${imageId}.${ext}`; // 确保存储目录存在 if (!fs.existsSync(currentConfig.storageDir)) { fs.mkdirSync(currentConfig.storageDir, { recursive: true }); } // 保存文件 const filePath = path.join(currentConfig.storageDir, filename); fs.writeFileSync(filePath, buffer); // 记录到索引 const image: StoredImage = { id: imageId, filename, mimeType, createdAt: Date.now(), ttl: ttlSeconds ?? currentConfig.ttlSeconds, }; imageIndex.set(imageId, image); saveImageIndex(); // 返回访问 URL const baseUrl = currentConfig.baseUrl || `http://localhost:${currentConfig.port}`; return `${baseUrl}/images/${imageId}.${ext}`; } /** * 从本地文件路径保存图片到图床 * @param filePath 本地文件路径 * @param ttlSeconds 过期时间(秒),默认使用配置值 * @returns 图片访问 URL,如果文件不存在或不是图片则返回 null */ export function saveImageFromPath(filePath: string, ttlSeconds?: number): string | null { try { console.log(`[image-server] saveImageFromPath: ${filePath}`); // 检查文件是否存在 if (!fs.existsSync(filePath)) { console.log(`[image-server] File not found: ${filePath}`); return null; } // 读取文件 const buffer = fs.readFileSync(filePath); console.log(`[image-server] File size: ${buffer.length}`); // 根据扩展名获取 MIME 类型 const ext = path.extname(filePath).toLowerCase().replace(".", ""); console.log(`[image-server] Extension: "${ext}"`); const mimeType = getMimeType(ext); console.log(`[image-server] MIME type: ${mimeType}`); // 只处理图片文件 if (!mimeType.startsWith("image/")) { console.log(`[image-server] Not an image file`); return null; } // 使用 saveImage 保存 return saveImage(buffer, mimeType, ttlSeconds); } catch (err) { console.error(`[image-server] saveImageFromPath error:`, err); return null; } } /** * 检查图床服务器是否运行中 */ export function isImageServerRunning(): boolean { return serverInstance !== null; } /** * 确保图床服务器正在运行 * 如果未运行,则自动启动 * @param publicBaseUrl 公网访问的基础 URL(如 http://your-server:18765) * @returns 基础 URL,启动失败返回 null */ export async function ensureImageServer(publicBaseUrl?: string): Promise { if (isImageServerRunning()) { return publicBaseUrl || currentConfig.baseUrl || `http://0.0.0.0:${currentConfig.port}`; } try { const config: Partial = { port: DEFAULT_CONFIG.port, storageDir: DEFAULT_CONFIG.storageDir, // 使用用户配置的公网地址 baseUrl: publicBaseUrl || `http://0.0.0.0:${DEFAULT_CONFIG.port}`, ttlSeconds: 3600, // 1 小时过期 }; await startImageServer(config); console.log(`[image-server] Auto-started on port ${config.port}, baseUrl: ${config.baseUrl}`); return config.baseUrl!; } catch (err) { console.error(`[image-server] Failed to auto-start: ${err}`); return null; } } /** * 下载远程文件并保存到本地 * @param url 远程文件 URL * @param destDir 目标目录 * @param originalFilename 原始文件名(可选,完整文件名包含扩展名) * @returns 本地文件路径,失败返回 null */ export async function downloadFile( url: string, destDir: string, originalFilename?: string ): Promise { try { // 确保目录存在 if (!fs.existsSync(destDir)) { fs.mkdirSync(destDir, { recursive: true }); } // 下载文件 const response = await fetch(url); if (!response.ok) { console.error(`[image-server] Download failed: ${response.status} ${response.statusText}`); return null; } const buffer = Buffer.from(await response.arrayBuffer()); // 确定文件名 let finalFilename: string; if (originalFilename) { // 使用原始文件名,但添加时间戳避免冲突 const ext = path.extname(originalFilename); const baseName = path.basename(originalFilename, ext); const timestamp = Date.now(); finalFilename = `${baseName}_${timestamp}${ext}`; } else { // 没有原始文件名,生成随机名 finalFilename = `${generateImageId()}.bin`; } const filePath = path.join(destDir, finalFilename); // 保存文件 fs.writeFileSync(filePath, buffer); console.log(`[image-server] Downloaded file: ${filePath}`); return filePath; } catch (err) { console.error(`[image-server] Download error:`, err); return null; } } /** * 获取图床服务器配置 */ export function getImageServerConfig(): Required { return { ...currentConfig }; }