diff --git a/dnfs/README.md b/dnfs/README.md index 597f992..1413830 100644 --- a/dnfs/README.md +++ b/dnfs/README.md @@ -4,6 +4,8 @@ Dendrachy Network File-System,基于父子关系(pid)组织的树状节点 监听 `127.0.0.1:8006`,提供节点的增删改查,支持以 Base64 编码形式在 `content` 中存储文件数据。所有写操作需携带由 gateway 签发的 Token。 +支持**特殊节点**(管理员专属):`content` 为信封 JSON 时,前端按类型渲染为链接卡片 / 可运行网页 / 图片 / 文件下载,详见[特殊节点](#特殊节点管理员专属)。 + ## 架构 ``` @@ -44,9 +46,26 @@ Authorization: Bearer _ - payload 中取 `user` 字段作为操作者,写入 `owner`。 - `delete_note` 与 `update_note` 校验所有权: - 节点 `owner` 必须等于当前 `user`,否则 `403`; - - **例外**:管理员账号(`ADMIN_USERS`:`admin`、`admin@qsont.me`)可删除/更新任意节点。 + - **例外**:管理员(Token payload `is_admin=true`,由 gateway 按 `[Admin] users` 白名单签发)可删除/更新任意节点。 - `get_notes` 不需要鉴权。 +## 特殊节点(管理员专属) + +`content` 为以下信封 JSON 时即为特殊节点,前端 `dnfs.js` 按 `_sp` 类型差异化渲染;普通 Markdown 内容不受影响: + +| `_sp` | 信封字段 | 前端表现 | +|-------|---------|---------| +| `link` | `url`(http/https,≤2048)、`note?` | favicon 图标(经 static `/api/favicon` 代理拉取)+ 可点击链接 | +| `html` | `code`(≤512KB)、`note?` | "审计源码"(纯文本展示)+ "运行"(新窗口执行)按钮 | +| `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`。 +- `{"_sp"` 开头但 JSON 非法的内容会被拒绝发布(400),防止半截信封流入。 + ## 接口文档 所有接口均为 `POST`,`Content-Type: application/json`。 @@ -154,7 +173,7 @@ curl -X POST http://localhost:8006/delete_note \ 更新节点的 pid / title / content(pid 变更可用于迁移节点)。流程:校验参数 → 校验 Token → 校验所有权 → AI 内容审核 → 更新。 -仅节点 owner 可更新;管理员账号(`ADMIN_USERS`:`admin`、`admin@qsont.me`)豁免,可更新任意节点。 +仅节点 owner 可更新;管理员(Token payload `is_admin`)豁免,可更新任意节点。 **Request** diff --git a/dnfs/dnfs.py b/dnfs/dnfs.py index 0be38d8..ff78c71 100755 --- a/dnfs/dnfs.py +++ b/dnfs/dnfs.py @@ -30,7 +30,7 @@ from log_client import logs # pymsg 导入 # 我们有在依赖列表中加入pymsg # 这个可以省略也许 -_sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from pymsg import RpcClient, RpcError # noqa: E402 # ==================== 配置加载(三层 fail-safe) ==================== @@ -183,6 +183,82 @@ class DnfsExp(Exception): def get_status(self) -> int: return self.status +# ==================== 特殊节点(管理员专属) ==================== +# 信封格式:content 为 JSON {"_sp": "link|html|image|file", ...},前端识别后差异化渲染; +# 普通 Markdown 内容不受影响。仅管理员可发布(Token payload is_admin)。 +_SP_TYPES = ('link', 'html', 'image', 'file') +_SP_MAX_URL = 2048 # link 节点 url 长度上限 +_SP_MAX_CODE = 512 * 1024 # html 节点代码长度上限(字符) +_SP_MAX_DATA = 3500 * 1024 # image/file 节点 base64 长度上限(字符,约 2.6MB 原始数据) +_SP_REVIEW_CODE_PREFIX = 4000 # html 节点送审的代码前缀长度 + + +def _parse_sp(content): + """识别特殊节点信封:合法信封返回 dict;普通内容返回 None; + 以 {"_sp" 开头但 JSON 非法视为格式错误(拒绝发布,防半截信封流入)。""" + if not isinstance(content, str): + return None + s = content.lstrip() + if not s.startswith('{"_sp"'): + return None + try: + obj = json.loads(s) + except Exception: + raise DnfsExp('特殊节点格式非法(JSON 解析失败)', 400) + if isinstance(obj, dict) and obj.get('_sp') in _SP_TYPES: + return obj + return None + + +def _validate_sp(sp): + """校验特殊节点信封字段与大小上限,非法抛 DnfsExp。""" + t = sp['_sp'] + if t == 'link': + url = sp.get('url', '') + if not isinstance(url, str) or not url.startswith(('http://', 'https://')): + raise DnfsExp('链接节点必须是 http/https URL', 400) + if len(url) > _SP_MAX_URL: + raise DnfsExp('链接过长', 400) + elif t == 'html': + code = sp.get('code', '') + if not isinstance(code, str) or not code.strip(): + raise DnfsExp('网页代码不能为空', 400) + if len(code) > _SP_MAX_CODE: + raise DnfsExp('网页代码过大(上限 512KB)', 400) + elif t == 'image': + data = sp.get('data', '') + if not isinstance(data, str) or not data.startswith('data:image/'): + raise DnfsExp('图片节点 data 必须是 data:image/ 开头的 DataURL', 400) + if len(data) > _SP_MAX_DATA: + raise DnfsExp('图片过大(上限约 2.6MB)', 400) + elif t == 'file': + name = sp.get('name', '') + data = sp.get('data', '') + if not isinstance(name, str) or not name.strip(): + raise DnfsExp('文件节点缺少文件名', 400) + if len(name) > 255: + raise DnfsExp('文件名过长', 400) + if not isinstance(data, str) or not data: + raise DnfsExp('文件节点缺少数据', 400) + if len(data) > _SP_MAX_DATA: + raise DnfsExp('文件过大(上限约 2.6MB)', 400) + + +def _sp_review_text(title, sp): + """特殊节点的 AI 审核文本:剔除 base64 负载(体量大且无审核意义),仅保留文本字段。""" + parts = [title or ''] + note = sp.get('note') + if isinstance(note, str) and note.strip(): + parts.append(note) + t = sp['_sp'] + if t == 'link': + parts.append(str(sp.get('url', ''))) + elif t == 'html': + parts.append(str(sp.get('code', ''))[:_SP_REVIEW_CODE_PREFIX]) + else: # image / file + parts.append(str(sp.get('name', ''))) + return '\n'.join(parts) + # 这个也许不用这么复杂 大不了每个异常点我们抛出DnfsExp类 #class InvalidRequest(DnfsExp): # '''非法参数异常''' @@ -253,8 +329,15 @@ def add_note(): raise DnfsExp('父节点不存在', 400) - # 内容审查 - review = review_content(title + '\n' + content) + # 特殊节点(管理员专属):非管理员拒绝,并做字段/大小校验 + sp = _parse_sp(content) + if sp is not None: + if not info.get('is_admin'): + raise DnfsExp('特殊节点仅管理员可发布,用户'+info.get('user')+',从'+request.remote_addr, 403) + _validate_sp(sp) + + # 内容审查(特殊节点剔除 base64 负载) + review = review_content(_sp_review_text(title, sp) if sp is not None else title + '\n' + content) if True != review: raise DnfsExp('内容审核不通过,用户'+info.get('user')+',从'+request.remote_addr, 400) # 执行插入 @@ -473,8 +556,19 @@ def update_note(): logs.warning('dnfs', f'update_note 所有权拒绝 id={id}', extra={'user': info['user']}) return jsonify({"error": 'It\'s not your note'}), 403 - # 内容审查 - review = review_content(title + '\n' + content) + # 特殊节点(管理员专属):非管理员拒绝,并做字段/大小校验 + sp = _parse_sp(content) + if sp is not None: + if not info.get('is_admin'): + logs.warning('dnfs', f'update_note 特殊节点非管理员拒绝 id={id}', extra={'user': info['user']}) + return jsonify({"error": '特殊节点仅管理员可发布'}), 403 + try: + _validate_sp(sp) + except DnfsExp as e: + return jsonify({"error": str(e)}), e.get_status() + + # 内容审查(特殊节点剔除 base64 负载) + review = review_content(_sp_review_text(title, sp) if sp is not None else title + '\n' + content) if True != review: #return jsonify({'error': 'review failed', 'data': review}), 400 logs.warning('dnfs', f'update_note 内容审核未通过 id={id} title={title}', extra={'user': info['user']}) diff --git a/static/README.md b/static/README.md index ce65916..6c7eb50 100644 --- a/static/README.md +++ b/static/README.md @@ -50,6 +50,7 @@ static :9200 (Flask + CORS) | `POST /api/hash` | SHA256 密码哈希 | | `POST /api/check_user` | 检查用户是否已注册(查询 gateway 数据库) | | `POST /api/reset_password` | 重置用户密码(直接操作 gateway 数据库) | +| `GET /api/favicon?url=` | favicon 服务端代理(dnfs 链接卡片用;SSRF 防护:仅 http/https + 内网地址拦截 + 重定向校验,512KB 上限,缓存 1 天) | ### yhdet OAuth 路由 @@ -79,6 +80,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` 强制 - 路径遍历防护:`safe_path_join` 限制访问范围在 `directory.root` 内 - Nuitka 编译:可打包为独立二进制 `fsys.bin`(`sys.frozen` 检测 + BASE_DIR 锚定) - 安全头注入:`security_middleware.py` 为所有响应注入 CSP、X-Frame-Options 等安全头 diff --git a/static/fsys.py b/static/fsys.py index 8401b12..f2542e2 100644 --- a/static/fsys.py +++ b/static/fsys.py @@ -531,6 +531,75 @@ def proxy_send_code(): +# ==================== favicon 代理(dnfs 特殊节点"链接"卡片用) ==================== +# 服务端拉取目标站 /favicon.ico:前端 CSP img-src 'self' 禁止外域图片,且跨域无法直取。 +# SSRF 防护:仅 http/https;解析目标 IP,禁止内网/环回/保留地址;重定向目标同样校验;512KB 上限。 +import ipaddress +import socket +from urllib.parse import urlparse + +_FAVICON_MAX_BYTES = 512 * 1024 +_FAVICON_TIMEOUT = 5 + + +def _is_private_host(hostname): + """主机解析后任一 IP 命中内网/环回/链路本地/保留/组播地址即视为非法(fail-closed)。""" + try: + infos = socket.getaddrinfo(hostname, None) + except Exception: + return True # 解析失败一律拒绝 + if not infos: + return True + for info in infos: + try: + ip = ipaddress.ip_address(info[4][0]) + except ValueError: + return True + if (ip.is_private or ip.is_loopback or ip.is_link_local + or ip.is_reserved or ip.is_multicast or ip.is_unspecified): + return True + return False + + +class _FaviconRedirectHandler(_urlreq.HTTPRedirectHandler): + """重定向时重新校验目标主机,防止 302 跳转绕过 SSRF 检查。""" + def redirect_request(self, req, fp, code, msg, headers, newurl): + if _is_private_host(urlparse(newurl).hostname or ''): + return None + return super().redirect_request(req, fp, code, msg, headers, newurl) + + +@app.route('/api/favicon', methods=['GET']) +def api_favicon(): + target = (request.args.get('url') or '').strip() + try: + parsed = urlparse(target) + except Exception: + return jsonify({'error': 'bad url'}), 400 + if parsed.scheme not in ('http', 'https') or not parsed.hostname: + return jsonify({'error': 'bad url'}), 400 + if _is_private_host(parsed.hostname): + return jsonify({'error': 'forbidden host'}), 403 + + favicon_url = f"{parsed.scheme}://{parsed.netloc}/favicon.ico" + try: + opener = _urlreq.build_opener(_FaviconRedirectHandler()) + req = _urlreq.Request(favicon_url, headers={'User-Agent': 'qsont-favicon-fetcher/1.0'}) + with opener.open(req, timeout=_FAVICON_TIMEOUT) as resp: + data = resp.read(_FAVICON_MAX_BYTES + 1) + if len(data) > _FAVICON_MAX_BYTES: + return jsonify({'error': 'favicon too large'}), 502 + ctype = (resp.headers.get('Content-Type') or '').split(';')[0].strip() + if not ctype.startswith('image/'): + ctype = 'image/x-icon' # 部分站点 favicon 返回 text/html 等,统一兜底 + out = Response(data, 200, mimetype=ctype) + out.headers['Cache-Control'] = 'public, max-age=86400' + return out + except Exception as e: + logs.info("static", "favicon 拉取失败", extra={"url": target[:128], "error": str(e)}) + return jsonify({'error': 'fetch failed'}), 502 + + # ==================== 主路由 ==================== @app.route('/') def serve_view(): diff --git a/static/public/css/theme.css b/static/public/css/theme.css index 7d569ee..2e7aa2d 100644 --- a/static/public/css/theme.css +++ b/static/public/css/theme.css @@ -119,6 +119,21 @@ textarea:focus { input::placeholder, textarea::placeholder { color: var(--muted); } textarea { min-height: 80px; resize: vertical; } +select { + display: block; + width: 100%; + padding: 0.6rem 0.9rem; + border: 1.5px solid var(--border); + border-radius: var(--radius-sm); + font-size: 0.9rem; + font-family: inherit; + background: var(--bg); + color: var(--fg); + outline: none; + transition: border-color 0.2s, box-shadow 0.2s; +} +select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); } + /* ===== Buttons ===== */ button, input[type="submit"], input[type="reset"], input[type="button"] { padding: 0.55rem 1.3rem; @@ -492,6 +507,30 @@ input[type="button"]:disabled { border-color: var(--accent); } +/* ===== Special Nodes (dnfs,管理员发布的特殊节点) ===== */ +.sp-link { display: flex; align-items: center; gap: 0.5rem; } +.sp-favicon { width: 20px; height: 20px; flex: none; border-radius: 4px; } +.sp-anchor { color: var(--accent); word-break: break-all; } +.sp-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.4rem; } +.sp-code { + max-height: 320px; + overflow: auto; + margin-top: 0.5rem; + padding: 0.75rem; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-size: 0.78rem; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-all; +} +.sp-image { display: block; max-width: 100%; border-radius: var(--radius-sm); } +.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; } +#sp-file-tip { font-size: 0.78rem; } + /* ===== Node Form (dnfs) ===== */ .node-form { margin-top: 1rem; } .node-form .form-actions { margin-top: 0.75rem; } diff --git a/static/public/dnfs.html b/static/public/dnfs.html index bc4c659..b9a8a99 100644 --- a/static/public/dnfs.html +++ b/static/public/dnfs.html @@ -15,8 +15,21 @@

节点操作

- - + + + +
diff --git a/static/public/js/dnfs.js b/static/public/js/dnfs.js index 5005a1d..0ac94ca 100644 --- a/static/public/js/dnfs.js +++ b/static/public/js/dnfs.js @@ -418,6 +418,151 @@ function renderMarkdown(element, content) { element.innerHTML = md.render(content, {html: false}); } +/* ===== by qing — 特殊节点(管理员专属)===== + * 信封:content 为 {"_sp":"link|html|image|file", ...} JSON,普通内容走 Markdown。 + * 发布权限由后端 dnfs 强制(is_admin),前端仅做展示与表单切换。 */ + +/* 与后端 base64 上限(3.5M 字符)对齐:原始文件约 2.5MB */ +var SP_MAX_FILE_BYTES = 2.5 * 1024 * 1024; + +function parseSp(content) { + if (typeof content !== 'string') return null; + var s = content.trim(); + if (s.indexOf('{"_sp"') !== 0) return null; + try { + var obj = JSON.parse(s); + } catch (e) { + return null; // 非法信封按普通文本展示(后端发布时已拦截,这里是兜底) + } + if (obj && typeof obj === 'object' && ['link', 'html', 'image', 'file'].indexOf(obj._sp) !== -1) return obj; + return null; +} + +/* 内容渲染分发:特殊节点按类型渲染,否则 Markdown */ +function renderContent(container, item) { + var sp = parseSp(item.content); + if (!sp) { + renderMarkdown(container, item.content); + return; + } + function appendNote() { + if (typeof sp.note === 'string' && sp.note.trim()) { + var note = document.createElement('div'); + note.className = 'sp-note'; + note.textContent = sp.note; + container.appendChild(note); + } + } + if (sp._sp === 'link') renderSpLink(container, sp, appendNote); + else if (sp._sp === 'html') renderSpHtml(container, sp, appendNote); + else if (sp._sp === 'image') renderSpImage(container, sp, appendNote); + else if (sp._sp === 'file') renderSpFile(container, sp, appendNote); +} + +/* 链接节点:服务端代理拉取的 favicon + 可点击链接(卡片内交互需 stopPropagation) */ +function renderSpLink(container, sp, appendNote) { + var box = document.createElement('div'); + box.className = 'sp-link'; + var icon = document.createElement('img'); + icon.className = 'sp-favicon'; + icon.alt = ''; + icon.src = '/api/favicon?url=' + encodeURIComponent(sp.url); + icon.addEventListener('error', function() { icon.style.display = 'none'; }); + var anchor = document.createElement('a'); + anchor.className = 'sp-anchor'; + anchor.href = sp.url; + anchor.target = '_blank'; + anchor.rel = 'noopener noreferrer'; + anchor.textContent = sp.url; + anchor.addEventListener('click', function(e) { e.stopPropagation(); }); + box.appendChild(icon); + box.appendChild(anchor); + container.appendChild(box); + appendNote(); +} + +/* 网页代码节点:审计(展开源码,textContent 防注入)+ 运行(新窗口 document.write) */ +function renderSpHtml(container, sp, appendNote) { + var bar = document.createElement('div'); + bar.className = 'sp-actions'; + var audit_btn = document.createElement('button'); + audit_btn.className = 'act-btn act-edit'; + audit_btn.textContent = '审计源码'; + var run_btn = document.createElement('button'); + run_btn.className = 'act-btn act-move'; + run_btn.textContent = '运行'; + var pre = document.createElement('pre'); + pre.className = 'sp-code'; + pre.style.display = 'none'; + pre.textContent = sp.code; + audit_btn.addEventListener('click', function(e) { + e.stopPropagation(); + var hidden = (pre.style.display === 'none'); + pre.style.display = hidden ? 'block' : 'none'; + audit_btn.textContent = hidden ? '收起源码' : '审计源码'; + }); + 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(); + }); + bar.appendChild(audit_btn); + bar.appendChild(run_btn); + container.appendChild(bar); + container.appendChild(pre); + appendNote(); +} + +/* 图片节点:DataURL 直接显示(CSP img-src 允许 data:) */ +function renderSpImage(container, sp, appendNote) { + var img = document.createElement('img'); + img.className = 'sp-image'; + img.src = sp.data; + img.alt = sp.name || '图片'; + container.appendChild(img); + appendNote(); +} + +/* 文件节点:base64 → Blob → 触发下载(blob: 下载不受 CSP 限制) */ +function renderSpFile(container, sp, appendNote) { + var bar = document.createElement('div'); + bar.className = 'sp-actions'; + var btn = document.createElement('button'); + btn.className = 'act-btn act-edit'; + btn.textContent = '下载 ' + (sp.name || '文件') + '(' + formatBytes(Math.floor((sp.data || '').length * 3 / 4)) + ')'; + btn.addEventListener('click', function(e) { + e.stopPropagation(); + try { + var bin = atob(sp.data); + var bytes = new Uint8Array(bin.length); + for (var i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + var blob = new Blob([bytes], { type: sp.mime || 'application/octet-stream' }); + var a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = sp.name || 'download'; + document.body.appendChild(a); + a.click(); + a.remove(); + setTimeout(function() { URL.revokeObjectURL(a.href); }, 10000); + } catch (err) { + alert('文件解码失败:' + err.message); + } + }); + bar.appendChild(btn); + container.appendChild(bar); + appendNote(); +} + +function formatBytes(n) { + var units = ['B', 'KB', 'MB', 'GB']; + var i = 0; + while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; } + return n.toFixed(1) + units[i]; +} + /* by qing — 互动统计图标:inline SVG,stroke=currentColor 自动跟随主题色 */ const ICON_EYE = ''; const ICON_HEART = ''; @@ -450,6 +595,12 @@ var recommend_list = document.getElementById('recommend-list'); var delete_note = document.getElementById('delete-note'); var minimap = document.getElementById("minimap"); var movebar = document.getElementById("movebar"); +/* 特殊节点面板(管理员专属,loadUserPanel 判定后显示) */ +var sp_panel = document.getElementById('sp-panel'); +var sp_type = document.getElementById('sp-type'); +var sp_file = document.getElementById('sp-file'); +var sp_file_tip = document.getElementById('sp-file-tip'); +var sp_edit_original = null; /* 编辑图片/文件节点时的原信封字符串:未选新文件则原样提交 */ /* 面板默认展开的最小视口宽度(px):窄屏下面板展开会遮挡内容,默认折叠;宽屏默认展开 */ var PANEL_EXPAND_MIN_WIDTH = 768; var is_expand = window.innerWidth >= PANEL_EXPAND_MIN_WIDTH; @@ -471,10 +622,69 @@ function resetTextareaHeight() { } autoResizeTextarea(input_note_content); /* 初始化高度 */ +/* ===== 特殊节点表单:类型切换控制输入区形态 ===== */ +sp_type.addEventListener('change', function() { + var t = sp_type.value; + var is_file = (t === 'image' || t === 'file'); + sp_file.style.display = is_file ? '' : 'none'; + input_note_content.style.display = is_file ? 'none' : ''; + sp_file_tip.textContent = ''; + sp_file_tip.className = is_file ? 'tip-yellow' : ''; + if (!is_file) { + sp_file.value = ''; + sp_edit_original = null; + } + if (t === 'link') input_note_content.placeholder = '请输入链接 URL(https://…)'; + else if (t === 'html') input_note_content.placeholder = '请输入完整 HTML 代码'; + else input_note_content.placeholder = ''; +}); + +function readFileAsDataURL(f) { + return new Promise(function(resolve, reject) { + var r = new FileReader(); + r.onload = function() { resolve(r.result); }; + r.onerror = function() { reject(new Error('文件读取失败')); }; + r.readAsDataURL(f); + }); +} + +/* by qing — 构造提交内容:普通节点返回原文本,特殊节点返回信封 JSON 字符串。 + * 校验失败返回 null(已弹提示);非管理员永远按普通文本提交。 */ +async function buildSubmitContent() { + var t = (current_user && current_user.is_admin) ? sp_type.value : 'text'; + if (t === 'text') return input_note_content.value; + if (t === 'link') { + var url = input_note_content.value.trim(); + if (!/^https?:\/\//i.test(url)) { alert('链接必须以 http:// 或 https:// 开头'); return null; } + return JSON.stringify({ _sp: 'link', url: url }); + } + if (t === 'html') { + var code = input_note_content.value; + if (!code.trim()) { alert('请输入 HTML 代码'); return null; } + return JSON.stringify({ _sp: 'html', code: code }); + } + /* image / file:优先使用新选择的文件;编辑模式下未选新文件则保留原信封 */ + if (sp_file.files.length === 0) { + if (sp_edit_original) return sp_edit_original; + alert('请选择要上传的' + (t === 'image' ? '图片' : '文件')); + return null; + } + var f = sp_file.files[0]; + if (f.size > SP_MAX_FILE_BYTES) { + alert('文件过大(上限约 2.5MB):' + f.name); + return null; + } + var dataUrl = await readFileAsDataURL(f); + if (t === 'image') { + if (dataUrl.indexOf('data:image/') !== 0) { alert('所选文件不是图片'); return null; } + return JSON.stringify({ _sp: 'image', name: f.name, data: dataUrl }); + } + return JSON.stringify({ _sp: 'file', name: f.name, mime: f.type || 'application/octet-stream', data: dataUrl.split(',')[1] }); +} + input_note_submit.addEventListener('click', function(event) { if (input_note_submit.disabled) return; // 防重复点击 var title = input_note_title.value - var content = input_note_content.value var pid = (stack.length == 0) ? 0 : stack[stack.length - 1]; //alert(stack.length) //alert(pid) @@ -482,9 +692,16 @@ input_note_submit.addEventListener('click', function(event) { input_note_submit.disabled = true; input_note_clear.disabled = true; delete_note.disabled = true; - // 编辑模式下保存修改,否则新增节点 - var req = (editing_id != null) ? updateNode(editing_id, pid, title, content) : addNode(pid, title, content); - Promise.resolve(req) + // 编辑模式下保存修改,否则新增节点;特殊节点先异步构造信封(可能涉及读文件) + buildSubmitContent() + .then(function(content) { + if (content == null) return null; // 校验失败,已弹提示 + return (editing_id != null) ? updateNode(editing_id, pid, title, content) : addNode(pid, title, content); + }) + .catch(function(err) { + alert(err.message || '内容处理失败'); + console.error('构造提交内容异常:', err); + }) .finally(() => { input_note_submit.disabled = false; input_note_clear.disabled = false; @@ -513,11 +730,28 @@ delete_note.addEventListener('click', (event) => { } }) -/* by qing — 点击卡片"编辑":把节点标题/内容载入下方表单,进入编辑模式 */ +/* by qing — 点击卡片"编辑":把节点标题/内容载入下方表单,进入编辑模式。 + * 特殊节点:link/html 回填 url/code 到文本域;image/file 记住原信封(未选新文件则原样提交) */ function startEdit(item) { editing_id = item.id; input_note_title.value = item.title; - input_note_content.value = item.content; + var sp = parseSp(item.content); + sp_edit_original = null; + if (sp && (sp._sp === 'link' || sp._sp === 'html')) { + sp_type.value = sp._sp; + sp_type.dispatchEvent(new Event('change')); + input_note_content.value = (sp._sp === 'link') ? (sp.url || '') : (sp.code || ''); + } else if (sp) { + sp_type.value = sp._sp; + sp_type.dispatchEvent(new Event('change')); + sp_edit_original = item.content; + sp_file_tip.textContent = '不选择新文件则保持原' + (sp._sp === 'image' ? '图片' : '文件'); + input_note_content.value = ''; + } else { + sp_type.value = 'text'; + sp_type.dispatchEvent(new Event('change')); + input_note_content.value = item.content; + } input_note_submit.textContent = "保存修改"; autoResizeTextarea(input_note_content); /* 根据载入内容调整高度 */ input_note_title.scrollIntoView({ behavior: 'smooth', block: 'center' }); @@ -527,6 +761,13 @@ function startEdit(item) { /* by qing — 退出编辑模式,表单恢复为新增状态 */ function cancelEdit() { editing_id = null; + sp_edit_original = null; + sp_file.value = ''; + sp_file_tip.textContent = ''; + if (sp_type.value !== 'text') { + sp_type.value = 'text'; + sp_type.dispatchEvent(new Event('change')); + } input_note_submit.textContent = "提交"; resetTextareaHeight(); /* 恢复初始高度 */ } @@ -597,7 +838,7 @@ function load_notes(main_node) { ttl.appendChild(wrt_div); ttl.appendChild(sts_div); let ctt = document.createElement('p'); - renderMarkdown(ctt, item.content); + renderContent(ctt, item); card.appendChild(ctt) if (item.can_operate === true) { let act_div = document.createElement('div'); @@ -699,6 +940,10 @@ async function loadUserPanel() { }); if (!res.ok) return; current_user = await res.json(); + /* 管理员显示特殊节点面板(普通用户始终普通文本) */ + if (current_user.login && current_user.is_admin) { + sp_panel.style.display = ''; + } renderUserPanel(); } catch (e) { console.error('获取用户信息失败:', e);