Merge branch 'main' of ssh://git.cancerai.top:2000/qing/blackbird into dev_shufeng2012

This commit is contained in:
shufeng2012 committed 2026-08-18 15:39:16 +08:00
commit 121e155248
357 files changed
+44067 -17806

No files matched your search

+1 -1
View File
@@ -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
View File
@@ -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 |
## 运行
+28 -1
View File
@@ -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; }
+4 -4
View File
@@ -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
View File
@@ -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 清空;回车不提交 ===== */
-3
View File
@@ -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
View File
@@ -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 防护代理
// ============================================================================