xinyu-pro 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +21 -0
- package/README.md +36 -0
- package/app/api/chat/route.ts +84 -0
- package/app/api/generate-svg/route.ts +171 -0
- package/app/api/generate-theme/route.ts +137 -0
- package/app/api/plugins/bindings/route.ts +173 -0
- package/app/api/plugins/export/route.ts +122 -0
- package/app/api/plugins/export-xye/route.ts +156 -0
- package/app/api/plugins/files/route.ts +146 -0
- package/app/api/plugins/files-list/route.ts +168 -0
- package/app/api/plugins/files-upload/route.ts +101 -0
- package/app/api/plugins/files-write/route.ts +272 -0
- package/app/api/plugins/import/route.ts +140 -0
- package/app/api/plugins/import-package/route.ts +231 -0
- package/app/api/plugins/resources/route.ts +109 -0
- package/app/api/plugins/route.ts +308 -0
- package/app/api/plugins/scan/route.ts +280 -0
- package/app/api/plugins/storage/route.ts +146 -0
- package/app/api/sessions/route.ts +165 -0
- package/app/api/settings/route.ts +40 -0
- package/app/api/suggest-fields/route.ts +129 -0
- package/app/api/templates/route.ts +159 -0
- package/app/api/test-api/route.ts +63 -0
- package/app/editor/page.tsx +1466 -0
- package/app/extensions/create/page.tsx +1422 -0
- package/app/extensions/edit/[id]/page.tsx +2342 -0
- package/app/extensions/page.tsx +1572 -0
- package/app/extensions/tutorial/page.tsx +4258 -0
- package/app/favicon.ico +0 -0
- package/app/fonts/GeistMonoVF.woff +0 -0
- package/app/fonts/GeistVF.woff +0 -0
- package/app/game/[id]/page.tsx +996 -0
- package/app/globals.css +3 -0
- package/app/layout.tsx +26 -0
- package/app/loading.tsx +26 -0
- package/app/page.tsx +345 -0
- package/app/settings/page.tsx +1490 -0
- package/bin/cli.js +262 -0
- package/components/ChatInput.tsx +106 -0
- package/components/ChatWindow.tsx +52 -0
- package/components/FullPageLoader.tsx +107 -0
- package/components/LoadingDots.tsx +20 -0
- package/components/MathCurveLoader.tsx +173 -0
- package/components/MessageBubble.tsx +147 -0
- package/components/WorldCardPreview.tsx +98 -0
- package/components/WorldCardUploader.tsx +58 -0
- package/components/ui/ConfirmDialog.tsx +135 -0
- package/components/ui/PageHeader.tsx +99 -0
- package/components/ui/PermissionConflictDialog.tsx +206 -0
- package/components/ui/PluginConfigForm.tsx +192 -0
- package/components/ui/PluginFloatingLayer.tsx +52 -0
- package/components/ui/PluginIcon.tsx +53 -0
- package/components/ui/PluginModalRenderer.tsx +185 -0
- package/components/ui/PluginProvider.tsx +1038 -0
- package/components/ui/PluginSlotRenderer.tsx +76 -0
- package/components/ui/ThemeCustomizer.tsx +174 -0
- package/components/ui/ThemeProvider.tsx +125 -0
- package/components/ui/ThemeSwitcher.tsx +140 -0
- package/components/ui/ToastProvider.tsx +141 -0
- package/lib/builtin-plugins.ts +11 -0
- package/lib/db-init.ts +35 -0
- package/lib/db.ts +244 -0
- package/lib/manifest-parser.ts +185 -0
- package/lib/parseWorldCard.ts +110 -0
- package/lib/plugin-dom-sandbox.ts +327 -0
- package/lib/plugin-events.ts +88 -0
- package/lib/plugin-files.ts +186 -0
- package/lib/plugin-html-sanitizer.ts +79 -0
- package/lib/plugin-resource-tracker.ts +175 -0
- package/lib/plugin-runtime.ts +2287 -0
- package/lib/plugin-security.ts +151 -0
- package/lib/plugin-types.ts +416 -0
- package/lib/prompt-builder.ts +55 -0
- package/lib/router-history.ts +119 -0
- package/lib/storage.ts +381 -0
- package/lib/themes.ts +129 -0
- package/lib/types.ts +117 -0
- package/lib/version.ts +55 -0
- package/next.config.mjs +43 -0
- package/package.json +56 -0
- package/plugins/xinyu.bag-system.xye +0 -0
- package/plugins/xinyu.cache-optimizer.xye +0 -0
- package/plugins/xinyu.dice-arbiter.xye +0 -0
- package/plugins/xinyu.game-auto-start-choices.xye +0 -0
- package/plugins/xinyu.markdown-render.xye +0 -0
- package/plugins/xinyu.slot-ui-beautify.xye +0 -0
- package/plugins/xinyu.world-info.xye +0 -0
- package/postcss.config.mjs +8 -0
- package/public/templates/atlantis.svg +63 -0
- package/public/templates/cyber-city.svg +68 -0
- package/public/templates/jianghu.svg +69 -0
- package/public/templates//351/255/224/346/263/225/344/270/226/347/225/214.svg +137 -0
- package/styles/themes.css +111 -0
- package/tailwind.config.ts +18 -0
- package/tsconfig.json +26 -0
- package/version.json +6 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// app/api/plugins/resources/route.ts - 插件资源文件 HTTP 访问
|
|
2
|
+
|
|
3
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
4
|
+
import { ensureDb } from '@/lib/db-init';
|
|
5
|
+
import { getDbPool } from '@/lib/db';
|
|
6
|
+
import path from 'path';
|
|
7
|
+
import { promises as fs } from 'fs';
|
|
8
|
+
|
|
9
|
+
/** 常见 MIME 类型映射 */
|
|
10
|
+
const MIME_TYPES: Record<string, string> = {
|
|
11
|
+
'.png': 'image/png',
|
|
12
|
+
'.jpg': 'image/jpeg',
|
|
13
|
+
'.jpeg': 'image/jpeg',
|
|
14
|
+
'.gif': 'image/gif',
|
|
15
|
+
'.svg': 'image/svg+xml',
|
|
16
|
+
'.webp': 'image/webp',
|
|
17
|
+
'.mp3': 'audio/mpeg',
|
|
18
|
+
'.wav': 'audio/wav',
|
|
19
|
+
'.ogg': 'audio/ogg',
|
|
20
|
+
'.mp4': 'video/mp4',
|
|
21
|
+
'.webm': 'video/webm',
|
|
22
|
+
'.json': 'application/json',
|
|
23
|
+
'.css': 'text/css',
|
|
24
|
+
'.html': 'text/html',
|
|
25
|
+
'.txt': 'text/plain',
|
|
26
|
+
'.md': 'text/markdown',
|
|
27
|
+
'.pdf': 'application/pdf',
|
|
28
|
+
'.woff': 'font/woff',
|
|
29
|
+
'.woff2': 'font/woff2',
|
|
30
|
+
'.ttf': 'font/ttf',
|
|
31
|
+
'.eot': 'application/vnd.ms-fontobject',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function getMimeType(filePath: string): string {
|
|
35
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
36
|
+
return MIME_TYPES[ext] || 'application/octet-stream';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 校验插件 ID 合法性 */
|
|
40
|
+
function isValidPluginId(id: string): boolean {
|
|
41
|
+
return /^[a-zA-Z0-9._-]+$/.test(id) && id.length <= 200 && !id.includes('..');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 校验文件路径安全性 */
|
|
45
|
+
function isSafePath(filePath: string): boolean {
|
|
46
|
+
const normalized = path.normalize(filePath);
|
|
47
|
+
return !normalized.includes('..') && !path.isAbsolute(normalized) && normalized !== '';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function GET(request: NextRequest) {
|
|
51
|
+
try {
|
|
52
|
+
const { searchParams } = new URL(request.url);
|
|
53
|
+
const pluginId = searchParams.get('pluginId');
|
|
54
|
+
const filePath = searchParams.get('path');
|
|
55
|
+
|
|
56
|
+
if (!pluginId || !filePath) {
|
|
57
|
+
return NextResponse.json({ error: '缺少 pluginId 或 path 参数' }, { status: 400 });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!isValidPluginId(pluginId)) {
|
|
61
|
+
return NextResponse.json({ error: '非法插件 ID' }, { status: 400 });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (!isSafePath(filePath)) {
|
|
65
|
+
return NextResponse.json({ error: '非法文件路径' }, { status: 400 });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 验证插件存在
|
|
69
|
+
await ensureDb();
|
|
70
|
+
const db = getDbPool();
|
|
71
|
+
const [rows] = await db.execute(
|
|
72
|
+
'SELECT id FROM extensions WHERE id = ?',
|
|
73
|
+
[pluginId]
|
|
74
|
+
) as [Array<{ id: string }>, unknown];
|
|
75
|
+
|
|
76
|
+
if (rows.length === 0) {
|
|
77
|
+
return NextResponse.json({ error: '插件不存在' }, { status: 404 });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 构建文件绝对路径
|
|
81
|
+
const absolutePath = path.join(process.cwd(), 'data', 'plugins', pluginId, filePath);
|
|
82
|
+
|
|
83
|
+
// 安全检查:确保路径在插件目录内
|
|
84
|
+
const pluginDir = path.join(process.cwd(), 'data', 'plugins', pluginId);
|
|
85
|
+
const resolvedPath = path.resolve(absolutePath);
|
|
86
|
+
const resolvedDir = path.resolve(pluginDir);
|
|
87
|
+
if (!resolvedPath.startsWith(resolvedDir + path.sep) && resolvedPath !== resolvedDir) {
|
|
88
|
+
return NextResponse.json({ error: '非法文件路径' }, { status: 403 });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 读取文件
|
|
92
|
+
try {
|
|
93
|
+
const fileBuffer = await fs.readFile(absolutePath);
|
|
94
|
+
const mimeType = getMimeType(filePath);
|
|
95
|
+
return new NextResponse(fileBuffer, {
|
|
96
|
+
headers: {
|
|
97
|
+
'Content-Type': mimeType,
|
|
98
|
+
'Cache-Control': 'public, max-age=3600',
|
|
99
|
+
'Content-Disposition': `inline; filename="${path.basename(filePath)}"`,
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
} catch {
|
|
103
|
+
return NextResponse.json({ error: '文件不存在' }, { status: 404 });
|
|
104
|
+
}
|
|
105
|
+
} catch (e) {
|
|
106
|
+
console.error('读取插件资源失败:', e);
|
|
107
|
+
return NextResponse.json({ error: '读取资源失败' }, { status: 500 });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
// app/api/plugins/route.ts - 插件 CRUD API
|
|
2
|
+
|
|
3
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
4
|
+
import { getDbPool } from '@/lib/db';
|
|
5
|
+
import { ensureDb } from '@/lib/db-init';
|
|
6
|
+
import { writePluginCode, deletePluginCode, getPluginDir } from '@/lib/plugin-files';
|
|
7
|
+
import path from 'path';
|
|
8
|
+
import { promises as fs } from 'fs';
|
|
9
|
+
|
|
10
|
+
/** 解析 JSON 字段 */
|
|
11
|
+
function parseJsonField<T>(value: unknown): T {
|
|
12
|
+
if (typeof value === 'string') {
|
|
13
|
+
try { return JSON.parse(value) as T; } catch { return value as unknown as T; }
|
|
14
|
+
}
|
|
15
|
+
return value as T;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** 解析权限字段(兼容旧格式 uiPermissions 数组和新格式对象) */
|
|
19
|
+
function parsePermissions(value: unknown): {
|
|
20
|
+
commonPermissions?: string[];
|
|
21
|
+
exclusivePermissions?: string[];
|
|
22
|
+
requiredPermissions?: string[];
|
|
23
|
+
} {
|
|
24
|
+
const raw = parseJsonField<unknown>(value);
|
|
25
|
+
if (Array.isArray(raw)) {
|
|
26
|
+
// 旧格式:uiPermissions 数组 → 映射为 commonPermissions
|
|
27
|
+
return { commonPermissions: raw };
|
|
28
|
+
}
|
|
29
|
+
if (raw && typeof raw === 'object') {
|
|
30
|
+
const perms = raw as Record<string, unknown>;
|
|
31
|
+
return {
|
|
32
|
+
commonPermissions: (perms.commonPermissions as string[]) || [],
|
|
33
|
+
exclusivePermissions: (perms.exclusivePermissions as string[]) || [],
|
|
34
|
+
requiredPermissions: (perms.requiredPermissions as string[]) || [],
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** 行数据转换为插件对象(不含 code,需异步填充) */
|
|
41
|
+
function rowToPlugin(row: Record<string, unknown>) {
|
|
42
|
+
const perms = parsePermissions(row.ui_permissions);
|
|
43
|
+
return {
|
|
44
|
+
id: row.id,
|
|
45
|
+
name: row.name,
|
|
46
|
+
version: row.version,
|
|
47
|
+
description: row.description,
|
|
48
|
+
author: row.author,
|
|
49
|
+
type: row.type,
|
|
50
|
+
icon: row.icon,
|
|
51
|
+
minAppVersion: row.min_app_version,
|
|
52
|
+
code: '', // 将由 fillPluginCode 异步填充
|
|
53
|
+
codePath: (row.code_path as string) || '', // 入口代码文件路径(如 data/plugins/x.y/index.js)
|
|
54
|
+
configSchema: parseJsonField(row.config_schema),
|
|
55
|
+
uiSlots: parseJsonField(row.ui_slots),
|
|
56
|
+
...perms,
|
|
57
|
+
publicExports: parseJsonField(row.public_exports),
|
|
58
|
+
dependencies: parseJsonField(row.dependencies),
|
|
59
|
+
createdAt: row.created_at,
|
|
60
|
+
updatedAt: row.updated_at,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 异步填充插件代码(从文件读取) */
|
|
65
|
+
async function fillPluginCode(plugin: ReturnType<typeof rowToPlugin>): Promise<typeof plugin> {
|
|
66
|
+
try {
|
|
67
|
+
// 从 code_path 提取文件名(如 data/plugins/x.y/index.js → index.js)
|
|
68
|
+
const codeFileName = plugin.codePath ? (plugin.codePath.split('/').pop() || 'plugin.js') : 'plugin.js';
|
|
69
|
+
const pluginDir = getPluginDir(plugin.id as string);
|
|
70
|
+
const filePath = path.join(pluginDir, codeFileName);
|
|
71
|
+
plugin.code = await fs.readFile(filePath, 'utf-8');
|
|
72
|
+
} catch {
|
|
73
|
+
plugin.code = '';
|
|
74
|
+
}
|
|
75
|
+
return plugin;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// GET: 获取所有插件或单个插件
|
|
79
|
+
export async function GET(request: NextRequest) {
|
|
80
|
+
try {
|
|
81
|
+
await ensureDb();
|
|
82
|
+
const db = getDbPool();
|
|
83
|
+
const { searchParams } = new URL(request.url);
|
|
84
|
+
const id = searchParams.get('id');
|
|
85
|
+
const type = searchParams.get('type');
|
|
86
|
+
|
|
87
|
+
if (id) {
|
|
88
|
+
const [rows] = await db.execute(
|
|
89
|
+
'SELECT * FROM extensions WHERE id = ?',
|
|
90
|
+
[id]
|
|
91
|
+
) as [Record<string, unknown>[], unknown];
|
|
92
|
+
if (rows.length === 0) {
|
|
93
|
+
return NextResponse.json({ error: '插件不存在' }, { status: 404 });
|
|
94
|
+
}
|
|
95
|
+
const plugin = await fillPluginCode(rowToPlugin(rows[0]));
|
|
96
|
+
return NextResponse.json(plugin);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let query = 'SELECT * FROM extensions';
|
|
100
|
+
const params: (string | number | null)[] = [];
|
|
101
|
+
|
|
102
|
+
if (type) {
|
|
103
|
+
query += ' WHERE type = ?';
|
|
104
|
+
params.push(type);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
query += ' ORDER BY updated_at DESC';
|
|
108
|
+
const [rows] = await db.execute(query, params) as [Record<string, unknown>[], unknown];
|
|
109
|
+
const plugins = await Promise.all(rows.map(r => fillPluginCode(rowToPlugin(r))));
|
|
110
|
+
return NextResponse.json(plugins);
|
|
111
|
+
} catch (e) {
|
|
112
|
+
console.error('获取插件失败:', e);
|
|
113
|
+
return NextResponse.json({ error: '获取插件失败' }, { status: 500 });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// POST: 创建插件
|
|
118
|
+
export async function POST(request: NextRequest) {
|
|
119
|
+
try {
|
|
120
|
+
await ensureDb();
|
|
121
|
+
const db = getDbPool();
|
|
122
|
+
const body = await request.json();
|
|
123
|
+
const { id, name, version, description, author, type, icon, minAppVersion, code, configSchema, uiSlots, commonPermissions, exclusivePermissions, requiredPermissions, publicExports, dependencies } = body;
|
|
124
|
+
|
|
125
|
+
if (!id || !name || !version || !type || !code) {
|
|
126
|
+
return NextResponse.json({ error: '缺少必填字段 (id, name, version, type, code)' }, { status: 400 });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 将代码写入文件
|
|
130
|
+
const codePath = await writePluginCode(id, code);
|
|
131
|
+
|
|
132
|
+
const permissionsJson = JSON.stringify({
|
|
133
|
+
commonPermissions: commonPermissions || [],
|
|
134
|
+
exclusivePermissions: exclusivePermissions || [],
|
|
135
|
+
requiredPermissions: requiredPermissions || [],
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
await db.execute(
|
|
139
|
+
`INSERT INTO extensions (id, name, version, description, author, type, icon, min_app_version, code_path, config_schema, ui_slots, ui_permissions, public_exports, dependencies)
|
|
140
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
141
|
+
ON DUPLICATE KEY UPDATE
|
|
142
|
+
name = VALUES(name), version = VALUES(version), description = VALUES(description),
|
|
143
|
+
author = VALUES(author), type = VALUES(type), icon = VALUES(icon),
|
|
144
|
+
min_app_version = VALUES(min_app_version), code_path = VALUES(code_path),
|
|
145
|
+
config_schema = VALUES(config_schema), ui_slots = VALUES(ui_slots),
|
|
146
|
+
ui_permissions = VALUES(ui_permissions),
|
|
147
|
+
public_exports = VALUES(public_exports), dependencies = VALUES(dependencies)`,
|
|
148
|
+
[id, name, version, description || '', author || '', type, icon || '', minAppVersion || '', codePath,
|
|
149
|
+
JSON.stringify(configSchema || []), JSON.stringify(uiSlots || []),
|
|
150
|
+
permissionsJson,
|
|
151
|
+
JSON.stringify(publicExports || null), JSON.stringify(dependencies || null)]
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
return NextResponse.json({ success: true, id });
|
|
155
|
+
} catch (e) {
|
|
156
|
+
console.error('创建插件失败:', e);
|
|
157
|
+
return NextResponse.json({ error: '创建插件失败' }, { status: 500 });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// PUT: 更新插件
|
|
162
|
+
export async function PUT(request: NextRequest) {
|
|
163
|
+
try {
|
|
164
|
+
await ensureDb();
|
|
165
|
+
const db = getDbPool();
|
|
166
|
+
const body = await request.json();
|
|
167
|
+
const { id, newId, ...fields } = body;
|
|
168
|
+
|
|
169
|
+
if (!id) {
|
|
170
|
+
return NextResponse.json({ error: '缺少插件 ID' }, { status: 400 });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// 处理 ID 变更
|
|
174
|
+
if (newId && newId !== id) {
|
|
175
|
+
// 检查新 ID 是否已存在
|
|
176
|
+
const [existing] = await db.execute('SELECT id FROM extensions WHERE id = ?', [newId]) as [Record<string, unknown>[], unknown];
|
|
177
|
+
if (existing.length > 0) {
|
|
178
|
+
return NextResponse.json({ error: `插件 ID「${newId}」已存在` }, { status: 409 });
|
|
179
|
+
}
|
|
180
|
+
// 临时禁用外键检查,同时更新父表和子表的 ID,再恢复
|
|
181
|
+
await db.execute('SET FOREIGN_KEY_CHECKS = 0');
|
|
182
|
+
try {
|
|
183
|
+
await db.execute('UPDATE extensions SET id = ? WHERE id = ?', [newId, id]);
|
|
184
|
+
await db.execute('UPDATE extension_bindings SET extension_id = ? WHERE extension_id = ?', [newId, id]);
|
|
185
|
+
} finally {
|
|
186
|
+
await db.execute('SET FOREIGN_KEY_CHECKS = 1');
|
|
187
|
+
}
|
|
188
|
+
// 后续字段更新使用新 ID
|
|
189
|
+
fields.id = newId;
|
|
190
|
+
// 重命名插件目录(data/plugins/{oldId}/ → data/plugins/{newId}/)
|
|
191
|
+
const oldDir = getPluginDir(id);
|
|
192
|
+
const newDir = getPluginDir(newId);
|
|
193
|
+
try {
|
|
194
|
+
await fs.rename(oldDir, newDir);
|
|
195
|
+
} catch (e) {
|
|
196
|
+
console.error(`重命名插件目录失败: ${oldDir} → ${newDir}`, e);
|
|
197
|
+
// 回滚数据库
|
|
198
|
+
await db.execute('UPDATE extensions SET id = ? WHERE id = ?', [id, newId]);
|
|
199
|
+
await db.execute('UPDATE extension_bindings SET extension_id = ? WHERE extension_id = ?', [id, newId]);
|
|
200
|
+
return NextResponse.json({ error: '重命名插件目录失败' }, { status: 500 });
|
|
201
|
+
}
|
|
202
|
+
// 更新 code_path 中的目录名
|
|
203
|
+
const [codePathRows] = await db.execute('SELECT code_path FROM extensions WHERE id = ?', [newId]) as [Record<string, string>[], unknown];
|
|
204
|
+
if (codePathRows.length > 0 && codePathRows[0].code_path) {
|
|
205
|
+
const oldCodePath = codePathRows[0].code_path;
|
|
206
|
+
const newCodePath = oldCodePath.replace(`data/plugins/${id}/`, `data/plugins/${newId}/`);
|
|
207
|
+
if (newCodePath !== oldCodePath) {
|
|
208
|
+
await db.execute('UPDATE extensions SET code_path = ? WHERE id = ?', [newCodePath, newId]);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const updates: string[] = [];
|
|
214
|
+
const params: (string | number | null)[] = [];
|
|
215
|
+
|
|
216
|
+
const allowedFields = ['name', 'version', 'description', 'author', 'type', 'icon', 'minAppVersion', 'code', 'configSchema', 'uiSlots', 'commonPermissions', 'exclusivePermissions', 'requiredPermissions', 'publicExports', 'dependencies', 'id'];
|
|
217
|
+
const columnMap: Record<string, string> = {
|
|
218
|
+
minAppVersion: 'min_app_version',
|
|
219
|
+
configSchema: 'config_schema',
|
|
220
|
+
uiSlots: 'ui_slots',
|
|
221
|
+
commonPermissions: 'ui_permissions',
|
|
222
|
+
exclusivePermissions: 'ui_permissions',
|
|
223
|
+
requiredPermissions: 'ui_permissions',
|
|
224
|
+
publicExports: 'public_exports',
|
|
225
|
+
dependencies: 'dependencies',
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
// code 字段特殊处理:写入文件,数据库存路径
|
|
229
|
+
const targetId = newId || id;
|
|
230
|
+
if (fields.code !== undefined) {
|
|
231
|
+
// 从当前 code_path 提取入口文件名(如 data/plugins/x.y/index.js → index.js)
|
|
232
|
+
const [existingRows] = await db.execute('SELECT code_path FROM extensions WHERE id = ?', [targetId]) as [Record<string, string>[], unknown];
|
|
233
|
+
const currentCodePath = existingRows.length > 0 ? existingRows[0].code_path : null;
|
|
234
|
+
const entryFile = currentCodePath ? currentCodePath.split('/').pop() : undefined;
|
|
235
|
+
const codePath = await writePluginCode(targetId, String(fields.code), entryFile);
|
|
236
|
+
updates.push('code_path = ?');
|
|
237
|
+
params.push(codePath);
|
|
238
|
+
delete fields.code; // 不再作为普通字段处理
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// 权限字段需要合并更新
|
|
242
|
+
const permFields = ['commonPermissions', 'exclusivePermissions', 'requiredPermissions'];
|
|
243
|
+
const hasPermUpdate = permFields.some(f => fields[f] !== undefined);
|
|
244
|
+
|
|
245
|
+
if (hasPermUpdate) {
|
|
246
|
+
// 先读取当前权限
|
|
247
|
+
const [rows] = await db.execute('SELECT ui_permissions FROM extensions WHERE id = ?', [targetId]) as [Record<string, unknown>[], unknown];
|
|
248
|
+
const currentPerms = rows.length > 0 ? parsePermissions(rows[0].ui_permissions) : {};
|
|
249
|
+
const newPerms = {
|
|
250
|
+
commonPermissions: fields.commonPermissions !== undefined ? fields.commonPermissions : (currentPerms.commonPermissions || []),
|
|
251
|
+
exclusivePermissions: fields.exclusivePermissions !== undefined ? fields.exclusivePermissions : (currentPerms.exclusivePermissions || []),
|
|
252
|
+
requiredPermissions: fields.requiredPermissions !== undefined ? fields.requiredPermissions : (currentPerms.requiredPermissions || []),
|
|
253
|
+
};
|
|
254
|
+
updates.push('ui_permissions = ?');
|
|
255
|
+
params.push(JSON.stringify(newPerms));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
for (const field of allowedFields) {
|
|
259
|
+
if (permFields.includes(field)) continue; // 权限字段已单独处理
|
|
260
|
+
if (field === 'id') continue; // ID 已单独处理
|
|
261
|
+
if (fields[field] !== undefined) {
|
|
262
|
+
const col = columnMap[field] || field;
|
|
263
|
+
updates.push(`${col} = ?`);
|
|
264
|
+
if (field === 'configSchema' || field === 'uiSlots' || field === 'publicExports' || field === 'dependencies') {
|
|
265
|
+
params.push(JSON.stringify(fields[field]));
|
|
266
|
+
} else {
|
|
267
|
+
params.push(fields[field]);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (updates.length === 0) {
|
|
273
|
+
return NextResponse.json({ success: true, id: targetId });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
params.push(targetId);
|
|
277
|
+
await db.execute(
|
|
278
|
+
`UPDATE extensions SET ${updates.join(', ')} WHERE id = ?`,
|
|
279
|
+
params
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
return NextResponse.json({ success: true, id: targetId });
|
|
283
|
+
} catch (e) {
|
|
284
|
+
console.error('更新插件失败:', e);
|
|
285
|
+
return NextResponse.json({ error: '更新插件失败' }, { status: 500 });
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// DELETE: 删除插件
|
|
290
|
+
export async function DELETE(request: NextRequest) {
|
|
291
|
+
try {
|
|
292
|
+
await ensureDb();
|
|
293
|
+
const db = getDbPool();
|
|
294
|
+
const { searchParams } = new URL(request.url);
|
|
295
|
+
const id = searchParams.get('id');
|
|
296
|
+
|
|
297
|
+
if (!id) {
|
|
298
|
+
return NextResponse.json({ error: '缺少插件 ID' }, { status: 400 });
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
await db.execute('DELETE FROM extensions WHERE id = ?', [id]);
|
|
302
|
+
await deletePluginCode(id);
|
|
303
|
+
return NextResponse.json({ success: true, id });
|
|
304
|
+
} catch (e) {
|
|
305
|
+
console.error('删除插件失败:', e);
|
|
306
|
+
return NextResponse.json({ error: '删除插件失败' }, { status: 500 });
|
|
307
|
+
}
|
|
308
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
// app/api/plugins/scan/route.ts - AI 安全检测 API
|
|
2
|
+
|
|
3
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
4
|
+
|
|
5
|
+
interface ScanRequest {
|
|
6
|
+
code: string;
|
|
7
|
+
manifest?: {
|
|
8
|
+
name?: string;
|
|
9
|
+
author?: string;
|
|
10
|
+
type?: string;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface Finding {
|
|
15
|
+
level: 'info' | 'warning' | 'danger';
|
|
16
|
+
category: string;
|
|
17
|
+
description: string;
|
|
18
|
+
code?: string;
|
|
19
|
+
recommendation: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface ScanResult {
|
|
23
|
+
riskLevel: 'low' | 'medium' | 'high' | 'critical';
|
|
24
|
+
score: number;
|
|
25
|
+
summary: string;
|
|
26
|
+
findings: Finding[];
|
|
27
|
+
recommendation: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 静态代码分析(不依赖 AI API 的基础检测) */
|
|
31
|
+
function staticAnalysis(code: string, manifest?: ScanRequest['manifest']): ScanResult {
|
|
32
|
+
const findings: Finding[] = [];
|
|
33
|
+
let riskScore = 100;
|
|
34
|
+
|
|
35
|
+
// 1. 网络请求检测
|
|
36
|
+
const fetchPatterns = [
|
|
37
|
+
/fetch\s*\(/g,
|
|
38
|
+
/XMLHttpRequest/g,
|
|
39
|
+
/\.ajax\s*\(/g,
|
|
40
|
+
/new\s+WebSocket/g,
|
|
41
|
+
];
|
|
42
|
+
for (const pattern of fetchPatterns) {
|
|
43
|
+
const matches = code.match(pattern);
|
|
44
|
+
if (matches) {
|
|
45
|
+
findings.push({
|
|
46
|
+
level: 'warning',
|
|
47
|
+
category: 'network',
|
|
48
|
+
description: `插件包含 ${matches.length} 处网络请求调用`,
|
|
49
|
+
recommendation: '确认请求目标地址是否可信,避免数据泄露',
|
|
50
|
+
});
|
|
51
|
+
riskScore -= matches.length * 5;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 2. DOM 操作检测
|
|
56
|
+
const domPatterns = [
|
|
57
|
+
/document\.(querySelector|getElementById|getElementsBy|createElement|write)/g,
|
|
58
|
+
/window\.(document|location|navigator)/g,
|
|
59
|
+
/\.innerHTML\s*=/g,
|
|
60
|
+
/\.outerHTML\s*=/g,
|
|
61
|
+
];
|
|
62
|
+
for (const pattern of domPatterns) {
|
|
63
|
+
const matches = code.match(pattern);
|
|
64
|
+
if (matches) {
|
|
65
|
+
findings.push({
|
|
66
|
+
level: 'info',
|
|
67
|
+
category: 'dom',
|
|
68
|
+
description: `插件包含 ${matches.length} 处 DOM 操作`,
|
|
69
|
+
recommendation: '确认 DOM 操作是否必要,避免破坏页面结构',
|
|
70
|
+
});
|
|
71
|
+
riskScore -= matches.length * 2;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// 3. 数据访问检测
|
|
76
|
+
const storagePatterns = [
|
|
77
|
+
/localStorage\.(getItem|setItem|removeItem)/g,
|
|
78
|
+
/sessionStorage\.(getItem|setItem|removeItem)/g,
|
|
79
|
+
/document\.cookie/g,
|
|
80
|
+
];
|
|
81
|
+
for (const pattern of storagePatterns) {
|
|
82
|
+
const matches = code.match(pattern);
|
|
83
|
+
if (matches) {
|
|
84
|
+
findings.push({
|
|
85
|
+
level: 'warning',
|
|
86
|
+
category: 'storage',
|
|
87
|
+
description: `插件访问浏览器存储 ${matches.length} 次`,
|
|
88
|
+
recommendation: '确认数据访问的必要性,避免读取敏感信息',
|
|
89
|
+
});
|
|
90
|
+
riskScore -= matches.length * 5;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 4. 代码执行检测
|
|
95
|
+
const evalPatterns = [
|
|
96
|
+
/eval\s*\(/g,
|
|
97
|
+
/new\s+Function\s*\(/g,
|
|
98
|
+
/setTimeout\s*\(\s*['"`]/g,
|
|
99
|
+
/setInterval\s*\(\s*['"`]/g,
|
|
100
|
+
];
|
|
101
|
+
for (const pattern of evalPatterns) {
|
|
102
|
+
const matches = code.match(pattern);
|
|
103
|
+
if (matches) {
|
|
104
|
+
findings.push({
|
|
105
|
+
level: 'danger',
|
|
106
|
+
category: 'execution',
|
|
107
|
+
description: `插件使用动态代码执行 ${matches.length} 次 (eval/Function)`,
|
|
108
|
+
code: matches[0],
|
|
109
|
+
recommendation: '动态代码执行存在严重安全风险,建议避免使用',
|
|
110
|
+
});
|
|
111
|
+
riskScore -= matches.length * 15;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// 5. 恶意模式检测
|
|
116
|
+
const maliciousPatterns = [
|
|
117
|
+
{ pattern: /key(?:down|up|press)/g, desc: '键盘事件监听', level: 'warning' as const, cat: 'keylogger' },
|
|
118
|
+
{ pattern: /addEventListener\s*\(\s*['"`](?:mouse|pointer|touch)/g, desc: '鼠标/触摸事件监听', level: 'info' as const, cat: 'tracking' },
|
|
119
|
+
{ pattern: /navigator\.(sendBeacon|geolocation|clipboard)/g, desc: '浏览器敏感 API 调用', level: 'warning' as const, cat: 'privacy' },
|
|
120
|
+
{ pattern: /new\s+Image\s*\(\s*\)\s*\.src\s*=/g, desc: '图片追踪(可能的像素追踪)', level: 'warning' as const, cat: 'tracking' },
|
|
121
|
+
];
|
|
122
|
+
for (const { pattern, desc, level, cat } of maliciousPatterns) {
|
|
123
|
+
const matches = code.match(pattern);
|
|
124
|
+
if (matches) {
|
|
125
|
+
findings.push({
|
|
126
|
+
level,
|
|
127
|
+
category: cat,
|
|
128
|
+
description: `检测到${desc} (${matches.length} 处)`,
|
|
129
|
+
recommendation: '确认这些操作是否为插件功能所必需',
|
|
130
|
+
});
|
|
131
|
+
riskScore -= matches.length * (level === 'warning' ? 8 : 3);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 6. 代码混淆检测
|
|
136
|
+
const obfuscationPatterns = [
|
|
137
|
+
{ pattern: /\\u[0-9a-fA-F]{4}/g, desc: 'Unicode 转义序列' },
|
|
138
|
+
{ pattern: /\\x[0-9a-fA-F]{2}/g, desc: '十六进制转义序列' },
|
|
139
|
+
{ pattern: /\[\s*['"][a-zA-Z]['"]\s*\+\s*['"][a-zA-Z]['"]\s*\]/g, desc: '字符串拼接混淆' },
|
|
140
|
+
];
|
|
141
|
+
for (const { pattern, desc } of obfuscationPatterns) {
|
|
142
|
+
const matches = code.match(pattern);
|
|
143
|
+
if (matches && matches.length > 3) {
|
|
144
|
+
findings.push({
|
|
145
|
+
level: 'warning',
|
|
146
|
+
category: 'obfuscation',
|
|
147
|
+
description: `检测到可能的代码混淆 (${desc}, ${matches.length} 处)`,
|
|
148
|
+
recommendation: '混淆代码可能隐藏恶意行为,建议审查源码',
|
|
149
|
+
});
|
|
150
|
+
riskScore -= 10;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// 限制分数范围
|
|
155
|
+
riskScore = Math.max(0, Math.min(100, riskScore));
|
|
156
|
+
|
|
157
|
+
// 确定风险等级
|
|
158
|
+
let riskLevel: 'low' | 'medium' | 'high' | 'critical';
|
|
159
|
+
if (riskScore >= 80) riskLevel = 'low';
|
|
160
|
+
else if (riskScore >= 60) riskLevel = 'medium';
|
|
161
|
+
else if (riskScore >= 30) riskLevel = 'high';
|
|
162
|
+
else riskLevel = 'critical';
|
|
163
|
+
|
|
164
|
+
// 生成摘要
|
|
165
|
+
const pluginName = manifest?.name || '未知插件';
|
|
166
|
+
const dangerCount = findings.filter(f => f.level === 'danger').length;
|
|
167
|
+
const warningCount = findings.filter(f => f.level === 'warning').length;
|
|
168
|
+
|
|
169
|
+
let summary: string;
|
|
170
|
+
if (dangerCount > 0) {
|
|
171
|
+
summary = `插件"${pluginName}"存在 ${dangerCount} 个高危项和 ${warningCount} 个警告项,请谨慎安装。`;
|
|
172
|
+
} else if (warningCount > 0) {
|
|
173
|
+
summary = `插件"${pluginName}"存在 ${warningCount} 个警告项,建议安装前审查代码。`;
|
|
174
|
+
} else if (findings.length > 0) {
|
|
175
|
+
summary = `插件"${pluginName}"未发现明显安全风险,仅有少量信息提示。`;
|
|
176
|
+
} else {
|
|
177
|
+
summary = `插件"${pluginName}"未发现安全风险,可以安全安装。`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
let recommendation: string;
|
|
181
|
+
if (riskLevel === 'critical') recommendation = '不建议安装,存在严重安全风险';
|
|
182
|
+
else if (riskLevel === 'high') recommendation = '建议谨慎安装,请仔细审查代码';
|
|
183
|
+
else if (riskLevel === 'medium') recommendation = '可以安装,建议关注警告项';
|
|
184
|
+
else recommendation = '可以安全安装';
|
|
185
|
+
|
|
186
|
+
return { riskLevel, score: riskScore, summary, findings, recommendation };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function POST(request: NextRequest) {
|
|
190
|
+
try {
|
|
191
|
+
const body: ScanRequest = await request.json();
|
|
192
|
+
const { code, manifest } = body;
|
|
193
|
+
|
|
194
|
+
if (!code) {
|
|
195
|
+
return NextResponse.json({ error: '缺少插件代码' }, { status: 400 });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// 尝试使用 AI API 进行深度分析
|
|
199
|
+
const aiResult = await aiAnalysis(code, manifest);
|
|
200
|
+
if (aiResult) {
|
|
201
|
+
return NextResponse.json(aiResult);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// 回退到静态分析
|
|
205
|
+
const result = staticAnalysis(code, manifest);
|
|
206
|
+
return NextResponse.json(result);
|
|
207
|
+
} catch (e) {
|
|
208
|
+
console.error('安全检测失败:', e);
|
|
209
|
+
return NextResponse.json({ error: '安全检测失败' }, { status: 500 });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** 尝试使用 AI API 进行深度分析 */
|
|
214
|
+
async function aiAnalysis(code: string, manifest?: ScanRequest['manifest']): Promise<ScanResult | null> {
|
|
215
|
+
try {
|
|
216
|
+
// 从数据库获取 API 设置
|
|
217
|
+
const { getDbPool } = await import('@/lib/db');
|
|
218
|
+
const { ensureDb } = await import('@/lib/db-init');
|
|
219
|
+
await ensureDb();
|
|
220
|
+
const db = getDbPool();
|
|
221
|
+
const [rows] = await db.execute('SELECT `value` FROM app_settings WHERE `key` = ?', ['app_settings']) as [Record<string, unknown>[], unknown];
|
|
222
|
+
|
|
223
|
+
let apiKey = process.env.AI_API_KEY || '';
|
|
224
|
+
let apiBase = process.env.AI_API_BASE || 'https://api.openai.com/v1';
|
|
225
|
+
let model = process.env.AI_MODEL || 'gpt-4o';
|
|
226
|
+
|
|
227
|
+
if (rows.length > 0 && rows[0].value) {
|
|
228
|
+
const settings = typeof rows[0].value === 'string' ? JSON.parse(rows[0].value) : rows[0].value;
|
|
229
|
+
if (settings.aiApiKey) apiKey = settings.aiApiKey;
|
|
230
|
+
if (settings.aiApiBase) apiBase = settings.aiApiBase;
|
|
231
|
+
if (settings.aiModel) model = settings.aiModel;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (!apiKey) return null;
|
|
235
|
+
|
|
236
|
+
const systemPrompt = `你是一个代码安全分析专家。分析以下插件代码的安全性,返回 JSON 格式的安全报告。
|
|
237
|
+
JSON 格式:
|
|
238
|
+
{
|
|
239
|
+
"riskLevel": "low|medium|high|critical",
|
|
240
|
+
"score": 0-100,
|
|
241
|
+
"summary": "一句话总结",
|
|
242
|
+
"findings": [{ "level": "info|warning|danger", "category": "类别", "description": "描述", "code": "相关代码片段(可选)", "recommendation": "建议" }],
|
|
243
|
+
"recommendation": "安装建议"
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
检测维度:网络请求、DOM 操作、数据访问、代码执行(eval/Function)、恶意模式、权限需求。`;
|
|
247
|
+
|
|
248
|
+
const response = await fetch(`${apiBase}/chat/completions`, {
|
|
249
|
+
method: 'POST',
|
|
250
|
+
headers: {
|
|
251
|
+
'Content-Type': 'application/json',
|
|
252
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
253
|
+
},
|
|
254
|
+
body: JSON.stringify({
|
|
255
|
+
model,
|
|
256
|
+
messages: [
|
|
257
|
+
{ role: 'system', content: systemPrompt },
|
|
258
|
+
{ role: 'user', content: `插件信息:${JSON.stringify(manifest || {})}\n\n插件代码:\n${code}` },
|
|
259
|
+
],
|
|
260
|
+
temperature: 0.3,
|
|
261
|
+
max_tokens: 2000,
|
|
262
|
+
}),
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
if (!response.ok) return null;
|
|
266
|
+
|
|
267
|
+
const data = await response.json();
|
|
268
|
+
const content = data.choices?.[0]?.message?.content;
|
|
269
|
+
if (!content) return null;
|
|
270
|
+
|
|
271
|
+
// 尝试从 AI 回复中提取 JSON
|
|
272
|
+
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
|
273
|
+
if (jsonMatch) {
|
|
274
|
+
return JSON.parse(jsonMatch[0]) as ScanResult;
|
|
275
|
+
}
|
|
276
|
+
return null;
|
|
277
|
+
} catch {
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
}
|