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,122 @@
1
+ // app/api/plugins/export/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 { readPluginCode } from '@/lib/plugin-files';
7
+
8
+ /** 解析 JSON 字段 */
9
+ function parseJsonField<T>(value: unknown): T {
10
+ if (typeof value === 'string') {
11
+ try { return JSON.parse(value) as T; } catch { return value as unknown as T; }
12
+ }
13
+ return value as T;
14
+ }
15
+
16
+ /** 解析权限字段(兼容旧格式数组和当前结构化对象) */
17
+ function parsePermissions(value: unknown): {
18
+ commonPermissions?: string[];
19
+ exclusivePermissions?: string[];
20
+ requiredPermissions?: string[];
21
+ } {
22
+ const raw = parseJsonField<unknown>(value);
23
+ if (Array.isArray(raw)) {
24
+ return { commonPermissions: raw };
25
+ }
26
+ if (raw && typeof raw === 'object') {
27
+ const perms = raw as Record<string, unknown>;
28
+ return {
29
+ commonPermissions: (perms.commonPermissions as string[]) || [],
30
+ exclusivePermissions: (perms.exclusivePermissions as string[]) || [],
31
+ requiredPermissions: (perms.requiredPermissions as string[]) || [],
32
+ };
33
+ }
34
+ return {};
35
+ }
36
+
37
+ /** 将数据库行转换为插件对象(导出格式,不含 code,需异步填充) */
38
+ function rowToPlugin(row: Record<string, unknown>) {
39
+ const perms = parsePermissions(row.ui_permissions);
40
+ return {
41
+ id: row.id,
42
+ name: row.name,
43
+ version: row.version,
44
+ description: row.description,
45
+ author: row.author,
46
+ type: row.type,
47
+ icon: row.icon,
48
+ minAppVersion: row.min_app_version,
49
+ code: '', // 将由 fillPluginCode 异步填充
50
+ configSchema: parseJsonField(row.config_schema),
51
+ uiSlots: parseJsonField(row.ui_slots),
52
+ ...perms,
53
+ publicExports: parseJsonField(row.public_exports),
54
+ dependencies: parseJsonField(row.dependencies),
55
+ createdAt: row.created_at,
56
+ updatedAt: row.updated_at,
57
+ };
58
+ }
59
+
60
+ /** 异步填充插件代码 */
61
+ async function fillPluginCode(plugin: ReturnType<typeof rowToPlugin>): Promise<typeof plugin> {
62
+ try {
63
+ plugin.code = await readPluginCode(plugin.id as string);
64
+ } catch {
65
+ plugin.code = '';
66
+ }
67
+ return plugin;
68
+ }
69
+
70
+ export async function GET(request: NextRequest) {
71
+ try {
72
+ await ensureDb();
73
+ const db = getDbPool();
74
+
75
+ const { searchParams } = new URL(request.url);
76
+ const pluginId = searchParams.get('id');
77
+
78
+ if (pluginId) {
79
+ // 导出单个插件(直接返回插件对象,不带 format 包裹)
80
+ const [rows] = await db.execute(
81
+ 'SELECT * FROM extensions WHERE id = ?',
82
+ [pluginId]
83
+ ) as [Record<string, unknown>[], unknown];
84
+
85
+ if (rows.length === 0) {
86
+ return NextResponse.json({ error: '插件不存在' }, { status: 404 });
87
+ }
88
+
89
+ const plugin = await fillPluginCode(rowToPlugin(rows[0]));
90
+
91
+ return new NextResponse(JSON.stringify(plugin, null, 2), {
92
+ headers: {
93
+ 'Content-Type': 'application/json',
94
+ 'Content-Disposition': `attachment; filename="${pluginId}.json"`,
95
+ },
96
+ });
97
+ }
98
+
99
+ // 导出全部插件(多插件包格式)
100
+ const [rows] = await db.execute(
101
+ 'SELECT * FROM extensions ORDER BY updated_at DESC'
102
+ ) as [Record<string, unknown>[], unknown];
103
+
104
+ const plugins = await Promise.all(rows.map(r => fillPluginCode(rowToPlugin(r))));
105
+
106
+ const exportData = {
107
+ format: 'xinyu-extension-v1',
108
+ exportedAt: new Date().toISOString(),
109
+ plugins,
110
+ };
111
+
112
+ return new NextResponse(JSON.stringify(exportData, null, 2), {
113
+ headers: {
114
+ 'Content-Type': 'application/json',
115
+ 'Content-Disposition': `attachment; filename="xinyu-plugins-${Date.now()}.json"`,
116
+ },
117
+ });
118
+ } catch (e) {
119
+ console.error('导出插件失败:', e);
120
+ return NextResponse.json({ error: '导出插件失败' }, { status: 500 });
121
+ }
122
+ }
@@ -0,0 +1,156 @@
1
+ // app/api/plugins/export-xye/route.ts - 导出插件为 .xye 包(ZIP 格式)
2
+
3
+ import { NextRequest, NextResponse } from 'next/server';
4
+ import { getDbPool } from '@/lib/db';
5
+ import { ensureDb } from '@/lib/db-init';
6
+ import { getPluginDir, readPluginCode } from '@/lib/plugin-files';
7
+ import { listPluginFiles } from '@/lib/plugin-files';
8
+ import AdmZip from 'adm-zip';
9
+ import path from 'path';
10
+ import { promises as fs } from 'fs';
11
+
12
+ /** 解析 JSON 字段 */
13
+ function parseJsonField<T>(value: unknown): T {
14
+ if (typeof value === 'string') {
15
+ try { return JSON.parse(value) as T; } catch { return value as unknown as T; }
16
+ }
17
+ return value as T;
18
+ }
19
+
20
+ /** 解析权限字段 */
21
+ function parsePermissions(value: unknown): {
22
+ commonPermissions?: string[];
23
+ exclusivePermissions?: string[];
24
+ requiredPermissions?: string[];
25
+ } {
26
+ const raw = parseJsonField<unknown>(value);
27
+ if (Array.isArray(raw)) {
28
+ return { commonPermissions: raw };
29
+ }
30
+ if (raw && typeof raw === 'object') {
31
+ const perms = raw as Record<string, unknown>;
32
+ return {
33
+ commonPermissions: (perms.commonPermissions as string[]) || [],
34
+ exclusivePermissions: (perms.exclusivePermissions as string[]) || [],
35
+ requiredPermissions: (perms.requiredPermissions as string[]) || [],
36
+ };
37
+ }
38
+ return {};
39
+ }
40
+
41
+ export async function GET(request: NextRequest) {
42
+ try {
43
+ await ensureDb();
44
+ const db = getDbPool();
45
+
46
+ const { searchParams } = new URL(request.url);
47
+ const pluginId = searchParams.get('id');
48
+
49
+ if (!pluginId) {
50
+ return NextResponse.json({ error: '缺少 id 参数' }, { status: 400 });
51
+ }
52
+
53
+ const [rows] = await db.execute(
54
+ 'SELECT * FROM extensions WHERE id = ?',
55
+ [pluginId]
56
+ ) as [Record<string, unknown>[], unknown];
57
+
58
+ if (rows.length === 0) {
59
+ return NextResponse.json({ error: '插件不存在' }, { status: 404 });
60
+ }
61
+
62
+ const row = rows[0];
63
+ const perms = parsePermissions(row.ui_permissions);
64
+ const pluginDir = getPluginDir(pluginId);
65
+
66
+ // 检查是否已有 manifest.json(.xye 格式插件)
67
+ const manifestPath = path.join(pluginDir, 'manifest.json');
68
+ let hasManifest = false;
69
+ try {
70
+ await fs.access(manifestPath);
71
+ hasManifest = true;
72
+ } catch { /* 不存在 */ }
73
+
74
+ const zip = new AdmZip();
75
+
76
+ if (hasManifest) {
77
+ // 已有 manifest.json:直接打包所有文件
78
+ const files = await listPluginFiles(pluginId);
79
+ for (const filePath of files) {
80
+ const fullPath = path.join(pluginDir, filePath);
81
+ try {
82
+ const stat = await fs.stat(fullPath);
83
+ if (stat.isFile()) {
84
+ const content = await fs.readFile(fullPath);
85
+ zip.addFile(filePath, content);
86
+ }
87
+ } catch { /* skip */ }
88
+ }
89
+ } else {
90
+ // 单 JS 插件:生成 manifest.json + 打包代码
91
+ let code = '';
92
+ try {
93
+ code = await readPluginCode(pluginId);
94
+ } catch { /* empty */ }
95
+
96
+ // 从 code_path 提取入口文件名
97
+ const codePath = row.code_path as string || '';
98
+ const entryName = codePath ? codePath.split('/').pop() || 'plugin.js' : 'plugin.js';
99
+
100
+ // 生成 manifest.json
101
+ const manifest: Record<string, unknown> = {
102
+ pluginEntry: entryName,
103
+ id: row.id,
104
+ name: row.name,
105
+ version: row.version,
106
+ type: row.type,
107
+ };
108
+ if (row.description) manifest.description = row.description;
109
+ if (row.author) manifest.author = row.author;
110
+ if (row.icon) manifest.icon = row.icon;
111
+ if (row.min_app_version) manifest.minAppVersion = row.min_app_version;
112
+ const configSchema = parseJsonField(row.config_schema);
113
+ if (Array.isArray(configSchema) && configSchema.length > 0) manifest.configSchema = configSchema;
114
+ if (perms.commonPermissions?.length) manifest.commonPermissions = perms.commonPermissions;
115
+ if (perms.exclusivePermissions?.length) manifest.exclusivePermissions = perms.exclusivePermissions;
116
+ if (perms.requiredPermissions?.length) manifest.requiredPermissions = perms.requiredPermissions;
117
+ const publicExports = parseJsonField(row.public_exports);
118
+ if (publicExports) manifest.publicExports = publicExports;
119
+ const dependencies = parseJsonField(row.dependencies);
120
+ if (dependencies) manifest.dependencies = dependencies;
121
+
122
+ zip.addFile('manifest.json', Buffer.from(JSON.stringify(manifest, null, 2), 'utf-8'));
123
+ zip.addFile(entryName, Buffer.from(code, 'utf-8'));
124
+
125
+ // 打包其他资源文件(如果有)
126
+ try {
127
+ const files = await listPluginFiles(pluginId);
128
+ for (const filePath of files) {
129
+ if (filePath === entryName) continue; // 跳过入口文件(已添加)
130
+ const fullPath = path.join(pluginDir, filePath);
131
+ try {
132
+ const stat = await fs.stat(fullPath);
133
+ if (stat.isFile()) {
134
+ const content = await fs.readFile(fullPath);
135
+ zip.addFile(filePath, content);
136
+ }
137
+ } catch { /* skip */ }
138
+ }
139
+ } catch { /* 无额外文件 */ }
140
+ }
141
+
142
+ const zipBuffer = zip.toBuffer();
143
+ const fileName = `${pluginId}.xye`;
144
+
145
+ return new NextResponse(zipBuffer, {
146
+ headers: {
147
+ 'Content-Type': 'application/octet-stream',
148
+ 'Content-Disposition': `attachment; filename="${fileName}"`,
149
+ 'Content-Length': String(zipBuffer.length),
150
+ },
151
+ });
152
+ } catch (e) {
153
+ console.error('导出 .xye 失败:', e);
154
+ return NextResponse.json({ error: '导出失败' }, { status: 500 });
155
+ }
156
+ }
@@ -0,0 +1,146 @@
1
+ // app/api/plugins/files/route.ts - 插件文件操作 API
2
+
3
+ import { NextRequest, NextResponse } from 'next/server';
4
+ import { promises as fs } from 'fs';
5
+ import path from 'path';
6
+
7
+ /** 插件私有数据文件根目录 */
8
+ const PLUGIN_FILES_ROOT = path.join(process.cwd(), 'data', 'plugins');
9
+
10
+ /** 验证 pluginId 格式(防止路径遍历攻击) */
11
+ function isValidPluginId(id: string): boolean {
12
+ return /^[a-zA-Z0-9._-]+$/.test(id) && id.length <= 200 && !id.includes('..');
13
+ }
14
+
15
+ /** 验证文件名格式(防止路径遍历) */
16
+ function isValidFileName(name: string): boolean {
17
+ // 禁止路径分隔符、.. 和空名称
18
+ return typeof name === 'string' &&
19
+ name.length > 0 &&
20
+ name.length <= 255 &&
21
+ !name.includes('..') &&
22
+ !name.includes('/') &&
23
+ !name.includes('\\') &&
24
+ !name.includes('\0');
25
+ }
26
+
27
+ /** 获取插件的私有数据目录路径(resources 子目录) */
28
+ function getPluginDir(pluginId: string): string {
29
+ return path.join(PLUGIN_FILES_ROOT, pluginId, 'resources');
30
+ }
31
+
32
+ /** 确保目录存在 */
33
+ async function ensureDir(dir: string): Promise<void> {
34
+ await fs.mkdir(dir, { recursive: true });
35
+ }
36
+
37
+ // GET /api/plugins/files?pluginId=xxx&fileName=yyy — 读取文件
38
+ export async function GET(request: NextRequest) {
39
+ try {
40
+ const { searchParams } = new URL(request.url);
41
+ const pluginId = searchParams.get('pluginId');
42
+ const fileName = searchParams.get('fileName');
43
+
44
+ if (!pluginId || !isValidPluginId(pluginId)) {
45
+ return NextResponse.json({ error: '无效的插件 ID' }, { status: 400 });
46
+ }
47
+
48
+ if (fileName) {
49
+ // 读取单个文件
50
+ if (!isValidFileName(fileName)) {
51
+ return NextResponse.json({ error: '无效的文件名' }, { status: 400 });
52
+ }
53
+ const filePath = path.join(getPluginDir(pluginId), fileName);
54
+ // 安全检查:确保解析后的路径仍在插件目录内
55
+ if (!filePath.startsWith(getPluginDir(pluginId))) {
56
+ return NextResponse.json({ error: '路径越界' }, { status: 403 });
57
+ }
58
+ try {
59
+ const content = await fs.readFile(filePath, 'utf-8');
60
+ return NextResponse.json({ success: true, content });
61
+ } catch {
62
+ return NextResponse.json({ success: true, content: null });
63
+ }
64
+ } else {
65
+ // 列出所有文件
66
+ const dir = getPluginDir(pluginId);
67
+ await ensureDir(dir);
68
+ const files = await fs.readdir(dir);
69
+ return NextResponse.json({ success: true, files });
70
+ }
71
+ } catch (e) {
72
+ console.error('[PluginFiles] GET error:', e);
73
+ return NextResponse.json({ error: '读取文件失败' }, { status: 500 });
74
+ }
75
+ }
76
+
77
+ // PUT /api/plugins/files — 写入文件
78
+ export async function PUT(request: NextRequest) {
79
+ try {
80
+ const body = await request.json();
81
+ const { pluginId, fileName, content } = body;
82
+
83
+ if (!pluginId || !isValidPluginId(pluginId)) {
84
+ return NextResponse.json({ error: '无效的插件 ID' }, { status: 400 });
85
+ }
86
+ if (!isValidFileName(fileName)) {
87
+ return NextResponse.json({ error: '无效的文件名' }, { status: 400 });
88
+ }
89
+
90
+ const dir = getPluginDir(pluginId);
91
+ await ensureDir(dir);
92
+ const filePath = path.join(dir, fileName);
93
+
94
+ // 安全检查
95
+ if (!filePath.startsWith(dir)) {
96
+ return NextResponse.json({ error: '路径越界' }, { status: 403 });
97
+ }
98
+
99
+ await fs.writeFile(filePath, typeof content === 'string' ? content : JSON.stringify(content), 'utf-8');
100
+ return NextResponse.json({ success: true });
101
+ } catch (e) {
102
+ console.error('[PluginFiles] PUT error:', e);
103
+ return NextResponse.json({ error: '写入文件失败' }, { status: 500 });
104
+ }
105
+ }
106
+
107
+ // DELETE /api/plugins/files?pluginId=xxx&fileName=yyy — 删除文件
108
+ export async function DELETE(request: NextRequest) {
109
+ try {
110
+ const { searchParams } = new URL(request.url);
111
+ const pluginId = searchParams.get('pluginId');
112
+ const fileName = searchParams.get('fileName');
113
+
114
+ if (!pluginId || !isValidPluginId(pluginId)) {
115
+ return NextResponse.json({ error: '无效的插件 ID' }, { status: 400 });
116
+ }
117
+
118
+ if (fileName) {
119
+ if (!isValidFileName(fileName)) {
120
+ return NextResponse.json({ error: '无效的文件名' }, { status: 400 });
121
+ }
122
+ const filePath = path.join(getPluginDir(pluginId), fileName);
123
+ if (!filePath.startsWith(getPluginDir(pluginId))) {
124
+ return NextResponse.json({ error: '路径越界' }, { status: 403 });
125
+ }
126
+ try {
127
+ await fs.unlink(filePath);
128
+ } catch {
129
+ // 文件不存在,忽略
130
+ }
131
+ } else {
132
+ // 删除整个插件目录
133
+ const dir = getPluginDir(pluginId);
134
+ try {
135
+ await fs.rm(dir, { recursive: true, force: true });
136
+ } catch {
137
+ // 目录不存在,忽略
138
+ }
139
+ }
140
+
141
+ return NextResponse.json({ success: true });
142
+ } catch (e) {
143
+ console.error('[PluginFiles] DELETE error:', e);
144
+ return NextResponse.json({ error: '删除文件失败' }, { status: 500 });
145
+ }
146
+ }
@@ -0,0 +1,168 @@
1
+ // app/api/plugins/files-list/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 { listPluginFiles, readPluginResourceText } from '@/lib/plugin-files';
7
+ import path from 'path';
8
+
9
+ function isValidPluginId(id: string): boolean {
10
+ return /^[a-zA-Z0-9._-]+$/.test(id) && id.length <= 200 && !id.includes('..');
11
+ }
12
+
13
+ function isSafePath(filePath: string): boolean {
14
+ const normalized = path.normalize(filePath);
15
+ return !normalized.includes('..') && !path.isAbsolute(normalized) && normalized !== '';
16
+ }
17
+
18
+ /** 获取文件图标 */
19
+ function getFileIcon(fileName: string): string {
20
+ const ext = path.extname(fileName).toLowerCase();
21
+ const icons: Record<string, string> = {
22
+ '.js': 'fa-brands fa-js', '.json': 'fa-solid fa-file-code', '.css': 'fa-brands fa-css3-alt',
23
+ '.html': 'fa-brands fa-html5', '.md': 'fa-brands fa-markdown', '.svg': 'fa-solid fa-bezier-curve',
24
+ '.png': 'fa-solid fa-image', '.jpg': 'fa-solid fa-image', '.jpeg': 'fa-solid fa-image',
25
+ '.gif': 'fa-solid fa-image', '.webp': 'fa-solid fa-image',
26
+ '.mp3': 'fa-solid fa-music', '.wav': 'fa-solid fa-wave-square', '.ogg': 'fa-solid fa-music',
27
+ '.mp4': 'fa-solid fa-film', '.webm': 'fa-solid fa-film',
28
+ '.txt': 'fa-solid fa-file-lines', '.ts': 'fa-brands fa-js', '.tsx': 'fa-brands fa-react',
29
+ '.jsx': 'fa-brands fa-react',
30
+ '.ttf': 'fa-solid fa-font', '.woff': 'fa-solid fa-font', '.woff2': 'fa-solid fa-font',
31
+ '.zip': 'fa-solid fa-file-zipper', '.xye': 'fa-solid fa-file-zipper',
32
+ '.pdf': 'fa-solid fa-file-pdf',
33
+ };
34
+ return icons[ext] || 'fa-solid fa-file';
35
+ }
36
+
37
+ /** 判断文件是否可编辑(文本文件) */
38
+ function isEditableFile(fileName: string): boolean {
39
+ const ext = path.extname(fileName).toLowerCase();
40
+ return ['.js', '.json', '.css', '.html', '.md', '.txt', '.svg', '.ts', '.jsx', '.tsx'].includes(ext);
41
+ }
42
+
43
+ export async function GET(request: NextRequest) {
44
+ try {
45
+ const { searchParams } = new URL(request.url);
46
+ const pluginId = searchParams.get('pluginId');
47
+ const filePath = searchParams.get('path');
48
+
49
+ if (!pluginId) {
50
+ return NextResponse.json({ error: '缺少 pluginId 参数' }, { status: 400 });
51
+ }
52
+
53
+ if (!isValidPluginId(pluginId)) {
54
+ return NextResponse.json({ error: '非法插件 ID' }, { status: 400 });
55
+ }
56
+
57
+ // 验证插件存在
58
+ await ensureDb();
59
+ const db = getDbPool();
60
+ const [rows] = await db.execute(
61
+ 'SELECT id FROM extensions WHERE id = ?',
62
+ [pluginId]
63
+ ) as [Array<{ id: string }>, unknown];
64
+
65
+ if (rows.length === 0) {
66
+ return NextResponse.json({ error: '插件不存在' }, { status: 404 });
67
+ }
68
+
69
+ // 如果指定了 path,读取文件内容
70
+ if (filePath) {
71
+ if (!isSafePath(filePath)) {
72
+ return NextResponse.json({ error: '非法文件路径' }, { status: 400 });
73
+ }
74
+ try {
75
+ const content = await readPluginResourceText(pluginId, filePath);
76
+ return NextResponse.json({
77
+ path: filePath,
78
+ content,
79
+ editable: isEditableFile(filePath),
80
+ });
81
+ } catch {
82
+ return NextResponse.json({ error: '文件不存在' }, { status: 404 });
83
+ }
84
+ }
85
+
86
+ // 否则列出所有文件
87
+ const files = await listPluginFiles(pluginId);
88
+
89
+ // 构建文件树
90
+ const tree: Array<{ name: string; path: string; icon: string; editable: boolean; type: 'file' | 'dir'; children?: typeof tree }> = [];
91
+ const dirMap = new Map<string, typeof tree>();
92
+
93
+ for (const filePath of files) {
94
+ // 统一路径分隔符为正斜杠
95
+ const normalizedPath = filePath.replace(/\\/g, '/');
96
+
97
+ // 以 / 结尾的是空目录
98
+ if (normalizedPath.endsWith('/')) {
99
+ const dirPath = normalizedPath.slice(0, -1);
100
+ const parts = dirPath.split('/');
101
+ let currentDir = tree;
102
+ for (let i = 0; i < parts.length; i++) {
103
+ const part = parts[i];
104
+ const fullPath = parts.slice(0, i + 1).join('/');
105
+ if (!dirMap.has(fullPath)) {
106
+ const dir: typeof tree[0] & { children: typeof tree } = {
107
+ name: part,
108
+ path: fullPath,
109
+ icon: 'fa-solid fa-folder',
110
+ editable: false,
111
+ type: 'dir',
112
+ children: [],
113
+ };
114
+ dirMap.set(fullPath, dir.children);
115
+ currentDir.push(dir);
116
+ }
117
+ currentDir = dirMap.get(fullPath)!;
118
+ }
119
+ continue;
120
+ }
121
+
122
+ const parts = normalizedPath.split('/');
123
+ let currentDir = tree;
124
+
125
+ for (let i = 0; i < parts.length; i++) {
126
+ const part = parts[i];
127
+ const isFile = i === parts.length - 1;
128
+ const fullPath = parts.slice(0, i + 1).join('/');
129
+
130
+ if (isFile) {
131
+ currentDir.push({
132
+ name: part,
133
+ path: fullPath,
134
+ icon: getFileIcon(part),
135
+ editable: isEditableFile(part),
136
+ type: 'file',
137
+ });
138
+ } else {
139
+ if (!dirMap.has(fullPath)) {
140
+ const dir: typeof tree[0] & { children: typeof tree } = {
141
+ name: part,
142
+ path: fullPath,
143
+ icon: 'fa-solid fa-folder',
144
+ editable: false,
145
+ type: 'dir',
146
+ children: [],
147
+ };
148
+ dirMap.set(fullPath, dir.children);
149
+ currentDir.push(dir);
150
+ }
151
+ currentDir = dirMap.get(fullPath)!;
152
+ }
153
+ }
154
+ }
155
+
156
+ // 检查是否为 .xye 格式(目录下有 manifest.json)
157
+ const isXye = files.includes('manifest.json');
158
+
159
+ return NextResponse.json({
160
+ pluginId,
161
+ isXye,
162
+ files: tree,
163
+ });
164
+ } catch (e) {
165
+ console.error('列出插件文件失败:', e);
166
+ return NextResponse.json({ error: '列出文件失败' }, { status: 500 });
167
+ }
168
+ }
@@ -0,0 +1,101 @@
1
+ // app/api/plugins/files-upload/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 { 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
+ // ==================== POST: 上传资源文件 ====================
34
+
35
+ export async function POST(request: NextRequest) {
36
+ try {
37
+ const { searchParams } = new URL(request.url);
38
+ const pluginId = searchParams.get('pluginId');
39
+ const targetDir = searchParams.get('dir') || ''; // 可选:目标子目录
40
+
41
+ if (!pluginId) {
42
+ return NextResponse.json({ error: '缺少 pluginId 参数' }, { status: 400 });
43
+ }
44
+ if (!isValidPluginId(pluginId)) {
45
+ return NextResponse.json({ error: '非法插件 ID' }, { status: 400 });
46
+ }
47
+ if (targetDir && !isSafePath(targetDir)) {
48
+ return NextResponse.json({ error: '非法目标目录' }, { status: 400 });
49
+ }
50
+
51
+ const db = await verifyPlugin(pluginId);
52
+ if (!db) {
53
+ return NextResponse.json({ error: '插件不存在' }, { status: 404 });
54
+ }
55
+
56
+ const formData = await request.formData();
57
+ const files = formData.getAll('files');
58
+
59
+ if (!files || files.length === 0) {
60
+ return NextResponse.json({ error: '没有选择文件' }, { status: 400 });
61
+ }
62
+
63
+ const pluginDir = getPluginDir(pluginId);
64
+ const uploadDir = targetDir ? path.join(pluginDir, targetDir) : pluginDir;
65
+
66
+ // 确保目标目录存在
67
+ await fs.mkdir(uploadDir, { recursive: true });
68
+
69
+ const uploadedFiles: string[] = [];
70
+
71
+ for (const file of files) {
72
+ if (!(file instanceof File)) continue;
73
+
74
+ // 安全检查:文件名不能包含路径遍历
75
+ const fileName = path.basename(file.name);
76
+ if (!fileName || fileName === '.' || fileName === '..') continue;
77
+
78
+ // 限制单个文件大小(10MB)
79
+ if (file.size > 10 * 1024 * 1024) {
80
+ return NextResponse.json({ error: `文件 ${fileName} 超过 10MB 限制` }, { status: 400 });
81
+ }
82
+
83
+ const filePath = path.join(uploadDir, fileName);
84
+ const buffer = Buffer.from(await file.arrayBuffer());
85
+ await fs.writeFile(filePath, buffer);
86
+
87
+ // 返回相对于插件根目录的路径
88
+ const relativePath = targetDir ? `${targetDir}/${fileName}` : fileName;
89
+ uploadedFiles.push(relativePath);
90
+ }
91
+
92
+ return NextResponse.json({
93
+ success: true,
94
+ files: uploadedFiles,
95
+ count: uploadedFiles.length,
96
+ });
97
+ } catch (e) {
98
+ console.error('上传文件失败:', e);
99
+ return NextResponse.json({ error: '上传文件失败' }, { status: 500 });
100
+ }
101
+ }