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.
Files changed (96) hide show
  1. package/.env.example +21 -0
  2. package/README.md +36 -0
  3. package/app/api/chat/route.ts +84 -0
  4. package/app/api/generate-svg/route.ts +171 -0
  5. package/app/api/generate-theme/route.ts +137 -0
  6. package/app/api/plugins/bindings/route.ts +173 -0
  7. package/app/api/plugins/export/route.ts +122 -0
  8. package/app/api/plugins/export-xye/route.ts +156 -0
  9. package/app/api/plugins/files/route.ts +146 -0
  10. package/app/api/plugins/files-list/route.ts +168 -0
  11. package/app/api/plugins/files-upload/route.ts +101 -0
  12. package/app/api/plugins/files-write/route.ts +272 -0
  13. package/app/api/plugins/import/route.ts +140 -0
  14. package/app/api/plugins/import-package/route.ts +231 -0
  15. package/app/api/plugins/resources/route.ts +109 -0
  16. package/app/api/plugins/route.ts +308 -0
  17. package/app/api/plugins/scan/route.ts +280 -0
  18. package/app/api/plugins/storage/route.ts +146 -0
  19. package/app/api/sessions/route.ts +165 -0
  20. package/app/api/settings/route.ts +40 -0
  21. package/app/api/suggest-fields/route.ts +129 -0
  22. package/app/api/templates/route.ts +159 -0
  23. package/app/api/test-api/route.ts +63 -0
  24. package/app/editor/page.tsx +1466 -0
  25. package/app/extensions/create/page.tsx +1422 -0
  26. package/app/extensions/edit/[id]/page.tsx +2342 -0
  27. package/app/extensions/page.tsx +1572 -0
  28. package/app/extensions/tutorial/page.tsx +4258 -0
  29. package/app/favicon.ico +0 -0
  30. package/app/fonts/GeistMonoVF.woff +0 -0
  31. package/app/fonts/GeistVF.woff +0 -0
  32. package/app/game/[id]/page.tsx +996 -0
  33. package/app/globals.css +3 -0
  34. package/app/layout.tsx +26 -0
  35. package/app/loading.tsx +26 -0
  36. package/app/page.tsx +345 -0
  37. package/app/settings/page.tsx +1490 -0
  38. package/bin/cli.js +262 -0
  39. package/components/ChatInput.tsx +106 -0
  40. package/components/ChatWindow.tsx +52 -0
  41. package/components/FullPageLoader.tsx +107 -0
  42. package/components/LoadingDots.tsx +20 -0
  43. package/components/MathCurveLoader.tsx +173 -0
  44. package/components/MessageBubble.tsx +147 -0
  45. package/components/WorldCardPreview.tsx +98 -0
  46. package/components/WorldCardUploader.tsx +58 -0
  47. package/components/ui/ConfirmDialog.tsx +135 -0
  48. package/components/ui/PageHeader.tsx +99 -0
  49. package/components/ui/PermissionConflictDialog.tsx +206 -0
  50. package/components/ui/PluginConfigForm.tsx +192 -0
  51. package/components/ui/PluginFloatingLayer.tsx +52 -0
  52. package/components/ui/PluginIcon.tsx +53 -0
  53. package/components/ui/PluginModalRenderer.tsx +185 -0
  54. package/components/ui/PluginProvider.tsx +1038 -0
  55. package/components/ui/PluginSlotRenderer.tsx +76 -0
  56. package/components/ui/ThemeCustomizer.tsx +174 -0
  57. package/components/ui/ThemeProvider.tsx +125 -0
  58. package/components/ui/ThemeSwitcher.tsx +140 -0
  59. package/components/ui/ToastProvider.tsx +141 -0
  60. package/lib/builtin-plugins.ts +11 -0
  61. package/lib/db-init.ts +35 -0
  62. package/lib/db.ts +244 -0
  63. package/lib/manifest-parser.ts +185 -0
  64. package/lib/parseWorldCard.ts +110 -0
  65. package/lib/plugin-dom-sandbox.ts +327 -0
  66. package/lib/plugin-events.ts +88 -0
  67. package/lib/plugin-files.ts +186 -0
  68. package/lib/plugin-html-sanitizer.ts +79 -0
  69. package/lib/plugin-resource-tracker.ts +175 -0
  70. package/lib/plugin-runtime.ts +2287 -0
  71. package/lib/plugin-security.ts +151 -0
  72. package/lib/plugin-types.ts +416 -0
  73. package/lib/prompt-builder.ts +55 -0
  74. package/lib/router-history.ts +119 -0
  75. package/lib/storage.ts +381 -0
  76. package/lib/themes.ts +129 -0
  77. package/lib/types.ts +117 -0
  78. package/lib/version.ts +55 -0
  79. package/next.config.mjs +43 -0
  80. package/package.json +56 -0
  81. package/plugins/xinyu.bag-system.xye +0 -0
  82. package/plugins/xinyu.cache-optimizer.xye +0 -0
  83. package/plugins/xinyu.dice-arbiter.xye +0 -0
  84. package/plugins/xinyu.game-auto-start-choices.xye +0 -0
  85. package/plugins/xinyu.markdown-render.xye +0 -0
  86. package/plugins/xinyu.slot-ui-beautify.xye +0 -0
  87. package/plugins/xinyu.world-info.xye +0 -0
  88. package/postcss.config.mjs +8 -0
  89. package/public/templates/atlantis.svg +63 -0
  90. package/public/templates/cyber-city.svg +68 -0
  91. package/public/templates/jianghu.svg +69 -0
  92. package/public/templates//351/255/224/346/263/225/344/270/226/347/225/214.svg +137 -0
  93. package/styles/themes.css +111 -0
  94. package/tailwind.config.ts +18 -0
  95. package/tsconfig.json +26 -0
  96. package/version.json +6 -0
@@ -0,0 +1,272 @@
1
+ // app/api/plugins/files-write/route.ts - 插件文件写入/删除/新建/重命名
2
+
3
+ import { NextRequest, NextResponse } from 'next/server';
4
+ import { ensureDb } from '@/lib/db-init';
5
+ import { getDbPool } from '@/lib/db';
6
+ import { writePluginResource, getPluginDir } from '@/lib/plugin-files';
7
+ import path from 'path';
8
+ import { promises as fs } from 'fs';
9
+
10
+ function isValidPluginId(id: string): boolean {
11
+ return /^[a-zA-Z0-9._-]+$/.test(id) && id.length <= 200 && !id.includes('..');
12
+ }
13
+
14
+ function isSafePath(filePath: string): boolean {
15
+ const normalized = path.normalize(filePath);
16
+ return !normalized.includes('..') && !path.isAbsolute(normalized) && normalized !== '';
17
+ }
18
+
19
+ /** 验证插件存在 */
20
+ async function verifyPlugin(pluginId: string) {
21
+ await ensureDb();
22
+ const db = getDbPool();
23
+ const [rows] = await db.execute(
24
+ 'SELECT id FROM extensions WHERE id = ?',
25
+ [pluginId]
26
+ ) as [Array<{ id: string }>, unknown];
27
+ if (rows.length === 0) {
28
+ return null;
29
+ }
30
+ return db;
31
+ }
32
+
33
+ /** 同步 manifest.json 到数据库 */
34
+ async function syncManifestToDb(db: ReturnType<typeof getDbPool>, pluginId: string, content: string) {
35
+ try {
36
+ const manifest = JSON.parse(content);
37
+ const updates: string[] = [];
38
+ const params: (string | null)[] = [];
39
+
40
+ if (manifest.name) { updates.push('name = ?'); params.push(String(manifest.name)); }
41
+ if (manifest.version) { updates.push('version = ?'); params.push(String(manifest.version)); }
42
+ if (manifest.description !== undefined) { updates.push('description = ?'); params.push(String(manifest.description || '')); }
43
+ if (manifest.author !== undefined) { updates.push('author = ?'); params.push(String(manifest.author || '')); }
44
+ if (manifest.type) { updates.push('type = ?'); params.push(String(manifest.type)); }
45
+ if (manifest.icon !== undefined) { updates.push('icon = ?'); params.push(String(manifest.icon || '')); }
46
+ if (manifest.minAppVersion !== undefined) { updates.push('min_app_version = ?'); params.push(String(manifest.minAppVersion || '')); }
47
+ if (manifest.configSchema) { updates.push('config_schema = ?'); params.push(JSON.stringify(manifest.configSchema)); }
48
+ if (manifest.commonPermissions || manifest.exclusivePermissions || manifest.requiredPermissions) {
49
+ updates.push('ui_permissions = ?');
50
+ params.push(JSON.stringify({
51
+ commonPermissions: manifest.commonPermissions || [],
52
+ exclusivePermissions: manifest.exclusivePermissions || [],
53
+ requiredPermissions: manifest.requiredPermissions || [],
54
+ }));
55
+ }
56
+ if (manifest.publicExports !== undefined) { updates.push('public_exports = ?'); params.push(JSON.stringify(manifest.publicExports)); }
57
+ if (manifest.dependencies !== undefined) { updates.push('dependencies = ?'); params.push(JSON.stringify(manifest.dependencies)); }
58
+
59
+ if (updates.length > 0) {
60
+ params.push(pluginId);
61
+ await db.execute(`UPDATE extensions SET ${updates.join(', ')} WHERE id = ?`, params);
62
+ }
63
+ } catch (e) {
64
+ console.error('同步 manifest 到数据库失败:', e);
65
+ }
66
+ }
67
+
68
+ // ==================== PUT: 写入文件内容 ====================
69
+
70
+ export async function PUT(request: NextRequest) {
71
+ try {
72
+ const { searchParams } = new URL(request.url);
73
+ const pluginId = searchParams.get('pluginId');
74
+ const filePath = searchParams.get('path');
75
+
76
+ if (!pluginId || !filePath) {
77
+ return NextResponse.json({ error: '缺少 pluginId 或 path 参数' }, { status: 400 });
78
+ }
79
+ if (!isValidPluginId(pluginId)) {
80
+ return NextResponse.json({ error: '非法插件 ID' }, { status: 400 });
81
+ }
82
+ if (!isSafePath(filePath)) {
83
+ return NextResponse.json({ error: '非法文件路径' }, { status: 400 });
84
+ }
85
+
86
+ const db = await verifyPlugin(pluginId);
87
+ if (!db) {
88
+ return NextResponse.json({ error: '插件不存在' }, { status: 404 });
89
+ }
90
+
91
+ const body = await request.json();
92
+ const content = body.content;
93
+
94
+ if (typeof content !== 'string') {
95
+ return NextResponse.json({ error: 'content 必须是字符串' }, { status: 400 });
96
+ }
97
+
98
+ await writePluginResource(pluginId, filePath, content);
99
+
100
+ if (filePath === 'manifest.json') {
101
+ await syncManifestToDb(db, pluginId, content);
102
+ }
103
+
104
+ return NextResponse.json({ success: true, path: filePath });
105
+ } catch (e) {
106
+ console.error('写入插件文件失败:', e);
107
+ return NextResponse.json({ error: '写入文件失败' }, { status: 500 });
108
+ }
109
+ }
110
+
111
+ // ==================== POST: 新建文件或文件夹 ====================
112
+
113
+ export async function POST(request: NextRequest) {
114
+ try {
115
+ const { searchParams } = new URL(request.url);
116
+ const pluginId = searchParams.get('pluginId');
117
+
118
+ if (!pluginId) {
119
+ return NextResponse.json({ error: '缺少 pluginId 参数' }, { status: 400 });
120
+ }
121
+ if (!isValidPluginId(pluginId)) {
122
+ return NextResponse.json({ error: '非法插件 ID' }, { status: 400 });
123
+ }
124
+
125
+ const db = await verifyPlugin(pluginId);
126
+ if (!db) {
127
+ return NextResponse.json({ error: '插件不存在' }, { status: 404 });
128
+ }
129
+
130
+ const body = await request.json();
131
+ const { path: filePath, type, content } = body;
132
+
133
+ if (!filePath || typeof filePath !== 'string') {
134
+ return NextResponse.json({ error: '缺少 path 参数' }, { status: 400 });
135
+ }
136
+ if (!isSafePath(filePath)) {
137
+ return NextResponse.json({ error: '非法文件路径' }, { status: 400 });
138
+ }
139
+
140
+ const pluginDir = getPluginDir(pluginId);
141
+ const fullPath = path.join(pluginDir, filePath);
142
+
143
+ if (type === 'dir') {
144
+ await fs.mkdir(fullPath, { recursive: true });
145
+ } else {
146
+ await fs.mkdir(path.dirname(fullPath), { recursive: true });
147
+ await fs.writeFile(fullPath, content || '', 'utf-8');
148
+ }
149
+
150
+ return NextResponse.json({ success: true, path: filePath, type: type || 'file' });
151
+ } catch (e) {
152
+ console.error('新建文件失败:', e);
153
+ return NextResponse.json({ error: '新建文件失败' }, { status: 500 });
154
+ }
155
+ }
156
+
157
+ // ==================== DELETE: 删除文件或文件夹 ====================
158
+
159
+ export async function DELETE(request: NextRequest) {
160
+ try {
161
+ const { searchParams } = new URL(request.url);
162
+ const pluginId = searchParams.get('pluginId');
163
+ const filePath = searchParams.get('path');
164
+
165
+ if (!pluginId || !filePath) {
166
+ return NextResponse.json({ error: '缺少 pluginId 或 path 参数' }, { status: 400 });
167
+ }
168
+ if (!isValidPluginId(pluginId)) {
169
+ return NextResponse.json({ error: '非法插件 ID' }, { status: 400 });
170
+ }
171
+ if (!isSafePath(filePath)) {
172
+ return NextResponse.json({ error: '非法文件路径' }, { status: 400 });
173
+ }
174
+
175
+ // 禁止删除关键文件:manifest.json、入口文件、resources 目录本身
176
+ if (filePath === 'manifest.json') {
177
+ return NextResponse.json({ error: '不能删除 manifest.json' }, { status: 400 });
178
+ }
179
+ if (filePath === 'resources') {
180
+ return NextResponse.json({ error: '不能删除 resources 目录(资源文件目录,仅支持删除其中的文件)' }, { status: 400 });
181
+ }
182
+
183
+ const db = await verifyPlugin(pluginId);
184
+ if (!db) {
185
+ return NextResponse.json({ error: '插件不存在' }, { status: 404 });
186
+ }
187
+
188
+ // 从数据库读取 code_path,提取入口文件名,禁止删除入口文件
189
+ const [rows] = await db.execute(
190
+ 'SELECT code_path FROM extensions WHERE id = ?',
191
+ [pluginId]
192
+ ) as [Array<{ code_path: string }>, unknown];
193
+ if (rows.length > 0 && rows[0].code_path) {
194
+ const entryFile = rows[0].code_path.split('/').pop();
195
+ if (entryFile && filePath === entryFile) {
196
+ return NextResponse.json({ error: `不能删除入口文件 ${entryFile}` }, { status: 400 });
197
+ }
198
+ }
199
+
200
+ const pluginDir = getPluginDir(pluginId);
201
+ const fullPath = path.join(pluginDir, filePath);
202
+
203
+ try {
204
+ const stat = await fs.stat(fullPath);
205
+ if (stat.isDirectory()) {
206
+ await fs.rm(fullPath, { recursive: true, force: true });
207
+ } else {
208
+ await fs.unlink(fullPath);
209
+ }
210
+ } catch {
211
+ return NextResponse.json({ error: '文件不存在' }, { status: 404 });
212
+ }
213
+
214
+ return NextResponse.json({ success: true, path: filePath });
215
+ } catch (e) {
216
+ console.error('删除文件失败:', e);
217
+ return NextResponse.json({ error: '删除文件失败' }, { status: 500 });
218
+ }
219
+ }
220
+
221
+ // ==================== PATCH: 重命名/移动文件 ====================
222
+
223
+ export async function PATCH(request: NextRequest) {
224
+ try {
225
+ const { searchParams } = new URL(request.url);
226
+ const pluginId = searchParams.get('pluginId');
227
+ const filePath = searchParams.get('path');
228
+
229
+ if (!pluginId || !filePath) {
230
+ return NextResponse.json({ error: '缺少 pluginId 或 path 参数' }, { status: 400 });
231
+ }
232
+ if (!isValidPluginId(pluginId)) {
233
+ return NextResponse.json({ error: '非法插件 ID' }, { status: 400 });
234
+ }
235
+ if (!isSafePath(filePath)) {
236
+ return NextResponse.json({ error: '非法文件路径' }, { status: 400 });
237
+ }
238
+
239
+ const db = await verifyPlugin(pluginId);
240
+ if (!db) {
241
+ return NextResponse.json({ error: '插件不存在' }, { status: 404 });
242
+ }
243
+
244
+ const body = await request.json();
245
+ const newPath = body.newPath;
246
+
247
+ if (!newPath || typeof newPath !== 'string') {
248
+ return NextResponse.json({ error: '缺少 newPath 参数' }, { status: 400 });
249
+ }
250
+ if (!isSafePath(newPath)) {
251
+ return NextResponse.json({ error: '非法目标路径' }, { status: 400 });
252
+ }
253
+
254
+ const pluginDir = getPluginDir(pluginId);
255
+ const oldFullPath = path.join(pluginDir, filePath);
256
+ const newFullPath = path.join(pluginDir, newPath);
257
+
258
+ try {
259
+ await fs.stat(oldFullPath);
260
+ } catch {
261
+ return NextResponse.json({ error: '源文件不存在' }, { status: 404 });
262
+ }
263
+
264
+ await fs.mkdir(path.dirname(newFullPath), { recursive: true });
265
+ await fs.rename(oldFullPath, newFullPath);
266
+
267
+ return NextResponse.json({ success: true, from: filePath, to: newPath });
268
+ } catch (e) {
269
+ console.error('重命名文件失败:', e);
270
+ return NextResponse.json({ error: '重命名文件失败' }, { status: 500 });
271
+ }
272
+ }
@@ -0,0 +1,140 @@
1
+ // app/api/plugins/import/route.ts - 插件导入 API
2
+
3
+ import { NextRequest, NextResponse } from 'next/server';
4
+ import { getDbPool } from '@/lib/db';
5
+ import { ensureDb } from '@/lib/db-init';
6
+ import { parseManifestFromCode } from '@/lib/manifest-parser';
7
+ import { writePluginCode } from '@/lib/plugin-files';
8
+
9
+ /** 校验插件 ID 合法性(只允许字母、数字、连字符、下划线、点) */
10
+ function isValidPluginId(id: string): boolean {
11
+ return /^[a-zA-Z0-9._-]+$/.test(id) && id.length <= 200;
12
+ }
13
+
14
+ export async function POST(request: NextRequest) {
15
+ try {
16
+ await ensureDb();
17
+ const db = getDbPool();
18
+ const body = await request.json();
19
+
20
+ // 支持两种格式:完整导出格式 或 直接传入插件数组
21
+ let plugins: Array<Record<string, unknown>> = [];
22
+
23
+ if (body.format === 'xinyu-extension-v1' && Array.isArray(body.plugins)) {
24
+ plugins = body.plugins;
25
+ } else if (Array.isArray(body)) {
26
+ plugins = body;
27
+ } else if (body.id && body.code) {
28
+ // 单个插件
29
+ plugins = [body];
30
+ } else {
31
+ return NextResponse.json({ error: '无效的导入格式' }, { status: 400 });
32
+ }
33
+
34
+ let imported = 0;
35
+ let skipped = 0;
36
+
37
+ // 使用事务保证原子性:要么全部成功,要么全部回滚
38
+ const conn = await db.getConnection();
39
+ try {
40
+ await conn.beginTransaction();
41
+
42
+ for (const plugin of plugins) {
43
+ const pId = String(plugin.id || '');
44
+ const pName = String(plugin.name || '');
45
+ const pVersion = String(plugin.version || '');
46
+ const pDescription = String(plugin.description || '');
47
+ const pAuthor = String(plugin.author || '');
48
+ const pType = String(plugin.type || '');
49
+ const pIcon = String(plugin.icon || '');
50
+ const pMinAppVersion = String(plugin.minAppVersion || '');
51
+ const pCode = String(plugin.code || '');
52
+ const pConfigSchema = JSON.stringify(plugin.configSchema || []);
53
+ const pUiSlots = JSON.stringify(plugin.uiSlots || []);
54
+
55
+ // 权限:优先使用 JSON 中的三个独立字段,兼容旧 uiPermissions 数组格式,最后兜底从代码注释解析
56
+ let commonPerms = Array.isArray(plugin.commonPermissions) ? plugin.commonPermissions : [];
57
+ const exclusivePerms = Array.isArray(plugin.exclusivePermissions) ? plugin.exclusivePermissions : [];
58
+ const requiredPerms = Array.isArray(plugin.requiredPermissions) ? plugin.requiredPermissions : [];
59
+
60
+ // 兼容旧格式:uiPermissions 数组 → 映射为 commonPermissions
61
+ if (commonPerms.length === 0 && exclusivePerms.length === 0 && requiredPerms.length === 0) {
62
+ const legacyPerms = plugin.uiPermissions;
63
+ if (Array.isArray(legacyPerms) && legacyPerms.length > 0) {
64
+ commonPerms = legacyPerms;
65
+ }
66
+ }
67
+
68
+ // 兜底:从代码注释解析权限
69
+ if (commonPerms.length === 0 && exclusivePerms.length === 0 && requiredPerms.length === 0) {
70
+ const parsed = parseManifestFromCode(pCode);
71
+ if (parsed.permissions && parsed.permissions.length > 0) {
72
+ commonPerms = parsed.permissions;
73
+ }
74
+ }
75
+
76
+ const pUiPermissions = (commonPerms.length > 0 || exclusivePerms.length > 0 || requiredPerms.length > 0)
77
+ ? JSON.stringify({ commonPermissions: commonPerms, exclusivePermissions: exclusivePerms, requiredPermissions: requiredPerms })
78
+ : null;
79
+
80
+ const pPublicExports = plugin.publicExports !== undefined ? JSON.stringify(plugin.publicExports) : null;
81
+ const pDependencies = plugin.dependencies !== undefined ? JSON.stringify(plugin.dependencies) : null;
82
+
83
+ // 校验必填字段和 ID 合法性
84
+ if (!pId || !pName || !pVersion || !pType || !pCode) {
85
+ skipped++;
86
+ continue;
87
+ }
88
+ if (!isValidPluginId(pId)) {
89
+ console.warn(`[导入] 跳过非法 ID 的插件: ${pId}`);
90
+ skipped++;
91
+ continue;
92
+ }
93
+
94
+ // 将代码写入文件
95
+ const codePath = await writePluginCode(pId, pCode);
96
+
97
+ const sql = `INSERT INTO extensions (id, name, version, description, author, type, icon, min_app_version, code_path, config_schema, ui_slots, ui_permissions, public_exports, dependencies) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE name = VALUES(name), version = VALUES(version), description = VALUES(description), author = VALUES(author), type = VALUES(type), icon = VALUES(icon), min_app_version = VALUES(min_app_version), code_path = VALUES(code_path), config_schema = VALUES(config_schema), ui_slots = VALUES(ui_slots), ui_permissions = VALUES(ui_permissions), public_exports = VALUES(public_exports), dependencies = VALUES(dependencies)`;
98
+ await conn.execute(sql, [pId, pName, pVersion, pDescription, pAuthor, pType, pIcon, pMinAppVersion, codePath, pConfigSchema, pUiSlots, pUiPermissions, pPublicExports, pDependencies]);
99
+
100
+ // 自动创建全局绑定,config 从 configSchema 的 defaultValue 填充
101
+ let defaultConfig: Record<string, unknown> = {};
102
+ try {
103
+ const schema = plugin.configSchema;
104
+ if (Array.isArray(schema) && schema.length > 0) {
105
+ for (const field of schema) {
106
+ if (field && typeof field === 'object' && 'key' in field && 'defaultValue' in field) {
107
+ defaultConfig[field.key as string] = field.defaultValue;
108
+ }
109
+ }
110
+ }
111
+ } catch { /* 解析失败时使用空对象 */ }
112
+ const configJson = Object.keys(defaultConfig).length > 0 ? JSON.stringify(defaultConfig) : '{}';
113
+
114
+ await conn.execute(
115
+ `INSERT INTO extension_bindings (extension_id, scope, world_id, enabled, config, sort_order) VALUES (?, 'global', '', TRUE, ?, 0) ON DUPLICATE KEY UPDATE enabled = VALUES(enabled)`,
116
+ [pId, configJson]
117
+ );
118
+
119
+ imported++;
120
+ }
121
+
122
+ await conn.commit();
123
+ } catch (e) {
124
+ try { await conn.rollback(); } catch { /* rollback 也失败时忽略 */ }
125
+ // 出错时销毁连接,防止错误状态的连接被放回连接池污染后续请求
126
+ conn.destroy();
127
+ throw e;
128
+ } finally {
129
+ // destroy 后 release 是空操作,正常路径走 release 归还连接
130
+ if (conn && typeof conn.release === 'function') {
131
+ try { conn.release(); } catch { /* ignore */ }
132
+ }
133
+ }
134
+
135
+ return NextResponse.json({ success: true, imported, skipped, total: plugins.length });
136
+ } catch (e) {
137
+ console.error('导入插件失败:', e);
138
+ return NextResponse.json({ error: '导入插件失败' }, { status: 500 });
139
+ }
140
+ }
@@ -0,0 +1,231 @@
1
+ // app/api/plugins/import-package/route.ts - .xye 插件包导入 API
2
+
3
+ import { NextRequest, NextResponse } from 'next/server';
4
+ import { getDbPool } from '@/lib/db';
5
+ import { ensureDb } from '@/lib/db-init';
6
+ import { parseManifestFromCode } from '@/lib/manifest-parser';
7
+ import { getPluginDir } from '@/lib/plugin-files';
8
+ import AdmZip from 'adm-zip';
9
+ import path from 'path';
10
+ import { promises as fs } from 'fs';
11
+
12
+ /** 校验插件 ID 合法性 */
13
+ function isValidPluginId(id: string): boolean {
14
+ return /^[a-zA-Z0-9._-]+$/.test(id) && id.length <= 200 && !id.includes('..');
15
+ }
16
+
17
+ /** 校验文件路径安全性(防止路径遍历) */
18
+ function isSafePath(filePath: string): boolean {
19
+ const normalized = path.normalize(filePath);
20
+ return !normalized.includes('..') && !path.isAbsolute(normalized);
21
+ }
22
+
23
+ export async function POST(request: NextRequest) {
24
+ try {
25
+ await ensureDb();
26
+ const db = getDbPool();
27
+
28
+ const formData = await request.formData();
29
+ const file = formData.get('file') as File | null;
30
+
31
+ if (!file) {
32
+ return NextResponse.json({ error: '未上传文件' }, { status: 400 });
33
+ }
34
+
35
+ if (!file.name.endsWith('.xye')) {
36
+ return NextResponse.json({ error: '仅支持 .xye 格式的插件包' }, { status: 400 });
37
+ }
38
+
39
+ // 读取文件到 Buffer
40
+ const buffer = Buffer.from(await file.arrayBuffer());
41
+
42
+ // 解压 ZIP
43
+ let zip: AdmZip;
44
+ try {
45
+ zip = new AdmZip(buffer);
46
+ } catch {
47
+ return NextResponse.json({ error: '无法解压插件包,文件可能已损坏' }, { status: 400 });
48
+ }
49
+
50
+ const entries = zip.getEntries();
51
+
52
+ // 查找 manifest.json
53
+ const manifestEntry = entries.find(e =>
54
+ e.entryName === 'manifest.json' || e.entryName.endsWith('/manifest.json')
55
+ );
56
+
57
+ if (!manifestEntry) {
58
+ return NextResponse.json({ error: '插件包中未找到 manifest.json' }, { status: 400 });
59
+ }
60
+
61
+ let manifest: Record<string, unknown>;
62
+ try {
63
+ manifest = JSON.parse(manifestEntry.getData().toString('utf-8'));
64
+ } catch {
65
+ return NextResponse.json({ error: 'manifest.json 格式错误' }, { status: 400 });
66
+ }
67
+
68
+ // 验证必填字段
69
+ const pluginId = String(manifest.id || '');
70
+ const pluginEntry = String(manifest.pluginEntry || manifest['plugin-entry'] || '');
71
+ const pluginName = String(manifest.name || '');
72
+ const pluginVersion = String(manifest.version || '');
73
+ const pluginType = String(manifest.type || '');
74
+
75
+ if (!pluginId || !pluginName || !pluginVersion || !pluginType) {
76
+ return NextResponse.json({ error: 'manifest.json 缺少必填字段 (id, name, version, type)' }, { status: 400 });
77
+ }
78
+
79
+ if (!isValidPluginId(pluginId)) {
80
+ return NextResponse.json({ error: `非法插件 ID: ${pluginId}` }, { status: 400 });
81
+ }
82
+
83
+ if (!pluginEntry) {
84
+ return NextResponse.json({ error: 'manifest.json 缺少 pluginEntry 字段(指定入口 JS 文件)' }, { status: 400 });
85
+ }
86
+
87
+ // 查找入口文件
88
+ const entryPath = pluginEntry.startsWith('/') ? pluginEntry.slice(1) : pluginEntry;
89
+ const codeEntry = entries.find(e => e.entryName === entryPath || e.entryName === `/${entryPath}`);
90
+
91
+ if (!codeEntry) {
92
+ return NextResponse.json({ error: `入口文件不存在: ${pluginEntry}` }, { status: 400 });
93
+ }
94
+
95
+ const pluginCode = codeEntry.getData().toString('utf-8');
96
+
97
+ // 确定包的根目录前缀(如果有子目录)
98
+ const manifestDir = manifestEntry.entryName === 'manifest.json'
99
+ ? ''
100
+ : manifestEntry.entryName.replace(/manifest\.json$/, '');
101
+
102
+ // 解析权限
103
+ let commonPerms = Array.isArray(manifest.commonPermissions) ? manifest.commonPermissions : [];
104
+ const exclusivePerms = Array.isArray(manifest.exclusivePermissions) ? manifest.exclusivePermissions : [];
105
+ const requiredPerms = Array.isArray(manifest.requiredPermissions) ? manifest.requiredPermissions : [];
106
+
107
+ // 兼容旧格式
108
+ if (commonPerms.length === 0 && exclusivePerms.length === 0 && requiredPerms.length === 0) {
109
+ const legacyPerms = manifest.uiPermissions;
110
+ if (Array.isArray(legacyPerms) && legacyPerms.length > 0) {
111
+ commonPerms = legacyPerms;
112
+ }
113
+ }
114
+
115
+ // 兜底从代码注释解析
116
+ if (commonPerms.length === 0 && exclusivePerms.length === 0 && requiredPerms.length === 0) {
117
+ const parsed = parseManifestFromCode(pluginCode);
118
+ if (parsed.permissions && parsed.permissions.length > 0) {
119
+ commonPerms = parsed.permissions;
120
+ }
121
+ }
122
+
123
+ const pUiPermissions = (commonPerms.length > 0 || exclusivePerms.length > 0 || requiredPerms.length > 0)
124
+ ? JSON.stringify({ commonPermissions: commonPerms, exclusivePermissions: exclusivePerms, requiredPermissions: requiredPerms })
125
+ : null;
126
+
127
+ const pConfigSchema = JSON.stringify(manifest.configSchema || []);
128
+ const pUiSlots = JSON.stringify(manifest.uiSlots || []);
129
+ const pPublicExports = manifest.publicExports !== undefined ? JSON.stringify(manifest.publicExports) : null;
130
+ const pDependencies = manifest.dependencies !== undefined ? JSON.stringify(manifest.dependencies) : null;
131
+
132
+ // 使用事务保证原子性
133
+ const conn = await db.getConnection();
134
+ try {
135
+ await conn.beginTransaction();
136
+
137
+ // 写入入口代码文件(使用原始文件名,如 index.js)
138
+ const pluginDir = getPluginDir(pluginId);
139
+ await fs.mkdir(pluginDir, { recursive: true });
140
+ const codeFilePath = path.join(pluginDir, entryPath);
141
+ await fs.writeFile(codeFilePath, pluginCode, 'utf-8');
142
+ const codePath = `data/plugins/${pluginId}/${entryPath}`;
143
+
144
+ // 写入数据库
145
+ await conn.execute(
146
+ `INSERT INTO extensions (id, name, version, description, author, type, icon, min_app_version, code_path, config_schema, ui_slots, ui_permissions, public_exports, dependencies)
147
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
148
+ ON DUPLICATE KEY UPDATE
149
+ name = VALUES(name), version = VALUES(version), description = VALUES(description),
150
+ author = VALUES(author), type = VALUES(type), icon = VALUES(icon),
151
+ min_app_version = VALUES(min_app_version), code_path = VALUES(code_path),
152
+ config_schema = VALUES(config_schema), ui_slots = VALUES(ui_slots),
153
+ ui_permissions = VALUES(ui_permissions),
154
+ public_exports = VALUES(public_exports), dependencies = VALUES(dependencies)`,
155
+ [
156
+ pluginId, pluginName, pluginVersion,
157
+ String(manifest.description || ''), String(manifest.author || ''),
158
+ pluginType, String(manifest.icon || ''), String(manifest.minAppVersion || ''),
159
+ codePath, pConfigSchema, pUiSlots, pUiPermissions, pPublicExports, pDependencies,
160
+ ]
161
+ );
162
+
163
+ // 自动创建全局绑定
164
+ const defaultConfig: Record<string, unknown> = {};
165
+ try {
166
+ const schema = manifest.configSchema;
167
+ if (Array.isArray(schema) && schema.length > 0) {
168
+ for (const field of schema) {
169
+ if (field && typeof field === 'object' && 'key' in field && 'defaultValue' in field) {
170
+ defaultConfig[field.key as string] = field.defaultValue;
171
+ }
172
+ }
173
+ }
174
+ } catch { /* ignore */ }
175
+ const configJson = Object.keys(defaultConfig).length > 0 ? JSON.stringify(defaultConfig) : '{}';
176
+
177
+ await conn.execute(
178
+ `INSERT INTO extension_bindings (extension_id, scope, world_id, enabled, config, sort_order)
179
+ VALUES (?, 'global', '', TRUE, ?, 0)
180
+ ON DUPLICATE KEY UPDATE enabled = VALUES(enabled)`,
181
+ [pluginId, configJson]
182
+ );
183
+
184
+ // 解压所有文件到插件目录(跳过已写入的入口文件)
185
+ for (const entry of entries) {
186
+ if (entry.isDirectory) continue;
187
+
188
+ const relativeName = entry.entryName.startsWith('/')
189
+ ? entry.entryName.slice(1)
190
+ : entry.entryName;
191
+
192
+ // 如果包有子目录前缀,去掉它
193
+ const cleanName = manifestDir ? relativeName.replace(manifestDir, '') : relativeName;
194
+ if (!cleanName || cleanName === entryPath) continue;
195
+ if (!isSafePath(cleanName)) {
196
+ console.warn(`[导入 .xye] 跳过不安全路径: ${cleanName}`);
197
+ continue;
198
+ }
199
+
200
+ const targetPath = path.join(pluginDir, cleanName);
201
+ await fs.mkdir(path.dirname(targetPath), { recursive: true });
202
+ await fs.writeFile(targetPath, entry.getData());
203
+ }
204
+
205
+ await conn.commit();
206
+ } catch (e) {
207
+ try { await conn.rollback(); } catch { /* ignore */ }
208
+ conn.destroy();
209
+ throw e;
210
+ } finally {
211
+ if (conn && typeof conn.release === 'function') {
212
+ try { conn.release(); } catch { /* ignore */ }
213
+ }
214
+ }
215
+
216
+ return NextResponse.json({
217
+ success: true,
218
+ imported: 1,
219
+ skipped: 0,
220
+ total: 1,
221
+ plugin: {
222
+ id: pluginId,
223
+ name: pluginName,
224
+ version: pluginVersion,
225
+ },
226
+ });
227
+ } catch (e) {
228
+ console.error('导入 .xye 插件包失败:', e);
229
+ return NextResponse.json({ error: '导入插件包失败' }, { status: 500 });
230
+ }
231
+ }