/** * @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 { 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(); 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 (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, 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};