diff --git a/dnfs/README.md b/dnfs/README.md index 39afce2..6e85d32 100644 --- a/dnfs/README.md +++ b/dnfs/README.md @@ -59,14 +59,14 @@ Authorization: Bearer _ | `_sp` | 信封字段 | 前端表现 | |-------|---------|---------| | `link` | `url`(http/https,≤2048)、`note?` | favicon 图标(经 static `/api/favicon` 代理拉取)+ 可点击链接 | -| `html` | `code`(≤512KB)、`note?` | "审计源码"(纯文本展示)+ "运行"(新窗口执行)按钮 | +| `js` | `code`(≤512KB)、`note?` | "审计源码"(纯文本展示)+ "运行"(插入页面 iframe 沙箱执行,捕获 console 输出)按钮 | | `image` | `name`、`data`(`data:image/` DataURL,≤3.5M 字符)、`note?` | 图片直接显示 | | `file` | `name`、`mime`、`data`(Base64,≤3.5M 字符)、`note?` | 下载按钮(Base64→Blob→下载) | 规则: - **仅管理员可发布/更新**(`add_note` / `update_note` 中按 `is_admin` 强制,非管理员返回 403);前端发布面板也仅对管理员显示。 -- AI 内容审核剔除 Base64 负载(体量大且无审核意义):`link` 审 `title+url+note`,`html` 审 `title+code 前 4000 字符+note`,`image`/`file` 审 `title+name+note`。 +- AI 内容审核剔除 Base64 负载(体量大且无审核意义):`link` 审 `title+url+note`,`js` 审 `title+code 前 4000 字符+note`,`image`/`file` 审 `title+name+note`。 - `{"_sp"` 开头但 JSON 非法的内容会被拒绝发布(400),防止半截信封流入。 ## 接口文档 @@ -214,7 +214,7 @@ curl -X POST http://localhost:8006/update_note \ - **安全响应头**([security_middleware.py](security_middleware.py)):`X-Frame-Options: SAMEORIGIN`、`X-XSS-Protection`、`X-Content-Type-Options: nosniff`、`Referrer-Policy: strict-origin-when-cross-origin`、`Permissions-Policy`(禁用摄像头/麦克风/地理位置)、CSP(`default-src 'self'` 等),并移除 `Server` 头。 - **Token 鉴权**:写操作强制校验,payload 取 `user` 字段。 - **所有权隔离**:普通用户只能改/删自己的节点。 -- **特殊节点管控**:link/html/image/file 类型有格式校验与大小限制(URL 前缀、文件名长度、内容大小上限)。 +- **特殊节点管控**:link/js/image/file 类型有格式校验与大小限制(URL 前缀、文件名长度、内容大小上限)。 - **内容审核**:写入与更新前调用 `ai_review`,非法内容会被拒绝(fail-closed 设计,见已知问题)。 - **异步日志**:通过 pymsg `AsyncRpcQueue` 异步批量写日志,fail-safe 不阻塞业务线程。 - **非 root 容器化运行**:Dockerfile 中使用 appuser 非特权用户运行服务。 diff --git a/dnfs/dnfs.py b/dnfs/dnfs.py index 636d59b..5c121a7 100755 --- a/dnfs/dnfs.py +++ b/dnfs/dnfs.py @@ -210,13 +210,13 @@ class DnfsExp(Exception): return self.status # ==================== 特殊节点(管理员专属) ==================== -# 信封格式:content 为 JSON {"_sp": "link|html|image|file", ...},前端识别后差异化渲染; +# 信封格式:content 为 JSON {"_sp": "link|js|image|file", ...},前端识别后差异化渲染; # 普通 Markdown 内容不受影响。仅管理员可发布(Token payload is_admin)。 -_SP_TYPES = ('link', 'html', 'image', 'file') +_SP_TYPES = ('link', 'js', 'image', 'file') _SP_MAX_URL = 2048 # link 节点 url 长度上限 -_SP_MAX_CODE = 512 * 1024 # html 节点代码长度上限(字符) +_SP_MAX_CODE = 512 * 1024 # js 节点代码长度上限(字符) _SP_MAX_DATA = 3500 * 1024 # image/file 节点 base64 长度上限(字符,约 2.6MB 原始数据) -_SP_REVIEW_CODE_PREFIX = 4000 # html 节点送审的代码前缀长度 +_SP_REVIEW_CODE_PREFIX = 4000 # js 节点送审的代码前缀长度 def _parse_sp(content): @@ -245,12 +245,12 @@ def _validate_sp(sp): raise DnfsExp('链接节点必须是 http/https URL', 400) if len(url) > _SP_MAX_URL: raise DnfsExp('链接过长', 400) - elif t == 'html': + elif t == 'js': code = sp.get('code', '') if not isinstance(code, str) or not code.strip(): - raise DnfsExp('网页代码不能为空', 400) + raise DnfsExp('JS 代码不能为空', 400) if len(code) > _SP_MAX_CODE: - raise DnfsExp('网页代码过大(上限 512KB)', 400) + raise DnfsExp('JS 代码过大(上限 512KB)', 400) elif t == 'image': data = sp.get('data', '') if not isinstance(data, str) or not data.startswith('data:image/'): @@ -279,7 +279,7 @@ def _sp_review_text(title, sp): t = sp['_sp'] if t == 'link': parts.append(str(sp.get('url', ''))) - elif t == 'html': + elif t == 'js': parts.append(str(sp.get('code', ''))[:_SP_REVIEW_CODE_PREFIX]) else: # image / file parts.append(str(sp.get('name', ''))) diff --git a/static/README.md b/static/README.md index 162c6ad..f3664b4 100644 --- a/static/README.md +++ b/static/README.md @@ -81,7 +81,7 @@ static :9200 (Flask + CORS) - 流媒体:视频(MP4/WebM/OGG/AVI/MOV/MKV)与音频(MP3/WAV/OGG/FLAC/AAC/M4A)在线播放,支持进度拖动 - 深色模式:`css/theme.css` 统一主题,自动适配系统偏好 - 表单组件:注册页图形/邮箱验证码统一 `.code-box` 行内布局(文字标签 + 输入框 + 控件同排,窄屏自动换行),验证码图片样式复用 `theme.css` 的 `.captcha-img` 类 -- 特殊节点(dnfs 管理员专属):信封 JSON `{"_sp":...}` 内容差异化渲染 —— 链接(favicon 卡片)/ 网页代码(可审计可运行)/ 图片(直显)/ 文件(下载);发布权限由 dnfs 后端按 `is_admin` 强制 +- 特殊节点(dnfs 管理员专属):信封 JSON `{"_sp":...}` 内容差异化渲染 —— 链接(favicon 卡片)/ JS 代码(可审计/可运行,插入页面 iframe 沙箱执行)/ 图片(直显)/ 文件(下载);发布权限由 dnfs 后端按 `is_admin` 强制 - aibox Web 客户端:`aibox.html` 移动端适配聊天页,经 gateway `[WsProxy]` 反向代理(如 `/ws/aibox → ws://127.0.0.1:8765`)与 aibox 智能体通信,握手由 gateway 强制 `is_admin` 验签;协议与 BotOps/client.py 兼容(chunk 流式 / reasoning 思考区 / task 任务面板 / 指数退避重连) - 路径遍历防护:`safe_path_join` 限制访问范围在 `directory.root` 内 - Nuitka 编译:可打包为独立二进制 `fsys.bin`(`sys.frozen` 检测 + BASE_DIR 锚定) diff --git a/static/public/css/theme.css b/static/public/css/theme.css index 2e7aa2d..0e46c89 100644 --- a/static/public/css/theme.css +++ b/static/public/css/theme.css @@ -526,6 +526,14 @@ input[type="button"]:disabled { word-break: break-all; } .sp-image { display: block; max-width: 100%; border-radius: var(--radius-sm); } +.sp-run { margin-top: 0.5rem; } +.sp-run-frame { + width: 100%; + height: 120px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg); +} .sp-note { font-size: 0.82rem; color: var(--fg-secondary); margin-top: 0.4rem; } #sp-panel { margin-top: 0.4rem; } #sp-panel select { margin-bottom: 0.4rem; } diff --git a/static/public/dnfs.html b/static/public/dnfs.html index 085acca..01cc7ce 100644 --- a/static/public/dnfs.html +++ b/static/public/dnfs.html @@ -27,7 +27,7 @@ diff --git a/static/public/js/dnfs.js b/static/public/js/dnfs.js index 099e644..1daeb22 100644 --- a/static/public/js/dnfs.js +++ b/static/public/js/dnfs.js @@ -430,7 +430,7 @@ function renderMarkdown(element, content) { } /* ===== by qing — 特殊节点(管理员专属)===== - * 信封:content 为 {"_sp":"link|html|image|file", ...} JSON,普通内容走 Markdown。 + * 信封:content 为 {"_sp":"link|js|image|file", ...} JSON,普通内容走 Markdown。 * 发布权限由后端 dnfs 强制(is_admin),前端仅做展示与表单切换。 */ /* 与后端 base64 上限(3.5M 字符)对齐:原始文件约 2.5MB */ @@ -445,7 +445,7 @@ function parseSp(content) { } catch (e) { return null; // 非法信封按普通文本展示(后端发布时已拦截,这里是兜底) } - if (obj && typeof obj === 'object' && ['link', 'html', 'image', 'file'].indexOf(obj._sp) !== -1) return obj; + if (obj && typeof obj === 'object' && ['link', 'js', 'image', 'file'].indexOf(obj._sp) !== -1) return obj; return null; } @@ -465,7 +465,7 @@ function renderContent(container, item) { } } if (sp._sp === 'link') renderSpLink(container, sp, appendNote); - else if (sp._sp === 'html') renderSpHtml(container, sp, appendNote); + else if (sp._sp === 'js') renderSpJs(container, sp, appendNote); else if (sp._sp === 'image') renderSpImage(container, sp, appendNote); else if (sp._sp === 'file') renderSpFile(container, sp, appendNote); } @@ -492,8 +492,8 @@ function renderSpLink(container, sp, appendNote) { appendNote(); } -/* 网页代码节点:审计(展开源码,textContent 防注入)+ 运行(新窗口 document.write) */ -function renderSpHtml(container, sp, appendNote) { +/* JS 代码节点:审计(展开源码,textContent 防注入)+ 运行(插入到页面中 iframe 沙箱执行,捕获 console 输出) */ +function renderSpJs(container, sp, appendNote) { var bar = document.createElement('div'); bar.className = 'sp-actions'; var audit_btn = document.createElement('button'); @@ -506,6 +506,9 @@ function renderSpHtml(container, sp, appendNote) { pre.className = 'sp-code'; pre.style.display = 'none'; pre.textContent = sp.code; + var run_box = document.createElement('div'); + run_box.className = 'sp-run'; + run_box.style.display = 'none'; audit_btn.addEventListener('click', function(e) { e.stopPropagation(); var hidden = (pre.style.display === 'none'); @@ -514,19 +517,52 @@ function renderSpHtml(container, sp, appendNote) { }); run_btn.addEventListener('click', function(e) { e.stopPropagation(); - var win = window.open('', '_blank'); - if (!win) { alert('浏览器拦截了弹窗,请允许后重试'); return; } - win.document.open(); - win.document.write(sp.code); - win.document.close(); + if (run_box.firstChild) { // 已注入过,仅切换显示,不重复执行 + run_box.style.display = run_box.style.display === 'none' ? 'block' : 'none'; + return; + } + run_box.style.display = 'block'; + var iframe = document.createElement('iframe'); + iframe.className = 'sp-run-frame'; // 高度由 iframe 内 postMessage 自适应 + iframe.setAttribute('sandbox', 'allow-scripts'); + /* 代码中若含 会提前闭合注入的 script 标签,先转义防逃逸 */ + var code = sp.code.replace(/<\/script/gi, '<\\/script'); + var html = '' + + '