feat(All):Initial

This commit is contained in:
2025-10-04 23:36:07 +08:00
commit 2b4e5d2668
1321 changed files with 415958 additions and 0 deletions
+573
View File
@@ -0,0 +1,573 @@
/**
* @author ErSan
* @email mlt131220@163.com
* @date 2025/4/10 0:29
* @description html面板
*/
import {JSONMeta} from "three";
import JSZip from "jszip";
import {CSS3DObject, CSS3DSprite} from 'three/examples/jsm/renderers/CSS3DRenderer.js';
interface IHtmlPanelOption {
// 类型是否是精灵
isSprite: boolean;
// 代码内容
codes: Array<{ name: string, content: string | ArrayBuffer, isIndex?: boolean }>;
// 对应的代码是单html文件还是多文件
isSingleHtml: boolean;
}
/**
* html面板转换器
* @description 如果是单html文件,沙箱环境使用with + proxy;如果是zip包,沙箱环境使用iframe
*/
class HtmlPanelConverter {
private static instance: HtmlPanelConverter | null = null;
private config = {
// 是否允许执行脚本
allowScripts: true,
// 文件大小限制
maxFileSize: 1024 * 1024 * 10, // 10M
// 标签黑名单
notAllowedTags: ['iframe'],
};
private readonly sandbox: any;
private constructor() {
// 创建安全沙箱环境
this.sandbox = this._createSandbox();
// 配套CSS样式(需添加到页面)
const style = document.createElement('style');
style.textContent = `
.css3d-imported-content {
pointer-events: none !important;
// overflow: hidden;
}
.css3d-imported-content [style] {
will-change: transform, opacity;
}
`;
document.head.appendChild(style);
}
// 获取单例实例
public static getInstance(): HtmlPanelConverter {
if (!HtmlPanelConverter.instance) {
HtmlPanelConverter.instance = new HtmlPanelConverter();
}
return HtmlPanelConverter.instance;
}
// 创建安全沙箱
private _createSandbox() {
// 重写全局的 fetch 方法
const originalFetch = window.fetch;
// 重写 XMLHttpRequest 的 open 方法
const originalXMLHttpRequest = window.XMLHttpRequest;
// 重写Websocket
const originalWebSocket = window.WebSocket;
// url检查
const checkUrl = (url: string) => {
if(url.indexOf(import.meta.env.VITE_GLOB_ORIGIN) > -1) return false;
if(url.indexOf('http://') === -1 && url.indexOf('https://') === -1 && url.indexOf('ws://') === -1 && url.indexOf('wss://') === -1) return false;
return true;
}
const sandbox = {
fetch: async (url: string, options: any) => {
if (checkUrl(url)) {
throw new Error('请求被禁止:无法向当前服务器发起请求');
}
// 如果不是当前服务器的请求,则调用原始的 fetch 方法
return originalFetch(url, options);
},
XMLHttpRequest: () => {
const xhr = new originalXMLHttpRequest();
// 拦截 open 方法
const originalOpen = xhr.open;
// @ts-ignore
xhr.open = function (method, url, async, user, password) {
if (checkUrl(url as string)) {
throw new Error('请求被禁止:无法向当前服务器发起请求');
}
// 如果不是当前服务器的请求,则调用原始的 open 方法
return originalOpen.call(xhr, method, url, async, user, password);
};
return xhr;
},
WebSocket: (url:string) => {
if (checkUrl(url)) {
throw new Error('请求被禁止:无法连接到当前服务器的 WebSocket');
}
return new originalWebSocket(url);
},
// 其他要修改的的全局对象...
};
return new Proxy(sandbox, {
// 拦截所有属性,防止到 Proxy 对象以外的作用域链查找。
has: () => true,
get: (target, prop) => {
// 加固,防止逃逸
if (prop === Symbol.unscopables) {
return undefined;
}
if (Reflect.has(target, prop)) {
return Reflect.get(target, prop);
}
// 暂时允许从window直接获取属性,后续考虑限制。因为不限制很危险,比如可以获取到window.location、window.history等敏感信息。
// else{
// return undefined;
// }
// 禁止访问的属性
if(['location', 'history', 'top', 'parent', 'frameElement'].includes(typeof prop === "string" ? prop : '')) return undefined;
//如果找不到,就直接从window对象上取值
const rawValue = Reflect.get(window, prop);
//如果兜底的是一个函数,需要绑定window对象,比如window.addEventListener
if (typeof rawValue === 'function') {
const valueStr = rawValue.toString();
if (!/^function\s+[A-Z]/.test(valueStr) && !/^class\s+/.test(valueStr)) {
return rawValue.bind(window); // 所有 window 上非构造函数调用时候的 this 绑定window对象
}
}
return rawValue;
}
});
}
// 主加载方法
loadAsync(option: { url: string, isSprite: boolean, fileName?: string }): Promise<HtmlPanel | HtmlSprite> {
return new Promise(async (resolve, reject) => {
if (!option.url) {
reject(new Error('请输入url参数'));
return;
}
const htmlPanelOption: IHtmlPanelOption = {
isSprite: option.isSprite,
isSingleHtml: true,
codes: []
}
const response = await fetch(option.url);
try{
this._validateResponse(response);
}catch (e){
reject(e);
return;
}
// 判断是zip包还是html文件
let suffix = option.fileName?.split('.').pop() || option.url.split('.').pop();
if (suffix && ['zip'].includes(suffix)) {
// 解压zip包
const zip = new JSZip();
const zipContent = await zip.loadAsync(await response.arrayBuffer());
// 强制检查根目录下的index.html
const mainHtmlFile = zipContent.file('index.html');
if (!mainHtmlFile) {
reject(new Error('The .zip file root directory must contain index.html'));
return;
}
// 获取所有文件路径列表(JSZip存储结构为 {路径: 文件对象})
const filePaths = Object.keys(zipContent.files);
for (let i = 0; i < filePaths.length; i++) {
const relativePath = filePaths[i];
const file = zipContent.file(relativePath); // 通过路径获取文件对象
if(!file) continue;
// 判断是否为文件(JSZip中目录路径以 '/' 结尾)
if (!file.dir && !relativePath.endsWith('/')) {
const _isEdit = this._isEditable(file.name);
// 同步读取文件内容
const content = _isEdit ? await file.async('text') : await file.async('arraybuffer') //await file.async("binarystring");
// 存储到目标数组
htmlPanelOption.codes.push({
name: relativePath,
content: content,
isIndex: relativePath === 'index.html'
});
}
}
htmlPanelOption.isSingleHtml = false;
} else {
if (!option.fileName) {
// 解析URL以获取文件名
// const _url = new URL(option.url, document.baseURI);
const _url = new URL(option.url, "https://editor.astraljs.com");
option.fileName = _url.pathname.split('/').pop() || 'index.html';
}
htmlPanelOption.codes.push({
name: option.fileName,
content: await response.text(),
isIndex: true
});
htmlPanelOption.isSingleHtml = true;
}
try{
const htmlObject3D = this.parseToCSS3D(htmlPanelOption);
resolve(htmlObject3D);
}catch (e){
reject(e);
}
})
}
// 安全验证方法
private _validateResponse(response: Response) {
if (!response.ok) throw new Error('加载失败');
if (Number(response.headers.get('content-length')) > this.config.maxFileSize) {
throw new Error('文件大小超出限制');
}
}
// 解析HTML生成CSS3D对象
parseToCSS3D(options: IHtmlPanelOption) {
if (options.codes.length === 0) throw new Error('解析内容不能为空');
// 解析文档
let container: HTMLDivElement | HTMLIFrameElement;
if (options.isSingleHtml) {
// 创建容器
container = document.createElement('div');
container.className = 'css3d-imported-content';
const htmlDoc = this._parseHtml(options.codes[0].content as string);
// 克隆并处理内容
const content = this._processContent(htmlDoc);
// 仅添加content(body)的子节点
while (content.firstChild) {
container.appendChild(content.firstChild);
}
} else {
// TODO 解析zip,但是现在只处理了单文件HTML,后续加强支持解析多文件HTML
// 创建容器
container = document.createElement('div');
container.className = 'css3d-imported-content';
// 创建虚拟文件系统映射表
const filesMap = new Map<string, string>();
options.codes.forEach(code => {
const mimeType = this._getMimeType(code.name);
const blob = new Blob([code.content], {type: mimeType});
const blobURL = URL.createObjectURL(blob);
filesMap.set(code.name, blobURL);
});
// 获取主HTML内容
const mainHtmlCode = options.codes.find(code => code.isIndex);
if (!mainHtmlCode) throw new Error('主文件index.html不存在');
// 深度克隆文档对象以便修改
const htmlDoc = this._parseHtml(mainHtmlCode.content as string);
const basePath = mainHtmlCode.name.split('/').slice(0, -1).join('/') || '';
this._replaceResourceUrls(htmlDoc, filesMap, basePath);
const iframe = document.createElement('iframe');
iframe.style.border = 'none';
iframe.width = 192 * 5 + '';
iframe.height = 108 * 5 + '';
// @ts-ignore
(<any>iframe).sandbox = 'allow-same-origin allow-scripts';
iframe.srcdoc = htmlDoc.documentElement.outerHTML;
container.appendChild(iframe);
document.body.appendChild(container);
// 创建iframe并注入所有资源
const iframeDoc = iframe.contentDocument!;
// 添加基础路径保证相对路径解析
const baseTag = document.createElement('base');
baseTag.href = URL.createObjectURL(new Blob([], {type: 'text/html'}));
iframeDoc.head.prepend(baseTag);
// 清理blob URLs
iframe.addEventListener('load', () => {
filesMap.forEach(url => URL.revokeObjectURL(url));
// document.body.removeChild(container);
// container.remove();
});
}
if (!container) throw new Error('解析失败');
if (options.isSprite) {
return new HtmlSprite(container, options);
} else {
return new HtmlPanel(container, options);
}
}
// 使用DOMParser解析HTML结构
private _parseHtml(content: string) {
const parser = new DOMParser();
return parser.parseFromString(content, "text/html");
}
// 处理文档内容
private _processContent(htmlDoc: Document) {
const container = htmlDoc.documentElement.cloneNode(true) as HTMLElement;
// 清理危险元素
this._sanitizeContent(container);
// 处理脚本
if (this.config.allowScripts) {
this._processScripts(container);
}
return container;
}
// 清理危险内容
private _sanitizeContent(content: HTMLElement) {
// 删除不允许的标签
content.querySelectorAll('*').forEach(node => {
if (this.config.notAllowedTags.includes(node.tagName.toLowerCase())) {
node.remove();
}
});
// 删除危险属性
const dangerousAttrs = ['onload', 'onerror', 'onclick'];
content.querySelectorAll('*').forEach(node => {
dangerousAttrs.forEach(attr => node.removeAttribute(attr));
});
}
// 处理脚本内容
private async _processScripts(content: HTMLElement) {
const scripts = content.querySelectorAll('script');
for (let i = 0; i < scripts.length; i++) {
const script = scripts[i];
const newScript = document.createElement('script');
let execute: Function | undefined;
// 执行脚本
const executeScript = (scriptContent:string) => {
try {
const code = `with(sandbox){${scriptContent}}`;
newScript.textContent = code;
execute = new Function('sandbox', code);
} catch (e) {
console.warn('脚本执行失败:', e);
}
// script.replaceWith(newScript);
execute && execute(this.sandbox);
}
// 检查是否存在src属性
const src = script.getAttribute('src');
if (src) {
// 处理外部脚本
try {
const res = await fetch(src);
if (!res.ok) {
console.error(`加载外部脚本失败: ${src}`);
continue; // 跳过当前脚本
}
executeScript(await res.text());
} catch (e) {
console.error(`加载外部脚本异常: ${src}`, e);
}
}else{
executeScript(script.textContent || '');
}
}
}
// 获取文件类型
private _getMimeType(filename: string): string {
const ext = filename.split('.').pop()?.toLowerCase() || '';
switch (ext) {
case 'html':
return 'text/html';
case 'css':
return 'text/css';
case 'js':
return 'application/javascript';
case 'json':
return 'application/json';
case 'png':
return 'image/png';
case 'jpg':
case 'jpeg':
return 'image/jpeg';
case 'gif':
return 'image/gif';
case 'svg':
return 'image/svg+xml';
case 'zip':
return 'application/zip';
default:
return 'text/plain';
}
}
// 通过filePath判断文件内容是否可编辑(html、css、js、json、svg
_isEditable(filePath: string): boolean {
const ext = filePath.split('.').pop()?.toLowerCase() || '';
return ['html', 'css', 'js', 'json','svg'].includes(ext);
}
// 替换资源URL
private _replaceResourceUrls(doc: Document, filesMap: Map<string, string>, basePath: string) {
const attrMap = {
'script': 'src',
'link': 'href',
'img': 'src',
'audio': 'src',
'video': 'src',
'source': 'src',
'embed': 'src',
'object': 'data'
};
Object.entries(attrMap).forEach(([tag, attr]) => {
doc.querySelectorAll(`${tag}[${attr}]`).forEach(el => {
const originPath = el.getAttribute(attr);
if (!originPath) return;
// 路径标准化处理
const resolvedPath = new URL(originPath, `https://editor.astraljs.com/${basePath}/`).pathname.slice(1);
if (filesMap.has(resolvedPath)) {
el.setAttribute(attr, filesMap.get(resolvedPath)!);
}
});
});
// 上面的代码中为了解析路径使用了固定域名,正常而已解析结果不会受此影响,如果结果不对,可以改用下面的代码
// 使用虚拟路径协议代替真实域名
// const virtualProtocol = 'virtual-resource:';
// Object.entries(attrMap).forEach(([tag, attr]) => {
// doc.querySelectorAll(`${tag}[${attr}]`).forEach(el => {
// const originPath = el.getAttribute(attr);
// if (!originPath) return;
//
// // 构造无域名的标准化路径
// const resolved = new URL(originPath, `${virtualProtocol}//${basePath}/`);
// const resolvedPath = resolved.href
// .replace(`${virtualProtocol}//`, '') // 移除协议头
// .replace(/(\/\/+)/g, '/') // 处理多余斜杠
// .replace(/^\/+/, ''); // 移除开头的斜杠
//
// if (filesMap.has(resolvedPath)) {
// el.setAttribute(attr, filesMap.get(resolvedPath)!);
// }
// });
// });
}
}
class HtmlPanel extends CSS3DObject {
type = 'HtmlPanel';
isHtmlPanel = true;
options: IHtmlPanelOption;
constructor(element: HTMLElement, options: IHtmlPanelOption) {
super(element);
this.options = options;
}
/**
* 获取json配置
*/
toJSON(meta?: JSONMeta) {
const superJSON = super.toJSON(meta).object;
return {
metadata: {
version: 4.6,
type: 'Object',
generator: 'HtmlPanel.toJSON'
},
object: {
...superJSON,
type: this.type,
options: this.options
},
} as any;
}
/**
* 从json配置解析
*/
static fromJSON(data: any) {
return HtmlPanelConverter.getInstance().parseToCSS3D(data.options);
}
}
class HtmlSprite extends CSS3DSprite {
type = 'HtmlSprite';
isHtmlSprite = true;
options: IHtmlPanelOption;
constructor(element: HTMLElement, options: IHtmlPanelOption) {
super(element);
this.options = options;
}
/**
* 获取json配置
*/
toJSON(meta?: JSONMeta) {
const superJSON = super.toJSON(meta).object;
return {
metadata: {
version: 4.6,
type: 'Object',
generator: 'HtmlSprite.toJSON'
},
object: {
...superJSON,
type: this.type,
options: this.options
},
} as any;
}
/**
* 从json配置解析
*/
static fromJSON(data: any) {
return HtmlPanelConverter.getInstance().parseToCSS3D(data.options);
}
}
export {HtmlPanelConverter, HtmlPanel, HtmlSprite};