Merge branch 'main' of ssh://git.cancerai.top:2000/qing/blackbird into dev_shufeng2012
This commit is contained in:
commit
121e155248
357 files changed
+44067
-17806
No files matched your search
+1
-1
@@ -2,7 +2,7 @@ CPP = g++
|
||||
CPPFLAGS = -std=c++20 -O3 -Wall -Werror -pipe
|
||||
INCS = -I ../libhttp/inc -I ../libmsg/inc -I /opt/homebrew/include
|
||||
LIBS = -L ../libhttp/build -L ../libmsg/build -L /opt/homebrew/lib \
|
||||
-lus_http -lus_httpclient -lus_msg -lboost_json \
|
||||
-lus_http -lus_httpclient -lus_msg -lboost_json -lboost_program_options \
|
||||
-lssl -lcrypto -lcurl -lpthread
|
||||
RPATH = -Wl,-rpath,'$$ORIGIN/../../libhttp/build' \
|
||||
-Wl,-rpath,'$$ORIGIN/../../libmsg/build' \
|
||||
|
||||
+20
-35
@@ -10,15 +10,18 @@ Python/Flask `static` 模块的生产级 C++ 重写,前端零改动兼容(
|
||||
waf :443 (TLS 终止 + 安全规则)
|
||||
│
|
||||
▼
|
||||
gateway :8443 (反向代理 + 认证)
|
||||
ca :8443 (认证 + 身份头注入 + 前置转发)
|
||||
│
|
||||
▼
|
||||
gateway :8445 (纯转发反向代理, 仅 loopback)
|
||||
│
|
||||
▼
|
||||
fsys :9200 (libhttp 异步多线程 + CORS)
|
||||
│
|
||||
├── public/ 静态文件根目录
|
||||
├── dnfs2 :8006 笔记代理(反向代理)
|
||||
├── db2 :8005 用户查询 + 日志写入
|
||||
└── confs :8010 配置拉取
|
||||
├── db2 :8005 日志写入
|
||||
└── (配置纯 CLI 传递,不依赖 confs)
|
||||
```
|
||||
|
||||
## 路由
|
||||
@@ -37,8 +40,6 @@ fsys :9200 (libhttp 异步多线程 + CORS)
|
||||
|
||||
| 路径 | 说明 |
|
||||
|------|------|
|
||||
| `POST /api/hash` | SHA-256 密码哈希(限速 20/min/IP) |
|
||||
| `POST /api/check_user` | 检查用户是否已注册(限速 10/min/IP) |
|
||||
| `GET /api/favicon?url=` | favicon SSRF 防护代理(512KB 上限,仅浏览器侧 Cache-Control 缓存 1 天,无服务端缓存) |
|
||||
|
||||
### dnfs2 代理路由
|
||||
@@ -54,36 +55,20 @@ fsys :9200 (libhttp 异步多线程 + CORS)
|
||||
|
||||
## 配置
|
||||
|
||||
与 `static` 模块完全兼容,使用相同的 `static.ini`(由 confs 服务分发)。
|
||||
纯 CLI + 环境变量 + 默认值(CLI > 环境变量 > 默认值),不依赖 confs;原 `static.ini` 各值已迁入 `core/services.yaml` 的 fsys 条目 args。
|
||||
|
||||
| Section | Key | 说明 |
|
||||
|---------|-----|------|
|
||||
| `[server]` | `host` | 监听地址(默认 127.0.0.1) |
|
||||
| `[server]` | `port` | 监听端口(默认 9200) |
|
||||
| `[directory]` | `root` | 静态文件根目录(相对路径基于工作目录,_run.sh 以模块目录启动) |
|
||||
| `[ssl]` | `enabled` | TLS 开关(默认 false) |
|
||||
| `[ssl]` | `cert_file` | 证书路径 |
|
||||
| `[ssl]` | `key_file` | 私钥路径 |
|
||||
| `[cors]` | `allowed_origins` | CORS 白名单,逗号分隔 |
|
||||
|
||||
### 环境变量
|
||||
|
||||
| 变量 | 说明 |
|
||||
|------|------|
|
||||
| `LISTEN_ADDR` | 覆盖监听地址 |
|
||||
| `LISTEN_PORT` | 覆盖监听端口 |
|
||||
| `STATIC_ROOT` | 覆盖静态文件根目录 |
|
||||
| `DB_HOST` / `DB_PORT` | 覆盖 db 服务地址 |
|
||||
| `DNFS_URL` | 覆盖 dnfs2 上游地址 |
|
||||
| `CONFS_HOST` / `CONFS_PORT` | 覆盖 confs 引导地址 |
|
||||
|
||||
### 命令行参数
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `--addr` / `-a` | 监听地址 |
|
||||
| `--port` / `-p` | 监听端口 |
|
||||
| `--threads` / `-t` | 工作线程数 |
|
||||
| 配置项 | CLI 参数 | 环境变量 | 默认值 |
|
||||
|---------|---------|----------|--------|
|
||||
| 监听地址 | `--addr` / `-a` | `LISTEN_ADDR` | 127.0.0.1 |
|
||||
| 监听端口 | `--port` / `-p` | `LISTEN_PORT` | 9200 |
|
||||
| 工作线程数 | `--threads` / `-t` | — | 4 |
|
||||
| 静态根目录 | `--root` | `STATIC_ROOT` | ./public |
|
||||
| TLS 开关 | `--ssl-enabled` | — | false |
|
||||
| 证书 / 私钥 | `--cert-file` / `--key-file` | — | cert.pem / key.pem |
|
||||
| CORS 白名单 | `--cors-origins` | — | 逗号分隔,必需 |
|
||||
| db2 地址 | `--db-host` / `--db-port` | `DB_HOST` / `DB_PORT` | 127.0.0.1:8005 |
|
||||
| dnfs2 上游 | `--dnfs-url` | `DNFS_URL` | http://127.0.0.1:8006 |
|
||||
| 实例名 / 日志级别 | `--instance` / `--log-level` | — | fsys / info |
|
||||
|
||||
## 相比 Python static 的改进
|
||||
|
||||
@@ -95,7 +80,7 @@ fsys :9200 (libhttp 异步多线程 + CORS)
|
||||
| 消除 Python 依赖 | 不再需要 venv/pip/Flask/waitress |
|
||||
| 命令行参数 | 支持 `--addr/-a --port/-p --threads/-t` |
|
||||
| 数值范围校验 | 所有端口/线程数有严格边界检查 |
|
||||
| 统一日志配置 | 复用主配置 confs 拉取的 db host/port |
|
||||
| 统一日志配置 | 复用主配置(CLI/环境变量)的 db host/port |
|
||||
|
||||
## 运行
|
||||
|
||||
|
||||
@@ -366,6 +366,11 @@ input[type="button"]:disabled {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.owner-time {
|
||||
font-size: 0.7rem;
|
||||
color: var(--fg-tertiary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ===== Card Stats (dnfs 互动统计:观看/点赞/分享) ===== */
|
||||
.card-stats {
|
||||
@@ -570,8 +575,30 @@ input[type="button"]:disabled {
|
||||
background: var(--accent-soft);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
/* 折叠态用户面板:圆形头像按钮,与 owner-avatar 同风格 */
|
||||
.userpanel .user-avatar-fold {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent-dark);
|
||||
border: 2px solid var(--accent);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: border-color 0.15s, transform 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
.userpanel .user-avatar-fold:hover {
|
||||
border-color: var(--accent-dark);
|
||||
transform: scale(1.08);
|
||||
box-shadow: 0 0 0 4px var(--accent-glow);
|
||||
}
|
||||
|
||||
/* ===== Special Nodes (dnfs,管理员发布的特殊节点) ===== */
|
||||
/* ===== 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; }
|
||||
|
||||
@@ -26,15 +26,15 @@
|
||||
<div class="card node-form">
|
||||
<h3>节点操作</h3>
|
||||
<label>新增节点</label><input id="input-note-title">
|
||||
<!-- 特殊节点面板:仅管理员可见(dnfs.js 按 /whoami is_admin 显示) -->
|
||||
<!-- 特殊节点面板:登录用户可见(dnfs.js 按 /whoami login 显示;发布权限由后端按节点拥有者/管理员强制) -->
|
||||
<div id="sp-panel" style="display:none">
|
||||
<label>节点类型</label>
|
||||
<select id="sp-type">
|
||||
<option value="text">普通文本</option>
|
||||
<option value="link">链接(自动显示站点图标)</option>
|
||||
<option value="js">前端代码(可审计/可运行)</option>
|
||||
<option value="image">图片(自动显示)</option>
|
||||
<option value="file">文件(可下载)</option>
|
||||
<option value="style">界面样式(CSS 注入,控制整个界面)</option>
|
||||
<option value="theme">主题变量(换肤,仅 CSS 变量)</option>
|
||||
</select>
|
||||
<input type="file" id="sp-file" style="display:none">
|
||||
<span id="sp-file-tip"></span>
|
||||
@@ -43,7 +43,7 @@
|
||||
<div class="form-actions">
|
||||
<button id="input-note-submit">提交</button>
|
||||
<button id="input-note-clear">重置</button>
|
||||
<button id="delete-note">删除当前节点</button>
|
||||
<button id="delete-note" style="display:none">删除当前节点</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <script src="https://cdnjs.cloudflare.com/ajax/libs/markdown-it/12.0.0/markdown-it.min.js"></script> -->
|
||||
|
||||
+343
-95
@@ -102,14 +102,13 @@ async function addNode(pid, title, content) {
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`添加节点失败(HTTP ${res.status})`);
|
||||
throw new Error(result.error || `添加节点失败(HTTP ${res.status})`);
|
||||
}
|
||||
|
||||
input_note_title.value = "";
|
||||
input_note_content.value = "";
|
||||
cancelEdit(); /* 退出编辑模式并重置高度 */
|
||||
getNode(stack[stack.length - 1]).then(load_notes);
|
||||
getNode(stack[stack.length - 1]).then(loadMap)
|
||||
getNode(stack[stack.length - 1]).then(function(r) { load_notes(r); loadMap(r); })
|
||||
|
||||
//if (result.success) {
|
||||
alert(`添加节点成功`, result);
|
||||
@@ -167,8 +166,8 @@ async function deleteNode(id) {
|
||||
}
|
||||
|
||||
stack.pop();
|
||||
getNode(stack[stack.length - 1]).then(load_notes);
|
||||
getNode(stack[stack.length - 1]).then(loadMap)
|
||||
stack_operate.pop();
|
||||
getNode(stack[stack.length - 1]).then(function(r) { load_notes(r); loadMap(r); })
|
||||
|
||||
console.log(`删除节点成功`);
|
||||
return result
|
||||
@@ -225,8 +224,7 @@ async function deleteChild(id) {
|
||||
input_note_content.value = "";
|
||||
}
|
||||
var cur = (stack.length == 0) ? 0 : stack[stack.length - 1];
|
||||
getNode(cur).then(load_notes);
|
||||
getNode(cur).then(loadMap);
|
||||
getNode(cur).then(function(r) { load_notes(r); loadMap(r); });
|
||||
|
||||
console.log(`删除节点成功`);
|
||||
return result;
|
||||
@@ -281,8 +279,7 @@ async function updateNode(id, pid, title, content) {
|
||||
input_note_content.value = "";
|
||||
cancelEdit();
|
||||
var cur = (stack.length == 0) ? 0 : stack[stack.length - 1];
|
||||
getNode(cur).then(load_notes);
|
||||
getNode(cur).then(loadMap);
|
||||
getNode(cur).then(function(r) { load_notes(r); loadMap(r); });
|
||||
|
||||
alert('修改成功');
|
||||
return result;
|
||||
@@ -334,8 +331,7 @@ async function moveNode(id, pid, title, content) {
|
||||
}
|
||||
|
||||
var cur = (stack.length == 0) ? 0 : stack[stack.length - 1];
|
||||
getNode(cur).then(load_notes);
|
||||
getNode(cur).then(loadMap);
|
||||
getNode(cur).then(function(r) { load_notes(r); loadMap(r); });
|
||||
|
||||
alert('移动成功');
|
||||
return result;
|
||||
@@ -429,9 +425,9 @@ function renderMarkdown(element, content) {
|
||||
element.innerHTML = md.render(content);
|
||||
}
|
||||
|
||||
/* ===== by qing — 特殊节点(管理员专属)=====
|
||||
* 信封:content 为 {"_sp":"link|js|image|file", ...} JSON,普通内容走 Markdown。
|
||||
* 发布权限由后端 dnfs 强制(is_admin),前端仅做展示与表单切换。 */
|
||||
/* ===== by qing — 特殊节点 =====
|
||||
* 信封:content 为 {"_sp":"js|file|style|theme", ...} JSON,普通内容走 Markdown。
|
||||
* 发布权限由后端 dnfs 强制(节点拥有者或管理员),前端仅做展示与表单切换。 */
|
||||
|
||||
/* 与后端 base64 上限(3.5M 字符)对齐:原始文件约 2.5MB */
|
||||
var SP_MAX_FILE_BYTES = 2.5 * 1024 * 1024;
|
||||
@@ -445,7 +441,7 @@ function parseSp(content) {
|
||||
} catch (e) {
|
||||
return null; // 非法信封按普通文本展示(后端发布时已拦截,这里是兜底)
|
||||
}
|
||||
if (obj && typeof obj === 'object' && ['link', 'js', 'image', 'file'].indexOf(obj._sp) !== -1) return obj;
|
||||
if (obj && typeof obj === 'object' && ['js', 'file', 'style', 'theme'].indexOf(obj._sp) !== -1) return obj;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -464,32 +460,9 @@ function renderContent(container, item) {
|
||||
container.appendChild(note);
|
||||
}
|
||||
}
|
||||
if (sp._sp === 'link') renderSpLink(container, sp, appendNote);
|
||||
else if (sp._sp === 'js') renderSpJs(container, sp, appendNote);
|
||||
else if (sp._sp === 'image') renderSpImage(container, sp, appendNote);
|
||||
if (sp._sp === 'js') renderSpJs(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();
|
||||
else if (sp._sp === 'style' || sp._sp === 'theme') renderSpCss(container, sp, appendNote, item);
|
||||
}
|
||||
|
||||
/* 前端代码节点:含前端内容的节点在渲染到当前页面时自动执行(隔离 iframe 沙箱)。
|
||||
@@ -499,14 +472,19 @@ function renderSpLink(container, sp, appendNote) {
|
||||
* - 全屏特效 iframe 挂载到 document.body(z-index:9999 在根层叠上下文生效,
|
||||
* 不被后续卡片盖住),pointer-events:none 不拦截父页面点击,不影响卡片点击/节点导航;
|
||||
* - 不使用 new Function/eval(CSP 未开 unsafe-eval),srcdoc 内联脚本由 CSP 'unsafe-inline' 放行。
|
||||
* 仅管理员可发布(后端强制)。 */
|
||||
* 仅节点拥有者或管理员可发布(后端强制)。 */
|
||||
/* 无特效模式:true 为关闭自动执行(用户面板开关控制,localStorage 持久化) */
|
||||
var sp_effects_disabled = (function() {
|
||||
try { return localStorage.getItem('sp_effects_disabled') === '1'; } catch (e) { return false; }
|
||||
})();
|
||||
/* 清除当前页面已运行的全部特效沙箱(进入新节点 / 关闭特效时调用) */
|
||||
/* __ 隐藏节点可见:true 时 __ 前缀节点正常显示(用户面板开关控制,localStorage 持久化) */
|
||||
var __show_hidden = (function() {
|
||||
try { return localStorage.getItem('dnfs_show_hidden') === '1'; } catch (e) { return false; }
|
||||
})();
|
||||
/* 清除当前页面已运行的全部特效沙箱与注入样式(进入新节点 / 关闭特效时调用) */
|
||||
function clearAllEffects() {
|
||||
document.querySelectorAll('.sp-effect-frame').forEach(function(f) { f.remove(); });
|
||||
document.querySelectorAll('.sp-injected-style').forEach(function(s) { s.remove(); });
|
||||
}
|
||||
function renderSpJs(container, sp, appendNote) {
|
||||
var bar = document.createElement('div');
|
||||
@@ -579,16 +557,6 @@ function renderSpJs(container, sp, appendNote) {
|
||||
if (!sp_effects_disabled) startEffect();
|
||||
}
|
||||
|
||||
/* 图片节点: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');
|
||||
@@ -619,6 +587,114 @@ function renderSpFile(container, sp, appendNote) {
|
||||
appendNote();
|
||||
}
|
||||
|
||||
/* ===== 样式类节点(style/theme):CSS 注入 <style> 控制整个界面 =====
|
||||
* 生命周期:节点作用域——进入该节点所在层时注入生效,切换/返回节点时随
|
||||
* clearAllEffects 移除,界面自动还原;卡片上可逐个停用/启用。
|
||||
* 安全:节点拥有者或管理员可发布(后端强制)且经 AI 送审;CSS 无法执行脚本,风险面仅为界面外观。
|
||||
* theme 是 style 的克制子集——仅允许覆盖 CSS 变量(:root 块),
|
||||
* 变量名/值合法性由后端 validate_sp 强制,前端这里只做兜底过滤。 */
|
||||
|
||||
function themeVarsToCss(vars) {
|
||||
var lines = [':root {'];
|
||||
for (var k in vars) {
|
||||
if (!Object.prototype.hasOwnProperty.call(vars, k)) continue;
|
||||
if (!/^--[A-Za-z0-9_-]+$/.test(k)) continue;
|
||||
var v = String(vars[k]);
|
||||
if (/[{}<>]/.test(v)) continue;
|
||||
lines.push(' ' + k + ': ' + v + ';');
|
||||
}
|
||||
lines.push('}');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/* vars 对象 → 编辑/审计用的行文本(每行 "--var: value;"),与 theme 提交输入格式一致;
|
||||
* 非 -- 前缀 key 后端不会接受,这里同口径过滤防止原型链属性混入展示 */
|
||||
function themeVarsToText(vars) {
|
||||
var lines = [];
|
||||
for (var k in vars) {
|
||||
if (!Object.prototype.hasOwnProperty.call(vars, k)) continue;
|
||||
if (k.indexOf('--') !== 0) continue;
|
||||
lines.push(k + ': ' + vars[k] + ';');
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/* 将 theme/style 信封注入为 <style> 元素到 <head>(供卡片渲染和祖先继承复用)。
|
||||
* 返回创建的 styleEl,cssText 为空时返回 null。 */
|
||||
function injectSpCss(sp, noteId) {
|
||||
var is_theme = (sp._sp === 'theme');
|
||||
var cssText = is_theme ? themeVarsToCss(sp.vars || {}) : String(sp.css || '');
|
||||
if (!cssText) return null;
|
||||
/* 同一 noteId 的旧注入先移除 */
|
||||
if (noteId) {
|
||||
document.querySelectorAll('.sp-injected-style[data-note-id="' + noteId + '"]')
|
||||
.forEach(function(s) { s.remove(); });
|
||||
}
|
||||
var styleEl = document.createElement('style');
|
||||
styleEl.className = 'sp-injected-style';
|
||||
if (noteId) styleEl.setAttribute('data-note-id', noteId);
|
||||
styleEl.textContent = cssText;
|
||||
document.head.appendChild(styleEl);
|
||||
return styleEl;
|
||||
}
|
||||
|
||||
function renderSpCss(container, sp, appendNote, item) {
|
||||
var is_theme = (sp._sp === 'theme');
|
||||
var kindLabel = is_theme ? '主题' : '样式';
|
||||
var cssText = is_theme ? themeVarsToCss(sp.vars || {}) : String(sp.css || '');
|
||||
var displayText = is_theme ? themeVarsToText(sp.vars || {}) : String(sp.css || '');
|
||||
var noteId = (item && item.id != null) ? String(item.id) : null;
|
||||
|
||||
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 toggle_btn = document.createElement('button');
|
||||
toggle_btn.className = 'act-btn act-move';
|
||||
toggle_btn.textContent = '停用' + kindLabel;
|
||||
var pre = document.createElement('pre');
|
||||
pre.className = 'sp-code';
|
||||
pre.style.display = 'none';
|
||||
pre.textContent = displayText;
|
||||
var out = document.createElement('div');
|
||||
out.className = 'sp-run';
|
||||
out.style.display = 'none';
|
||||
var styleEl = null; /* 当前注入的 <style> 实例,null 表示未生效 */
|
||||
|
||||
audit_btn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
var hidden = (pre.style.display === 'none');
|
||||
pre.style.display = hidden ? 'block' : 'none';
|
||||
audit_btn.textContent = hidden ? '收起源码' : '审计源码';
|
||||
});
|
||||
function applyCss() {
|
||||
if (styleEl) return;
|
||||
styleEl = injectSpCss(sp, noteId);
|
||||
if (!styleEl) return;
|
||||
toggle_btn.textContent = '停用' + kindLabel;
|
||||
out.style.display = 'block';
|
||||
out.textContent = kindLabel + '已生效(本层)';
|
||||
}
|
||||
function stopCss() {
|
||||
if (styleEl) { styleEl.remove(); styleEl = null; }
|
||||
toggle_btn.textContent = '启用' + kindLabel;
|
||||
out.style.display = 'none';
|
||||
}
|
||||
toggle_btn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
if (styleEl) stopCss(); else applyCss();
|
||||
});
|
||||
bar.appendChild(audit_btn);
|
||||
bar.appendChild(toggle_btn);
|
||||
container.appendChild(bar);
|
||||
container.appendChild(pre);
|
||||
container.appendChild(out);
|
||||
appendNote();
|
||||
/* 渲染即生效 */
|
||||
applyCss();
|
||||
}
|
||||
|
||||
function formatBytes(n) {
|
||||
var units = ['B', 'KB', 'MB', 'GB'];
|
||||
var i = 0;
|
||||
@@ -626,6 +702,65 @@ function formatBytes(n) {
|
||||
return n.toFixed(1) + units[i];
|
||||
}
|
||||
|
||||
/* ===== 祖先主题/样式继承 =====
|
||||
* 当前节点不存在 theme/style 子节点时,沿 stack 向根搜索最近祖先的对应节点,
|
||||
* 自动注入其 CSS。已存在的不重复搜索(hasTheme/hasStyle 分别控制)。
|
||||
* 用静默 fetch 替代 getNode(不弹 alert、不抛异常),仅日志输出。 */
|
||||
var _inherit_gen = 0;
|
||||
function inheritThemeStyle() {
|
||||
var hasTheme = false, hasStyle = false;
|
||||
for (var i = 0; i < current_children.length; i++) {
|
||||
var sp = parseSp(current_children[i].content);
|
||||
if (!sp) continue;
|
||||
if (sp._sp === 'theme') hasTheme = true;
|
||||
if (sp._sp === 'style') hasStyle = true;
|
||||
}
|
||||
if (hasTheme && hasStyle) return;
|
||||
|
||||
console.log('[继承] stack=', JSON.stringify(stack),
|
||||
'current_children.length=', current_children.length,
|
||||
'hasTheme=', hasTheme, 'hasStyle=', hasStyle);
|
||||
var gen = ++_inherit_gen;
|
||||
(async function() {
|
||||
for (var i = stack.length - 1; i >= 0; i--) {
|
||||
if (hasTheme && hasStyle) break;
|
||||
if (gen !== _inherit_gen) return;
|
||||
var pid = (i === 0) ? 0 : stack[i - 1];
|
||||
console.log('[继承] 查询 pid=', pid, '(stack[' + i + '] 的父节点)');
|
||||
try {
|
||||
var res = await fetch('/get_notes', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pid: pid, noread: true })
|
||||
});
|
||||
if (gen !== _inherit_gen) return;
|
||||
if (!res.ok) { console.log('[继承] pid=', pid, 'HTTP', res.status); continue; }
|
||||
var data = await res.json();
|
||||
if (gen !== _inherit_gen) return;
|
||||
if (!Array.isArray(data)) { console.log('[继承] pid=', pid, '非数组'); continue; }
|
||||
console.log('[继承] pid=', pid, '返回', data.length, '个子节点');
|
||||
for (var j = 0; j < data.length; j++) {
|
||||
var sp = parseSp(data[j].content);
|
||||
if (!sp) continue;
|
||||
if (!hasTheme && sp._sp === 'theme') {
|
||||
console.log('[继承] 找到主题:', data[j].title, 'id=', data[j].id);
|
||||
injectSpCss(sp, 'inh-' + data[j].id);
|
||||
hasTheme = true;
|
||||
}
|
||||
if (!hasStyle && sp._sp === 'style') {
|
||||
console.log('[继承] 找到样式:', data[j].title, 'id=', data[j].id);
|
||||
injectSpCss(sp, 'inh-' + data[j].id);
|
||||
hasStyle = true;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[继承] 异常 pid=', pid, e);
|
||||
}
|
||||
}
|
||||
console.log('[继承] 完成 hasTheme=', hasTheme, 'hasStyle=', hasStyle);
|
||||
})();
|
||||
}
|
||||
|
||||
/* by qing — 互动统计图标:inline SVG,stroke=currentColor 自动跟随主题色 */
|
||||
const ICON_EYE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
|
||||
const ICON_HEART = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>';
|
||||
@@ -648,6 +783,9 @@ function makeStatItem(iconSvg, count) {
|
||||
|
||||
/* ---- by qing ---- */
|
||||
const stack = []; /* 用于存储当前节点的路径,也就是经过的每一个节点的id, 否则就回不去上一层*/
|
||||
/* 与 stack 平行的权限栈:进入每个节点时记录其 can_operate(后端按 owner/is_admin 判定,
|
||||
* 与 delete_note 鉴权口径一致),用于"删除当前节点"按钮显隐;入栈/出栈必须与 stack 同步 */
|
||||
const stack_operate = [];
|
||||
var article_title = document.getElementById('article-title');
|
||||
var article_content = document.getElementById('article-content');
|
||||
var input_note_title = document.getElementById('input-note-title');
|
||||
@@ -663,7 +801,7 @@ 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; /* 编辑图片/文件节点时的原信封字符串:未选新文件则原样提交 */
|
||||
var sp_edit_original = null; /* 编辑文件节点时的原信封字符串:未选新文件则原样提交 */
|
||||
/* 搜索框:当前层卡片标题/内容关键词过滤 */
|
||||
var searchtext = document.getElementById('searchtext');
|
||||
var searchsubmit = document.getElementById('searchsubmit');
|
||||
@@ -675,12 +813,26 @@ var editing_id = null; /* 编辑模式:正在编辑的节点 id,null 表示
|
||||
var moving_node = null; /* 移动模式:待移动的节点 {id, pid, title, content},null 表示未在移动 */
|
||||
|
||||
/* ===== 输入框自动高度调整 ===== */
|
||||
/* 中文输入法 composition 期间每个拼音字母都会触发 input;若每次都做塌陷测量,
|
||||
* 页面总高度反复变化,滚到页面底部输入时 scrollTop 被浏览器钳制又回弹,
|
||||
* 表现为页面位置反复横跳。故 composition 中跳过,compositionend 后统一调整一次。 */
|
||||
var _content_composing = false;
|
||||
function autoResizeTextarea(el) {
|
||||
var doc = document.scrollingElement || document.documentElement;
|
||||
var prevTop = doc.scrollTop;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = Math.min(el.scrollHeight, 400) + 'px';
|
||||
el.style.overflowY = el.scrollHeight > 400 ? 'auto' : 'hidden';
|
||||
/* 塌陷测量会触发整页同步布局:若页面瞬时变矮导致滚动位置被钳制,恢复原位 */
|
||||
if (doc.scrollTop < prevTop) doc.scrollTop = prevTop;
|
||||
}
|
||||
input_note_content.addEventListener('input', function() {
|
||||
input_note_content.addEventListener('compositionstart', function() { _content_composing = true; });
|
||||
input_note_content.addEventListener('compositionend', function() {
|
||||
_content_composing = false;
|
||||
autoResizeTextarea(input_note_content);
|
||||
});
|
||||
input_note_content.addEventListener('input', function(e) {
|
||||
if (_content_composing || (e && e.isComposing)) return;
|
||||
autoResizeTextarea(input_note_content);
|
||||
});
|
||||
function resetTextareaHeight() {
|
||||
@@ -692,7 +844,7 @@ autoResizeTextarea(input_note_content); /* 初始化高度 */
|
||||
/* ===== 特殊节点表单:类型切换控制输入区形态 ===== */
|
||||
sp_type.addEventListener('change', function() {
|
||||
var t = sp_type.value;
|
||||
var is_file = (t === 'image' || t === 'file');
|
||||
var is_file = (t === 'file');
|
||||
sp_file.style.display = is_file ? '' : 'none';
|
||||
input_note_content.style.display = is_file ? 'none' : '';
|
||||
sp_file_tip.textContent = '';
|
||||
@@ -701,8 +853,9 @@ sp_type.addEventListener('change', function() {
|
||||
sp_file.value = '';
|
||||
sp_edit_original = null;
|
||||
}
|
||||
if (t === 'link') input_note_content.placeholder = '请输入链接 URL(https://…)';
|
||||
else if (t === 'js') input_note_content.placeholder = '请输入完整前端代码(可含 HTML/CSS/JS)';
|
||||
if (t === 'js') input_note_content.placeholder = '请输入完整前端代码(可含 HTML/CSS/JS)';
|
||||
else if (t === 'style') input_note_content.placeholder = '请输入 CSS(如 .card { border-radius: 16px }),发布后全界面生效';
|
||||
else if (t === 'theme') input_note_content.placeholder = '每行一个变量,如 --accent: #e8853a';
|
||||
else input_note_content.placeholder = '';
|
||||
});
|
||||
|
||||
@@ -715,25 +868,51 @@ function readFileAsDataURL(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 });
|
||||
/* 解析主题变量输入:每行一条 "--变量名: 值"(末尾分号可选),返回 vars 对象;非法返回 null(已弹提示) */
|
||||
function parseThemeVars(text) {
|
||||
var vars = {};
|
||||
var lines = text.split('\n');
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var line = lines[i].trim();
|
||||
if (!line) continue;
|
||||
if (line.indexOf('--') !== 0) { alert('第 ' + (i + 1) + ' 行不是 CSS 变量(须以 -- 开头):' + line); return null; }
|
||||
var idx = line.indexOf(':');
|
||||
if (idx <= 0) { alert('第 ' + (i + 1) + ' 行缺少冒号:' + line); return null; }
|
||||
var k = line.substring(0, idx).trim();
|
||||
var v = line.substring(idx + 1).trim().replace(/;+$/, '');
|
||||
if (!/^--[A-Za-z0-9_-]+$/.test(k)) { alert('非法变量名:' + k); return null; }
|
||||
if (!v) { alert('变量缺少值:' + k); return null; }
|
||||
if (/[{}<>]/.test(v)) { alert('变量值含非法字符({}<>):' + k); return null; }
|
||||
vars[k] = v;
|
||||
}
|
||||
if (Object.keys(vars).length === 0) { alert('请至少输入一个 CSS 变量,如 --accent: #e8853a'); return null; }
|
||||
return vars;
|
||||
}
|
||||
|
||||
/* by qing — 构造提交内容:普通节点返回原文本,特殊节点返回信封 JSON 字符串。
|
||||
* 校验失败返回 null(已弹提示);未登录永远按普通文本提交。 */
|
||||
async function buildSubmitContent() {
|
||||
var t = (current_user && current_user.login) ? sp_type.value : 'text';
|
||||
if (t === 'text') return input_note_content.value;
|
||||
if (t === 'js') {
|
||||
var code = input_note_content.value;
|
||||
if (!code.trim()) { alert('请输入前端代码'); return null; }
|
||||
return JSON.stringify({ _sp: 'js', code: code });
|
||||
}
|
||||
/* image / file:优先使用新选择的文件;编辑模式下未选新文件则保留原信封 */
|
||||
if (t === 'style') {
|
||||
var css = input_note_content.value;
|
||||
if (!css.trim()) { alert('请输入 CSS 样式'); return null; }
|
||||
return JSON.stringify({ _sp: 'style', css: css });
|
||||
}
|
||||
if (t === 'theme') {
|
||||
var vars = parseThemeVars(input_note_content.value);
|
||||
if (!vars) return null;
|
||||
return JSON.stringify({ _sp: 'theme', vars: vars });
|
||||
}
|
||||
/* file:优先使用新选择的文件;编辑模式下未选新文件则保留原信封 */
|
||||
if (sp_file.files.length === 0) {
|
||||
if (sp_edit_original) return sp_edit_original;
|
||||
alert('请选择要上传的' + (t === 'image' ? '图片' : '文件'));
|
||||
alert('请选择要上传的文件');
|
||||
return null;
|
||||
}
|
||||
var f = sp_file.files[0];
|
||||
@@ -742,10 +921,6 @@ async function buildSubmitContent() {
|
||||
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] });
|
||||
}
|
||||
|
||||
@@ -798,21 +973,29 @@ delete_note.addEventListener('click', (event) => {
|
||||
})
|
||||
|
||||
/* by qing — 点击卡片"编辑":把节点标题/内容载入下方表单,进入编辑模式。
|
||||
* 特殊节点:link/js 回填 url/code 到文本域;image/file 记住原信封(未选新文件则原样提交) */
|
||||
* 特殊节点:js/style/theme 回填对应文本到文本域;file 记住原信封(未选新文件则原样提交) */
|
||||
function startEdit(item) {
|
||||
editing_id = item.id;
|
||||
input_note_title.value = item.title;
|
||||
var sp = parseSp(item.content);
|
||||
sp_edit_original = null;
|
||||
if (sp && (sp._sp === 'link' || sp._sp === 'js')) {
|
||||
sp_type.value = sp._sp;
|
||||
if (sp && sp._sp === 'js') {
|
||||
sp_type.value = 'js';
|
||||
sp_type.dispatchEvent(new Event('change'));
|
||||
input_note_content.value = (sp._sp === 'link') ? (sp.url || '') : (sp.code || '');
|
||||
input_note_content.value = sp.code || '';
|
||||
} else if (sp && sp._sp === 'style') {
|
||||
sp_type.value = 'style';
|
||||
sp_type.dispatchEvent(new Event('change'));
|
||||
input_note_content.value = sp.css || '';
|
||||
} else if (sp && sp._sp === 'theme') {
|
||||
sp_type.value = 'theme';
|
||||
sp_type.dispatchEvent(new Event('change'));
|
||||
input_note_content.value = themeVarsToText(sp.vars || {});
|
||||
} 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' ? '图片' : '文件');
|
||||
sp_file_tip.textContent = '不选择新文件则保持原文件';
|
||||
input_note_content.value = '';
|
||||
} else {
|
||||
sp_type.value = 'text';
|
||||
@@ -851,6 +1034,18 @@ function confirmLeaveIfDirty() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/* by qing — "删除当前节点"按钮显隐:仅当前节点的 can_operate 为真(本人或管理员,
|
||||
* 后端 get_notes 判定,与 delete_note 鉴权口径一致)时显示;
|
||||
* 根节点(无主人)/未登录/非本人节点一律隐藏。
|
||||
* 所有改变 stack 的导航(进入/返回/删除当前节点)最终都会走到 load_notes,在此统一刷新 */
|
||||
function updateDeleteBtnVisibility() {
|
||||
if (stack.length == 0) {
|
||||
delete_note.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
delete_note.style.display = (stack_operate[stack_operate.length - 1] === true) ? '' : 'none';
|
||||
}
|
||||
|
||||
function load_notes(main_node) {
|
||||
|
||||
/* 这个不能设置为空的 */
|
||||
@@ -878,13 +1073,37 @@ function load_notes(main_node) {
|
||||
if (searchtext) searchtext.value = "";
|
||||
clearAllEffects();
|
||||
renderCards(current_children);
|
||||
inheritThemeStyle();
|
||||
updateDeleteBtnVisibility();
|
||||
};
|
||||
|
||||
/* 对 __ 隐藏节点应用特效(注入 CSS / 运行 js),卡片不渲染 */
|
||||
function applyHiddenNodeEffects(item) {
|
||||
var sp = parseSp(item.content);
|
||||
if (!sp) return;
|
||||
var noteId = (item.id != null) ? 'hid-' + String(item.id) : null;
|
||||
if (sp._sp === 'style' || sp._sp === 'theme') {
|
||||
injectSpCss(sp, noteId);
|
||||
} else if (sp._sp === 'js' && !sp_effects_disabled) {
|
||||
/* 仅全屏模式(非 in-node):无卡片容器,in-node 无法合理放置,忽略 */
|
||||
if (/<!--\s*in-node\s*-->/.test(sp.code || '')) return;
|
||||
var frame = document.createElement('iframe');
|
||||
frame.className = 'sp-effect-frame';
|
||||
frame.setAttribute('sandbox', 'allow-scripts');
|
||||
frame.srcdoc = sp.code;
|
||||
frame.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;border:0;pointer-events:none;z-index:9999;';
|
||||
document.body.appendChild(frame);
|
||||
}
|
||||
}
|
||||
|
||||
/* 把卡片列表渲染到 recommend_list(从 load_notes/搜索过滤复用) */
|
||||
function renderCards(list) {
|
||||
recommend_list.replaceChildren();
|
||||
for (let i in list) {
|
||||
let item = list[i]
|
||||
if (item.title && typeof item.title === 'string' && item.title.indexOf('__') === 0) {
|
||||
if (!__show_hidden) { applyHiddenNodeEffects(item); continue; }
|
||||
}
|
||||
console.log(item)
|
||||
let card = document.createElement('div');
|
||||
card.className = "card";
|
||||
@@ -898,8 +1117,8 @@ function renderCards(list) {
|
||||
if (!confirmLeaveIfDirty()) return; /* 未放弃输入则不跳转,stack 保持原样 */
|
||||
var id = Number(item.id)
|
||||
stack.push(id)
|
||||
getNode(id).then(load_notes)
|
||||
getNode(id).then(loadMap)
|
||||
stack_operate.push(item.can_operate === true)
|
||||
getNode(id).then(function(r) { load_notes(r); loadMap(r); })
|
||||
});
|
||||
ttl.appendChild(ttl1);
|
||||
card.appendChild(ttl);
|
||||
@@ -915,12 +1134,20 @@ function renderCards(list) {
|
||||
owner_name.textContent = owner_text;
|
||||
wrt_div.appendChild(owner_avatar);
|
||||
wrt_div.appendChild(owner_name);
|
||||
// 互动统计:眼睛=观看、爱心=点赞、转发=分享(后端 Notes 表暂无计数字段,缺省显示 0)
|
||||
// 最后编辑时间(新增时 created_at = updated_at,仅更新时 updated_at 变化)
|
||||
let time_span = document.createElement('span');
|
||||
time_span.className = "owner-time";
|
||||
if (item.updated_at) {
|
||||
time_span.textContent = ' · ' + item.updated_at.substring(0, 16);
|
||||
}
|
||||
wrt_div.appendChild(time_span);
|
||||
// 互动统计:眼睛=观看数(后端 views 列)、爱心=点赞、转发=子节点数(后端 child_count)。
|
||||
// views / child_count 由 dnfs2 get_notes 返回;like_count 后端暂无字段,缺省显示 0。
|
||||
let sts_div = document.createElement('div');
|
||||
sts_div.className = "card-stats";
|
||||
sts_div.appendChild(makeStatItem(ICON_EYE, item.visit_count));
|
||||
sts_div.appendChild(makeStatItem(ICON_EYE, item.views));
|
||||
sts_div.appendChild(makeStatItem(ICON_HEART, item.like_count));
|
||||
sts_div.appendChild(makeStatItem(ICON_SHARE, item.share_count));
|
||||
sts_div.appendChild(makeStatItem(ICON_SHARE, item.child_count));
|
||||
// 卡片操作按钮:编辑 / 移动 / 删除(stopPropagation 防止触发卡片自身的点入跳转)
|
||||
// 按钮组整体按 can_operate 显隐:操作权由后端 get_notes 按 owner / is_admin 判定,
|
||||
// 与 delete_note / update_note 鉴权口径一致(移动本质也是 update_note);
|
||||
@@ -974,13 +1201,16 @@ function itemSearchText(item) {
|
||||
var sp = parseSp(c);
|
||||
if (!sp) {
|
||||
parts.push(c);
|
||||
} else if (sp._sp === 'link') {
|
||||
if (sp.url) parts.push(sp.url);
|
||||
if (sp.note) parts.push(sp.note);
|
||||
} else if (sp._sp === 'js') {
|
||||
if (sp.code) parts.push(sp.code);
|
||||
if (sp.note) parts.push(sp.note);
|
||||
} else { /* image / file:不把 base64 数据纳入搜索 */
|
||||
} else if (sp._sp === 'style') {
|
||||
if (sp.css) parts.push(sp.css);
|
||||
if (sp.note) parts.push(sp.note);
|
||||
} else if (sp._sp === 'theme') {
|
||||
parts.push(themeVarsToText(sp.vars || {}));
|
||||
if (sp.note) parts.push(sp.note);
|
||||
} else { /* file:不把 base64 数据纳入搜索 */
|
||||
if (sp.name) parts.push(sp.name);
|
||||
if (sp.note) parts.push(sp.note);
|
||||
}
|
||||
@@ -1050,17 +1280,18 @@ function loadMap(children_json) {
|
||||
back.addEventListener("click", () => {
|
||||
if (!confirmLeaveIfDirty()) return; /* 未放弃输入则不返回,stack 保持原样 */
|
||||
stack.pop();
|
||||
stack_operate.pop();
|
||||
/* 栈空(回到根)时取 0:原判断 stack.length >= 0 恒真,空栈取 stack[-1] 得 undefined */
|
||||
var id = (stack.length > 0) ? stack[stack.length - 1] : 0;
|
||||
getNode(id).then(load_notes);
|
||||
getNode(id).then(loadMap);
|
||||
getNode(id).then(function(r) { load_notes(r); loadMap(r); });
|
||||
});
|
||||
minimap.appendChild(back);
|
||||
}
|
||||
for (let i in children_json) {
|
||||
let item = children_json[i];
|
||||
let tag = document.createElement("div");
|
||||
let title = item.title;
|
||||
if (title && typeof title === 'string' && title.indexOf('__') === 0 && !__show_hidden) continue;
|
||||
let tag = document.createElement("div");
|
||||
tag.className = "tag";
|
||||
tag.textContent = title;
|
||||
tag.addEventListener("click", () => {
|
||||
@@ -1086,8 +1317,9 @@ async function loadUserPanel() {
|
||||
});
|
||||
if (!res.ok) return;
|
||||
current_user = await res.json();
|
||||
/* 管理员显示特殊节点面板(普通用户始终普通文本) */
|
||||
if (current_user.login && current_user.is_admin) {
|
||||
/* 登录用户显示特殊节点面板(发布权限由后端按"节点拥有者或管理员"强制,
|
||||
* 非拥有者在他人节点下提交特殊节点会被 403 拒绝并提示) */
|
||||
if (current_user.login) {
|
||||
sp_panel.style.display = '';
|
||||
}
|
||||
renderUserPanel();
|
||||
@@ -1100,11 +1332,12 @@ function renderUserPanel() {
|
||||
if (current_user == null) return;
|
||||
userpanel.replaceChildren();
|
||||
let fold = document.createElement("div");
|
||||
fold.className = "tag fold-btn";
|
||||
if (userpanel_expand) {
|
||||
fold.className = "tag fold-btn";
|
||||
fold.textContent = "v 折叠";
|
||||
} else {
|
||||
fold.textContent = "> 展开";
|
||||
fold.className = "user-avatar-fold";
|
||||
fold.textContent = current_user.login ? (current_user.user || "?").trim().charAt(0).toUpperCase() : "?";
|
||||
}
|
||||
fold.addEventListener("click", () => {
|
||||
userpanel_expand = !userpanel_expand;
|
||||
@@ -1145,6 +1378,22 @@ function renderUserPanel() {
|
||||
});
|
||||
userpanel.appendChild(effect_btn);
|
||||
|
||||
/* __ 系统节点显隐开关(所有用户) */
|
||||
let hidden_btn = document.createElement("div");
|
||||
hidden_btn.className = "tag";
|
||||
hidden_btn.textContent = __show_hidden ? "隐藏系统节点" : "显示系统节点";
|
||||
hidden_btn.addEventListener("click", () => {
|
||||
__show_hidden = !__show_hidden;
|
||||
try { localStorage.setItem('dnfs_show_hidden', __show_hidden ? '1' : '0'); } catch (e) {}
|
||||
/* 切换时重新渲染当前层卡片和 minimap */
|
||||
clearAllEffects();
|
||||
renderCards(current_children);
|
||||
var cur = (stack.length == 0) ? 0 : stack[stack.length - 1];
|
||||
getNode(cur).then(function(r) { loadMap(r); });
|
||||
renderUserPanel();
|
||||
});
|
||||
userpanel.appendChild(hidden_btn);
|
||||
|
||||
/* 管理员专属入口:运维机器人 / 进程管理器 */
|
||||
if (current_user.is_admin) {
|
||||
let ops_btn = document.createElement("div");
|
||||
@@ -1170,8 +1419,7 @@ function renderUserPanel() {
|
||||
userpanel.appendChild(logout_tag);
|
||||
}
|
||||
|
||||
getNode(0).then(load_notes);
|
||||
getNode(0).then(loadMap)
|
||||
getNode(0).then(function(r) { load_notes(r); loadMap(r); })
|
||||
loadUserPanel();
|
||||
|
||||
/* ===== 搜索框事件绑定:实时过滤当前层卡片;ESC 清空;回车不提交 ===== */
|
||||
|
||||
@@ -37,9 +37,6 @@
|
||||
</div>
|
||||
</form>
|
||||
<a href="forgot.html" style="display:block;text-align:center;color:#999;font-size:13px;margin:8px 0">忘记密码?</a>
|
||||
<div style="text-align:center;margin:12px 0;position:relative"><div style="position:absolute;top:50%;left:0;right:0;height:1px;background:#e0d6cc"></div><span style="background:#fff;padding:0 12px;position:relative;color:#999;font-size:13px">或</span></div>
|
||||
<a href="https://yhdet.top/yhdet_auth?callback=https://qsont.me/yhdet_callback" class="yhdet-login-btn">开源社区 快捷登录</a>
|
||||
<style>.yhdet-login-btn{display:block;width:100%;padding:12px;border:1.5px solid #1a73e8;border-radius:8px;background:white;color:#1a73e8;font-size:15px;font-weight:600;text-decoration:none;text-align:center;box-sizing:border-box;margin-bottom:12px}.yhdet-login-btn:hover{background:#e8f0fe}</style>
|
||||
|
||||
<!-- 用户使用条例弹窗 -->
|
||||
<div id="licenseModal" class="license-modal-overlay" style="display:none">
|
||||
|
||||
+62
-342
@@ -5,8 +5,6 @@
|
||||
* GET /<path> 静态文件 / 目录浏览
|
||||
* GET /api/filelist?path= 文件列表 JSON
|
||||
* GET /video/... 视频/音频流媒体(HTTP Range 206)
|
||||
* POST /api/hash SHA-256 密码哈希(限速 20/min/IP)
|
||||
* POST /api/check_user 用户存在性检查(限速 10/min/IP)
|
||||
* GET /api/favicon?url= favicon SSRF 防护代理
|
||||
* POST /get_notes 等 4 个 dnfs2 反向代理
|
||||
* GET /health 健康检查
|
||||
@@ -18,7 +16,7 @@
|
||||
* F-4 所有端口/线程数有严格边界校验
|
||||
* F-5 Boost.Beast 异步多线程替代 Flask 同步
|
||||
* F-6 消除 Python/Flask/waitress 依赖
|
||||
* F-7 日志客户端复用主配置(confs 拉取的 db host/port)
|
||||
* F-7 日志客户端复用主配置(CLI 传递的 db host/port)
|
||||
*
|
||||
* 依赖: libhttp (HttpServer/HttpClient), libmsg (RpcClient/AsyncQueue),
|
||||
* boost::json, OpenSSL, libcurl
|
||||
@@ -30,8 +28,8 @@
|
||||
#include <us/AsyncQueue.hpp>
|
||||
|
||||
#include <boost/json.hpp>
|
||||
#include <boost/program_options.hpp>
|
||||
#include <curl/curl.h>
|
||||
#include <openssl/evp.h>
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
@@ -64,6 +62,7 @@
|
||||
#include <sys/types.h>
|
||||
|
||||
namespace json = boost::json;
|
||||
namespace po = boost::program_options;
|
||||
namespace http = boost::beast::http;
|
||||
namespace fs = std::filesystem;
|
||||
using namespace pmc::net;
|
||||
@@ -92,12 +91,6 @@ static constexpr size_t kHttpMaxResponse = 10 * 1024 * 1024;
|
||||
// 单次文件响应大小上限(string_body 全量读入内存,防大文件 DoS;审计 F-4)
|
||||
static constexpr uintmax_t kMaxServeFileSize = 50 * 1024 * 1024;
|
||||
|
||||
// 限速
|
||||
static constexpr int kHashRateLimit = 20;
|
||||
static constexpr int kCheckUserRateLimit = 10;
|
||||
static constexpr int kRateWindowSeconds = 60;
|
||||
static constexpr size_t kRateMaxKeys = 65536; // 限速键数上限,防伪造 XFF 旋转导致内存增长
|
||||
|
||||
// 视图文件名
|
||||
static const std::string kViewFile = "__view.html";
|
||||
|
||||
@@ -132,30 +125,6 @@ static int safe_stoi(const std::string& s, const char* field_name) {
|
||||
}
|
||||
}
|
||||
|
||||
static std::string sha256_hex(const std::string& input) {
|
||||
EVP_MD* md = EVP_MD_fetch(nullptr, "SHA2-256", nullptr);
|
||||
const bool fetched = (md != nullptr);
|
||||
if (!md) {
|
||||
// OpenSSL < 3.0 回退
|
||||
md = const_cast<EVP_MD*>(EVP_sha256());
|
||||
}
|
||||
unsigned char hash[EVP_MAX_MD_SIZE];
|
||||
unsigned int hash_len = 0;
|
||||
EVP_Digest(input.data(), input.size(), hash, &hash_len, md, nullptr);
|
||||
// EVP_MD_fetch 返回的无论 digest 成败都需释放,EVP_sha256 返回的不需要
|
||||
if (fetched) {
|
||||
EVP_MD_free(md);
|
||||
}
|
||||
static const char hex_chars[] = "0123456789abcdef";
|
||||
std::string result;
|
||||
result.reserve(hash_len * 2);
|
||||
for (unsigned int i = 0; i < hash_len; ++i) {
|
||||
result += hex_chars[hash[i] >> 4];
|
||||
result += hex_chars[hash[i] & 0x0f];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static std::string format_file_size(uintmax_t size) {
|
||||
double s = static_cast<double>(size);
|
||||
const char* units[] = {"B", "KB", "MB", "GB", "TB"};
|
||||
@@ -209,44 +178,6 @@ static std::string extract_path(const std::string& target) {
|
||||
return (q == std::string::npos) ? target : target.substr(0, q);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// INI 解析(白名单模式,与 dnfs2 一致)
|
||||
// ============================================================================
|
||||
|
||||
using IniMap = std::map<std::string, std::map<std::string, std::string>>;
|
||||
|
||||
static IniMap parse_ini(const std::string& text) {
|
||||
IniMap result;
|
||||
std::string section;
|
||||
std::istringstream ss(text);
|
||||
std::string line;
|
||||
while (std::getline(ss, line)) {
|
||||
auto b = line.find_first_not_of(" \t\r\n");
|
||||
if (b == std::string::npos) continue;
|
||||
line = line.substr(b);
|
||||
auto e = line.find_last_not_of(" \t\r\n");
|
||||
if (e != std::string::npos) line = line.substr(0, e + 1);
|
||||
|
||||
if (line.empty() || line[0] == '#' || line[0] == ';') continue;
|
||||
if (line.front() == '[' && line.back() == ']') {
|
||||
section = line.substr(1, line.size() - 2);
|
||||
continue;
|
||||
}
|
||||
auto eq = line.find('=');
|
||||
if (eq == std::string::npos || section.empty()) continue;
|
||||
std::string key = line.substr(0, eq);
|
||||
std::string val = line.substr(eq + 1);
|
||||
auto trim = [](std::string& s) {
|
||||
auto bb = s.find_first_not_of(" \t");
|
||||
auto ee = s.find_last_not_of(" \t");
|
||||
s = (bb == std::string::npos) ? "" : s.substr(bb, ee - bb + 1);
|
||||
};
|
||||
trim(key); trim(val);
|
||||
result[section][key] = val;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 配置
|
||||
// ============================================================================
|
||||
@@ -264,9 +195,6 @@ struct Config {
|
||||
|
||||
std::vector<std::string> cors_origins;
|
||||
|
||||
std::string confs_host = "127.0.0.1";
|
||||
int confs_port = 8010;
|
||||
|
||||
std::string db_host = "127.0.0.1";
|
||||
int db_port = 8005;
|
||||
|
||||
@@ -276,63 +204,34 @@ struct Config {
|
||||
static Config load_config(int argc, char* argv[]) {
|
||||
Config cfg;
|
||||
|
||||
// 1. confs 引导地址
|
||||
if (const char* h = std::getenv("CONFS_HOST")) cfg.confs_host = h;
|
||||
if (const char* p = std::getenv("CONFS_PORT")) {
|
||||
cfg.confs_port = safe_stoi(p, "CONFS_PORT");
|
||||
if (cfg.confs_port < 1 || cfg.confs_port > 65535) {
|
||||
std::fprintf(stderr, "[fsys] invalid CONFS_PORT: %d (must be 1-65535)\n", cfg.confs_port);
|
||||
std::exit(1);
|
||||
}
|
||||
// ── 命令行参数 ──
|
||||
po::options_description desc("fsys — 静态文件托管服务");
|
||||
desc.add_options()
|
||||
("help,h", "显示帮助")
|
||||
("instance", po::value<std::string>()->default_value("fsys"), "实例名")
|
||||
("log-level", po::value<std::string>()->default_value("info"), "日志级别")
|
||||
("addr,a", po::value<std::string>(), "监听地址")
|
||||
("port,p", po::value<int>(), "监听端口")
|
||||
("threads,t", po::value<int>(), "工作线程数")
|
||||
("root", po::value<std::string>(), "静态文件根目录")
|
||||
("ssl-enabled", po::value<std::string>(), "SSL 开关 (true/false)")
|
||||
("cert-file", po::value<std::string>(), "SSL 证书路径")
|
||||
("key-file", po::value<std::string>(), "SSL 私钥路径")
|
||||
("cors-origins", po::value<std::string>(), "CORS 白名单(逗号分隔)")
|
||||
("db-host", po::value<std::string>(), "数据库 RPC 地址")
|
||||
("db-port", po::value<int>(), "数据库 RPC 端口")
|
||||
("dnfs-url", po::value<std::string>(), "dnfs2 服务 URL");
|
||||
po::variables_map vm;
|
||||
po::store(po::parse_command_line(argc, argv, desc), vm);
|
||||
po::notify(vm);
|
||||
|
||||
if (vm.count("help")) {
|
||||
std::cout << desc << std::endl;
|
||||
std::exit(0);
|
||||
}
|
||||
|
||||
// 2. 从 confs 拉取 static.ini(retry_on_refused=true)
|
||||
std::string ini_text;
|
||||
try {
|
||||
RpcClient confs_client(cfg.confs_host, cfg.confs_port, "fsys",
|
||||
/*timeout=*/5.0, /*retry_on_refused=*/true);
|
||||
json::value data = confs_client.call("get", {{"name", "static.ini"}});
|
||||
if (data.is_object() && data.as_object().contains("content")) {
|
||||
ini_text = std::string(data.as_object().at("content").as_string());
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
std::fprintf(stderr, "[fsys] 从 confs 获取 static.ini 失败: %s\n", e.what());
|
||||
std::exit(1);
|
||||
}
|
||||
|
||||
// 3. 解析 INI
|
||||
IniMap ini = parse_ini(ini_text);
|
||||
|
||||
auto get = [&](const std::string& sec, const std::string& key,
|
||||
const std::string& def = "") -> std::string {
|
||||
auto si = ini.find(sec);
|
||||
if (si == ini.end()) return def;
|
||||
auto ki = si->second.find(key);
|
||||
return (ki == si->second.end()) ? def : ki->second;
|
||||
};
|
||||
|
||||
cfg.listen_host = get("server", "host", cfg.listen_host);
|
||||
cfg.listen_port = safe_stoi(get("server", "port", std::to_string(cfg.listen_port)), "[server] port");
|
||||
cfg.static_root = get("directory", "root", cfg.static_root);
|
||||
|
||||
std::string ssl_str = get("ssl", "enabled", "false");
|
||||
cfg.ssl_enabled = (ssl_str == "true" || ssl_str == "True" || ssl_str == "1");
|
||||
cfg.ssl_cert = get("ssl", "cert_file", cfg.ssl_cert);
|
||||
cfg.ssl_key = get("ssl", "key_file", cfg.ssl_key);
|
||||
|
||||
std::string origins_str = get("cors", "allowed_origins", "");
|
||||
if (!origins_str.empty()) {
|
||||
std::istringstream oss(origins_str);
|
||||
std::string origin;
|
||||
while (std::getline(oss, origin, ',')) {
|
||||
auto b = origin.find_first_not_of(" \t");
|
||||
auto e = origin.find_last_not_of(" \t");
|
||||
if (b != std::string::npos)
|
||||
cfg.cors_origins.push_back(origin.substr(b, e - b + 1));
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 环境变量覆盖(Docker 兼容)
|
||||
// ── 配置加载:CLI > 环境变量 > 默认值 ──
|
||||
// 1. 环境变量覆盖(Docker 兼容)
|
||||
if (const char* v = std::getenv("LISTEN_ADDR")) cfg.listen_host = v;
|
||||
if (const char* v = std::getenv("LISTEN_PORT")) cfg.listen_port = safe_stoi(v, "LISTEN_PORT");
|
||||
if (const char* v = std::getenv("STATIC_ROOT")) cfg.static_root = v;
|
||||
@@ -340,29 +239,49 @@ static Config load_config(int argc, char* argv[]) {
|
||||
if (const char* v = std::getenv("DB_PORT")) cfg.db_port = safe_stoi(v, "DB_PORT");
|
||||
if (const char* v = std::getenv("DNFS_URL")) cfg.dnfs_url = v;
|
||||
|
||||
// 5. 命令行参数(最高优先级)
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
std::string arg = argv[i];
|
||||
if ((arg == "--addr" || arg == "-a") && i + 1 < argc) cfg.listen_host = argv[++i];
|
||||
else if ((arg == "--port" || arg == "-p") && i + 1 < argc) cfg.listen_port = safe_stoi(argv[++i], "--port");
|
||||
else if ((arg == "--threads" || arg == "-t") && i + 1 < argc) cfg.threads = safe_stoi(argv[++i], "--threads");
|
||||
// 2. CLI 参数覆盖(最高优先级)
|
||||
if (vm.count("addr")) cfg.listen_host = vm["addr"].as<std::string>();
|
||||
if (vm.count("port")) cfg.listen_port = vm["port"].as<int>();
|
||||
if (vm.count("threads")) cfg.threads = vm["threads"].as<int>();
|
||||
if (vm.count("root")) cfg.static_root = vm["root"].as<std::string>();
|
||||
if (vm.count("ssl-enabled")) {
|
||||
std::string s = vm["ssl-enabled"].as<std::string>();
|
||||
cfg.ssl_enabled = (s == "true" || s == "True" || s == "1");
|
||||
}
|
||||
if (vm.count("cert-file")) cfg.ssl_cert = vm["cert-file"].as<std::string>();
|
||||
if (vm.count("key-file")) cfg.ssl_key = vm["key-file"].as<std::string>();
|
||||
if (vm.count("cors-origins")) {
|
||||
std::string origins_str = vm["cors-origins"].as<std::string>();
|
||||
if (!origins_str.empty()) {
|
||||
std::istringstream oss(origins_str);
|
||||
std::string origin;
|
||||
while (std::getline(oss, origin, ',')) {
|
||||
auto b = origin.find_first_not_of(" \t");
|
||||
auto e = origin.find_last_not_of(" \t");
|
||||
if (b != std::string::npos)
|
||||
cfg.cors_origins.push_back(origin.substr(b, e - b + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (vm.count("db-host")) cfg.db_host = vm["db-host"].as<std::string>();
|
||||
if (vm.count("db-port")) cfg.db_port = vm["db-port"].as<int>();
|
||||
if (vm.count("dnfs-url")) cfg.dnfs_url = vm["dnfs-url"].as<std::string>();
|
||||
|
||||
// 6. 校验必需配置项
|
||||
// 3. 校验必需配置项
|
||||
if (cfg.listen_host.empty()) {
|
||||
std::fprintf(stderr, "[fsys] 缺少必需配置: [server] host\n");
|
||||
std::fprintf(stderr, "[fsys] 缺少必需配置: --addr\n");
|
||||
std::exit(1);
|
||||
}
|
||||
if (cfg.static_root.empty()) {
|
||||
std::fprintf(stderr, "[fsys] 缺少必需配置: [directory] root\n");
|
||||
std::fprintf(stderr, "[fsys] 缺少必需配置: --root\n");
|
||||
std::exit(1);
|
||||
}
|
||||
if (cfg.cors_origins.empty()) {
|
||||
std::fprintf(stderr, "[fsys] 缺少必需配置: [cors] allowed_origins\n");
|
||||
std::fprintf(stderr, "[fsys] 缺少必需配置: --cors-origins\n");
|
||||
std::exit(1);
|
||||
}
|
||||
|
||||
// 7. 数值范围校验
|
||||
// 4. 数值范围校验
|
||||
if (cfg.listen_port < 1 || cfg.listen_port > 65535) {
|
||||
std::fprintf(stderr, "[fsys] invalid listen_port: %d (must be 1-65535)\n", cfg.listen_port);
|
||||
std::exit(1);
|
||||
@@ -372,7 +291,7 @@ static Config load_config(int argc, char* argv[]) {
|
||||
std::exit(1);
|
||||
}
|
||||
|
||||
// 8. 路径规范化:static_root/SSL 相对路径基于当前工作目录
|
||||
// 5. 路径规范化:static_root/SSL 相对路径基于当前工作目录
|
||||
// (_run.sh 以模块目录为 cwd 启动,与 Python 版 BASE_DIR 语义一致;
|
||||
// Docker 场景使用绝对路径 STATIC_ROOT,不受影响)
|
||||
if (!cfg.static_root.empty() && cfg.static_root[0] != '/') {
|
||||
@@ -389,19 +308,6 @@ static Config load_config(int argc, char* argv[]) {
|
||||
return cfg;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 异常类
|
||||
// ============================================================================
|
||||
|
||||
class FsysExp : public std::runtime_error {
|
||||
public:
|
||||
FsysExp(const std::string& msg, int code)
|
||||
: std::runtime_error(msg), status_(code) {}
|
||||
int status() const noexcept { return status_; }
|
||||
private:
|
||||
int status_;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// MIME 类型映射
|
||||
// ============================================================================
|
||||
@@ -630,8 +536,6 @@ class FsysServer {
|
||||
public:
|
||||
explicit FsysServer(Config cfg)
|
||||
: _cfg(std::move(cfg)),
|
||||
_rpc(_cfg.db_host, _cfg.db_port, "fsys", 5.0,
|
||||
/*retry_on_refused=*/true),
|
||||
_log_rpc(_cfg.db_host, _cfg.db_port, "fsys-log", kLogRpcTimeout,
|
||||
/*retry_on_refused=*/false)
|
||||
{
|
||||
@@ -654,8 +558,7 @@ public:
|
||||
_filesystem_enabled = false;
|
||||
}
|
||||
|
||||
std::printf("[fsys] 配置来源: confs (static.ini @ %s:%d)\n",
|
||||
_cfg.confs_host.c_str(), _cfg.confs_port);
|
||||
std::printf("[fsys] 配置来源: CLI > 环境变量 > 默认值\n");
|
||||
std::printf("[fsys] 静态目录: %s\n", _cfg.static_root.c_str());
|
||||
}
|
||||
|
||||
@@ -667,7 +570,6 @@ public:
|
||||
|
||||
private:
|
||||
Config _cfg;
|
||||
RpcClient _rpc; // db RPC(retry_on_refused=true)
|
||||
RpcClient _log_rpc; // 日志写入
|
||||
|
||||
std::unique_ptr<AsyncQueue<LogEntry>> _log_queue;
|
||||
@@ -676,10 +578,6 @@ private:
|
||||
HttpServer* _server = nullptr;
|
||||
static std::atomic<bool> _should_stop;
|
||||
|
||||
// 限速存储
|
||||
std::mutex _rate_mutex;
|
||||
std::unordered_map<std::string, std::vector<std::chrono::steady_clock::time_point>> _rate_store;
|
||||
|
||||
static void _signal_handler(int) { _should_stop.store(true); }
|
||||
|
||||
// ---- 日志 --------------------------------------------------------------
|
||||
@@ -720,68 +618,6 @@ private:
|
||||
}
|
||||
}
|
||||
|
||||
// ---- db 调用 -----------------------------------------------------------
|
||||
|
||||
struct DbResult {
|
||||
bool success = false;
|
||||
json::value data;
|
||||
std::string error;
|
||||
};
|
||||
|
||||
DbResult _call_db(const json::object& payload) {
|
||||
try {
|
||||
json::value data = _rpc.call("execute", payload);
|
||||
return {true, data, ""};
|
||||
} catch (const RpcError& e) {
|
||||
return {false, nullptr, e.what()};
|
||||
} catch (const std::exception& e) {
|
||||
_log("ERROR", "fsys", std::string("db 调用失败: ") + e.what());
|
||||
throw FsysExp("数据库服务暂时不可用", 502);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 客户端 IP ---------------------------------------------------------
|
||||
|
||||
static std::string client_ip(const http::request<http::string_body>& req) {
|
||||
auto xff = req.find("X-Forwarded-For");
|
||||
if (xff != req.end()) {
|
||||
std::string val(xff->value());
|
||||
auto comma = val.find(',');
|
||||
std::string ip = (comma == std::string::npos) ? val : val.substr(0, comma);
|
||||
auto b = ip.find_first_not_of(" ");
|
||||
auto e = ip.find_last_not_of(" ");
|
||||
if (b != std::string::npos) return ip.substr(b, e - b + 1);
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
// ---- 限速 --------------------------------------------------------------
|
||||
|
||||
bool _check_rate_limit(const std::string& key, int max_requests) {
|
||||
auto now = std::chrono::steady_clock::now();
|
||||
auto cutoff = now - std::chrono::seconds(kRateWindowSeconds);
|
||||
std::lock_guard<std::mutex> lock(_rate_mutex);
|
||||
// 键数超限:全局驱逐过期窗口(防伪造 XFF 旋转导致无限增长)
|
||||
if (_rate_store.size() > kRateMaxKeys) {
|
||||
for (auto it = _rate_store.begin(); it != _rate_store.end();) {
|
||||
auto& ts = it->second;
|
||||
ts.erase(std::remove_if(ts.begin(), ts.end(),
|
||||
[&](const auto& t) { return t < cutoff; }),
|
||||
ts.end());
|
||||
if (ts.empty()) it = _rate_store.erase(it);
|
||||
else ++it;
|
||||
}
|
||||
}
|
||||
auto& timestamps = _rate_store[key];
|
||||
timestamps.erase(
|
||||
std::remove_if(timestamps.begin(), timestamps.end(),
|
||||
[&](const auto& t) { return t < cutoff; }),
|
||||
timestamps.end());
|
||||
if (static_cast<int>(timestamps.size()) >= max_requests) return false;
|
||||
timestamps.push_back(now);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---- CORS --------------------------------------------------------------
|
||||
|
||||
bool _is_allowed_origin(const std::string& origin) const {
|
||||
@@ -827,10 +663,6 @@ private:
|
||||
const http::request<http::string_body>& req);
|
||||
http::response<http::string_body> _handle_video(
|
||||
const http::request<http::string_body>& req);
|
||||
http::response<http::string_body> _handle_hash(
|
||||
const http::request<http::string_body>& req);
|
||||
http::response<http::string_body> _handle_check_user(
|
||||
const http::request<http::string_body>& req);
|
||||
http::response<http::string_body> _handle_favicon(
|
||||
const http::request<http::string_body>& req);
|
||||
http::response<http::string_body> _handle_dnfs_proxy(
|
||||
@@ -871,9 +703,6 @@ void FsysServer::run() {
|
||||
server.get("/api/favicon", [this](const auto& req, const auto&) { return _handle_favicon(req); });
|
||||
server.get("/health", [this](const auto& req, const auto&) { return _handle_health(req); });
|
||||
|
||||
server.post("/api/hash", [this](const auto& req, const auto&) { return _handle_hash(req); });
|
||||
server.post("/api/check_user", [this](const auto& req, const auto&) { return _handle_check_user(req); });
|
||||
|
||||
// dnfs2 代理路由
|
||||
auto dnfs_handler = [this](const std::string& route) {
|
||||
return [this, route](const auto& req, const auto&) {
|
||||
@@ -1121,8 +950,6 @@ http::response<http::string_body> FsysServer::_handle_filelist(
|
||||
result["items"] = items;
|
||||
return make_json_obj(http::status::ok, result, req);
|
||||
|
||||
} catch (const FsysExp& e) {
|
||||
return make_error(static_cast<http::status>(e.status()), e.what(), req);
|
||||
} catch (const std::exception& e) {
|
||||
_log("ERROR", "fsys", std::string("\u6587\u4ef6\u5217\u8868\u9519\u8bef: ") + e.what());
|
||||
return make_error(http::status::internal_server_error, "\u5185\u90e8\u9519\u8bef", req);
|
||||
@@ -1226,113 +1053,6 @@ http::response<http::string_body> FsysServer::_handle_video(
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// POST /api/hash — SHA-256 密码哈希
|
||||
// ============================================================================
|
||||
|
||||
http::response<http::string_body> FsysServer::_handle_hash(
|
||||
const http::request<http::string_body>& req)
|
||||
{
|
||||
try {
|
||||
std::string ip = client_ip(req);
|
||||
if (!_check_rate_limit("hash:" + ip, kHashRateLimit))
|
||||
return make_error(http::status::too_many_requests, "rate limit exceeded", req);
|
||||
|
||||
json::value val;
|
||||
try { val = json::parse(req.body()); }
|
||||
catch (...) { return make_error(http::status::bad_request, "invalid input", req); }
|
||||
|
||||
if (!val.is_object())
|
||||
return make_error(http::status::bad_request, "invalid input", req);
|
||||
|
||||
const auto& obj = val.as_object();
|
||||
auto it = obj.find("password");
|
||||
if (it == obj.end() || !it->value().is_string())
|
||||
return make_error(http::status::bad_request, "invalid input", req);
|
||||
|
||||
std::string password(it->value().as_string());
|
||||
std::string hash = sha256_hex(password);
|
||||
|
||||
json::object result;
|
||||
result["hash"] = hash;
|
||||
return make_json_obj(http::status::ok, result, req);
|
||||
|
||||
} catch (const std::exception& e) {
|
||||
_log("ERROR", "fsys", std::string("hash 错误: ") + e.what());
|
||||
return make_error(http::status::internal_server_error, "内部错误", req);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// POST /api/check_user — 检查用户是否存在
|
||||
// ============================================================================
|
||||
|
||||
http::response<http::string_body> FsysServer::_handle_check_user(
|
||||
const http::request<http::string_body>& req)
|
||||
{
|
||||
try {
|
||||
std::string ip = client_ip(req);
|
||||
if (!_check_rate_limit("check_user:" + ip, kCheckUserRateLimit))
|
||||
return make_error(http::status::too_many_requests, "rate limit exceeded", req);
|
||||
|
||||
json::value val;
|
||||
try { val = json::parse(req.body()); }
|
||||
catch (...) {
|
||||
json::object r; r["success"] = false; r["error"] = "missing email";
|
||||
return make_json_obj(http::status::bad_request, r, req);
|
||||
}
|
||||
|
||||
if (!val.is_object()) {
|
||||
json::object r; r["success"] = false; r["error"] = "missing email";
|
||||
return make_json_obj(http::status::bad_request, r, req);
|
||||
}
|
||||
|
||||
const auto& obj = val.as_object();
|
||||
auto it = obj.find("email");
|
||||
if (it == obj.end() || !it->value().is_string()) {
|
||||
json::object r; r["success"] = false; r["error"] = "missing email";
|
||||
return make_json_obj(http::status::bad_request, r, req);
|
||||
}
|
||||
|
||||
std::string email(it->value().as_string());
|
||||
// trim
|
||||
auto b = email.find_first_not_of(" \t");
|
||||
auto e = email.find_last_not_of(" \t");
|
||||
if (b == std::string::npos) {
|
||||
json::object r; r["success"] = false; r["error"] = "missing email";
|
||||
return make_json_obj(http::status::bad_request, r, req);
|
||||
}
|
||||
email = email.substr(b, e - b + 1);
|
||||
|
||||
json::object payload;
|
||||
payload["action"] = "select";
|
||||
payload["table"] = "Users";
|
||||
payload["where"] = json::object{{"email", email}};
|
||||
payload["columns"] = json::array{"email"};
|
||||
|
||||
DbResult r = _call_db(payload);
|
||||
if (!r.success) {
|
||||
_log("WARNING", "fsys", "check_user 查询失败: " + r.error);
|
||||
json::object result; result["success"] = false; result["error"] = "query failed";
|
||||
return make_json_obj(http::status::internal_server_error, result, req);
|
||||
}
|
||||
|
||||
bool found = r.data.is_array() && !r.data.as_array().empty();
|
||||
json::object result;
|
||||
result["success"] = true;
|
||||
result["found"] = found;
|
||||
return make_json_obj(http::status::ok, result, req);
|
||||
|
||||
} catch (const FsysExp& exp) {
|
||||
json::object r; r["success"] = false; r["error"] = exp.what();
|
||||
return make_json_obj(static_cast<http::status>(exp.status()), r, req);
|
||||
} catch (const std::exception& e) {
|
||||
_log("ERROR", "fsys", std::string("check_user 错误: ") + e.what());
|
||||
json::object r; r["success"] = false; r["error"] = "internal error";
|
||||
return make_json_obj(http::status::internal_server_error, r, req);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// GET /api/favicon?url= — favicon SSRF 防护代理
|
||||
// ============================================================================
|
||||
|
||||
Reference in new issue
Block a user