Files
NapCatQQ/packages/napcat-webui-backend/src/api/PluginStore.ts
时瑾 172a75b514
Some checks failed
Build NapCat Artifacts / Build-Framework (push) Has been cancelled
Build NapCat Artifacts / Build-Shell (push) Has been cancelled
fix(webui-backend): sanitize plugin ID to prevent path injection (CodeQL js/path-injection)
2026-02-07 13:52:15 +08:00

522 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { RequestHandler } from 'express';
import { sendError, sendSuccess } from '@/napcat-webui-backend/src/utils/response';
import { PluginStoreList } from '@/napcat-webui-backend/src/types/PluginStore';
import * as fs from 'fs';
import * as path from 'path';
import { pipeline } from 'stream/promises';
import { createWriteStream } from 'fs';
import compressing from 'compressing';
import { findAvailableDownloadUrl, GITHUB_RAW_MIRRORS } from 'napcat-common/src/mirror';
import { webUiPathWrapper } from '@/napcat-webui-backend/index';
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
import { NapCatOneBot11Adapter } from '@/napcat-onebot/index';
import { OB11PluginMangerAdapter } from '@/napcat-onebot/network/plugin-manger';
// Helper to get the plugin manager adapter
const getPluginManager = (): OB11PluginMangerAdapter | null => {
const ob11 = WebUiDataRuntime.getOneBotContext() as NapCatOneBot11Adapter;
if (!ob11) return null;
return ob11.networkManager.findSomeAdapter('plugin_manager') as OB11PluginMangerAdapter;
};
// 插件商店源配置
const PLUGIN_STORE_SOURCES = [
'https://raw.githubusercontent.com/NapNeko/napcat-plugin-index/main/plugins.v4.json',
];
// 插件目录 - 使用 pathWrapper
const getPluginsDir = () => webUiPathWrapper.pluginPath;
/**
* 验证插件 ID防止路径注入攻击
*/
function validatePluginId (id: any): string {
if (typeof id !== 'string') {
throw new Error('Invalid plugin ID');
}
// 仅允许字母、数字、点、下划线、连字符,禁止路径遍历字符
// 通过 path.basename 进一步确保不包含路径分隔符
const safeId = path.basename(id);
if (!/^[a-zA-Z0-9._-]+$/.test(safeId) || safeId !== id) {
throw new Error('Invalid plugin ID format');
}
return safeId;
}
// 插件列表缓存
let pluginListCache: PluginStoreList | null = null;
let cacheTimestamp: number = 0;
const CACHE_TTL = 10 * 60 * 1000; // 10分钟缓存
/**
* 从多个源获取插件列表,使用镜像系统
* 带10分钟缓存支持强制刷新
*/
async function fetchPluginList (forceRefresh: boolean = false): Promise<PluginStoreList> {
// 检查缓存(如果不是强制刷新)
const now = Date.now();
if (!forceRefresh && pluginListCache && (now - cacheTimestamp) < CACHE_TTL) {
// console.log('Using cached plugin list');
return pluginListCache;
}
const errors: string[] = [];
for (const source of PLUGIN_STORE_SOURCES) {
// 使用镜像系统的 raw 镜像列表
for (const mirror of GITHUB_RAW_MIRRORS) {
try {
const url = mirror ? `${mirror}/${source.replace('https://raw.githubusercontent.com/', '')}` : source;
const response = await fetch(url, {
headers: {
'User-Agent': 'NapCat-WebUI',
},
signal: AbortSignal.timeout(10000), // 10秒超时
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
// console.log(`Successfully fetched plugin list from: ${url}`);
// 更新缓存
pluginListCache = data as PluginStoreList;
cacheTimestamp = now;
return pluginListCache;
} catch (e: any) {
const errorMsg = `Failed to fetch from ${source} via mirror: ${e.message}`;
console.warn(errorMsg);
errors.push(errorMsg);
}
}
}
throw new Error(`All plugin sources failed:\n${errors.join('\n')}`);
}
/**
* 下载文件,使用镜像系统
* 自动识别 GitHub Release URL 并使用镜像加速
*/
async function downloadFile (
url: string,
destPath: string,
customMirror?: string,
onProgress?: (percent: number, downloaded: number, total: number, speed: number) => void,
timeout: number = 120000 // 默认120秒超时
): Promise<void> {
try {
let downloadUrl: string;
// 判断是否是 GitHub Release URL
// 格式: https://github.com/{owner}/{repo}/releases/download/{tag}/{filename}
const githubReleasePattern = /^https:\/\/github\.com\/[^/]+\/[^/]+\/releases\/download\//;
if (githubReleasePattern.test(url)) {
// 使用镜像系统查找可用的下载 URL支持 GitHub Release 镜像)
console.log(`Detected GitHub Release URL: ${url}`);
console.log(`Custom mirror: ${customMirror || 'auto'}`);
downloadUrl = await findAvailableDownloadUrl(url, {
validateContent: false, // 不验证内容,只检查状态码和 Content-Type
timeout: 5000, // 每个镜像测试5秒超时
useFastMirrors: false, // 不使用快速镜像列表(避免测速阻塞)
customMirror: customMirror || undefined, // 使用用户选择的镜像
});
console.log(`Selected download URL: ${downloadUrl}`);
} else {
// 其他URL直接下载
console.log(`Direct download URL: ${url}`);
downloadUrl = url;
}
console.log(`Starting download from: ${downloadUrl}`);
// 确保目标目录存在
const destDir = path.dirname(destPath);
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true });
console.log(`Created directory: ${destDir}`);
}
const response = await fetch(downloadUrl, {
headers: {
'User-Agent': 'NapCat-WebUI',
},
signal: AbortSignal.timeout(timeout), // 使用传入的超时时间
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
if (!response.body) {
throw new Error('Response body is null');
}
const totalLength = Number(response.headers.get('content-length')) || 0;
// 初始进度通知
if (onProgress) {
onProgress(0, 0, totalLength, 0);
}
let downloaded = 0;
let lastTime = Date.now();
let lastDownloaded = 0;
// 进度监控流
// eslint-disable-next-line @stylistic/generator-star-spacing
const progressMonitor = async function* (source: any) {
for await (const chunk of source) {
downloaded += chunk.length;
const now = Date.now();
const elapsedSinceLast = now - lastTime;
// 每隔 500ms 或完成时计算一次速度并更新进度
if (elapsedSinceLast >= 500 || (totalLength && downloaded === totalLength)) {
const percent = totalLength ? Math.round((downloaded / totalLength) * 100) : 0;
const speed = (downloaded - lastDownloaded) / (elapsedSinceLast / 1000); // bytes/s
if (onProgress) {
onProgress(percent, downloaded, totalLength, speed);
}
lastTime = now;
lastDownloaded = downloaded;
}
yield chunk;
}
};
// 写入文件
const fileStream = createWriteStream(destPath);
await pipeline(progressMonitor(response.body), fileStream);
console.log(`Successfully downloaded to: ${destPath}`);
} catch (e: any) {
// 删除可能的不完整文件
if (fs.existsSync(destPath)) {
fs.unlinkSync(destPath);
}
throw new Error(`Download failed: ${e.message}`);
}
}
/**
* 解压插件到指定目录
*/
async function extractPlugin (zipPath: string, pluginId: string): Promise<void> {
// 验证 pluginId 确保安全
const safeId = validatePluginId(pluginId);
const PLUGINS_DIR = getPluginsDir();
const pluginDir = path.join(PLUGINS_DIR, safeId);
const dataDir = path.join(pluginDir, 'data');
const tempDataDir = path.join(PLUGINS_DIR, `${safeId}.data.backup`);
console.log(`[extractPlugin] PLUGINS_DIR: ${PLUGINS_DIR}`);
console.log(`[extractPlugin] pluginId: ${safeId}`);
console.log(`[extractPlugin] Target directory: ${pluginDir}`);
console.log(`[extractPlugin] Zip file: ${zipPath}`);
// 确保插件根目录存在
if (!fs.existsSync(PLUGINS_DIR)) {
fs.mkdirSync(PLUGINS_DIR, { recursive: true });
console.log(`[extractPlugin] Created plugins root directory: ${PLUGINS_DIR}`);
}
// 如果目录已存在,先备份 data 文件夹,再删除
let hasDataBackup = false;
if (fs.existsSync(pluginDir)) {
// 备份 data 文件夹
if (fs.existsSync(dataDir)) {
console.log(`[extractPlugin] Backing up data directory: ${dataDir}`);
if (fs.existsSync(tempDataDir)) {
fs.rmSync(tempDataDir, { recursive: true, force: true });
}
fs.renameSync(dataDir, tempDataDir);
hasDataBackup = true;
}
console.log(`[extractPlugin] Directory exists, removing: ${pluginDir}`);
fs.rmSync(pluginDir, { recursive: true, force: true });
}
// 创建插件目录
fs.mkdirSync(pluginDir, { recursive: true });
console.log(`[extractPlugin] Created directory: ${pluginDir}`);
try {
// 解压
await compressing.zip.uncompress(zipPath, pluginDir);
console.log(`[extractPlugin] Plugin extracted to: ${pluginDir}`);
// 恢复 data 文件夹
if (hasDataBackup && fs.existsSync(tempDataDir)) {
// 如果新版本也有 data 文件夹,先删除
if (fs.existsSync(dataDir)) {
fs.rmSync(dataDir, { recursive: true, force: true });
}
console.log(`[extractPlugin] Restoring data directory: ${dataDir}`);
fs.renameSync(tempDataDir, dataDir);
}
} catch (e) {
// 解压失败时,尝试恢复 data 文件夹
if (hasDataBackup && fs.existsSync(tempDataDir)) {
console.log('[extractPlugin] Extract failed, restoring data directory');
if (!fs.existsSync(pluginDir)) {
fs.mkdirSync(pluginDir, { recursive: true });
}
if (fs.existsSync(dataDir)) {
fs.rmSync(dataDir, { recursive: true, force: true });
}
fs.renameSync(tempDataDir, dataDir);
}
throw e;
}
// 列出解压后的文件
const files = fs.readdirSync(pluginDir);
console.log('[extractPlugin] Extracted files:', files);
}
/**
* 获取插件商店列表
*/
export const GetPluginStoreListHandler: RequestHandler = async (req, res) => {
try {
// 支持 forceRefresh 查询参数强制刷新缓存
const forceRefresh = req.query['forceRefresh'] === 'true';
const data = await fetchPluginList(forceRefresh);
return sendSuccess(res, data);
} catch (e: any) {
return sendError(res, 'Failed to fetch plugin store list: ' + e.message);
}
};
/**
* 获取单个插件详情
*/
export const GetPluginStoreDetailHandler: RequestHandler = async (req, res) => {
try {
const id = validatePluginId(req.params['id']);
const data = await fetchPluginList();
const plugin = data.plugins.find(p => p.id === id);
if (!plugin) {
return sendError(res, 'Plugin not found');
}
return sendSuccess(res, plugin);
} catch (e: any) {
return sendError(res, 'Failed to fetch plugin detail: ' + e.message);
}
};
/**
* 安装插件(从商店)- 普通 POST 接口
*/
export const InstallPluginFromStoreHandler: RequestHandler = async (req, res) => {
try {
const { id: rawId, mirror } = req.body;
if (!rawId) {
return sendError(res, 'Plugin ID is required');
}
const id = validatePluginId(rawId);
// 获取插件信息
const data = await fetchPluginList();
const plugin = data.plugins.find(p => p.id === id);
if (!plugin) {
return sendError(res, 'Plugin not found in store');
}
// 检查是否已安装相同版本
const pm = getPluginManager();
if (pm) {
const installedInfo = pm.getPluginInfo(id);
if (installedInfo && installedInfo.version === plugin.version) {
return sendError(res, '该插件已安装且版本相同,无需重复安装');
}
}
// 下载插件
const PLUGINS_DIR = getPluginsDir();
const tempZipPath = path.join(PLUGINS_DIR, `${id}.temp.zip`);
try {
await downloadFile(plugin.downloadUrl, tempZipPath, mirror, undefined, 300000);
// 解压插件
await extractPlugin(tempZipPath, id);
// 删除临时文件
fs.unlinkSync(tempZipPath);
// 如果 pluginManager 存在,立即注册或重载插件
const pluginManager = getPluginManager();
if (pluginManager) {
// 如果插件已存在,则重载以刷新版本信息;否则注册新插件
if (pluginManager.getPluginInfo(id)) {
await pluginManager.reloadPlugin(id);
} else {
await pluginManager.loadPluginById(id);
}
}
return sendSuccess(res, {
message: 'Plugin installed successfully',
plugin,
installPath: path.join(PLUGINS_DIR, id),
});
} catch (downloadError: any) {
// 清理临时文件
if (fs.existsSync(tempZipPath)) {
fs.unlinkSync(tempZipPath);
}
throw downloadError;
}
} catch (e: any) {
return sendError(res, 'Failed to install plugin: ' + e.message);
}
};
/**
* 安装插件(从商店)- SSE 版本,实时推送进度
*/
export const InstallPluginFromStoreSSEHandler: RequestHandler = async (req, res) => {
const { id: rawId, mirror } = req.query;
if (!rawId || typeof rawId !== 'string') {
res.status(400).json({ error: 'Plugin ID is required' });
return;
}
let id: string;
try {
id = validatePluginId(rawId);
} catch (err: any) {
res.status(400).json({ error: err.message });
return;
}
// 设置 SSE 响应头
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
const sendProgress = (message: string, progress?: number, detail?: any) => {
res.write(`data: ${JSON.stringify({ message, progress, ...detail })}\n\n`);
};
try {
sendProgress('正在获取插件信息...', 10);
// 获取插件信息
const data = await fetchPluginList();
const plugin = data.plugins.find(p => p.id === id);
if (!plugin) {
sendProgress('错误: 插件不存在', 0);
res.write(`data: ${JSON.stringify({ error: 'Plugin not found in store' })}\n\n`);
res.end();
return;
}
// 检查是否已安装相同版本
const pm = getPluginManager();
if (pm) {
const installedInfo = pm.getPluginInfo(id);
if (installedInfo && installedInfo.version === plugin.version) {
sendProgress('错误: 该插件已安装且版本相同', 0);
res.write(`data: ${JSON.stringify({ error: '该插件已安装且版本相同,无需重复安装' })}\n\n`);
res.end();
return;
}
}
sendProgress(`找到插件: ${plugin.name} v${plugin.version}`, 20);
sendProgress(`下载地址: ${plugin.downloadUrl}`, 25);
if (mirror && typeof mirror === 'string') {
sendProgress(`使用镜像: ${mirror}`, 28);
}
// 下载插件
const PLUGINS_DIR = getPluginsDir();
const tempZipPath = path.join(PLUGINS_DIR, `${id}.temp.zip`);
try {
sendProgress('正在下载插件...', 30);
await downloadFile(plugin.downloadUrl, tempZipPath, mirror as string | undefined, (percent, downloaded, total, speed) => {
const overallProgress = 30 + Math.round(percent * 0.5);
const downloadedMb = (downloaded / 1024 / 1024).toFixed(1);
const totalMb = total ? (total / 1024 / 1024).toFixed(1) : '?';
const speedMb = (speed / 1024 / 1024).toFixed(2);
const eta = (total > 0 && speed > 0) ? Math.round((total - downloaded) / speed) : -1;
sendProgress(`正在下载插件... ${percent}%`, overallProgress, {
downloaded,
total,
speed,
eta,
downloadedStr: `${downloadedMb}MB`,
totalStr: `${totalMb}MB`,
speedStr: `${speedMb}MB/s`,
});
}, 300000);
sendProgress('下载完成,正在解压...', 85);
await extractPlugin(tempZipPath, id);
sendProgress('解压完成,正在清理...', 95);
fs.unlinkSync(tempZipPath);
// 如果 pluginManager 存在,立即注册或重载插件
const pluginManager = getPluginManager();
if (pluginManager) {
// 如果插件已存在,则重载以刷新版本信息;否则注册新插件
if (pluginManager.getPluginInfo(id)) {
sendProgress('正在刷新插件信息...', 95);
await pluginManager.reloadPlugin(id);
} else {
sendProgress('正在注册插件...', 95);
await pluginManager.loadPluginById(id);
}
}
sendProgress('安装成功!', 100);
res.write(`data: ${JSON.stringify({
success: true,
message: 'Plugin installed successfully',
plugin,
installPath: path.join(PLUGINS_DIR, id),
})}\n\n`);
res.end();
} catch (downloadError: any) {
// 清理临时文件
if (fs.existsSync(tempZipPath)) {
fs.unlinkSync(tempZipPath);
}
sendProgress(`错误: ${downloadError.message}`, 0);
res.write(`data: ${JSON.stringify({ error: downloadError.message })}\n\n`);
res.end();
}
} catch (e: any) {
sendProgress(`错误: ${e.message}`, 0);
res.write(`data: ${JSON.stringify({ error: e.message })}\n\n`);
res.end();
}
};