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,327 @@
|
|
|
1
|
+
// lib/plugin-dom-sandbox.ts - DOM 安全沙箱
|
|
2
|
+
// 为插件提供受限的 DOM 操作能力,确保安全边界
|
|
3
|
+
|
|
4
|
+
import { PluginResourceTracker } from './plugin-resource-tracker';
|
|
5
|
+
import { sanitizeHTML, isTagAllowed, isAttrAllowed } from './plugin-html-sanitizer';
|
|
6
|
+
import { UISlotId } from './plugin-types';
|
|
7
|
+
|
|
8
|
+
/** DOM 沙箱配置 */
|
|
9
|
+
interface DOMSandboxConfig {
|
|
10
|
+
pluginId: string;
|
|
11
|
+
resourceTracker: PluginResourceTracker;
|
|
12
|
+
getContainerElement: (slotId: UISlotId) => HTMLElement | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** 创建的 DOM 元素代理信息 */
|
|
16
|
+
interface DOMElementProxy {
|
|
17
|
+
__proxyId: string;
|
|
18
|
+
__pluginId: string;
|
|
19
|
+
__tag: string;
|
|
20
|
+
__attrs: Record<string, string>;
|
|
21
|
+
__children: Array<DOMElementProxy | string>;
|
|
22
|
+
__eventHandlers: Map<string, Array<(...args: unknown[]) => unknown>>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 元素 ID 计数器(按插件隔离) */
|
|
26
|
+
const elementCounters: Map<string, number> = new Map();
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 创建 DOM 沙箱 API
|
|
30
|
+
* 返回一个对象,包含所有安全的 DOM 操作方法
|
|
31
|
+
*/
|
|
32
|
+
export function createDOMSandbox(config: DOMSandboxConfig) {
|
|
33
|
+
const { pluginId, resourceTracker, getContainerElement } = config;
|
|
34
|
+
|
|
35
|
+
if (!elementCounters.has(pluginId)) {
|
|
36
|
+
elementCounters.set(pluginId, 0);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 创建一个 DOM 元素代理
|
|
41
|
+
*/
|
|
42
|
+
function create(
|
|
43
|
+
tag: string,
|
|
44
|
+
attrs?: Record<string, string>,
|
|
45
|
+
children?: string | Array<DOMElementProxy | string>
|
|
46
|
+
): DOMElementProxy {
|
|
47
|
+
// 验证标签名
|
|
48
|
+
if (!isTagAllowed(tag)) {
|
|
49
|
+
throw new Error(`[DOMSandbox] 插件 ${pluginId}: 不允许创建 <${tag}> 标签`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const counter = (elementCounters.get(pluginId) || 0) + 1;
|
|
53
|
+
elementCounters.set(pluginId, counter);
|
|
54
|
+
|
|
55
|
+
const proxyId = `${pluginId}_el_${counter}`;
|
|
56
|
+
|
|
57
|
+
// 过滤属性
|
|
58
|
+
const safeAttrs: Record<string, string> = {};
|
|
59
|
+
if (attrs) {
|
|
60
|
+
for (const [key, value] of Object.entries(attrs)) {
|
|
61
|
+
if (isAttrAllowed(key)) {
|
|
62
|
+
safeAttrs[key] = String(value);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const proxy: DOMElementProxy = {
|
|
68
|
+
__proxyId: proxyId,
|
|
69
|
+
__pluginId: pluginId,
|
|
70
|
+
__tag: tag,
|
|
71
|
+
__attrs: safeAttrs,
|
|
72
|
+
__children: [],
|
|
73
|
+
__eventHandlers: new Map(),
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// 处理子元素
|
|
77
|
+
if (children !== undefined) {
|
|
78
|
+
if (typeof children === 'string') {
|
|
79
|
+
proxy.__children.push(children);
|
|
80
|
+
} else if (Array.isArray(children)) {
|
|
81
|
+
proxy.__children.push(...children);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return proxy;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 将代理元素渲染为真实 DOM 并追加到容器
|
|
90
|
+
*/
|
|
91
|
+
function append(containerId: string, element: DOMElementProxy | string): string | null {
|
|
92
|
+
const container = getContainerElement(containerId as UISlotId);
|
|
93
|
+
if (!container) {
|
|
94
|
+
console.warn(`[DOMSandbox] 插件 ${pluginId}: 容器 "${containerId}" 不存在`);
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let realElement: HTMLElement;
|
|
99
|
+
|
|
100
|
+
if (typeof element === 'string') {
|
|
101
|
+
// HTML 字符串模式
|
|
102
|
+
const sanitized = sanitizeHTML(element);
|
|
103
|
+
const wrapper = document.createElement('div');
|
|
104
|
+
wrapper.innerHTML = sanitized;
|
|
105
|
+
wrapper.setAttribute('data-plugin-id', pluginId);
|
|
106
|
+
|
|
107
|
+
// 追加所有子节点
|
|
108
|
+
while (wrapper.firstChild) {
|
|
109
|
+
container.appendChild(wrapper.firstChild);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const wrapperId = `${pluginId}_html_${Date.now()}`;
|
|
113
|
+
resourceTracker.trackElement(pluginId, wrapperId, wrapper);
|
|
114
|
+
return wrapperId;
|
|
115
|
+
} else {
|
|
116
|
+
// 代理元素模式
|
|
117
|
+
realElement = renderProxyToDOM(element);
|
|
118
|
+
container.appendChild(realElement);
|
|
119
|
+
resourceTracker.trackElement(pluginId, element.__proxyId, realElement);
|
|
120
|
+
return element.__proxyId;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* 移除插件创建的元素
|
|
126
|
+
*/
|
|
127
|
+
function remove(elementId: string): boolean {
|
|
128
|
+
const element = resourceTracker.getElement(pluginId, elementId);
|
|
129
|
+
if (!element) {
|
|
130
|
+
console.warn(`[DOMSandbox] 插件 ${pluginId}: 元素 "${elementId}" 不存在或不属于该插件`);
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
if (element.parentNode) {
|
|
136
|
+
element.parentNode.removeChild(element);
|
|
137
|
+
}
|
|
138
|
+
resourceTracker.untrackElement(pluginId, elementId);
|
|
139
|
+
return true;
|
|
140
|
+
} catch (e) {
|
|
141
|
+
console.error(`[DOMSandbox] 插件 ${pluginId}: 移除元素失败:`, e);
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* 更新元素属性
|
|
148
|
+
*/
|
|
149
|
+
function update(elementId: string, props: Record<string, string>): boolean {
|
|
150
|
+
const element = resourceTracker.getElement(pluginId, elementId);
|
|
151
|
+
if (!element) {
|
|
152
|
+
console.warn(`[DOMSandbox] 插件 ${pluginId}: 元素 "${elementId}" 不存在或不属于该插件`);
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
for (const [key, value] of Object.entries(props)) {
|
|
158
|
+
if (key === 'style') {
|
|
159
|
+
element.setAttribute('style', value);
|
|
160
|
+
} else if (key === 'className' || key === 'class') {
|
|
161
|
+
element.className = value;
|
|
162
|
+
} else if (key === 'textContent') {
|
|
163
|
+
element.textContent = value;
|
|
164
|
+
} else if (key === 'innerHTML') {
|
|
165
|
+
element.innerHTML = sanitizeHTML(value);
|
|
166
|
+
} else if (isAttrAllowed(key)) {
|
|
167
|
+
element.setAttribute(key, value);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return true;
|
|
171
|
+
} catch (e) {
|
|
172
|
+
console.error(`[DOMSandbox] 插件 ${pluginId}: 更新元素失败:`, e);
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* 获取容器引用(返回受限信息)
|
|
179
|
+
*/
|
|
180
|
+
function getContainer(containerId: string): { id: string; exists: boolean; childCount: number } | null {
|
|
181
|
+
const container = getContainerElement(containerId as UISlotId);
|
|
182
|
+
if (!container) return null;
|
|
183
|
+
return {
|
|
184
|
+
id: containerId,
|
|
185
|
+
exists: true,
|
|
186
|
+
childCount: container.children.length,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* 在容器内查询元素(仅限插件自己的元素)
|
|
192
|
+
*/
|
|
193
|
+
function query(containerId: string, selector: string): Array<{ id: string; tag: string; textContent: string }> {
|
|
194
|
+
const container = getContainerElement(containerId as UISlotId);
|
|
195
|
+
if (!container) return [];
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
const results: Array<{ id: string; tag: string; textContent: string }> = [];
|
|
199
|
+
const elements = container.querySelectorAll(selector);
|
|
200
|
+
|
|
201
|
+
elements.forEach((el) => {
|
|
202
|
+
// 检查元素自身或最近祖先的 data-plugin-id
|
|
203
|
+
const pluginOwner = el.closest<HTMLElement>('[data-plugin-id]');
|
|
204
|
+
if (pluginOwner && pluginOwner.getAttribute('data-plugin-id') === pluginId) {
|
|
205
|
+
results.push({
|
|
206
|
+
id: el.id || '',
|
|
207
|
+
tag: el.tagName.toLowerCase(),
|
|
208
|
+
textContent: el.textContent || '',
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
return results;
|
|
214
|
+
} catch (e) {
|
|
215
|
+
console.error(`[DOMSandbox] 插件 ${pluginId}: 查询失败:`, e);
|
|
216
|
+
return [];
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* 绑定事件
|
|
222
|
+
*/
|
|
223
|
+
function on(elementId: string, event: string, handler: (...args: unknown[]) => unknown): boolean {
|
|
224
|
+
const element = resourceTracker.getElement(pluginId, elementId);
|
|
225
|
+
if (!element) {
|
|
226
|
+
console.warn(`[DOMSandbox] 插件 ${pluginId}: 元素 "${elementId}" 不存在`);
|
|
227
|
+
return false;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// 拦截危险事件
|
|
231
|
+
const forbiddenEvents = ['keydown', 'keyup', 'keypress'];
|
|
232
|
+
if (forbiddenEvents.includes(event)) {
|
|
233
|
+
console.warn(`[DOMSandbox] 插件 ${pluginId}: 不允许监听 "${event}" 事件`);
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const wrappedHandler = (() => {
|
|
238
|
+
try {
|
|
239
|
+
handler();
|
|
240
|
+
} catch (err) {
|
|
241
|
+
console.error(`[DOMSandbox] 插件 ${pluginId} 事件处理器错误:`, err);
|
|
242
|
+
}
|
|
243
|
+
}) as EventListener;
|
|
244
|
+
|
|
245
|
+
element.addEventListener(event, wrappedHandler);
|
|
246
|
+
resourceTracker.trackEventListener(pluginId, {
|
|
247
|
+
element,
|
|
248
|
+
event,
|
|
249
|
+
handler: wrappedHandler,
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* 解绑事件
|
|
257
|
+
* 注意:简化处理,实际应精确匹配 handler
|
|
258
|
+
*/
|
|
259
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
260
|
+
function off(elementId: string, event: string): boolean {
|
|
261
|
+
const element = resourceTracker.getElement(pluginId, elementId);
|
|
262
|
+
if (!element) return false;
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* 将代理元素递归渲染为真实 DOM
|
|
268
|
+
*/
|
|
269
|
+
function renderProxyToDOM(proxy: DOMElementProxy): HTMLElement {
|
|
270
|
+
const element = document.createElement(proxy.__tag);
|
|
271
|
+
element.setAttribute('data-plugin-id', proxy.__pluginId);
|
|
272
|
+
element.setAttribute('data-proxy-id', proxy.__proxyId);
|
|
273
|
+
|
|
274
|
+
// 设置属性
|
|
275
|
+
for (const [key, value] of Object.entries(proxy.__attrs)) {
|
|
276
|
+
if (key === 'style') {
|
|
277
|
+
element.setAttribute('style', value);
|
|
278
|
+
} else if (key === 'className' || key === 'class') {
|
|
279
|
+
element.className = value;
|
|
280
|
+
} else {
|
|
281
|
+
element.setAttribute(key, value);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// 渲染子元素
|
|
286
|
+
for (const child of proxy.__children) {
|
|
287
|
+
if (typeof child === 'string') {
|
|
288
|
+
element.textContent = child;
|
|
289
|
+
} else if (child.__proxyId) {
|
|
290
|
+
element.appendChild(renderProxyToDOM(child));
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// 绑定事件
|
|
295
|
+
proxy.__eventHandlers.forEach((handlers, eventName) => {
|
|
296
|
+
handlers.forEach((handler) => {
|
|
297
|
+
const wrappedHandler = ((e: Event) => {
|
|
298
|
+
try {
|
|
299
|
+
handler(e);
|
|
300
|
+
} catch (err) {
|
|
301
|
+
console.error(`[DOMSandbox] 事件处理器错误:`, err);
|
|
302
|
+
}
|
|
303
|
+
}) as EventListener;
|
|
304
|
+
|
|
305
|
+
element.addEventListener(eventName, wrappedHandler);
|
|
306
|
+
resourceTracker.trackEventListener(proxy.__pluginId, {
|
|
307
|
+
element,
|
|
308
|
+
event: eventName,
|
|
309
|
+
handler: wrappedHandler,
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
return element;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
create,
|
|
319
|
+
append,
|
|
320
|
+
remove,
|
|
321
|
+
update,
|
|
322
|
+
getContainer,
|
|
323
|
+
query,
|
|
324
|
+
on,
|
|
325
|
+
off,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// lib/plugin-events.ts - 插件状态变更跨标签页同步
|
|
2
|
+
|
|
3
|
+
const CHANNEL_NAME = 'xinyu-plugin-binding-change';
|
|
4
|
+
|
|
5
|
+
export interface PluginBindingChangeEvent {
|
|
6
|
+
/** 变更的插件 ID */
|
|
7
|
+
pluginId: string;
|
|
8
|
+
/** 新的启用状态 */
|
|
9
|
+
enabled: boolean;
|
|
10
|
+
/** 变更来源:'global' = 设置页(局外),'world' = 游戏页(局内) */
|
|
11
|
+
source: 'global' | 'world';
|
|
12
|
+
/** 关联的世界 ID(局内变更时) */
|
|
13
|
+
worldId?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** 插件配置变更事件 */
|
|
17
|
+
export interface PluginConfigChangeEvent {
|
|
18
|
+
/** 变更的插件 ID */
|
|
19
|
+
pluginId: string;
|
|
20
|
+
/** 新的配置值 */
|
|
21
|
+
config: Record<string, unknown>;
|
|
22
|
+
/** 关联的世界 ID(如果有) */
|
|
23
|
+
worldId?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let channel: BroadcastChannel | null = null;
|
|
27
|
+
|
|
28
|
+
/** 获取 BroadcastChannel 单例(仅客户端) */
|
|
29
|
+
function getChannel(): BroadcastChannel | null {
|
|
30
|
+
if (typeof window === 'undefined') return null;
|
|
31
|
+
if (!channel) {
|
|
32
|
+
try {
|
|
33
|
+
channel = new BroadcastChannel(CHANNEL_NAME);
|
|
34
|
+
} catch {
|
|
35
|
+
// 浏览器不支持 BroadcastChannel 时降级
|
|
36
|
+
console.warn('[PluginEvents] BroadcastChannel 不可用');
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return channel;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 广播插件绑定变更事件(启用/禁用) */
|
|
43
|
+
export function broadcastPluginBindingChange(event: PluginBindingChangeEvent): void {
|
|
44
|
+
const ch = getChannel();
|
|
45
|
+
if (ch) {
|
|
46
|
+
ch.postMessage({ type: 'binding', ...event });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 广播插件配置变更事件 */
|
|
51
|
+
export function broadcastPluginConfigChange(event: PluginConfigChangeEvent): void {
|
|
52
|
+
const ch = getChannel();
|
|
53
|
+
if (ch) {
|
|
54
|
+
ch.postMessage({ type: 'config', ...event });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 监听插件绑定变更事件(启用/禁用) */
|
|
59
|
+
export function onPluginBindingChange(
|
|
60
|
+
callback: (event: PluginBindingChangeEvent) => void
|
|
61
|
+
): () => void {
|
|
62
|
+
const ch = getChannel();
|
|
63
|
+
if (!ch) return () => {};
|
|
64
|
+
|
|
65
|
+
const handler = (e: MessageEvent) => {
|
|
66
|
+
if (e.data?.type === 'binding') {
|
|
67
|
+
callback(e.data as PluginBindingChangeEvent);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
ch.addEventListener('message', handler);
|
|
71
|
+
return () => ch.removeEventListener('message', handler);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 监听插件配置变更事件 */
|
|
75
|
+
export function onPluginConfigChange(
|
|
76
|
+
callback: (event: PluginConfigChangeEvent) => void
|
|
77
|
+
): () => void {
|
|
78
|
+
const ch = getChannel();
|
|
79
|
+
if (!ch) return () => {};
|
|
80
|
+
|
|
81
|
+
const handler = (e: MessageEvent) => {
|
|
82
|
+
if (e.data?.type === 'config') {
|
|
83
|
+
callback(e.data as PluginConfigChangeEvent);
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
ch.addEventListener('message', handler);
|
|
87
|
+
return () => ch.removeEventListener('message', handler);
|
|
88
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// lib/plugin-files.ts - 插件文件读写工具(代码 + 资源)
|
|
2
|
+
|
|
3
|
+
import { promises as fs } from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
|
|
6
|
+
/** 插件文件根目录 */
|
|
7
|
+
const PLUGIN_ROOT = path.join(process.cwd(), 'data', 'plugins');
|
|
8
|
+
|
|
9
|
+
/** 校验插件 ID 合法性(防止路径遍历攻击) */
|
|
10
|
+
function isValidPluginId(id: string): boolean {
|
|
11
|
+
return /^[a-zA-Z0-9._-]+$/.test(id) && id.length <= 200 && !id.includes('..');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** 校验文件路径安全性 */
|
|
15
|
+
function isSafePath(filePath: string): boolean {
|
|
16
|
+
const normalized = path.normalize(filePath);
|
|
17
|
+
return !normalized.includes('..') && !path.isAbsolute(normalized) && normalized !== '';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 获取插件目录的绝对路径
|
|
22
|
+
*/
|
|
23
|
+
export function getPluginDir(pluginId: string): string {
|
|
24
|
+
if (!isValidPluginId(pluginId)) {
|
|
25
|
+
throw new Error(`非法插件 ID: ${pluginId}`);
|
|
26
|
+
}
|
|
27
|
+
return path.join(PLUGIN_ROOT, pluginId);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 获取插件代码文件的绝对路径
|
|
32
|
+
* @param pluginId 插件 ID
|
|
33
|
+
* @param entryFile 入口文件名(如 index.js),默认 plugin.js
|
|
34
|
+
*/
|
|
35
|
+
export function getPluginCodePath(pluginId: string, entryFile?: string): string {
|
|
36
|
+
return path.join(getPluginDir(pluginId), entryFile || 'plugin.js');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 获取插件代码的相对路径(存入数据库)
|
|
41
|
+
* @param pluginId 插件 ID
|
|
42
|
+
* @param entryFile 入口文件名(如 index.js),默认 plugin.js
|
|
43
|
+
*/
|
|
44
|
+
export function getPluginCodeRelativePath(pluginId: string, entryFile?: string): string {
|
|
45
|
+
return `data/plugins/${pluginId}/${entryFile || 'plugin.js'}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 读取插件代码
|
|
50
|
+
* @param pluginId 插件 ID
|
|
51
|
+
* @param entryFile 入口文件名(如 index.js),默认 plugin.js
|
|
52
|
+
*/
|
|
53
|
+
export async function readPluginCode(pluginId: string, entryFile?: string): Promise<string> {
|
|
54
|
+
const filePath = getPluginCodePath(pluginId, entryFile);
|
|
55
|
+
try {
|
|
56
|
+
return await fs.readFile(filePath, 'utf-8');
|
|
57
|
+
} catch {
|
|
58
|
+
throw new Error(`插件代码文件不存在: ${pluginId}/${entryFile || 'plugin.js'}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 写入插件代码
|
|
64
|
+
* @param pluginId 插件 ID
|
|
65
|
+
* @param code 代码内容
|
|
66
|
+
* @param entryFile 入口文件名(如 index.js),默认 plugin.js
|
|
67
|
+
*/
|
|
68
|
+
export async function writePluginCode(pluginId: string, code: string, entryFile?: string): Promise<string> {
|
|
69
|
+
const pluginDir = getPluginDir(pluginId);
|
|
70
|
+
await fs.mkdir(pluginDir, { recursive: true });
|
|
71
|
+
const fileName = entryFile || 'plugin.js';
|
|
72
|
+
const filePath = path.join(pluginDir, fileName);
|
|
73
|
+
await fs.writeFile(filePath, code, 'utf-8');
|
|
74
|
+
return getPluginCodeRelativePath(pluginId, fileName);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 删除插件整个目录(代码 + 资源)
|
|
79
|
+
*/
|
|
80
|
+
export async function deletePluginCode(pluginId: string): Promise<void> {
|
|
81
|
+
const pluginDir = getPluginDir(pluginId);
|
|
82
|
+
try {
|
|
83
|
+
await fs.rm(pluginDir, { recursive: true, force: true });
|
|
84
|
+
} catch {
|
|
85
|
+
// 目录不存在时忽略
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 读取插件资源文件(返回 Buffer)
|
|
91
|
+
*/
|
|
92
|
+
export async function readPluginResource(pluginId: string, resourcePath: string): Promise<Buffer> {
|
|
93
|
+
if (!isValidPluginId(pluginId)) {
|
|
94
|
+
throw new Error(`非法插件 ID: ${pluginId}`);
|
|
95
|
+
}
|
|
96
|
+
if (!isSafePath(resourcePath)) {
|
|
97
|
+
throw new Error(`非法资源路径: ${resourcePath}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const absolutePath = path.join(PLUGIN_ROOT, pluginId, resourcePath);
|
|
101
|
+
|
|
102
|
+
// 安全检查:确保路径在插件目录内
|
|
103
|
+
const pluginDir = path.resolve(getPluginDir(pluginId));
|
|
104
|
+
const resolvedPath = path.resolve(absolutePath);
|
|
105
|
+
if (!resolvedPath.startsWith(pluginDir + path.sep)) {
|
|
106
|
+
throw new Error(`资源路径越界: ${resourcePath}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return await fs.readFile(absolutePath);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 读取插件资源文件(返回文本)
|
|
114
|
+
*/
|
|
115
|
+
export async function readPluginResourceText(pluginId: string, resourcePath: string): Promise<string> {
|
|
116
|
+
const buffer = await readPluginResource(pluginId, resourcePath);
|
|
117
|
+
return buffer.toString('utf-8');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* 写入插件资源文件
|
|
122
|
+
*/
|
|
123
|
+
export async function writePluginResource(pluginId: string, resourcePath: string, data: Buffer | string): Promise<void> {
|
|
124
|
+
if (!isValidPluginId(pluginId)) {
|
|
125
|
+
throw new Error(`非法插件 ID: ${pluginId}`);
|
|
126
|
+
}
|
|
127
|
+
if (!isSafePath(resourcePath)) {
|
|
128
|
+
throw new Error(`非法资源路径: ${resourcePath}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const absolutePath = path.join(PLUGIN_ROOT, pluginId, resourcePath);
|
|
132
|
+
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
|
133
|
+
await fs.writeFile(absolutePath, data);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 删除插件资源文件
|
|
138
|
+
*/
|
|
139
|
+
export async function deletePluginResource(pluginId: string, resourcePath: string): Promise<void> {
|
|
140
|
+
if (!isValidPluginId(pluginId)) {
|
|
141
|
+
throw new Error(`非法插件 ID: ${pluginId}`);
|
|
142
|
+
}
|
|
143
|
+
if (!isSafePath(resourcePath)) {
|
|
144
|
+
throw new Error(`非法资源路径: ${resourcePath}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const absolutePath = path.join(PLUGIN_ROOT, pluginId, resourcePath);
|
|
148
|
+
try {
|
|
149
|
+
await fs.unlink(absolutePath);
|
|
150
|
+
} catch {
|
|
151
|
+
// 文件不存在时忽略
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 列出插件目录下的所有文件
|
|
157
|
+
*/
|
|
158
|
+
export async function listPluginFiles(pluginId: string): Promise<string[]> {
|
|
159
|
+
const pluginDir = getPluginDir(pluginId);
|
|
160
|
+
const files: string[] = [];
|
|
161
|
+
try {
|
|
162
|
+
await collectFiles(pluginDir, pluginDir, files);
|
|
163
|
+
} catch {
|
|
164
|
+
// 目录不存在时返回空数组
|
|
165
|
+
}
|
|
166
|
+
return files;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** 递归收集文件和目录 */
|
|
170
|
+
async function collectFiles(baseDir: string, currentDir: string, result: string[]): Promise<void> {
|
|
171
|
+
const entries = await fs.readdir(currentDir, { withFileTypes: true });
|
|
172
|
+
let hasChild = false;
|
|
173
|
+
for (const entry of entries) {
|
|
174
|
+
hasChild = true;
|
|
175
|
+
const fullPath = path.join(currentDir, entry.name);
|
|
176
|
+
if (entry.isDirectory()) {
|
|
177
|
+
await collectFiles(baseDir, fullPath, result);
|
|
178
|
+
} else {
|
|
179
|
+
result.push(path.relative(baseDir, fullPath));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// 空目录也收集(以 / 结尾标识)
|
|
183
|
+
if (!hasChild && currentDir !== baseDir) {
|
|
184
|
+
result.push(path.relative(baseDir, currentDir) + '/');
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// lib/plugin-html-sanitizer.ts - HTML 净化器
|
|
2
|
+
// 对插件注入的 HTML 字符串进行安全净化处理
|
|
3
|
+
|
|
4
|
+
/** 允许的 HTML 标签 */
|
|
5
|
+
const ALLOWED_TAGS = new Set([
|
|
6
|
+
'div', 'span', 'p', 'a', 'button', 'input', 'textarea', 'select', 'option',
|
|
7
|
+
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
|
8
|
+
'ul', 'ol', 'li', 'table', 'thead', 'tbody', 'tr', 'th', 'td',
|
|
9
|
+
'img', 'svg', 'canvas', 'br', 'hr',
|
|
10
|
+
'strong', 'em', 'b', 'i', 'u', 's', 'code', 'pre', 'blockquote',
|
|
11
|
+
'label', 'form', 'fieldset', 'legend',
|
|
12
|
+
'header', 'footer', 'nav', 'main', 'section', 'article', 'aside',
|
|
13
|
+
'details', 'summary',
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
/** 禁止的属性(事件处理器等) */
|
|
17
|
+
const FORBIDDEN_ATTR_PREFIXES = ['on', 'srcdoc', 'formaction', 'xlink:href'];
|
|
18
|
+
|
|
19
|
+
/** 禁止的标签 */
|
|
20
|
+
const FORBIDDEN_TAGS = new Set(['script', 'style', 'link', 'meta', 'iframe', 'object', 'embed', 'applet']);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 净化 HTML 字符串,移除危险标签和属性
|
|
24
|
+
* @param html 原始 HTML 字符串
|
|
25
|
+
* @param pluginId 插件 ID(用于添加标识属性)
|
|
26
|
+
* @returns 净化后的 HTML 字符串
|
|
27
|
+
*/
|
|
28
|
+
export function sanitizeHTML(html: string): string {
|
|
29
|
+
if (!html || typeof html !== 'string') return '';
|
|
30
|
+
|
|
31
|
+
let sanitized = html;
|
|
32
|
+
|
|
33
|
+
// 1. 移除 script 标签及其内容
|
|
34
|
+
sanitized = sanitized.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
|
|
35
|
+
|
|
36
|
+
// 2. 移除其他禁止标签(自闭合和开闭标签)
|
|
37
|
+
FORBIDDEN_TAGS.forEach((tag) => {
|
|
38
|
+
const regex = new RegExp(`<${tag}\\b[^>]*\\/?>`, 'gi');
|
|
39
|
+
sanitized = sanitized.replace(regex, '');
|
|
40
|
+
// 闭合标签
|
|
41
|
+
const closeRegex = new RegExp(`<\\/${tag}>`, 'gi');
|
|
42
|
+
sanitized = sanitized.replace(closeRegex, '');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// 3. 移除所有 on* 事件属性
|
|
46
|
+
sanitized = sanitized.replace(/\s+on\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]*)/gi, '');
|
|
47
|
+
|
|
48
|
+
// 4. 移除 srcdoc 属性
|
|
49
|
+
sanitized = sanitized.replace(/\s+srcdoc\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]*)/gi, '');
|
|
50
|
+
|
|
51
|
+
// 5. 移除 javascript: 协议
|
|
52
|
+
sanitized = sanitized.replace(/href\s*=\s*["']?\s*javascript\s*:/gi, 'href="about:blank"');
|
|
53
|
+
|
|
54
|
+
// 6. 移除 data: 协议中的非图片类型
|
|
55
|
+
sanitized = sanitized.replace(/src\s*=\s*["']?\s*data\s*:(?!image\/)[^"'\s>]*/gi, 'src=""');
|
|
56
|
+
|
|
57
|
+
// 7. 移除 expression() CSS(IE 特有)
|
|
58
|
+
sanitized = sanitized.replace(/expression\s*\([^)]*\)/gi, '');
|
|
59
|
+
|
|
60
|
+
// 8. 移除 vbscript:
|
|
61
|
+
sanitized = sanitized.replace(/vbscript\s*:/gi, '');
|
|
62
|
+
|
|
63
|
+
return sanitized;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 验证标签名是否安全
|
|
68
|
+
*/
|
|
69
|
+
export function isTagAllowed(tag: string): boolean {
|
|
70
|
+
return ALLOWED_TAGS.has(tag.toLowerCase());
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 验证属性名是否安全
|
|
75
|
+
*/
|
|
76
|
+
export function isAttrAllowed(attr: string): boolean {
|
|
77
|
+
const lower = attr.toLowerCase();
|
|
78
|
+
return !FORBIDDEN_ATTR_PREFIXES.some((prefix) => lower.startsWith(prefix));
|
|
79
|
+
}
|