diff --git a/docs/security/fsys_SECURITY_AUDIT_20260808.md b/docs/security/fsys_SECURITY_AUDIT_20260808.md new file mode 100644 index 0000000..ad25cdd --- /dev/null +++ b/docs/security/fsys_SECURITY_AUDIT_20260808.md @@ -0,0 +1,210 @@ +# fsys 安全审计报告 + +**审计日期**: 2026-08-08 +**审计范围**: `fsys/src/fsys.cpp`(1589 行,修复后) +**对比基准**: `static/fsys.py`(792 行)+ `static_SECURITY_AUDIT_20260801.md`(static 24 项审计结论)+ `dnfs2_SECURITY_AUDIT_20260808.md` +**审计轮次**: 第 1 轮(对照 static 审计逐项验证 + C++ 实现深度复查) +**安全评级**: A- + +--- + +## 1. 审计概述 + +fsys 是 Python `static` 模块的 C++20 重实现(libhttp + libmsg + boost::json),审计目标: + +1. **逐条验证** static 审计(24 项)中的修复在 C++ 重写中是否保留,防止回归; +2. **深度复查** C++ 实现特有的问题(内存模型、libcurl 使用、路径解析、路由机制)。 + +本轮发现 8 项问题(4 P1 / 1 P2 / 3 P3),**全部已修复**;另记录 6 项已知限制。 + +--- + +## 2. 本轮新发现问题(全部已修复) + +### F-1: static_root/SSL 相对路径基于可执行文件目录解析(服务无法提供文件) ✅ 已修复 + +- **等级**: P1(功能性,启动即失效) +- **位置**: `load_config()` 步骤 8(原 L347-369) +- **问题**: 相对路径 `static_root` 通过 `/proc/self/exe` 基于可执行文件目录解析。二进制位于 `fsys/build/fsys`,而 `public` 符号链接位于 `fsys/public`,因此 `./public` 解析为 `fsys/build/./public`(不存在)。更严重的是构造函数 `fs::create_directories()` 会**静默创建空目录**,服务启动正常但所有路由返回 404/500,无告警。 +- **修复**: 相对路径改为基于 `fs::current_path()` 解析(`_run.sh` 以模块目录为 cwd 启动,与 Python 版 `BASE_DIR` 语义一致;Docker 使用绝对路径 `STATIC_ROOT=/app/public`,不受影响)。SSL 证书路径同样修复。 + +### F-2: favicon 代理 `CURLOPT_FOLLOWLOCATION` 盲跟重定向绕过 SSRF 校验(回归) ✅ 已修复 + +- **等级**: P1 +- **位置**: `_handle_favicon()` +- **问题**: 原实现 `CURLOPT_FOLLOWLOCATION=1L` 让 libcurl 自动跟随重定向,**重定向目标不再经过私有 IP 校验**。攻击者可用公网主机返回 `302 Location: http://127.0.0.1:8005/...` 探测/访问内网服务。Python 版有 `_FaviconRedirectHandler` 逐跳重新校验(static 审计已有机制第 8 条),C++ 重写丢失了该防护,属于**安全回归**。 +- **修复**: 禁用自动跟随(`CURLOPT_FOLLOWLOCATION=0L`),提取 `validate_url_ssrf()` 校验函数(scheme/端口/私有 IP),手动逐跳跟随(最多 3 跳),每跳重新校验;同时设置 `CURLOPT_PROTOCOLS_STR="http,https"` 纵深防御。 + +### F-3: 查询参数与路径缺失 URL 解码(favicon/filelist/video/catch-all 功能性回归) ✅ 已修复 + +- **等级**: P1(功能性) +- **位置**: `parse_query()`、`_handle_catch_all()`、`_handle_video()`、`_handle_favicon()` +- **问题**: 前端全部使用 `encodeURIComponent`(已核实:`view.js:139` `/api/filelist?path=${encodeURIComponent(currentPath)}`、`dnfs.js:480` `/api/favicon?url=${encodeURIComponent(sp.url)}`、`view.js:73` 面包屑链接),Flask 自动解码。C++ 版不解码导致: + 1. `/api/favicon?url=https%3A%2F%2F...` → scheme 校验失败 → **favicon 功能完全失效** + 2. `/api/filelist?path=a%2Fb` → 按字面名 `a%2Fb` 查找 → **子目录导航失效** + 3. 中文/空格文件名的静态文件访问失效 +- **修复**: 新增 `url_decode()`(`%XX` + 查询串 `+`→空格,路径解码不转换 `+`);`parse_query` 键值均解码;catch-all/video 路径解码;`safe_path_join` 新增 NUL 字节拒绝(防解码 `%00` 截断)。解码后的遍历序列(如 `..%2F..`)仍被 `safe_path_join` 前缀校验拦截,仅解码一次无双解码风险。 + +### F-4: 静态文件/无 Range 视频全量读入内存(NEW-7 回归) ✅ 已修复(上限防护) + +- **等级**: P1 +- **位置**: `_handle_catch_all()`、`_handle_root()`、`_handle_video()` +- **问题**: libhttp 响应为 `string_body`,所有文件全量读入内存返回。Python 版 NEW-7 已用 `send_file` 流式修复该 DoS;C++ 重写回归。攻击者请求 public 下大文件(或 `Range: bytes=0-` 全量拉取大视频)可导致内存耗尽。 +- **修复**: 新增 `kMaxServeFileSize = 50MB`: + - 普通文件 >50MB → 413 + - 视频无 Range 全量 >50MB → 413(浏览器拉取媒体必带 Range,实际无感) + - Range 片段 >50MB → 416(防 `bytes=0-`) + - 残留限制见 L-1(需 libhttp 支持 file_body/chunked 才能彻底解决) + +### F-5: 限速表 `_rate_store` 无界增长 ✅ 已修复 + +- **等级**: P2 +- **位置**: `_check_rate_limit()` +- **问题**: 限速键为 `"hash:" + client_ip()`,XFF 可伪造且无上限。攻击者旋转 XFF 值可令 map 无限增长耗尽内存(DoS)。Python 版有相同问题(未修),C++ 重写有机会修复。 +- **修复**: 键数超过 `kRateMaxKeys=65536` 时全局驱逐过期窗口键。 + +### F-6: Cookie `q4_token` 子串匹配误判 ✅ 已修复 + +- **等级**: P3 +- **位置**: `_handle_dnfs_proxy()` +- **问题**: `cookies.find("q4_token=")` 子串搜索,名为 `x_q4_token=` 的 Cookie 也会命中,可能把非预期 Cookie 值作为 Bearer Token 转发给 dnfs2。 +- **修复**: 新增 `get_cookie()` 按 `;` 拆分、trim、精确按名匹配。 + +### F-7: `sha256_hex` EVP_MD 泄漏窗口 ✅ 已修复 + +- **等级**: P3 +- **位置**: `sha256_hex()` +- **问题**: 释放条件 `hash_len > 0 && md != EVP_sha256()`,digest 失败(hash_len==0)时 `EVP_MD_fetch` 返回的 md 泄漏。 +- **修复**: 改用 `fetched` 标志,digest 成败均释放。 + +### F-8: `CURLOPT_PROTOCOLS` 已弃用(编译错误) ✅ 已修复 + +- **等级**: P3 +- **问题**: libcurl ≥7.85 弃用 `CURLOPT_PROTOCOLS`,`-Werror` 下编译失败。 +- **修复**: 改用 `CURLOPT_PROTOCOLS_STR`。 + +--- + +## 3. static 审计 24 项逐条验证(防回归检查) + +| static 审计项 | fsys 状态 | 验证说明 | +|------|------|------| +| P0-1 无认证密码重置 | ✅ 未重实现 | 危险功能未移植 | +| P0-2/3/4 yhdet OAuth(Token/SSL 跳过/硬编码密钥) | ✅ 未重实现 | 同上 | +| P0-5 直连 SQLite | ✅ | 通过 `_call_db()` RpcClient 调 db2:8005 | +| NEW-1/2 proxy_send_code / PRIVATE_SECRET | ✅ 未重实现 | 同上 | +| P1-1 check_user/hash 限速 | ✅ | 滑动窗口 20/10 次每分钟,XFF 取真实 IP | +| P1-2/NEW-5 路径遍历前缀 bypass | ✅ | `base_str + "/"` 边界校验 + `weakly_canonical`(symlink 逃逸同样拦截) | +| NEW-3 dnfs 代理 CORS 通配 | ✅ | `_apply_cors` 白名单回显 + `Vary: Origin` | +| NEW-6 代理错误泄露内部异常 | ✅ | 统一返回 `{"error": "upstream unavailable"}`,详情仅记日志 | +| NEW-7 大文件全量读内存 | ⚠️→✅ | 本轮回归后由 F-4 上限防护覆盖,残留限制 L-1 | +| NEW-8 api_hash 校验 | ✅ | `json::parse` 失败/非 object/password 非 string 均 400 + 限速 | +| NEW-9 db 单例/retry/env | ✅ | 成员 RpcClient(retry_on_refused=true)+ DB_HOST/DB_PORT env | +| NEW-10 favicon 端口校验 | ✅ | 端口 [1,65535] 校验,IPv6/userinfo 畸形 URL fail-closed 拒绝 | +| NEW-11 CSP unsafe-inline | ⚠️ 继承 | 与 static/dnfs2 一致的已知限制(前端内联脚本依赖),见 L-3 | +| NEW-12 SSL 回退开发服务器 | ✅ 消除 | libhttp 原生 TLS(F-2 改进项) | +| NEW-13 日志单条失败弃批 | ✅ | `_send_log_batch` 单条 catch 后继续 | +| NEW-14 端口 env 未捕获异常 | ✅ | `safe_stoi` 统一校验,main 捕获后打印 `[fsys] fatal` | +| P2-1 Range 边界校验 | ✅ | start/end 范围 + end≥start + stoi 异常处理 | +| P2-2 日志敏感信息 | ✅ | 无密码/Token 入日志 | +| confs retry 强依赖 | ✅ | `retry_on_refused=true` | +| 配置白名单合并 / 必需项校验 | ✅ | INI 解析 + cors_origins/static_root 必需校验 | +| 安全响应头(6 项) | ✅ | `make_secured_response` 内联每个响应 | +| `__` 前缀系统文件过滤 | ✅ | filelist 过滤 | +| 请求体大小限制 | ✅ | `setMaxBodySize(10MB)`,超限 libhttp 返回 413 | +| curl 全局初始化/清理 | ✅ | main 遵循 dnfs2 P3-N9 模式 | + +**结论**: static 审计的全部有效修复在 fsys 中均得到保留;NEW-7(F-4)与 favicon 重定向校验(F-2)两处曾发生回归,已在本轮修复。 + +--- + +## 4. 行为差异说明(非缺陷) + +### D-1: dnfs 代理透传上游状态码(改进) + +Python 版 `_proxy_to_dnfs` 用 urllib,上游非 2xx 抛 HTTPError → 一律返回 502;因此前端 `dnfs.js` 中 `res.status === 401 → 跳转登录页` 的逻辑在 Python 版下是**死代码**(凭证过期时收到 502 而非 401)。fsys 透传上游真实状态码(400/401/404/500),使前端 401 跳转逻辑首次生效。前端其余错误处理读取 `body.error`,与状态码无关,无兼容性问题。 + +### D-2: libhttp 内置 OPTIONS 反射任意 Origin(继承,影响有限) + +fsys 仅对 4 个 dnfs 路由注册了显式 OPTIONS handler(走白名单)。其余路由(如 `/api/hash`)的预检落入 libhttp 内置 OPTIONS 处理——该处理反射任意 Origin(libhttp 审计 P2-1,未修复)。由于 fsys 实际响应(POST 结果)不附带 `Access-Control-Allow-Origin` 头,浏览器仍会阻止跨域读取响应,泄露面被封堵;预检放行本身影响有限。根治依赖 libhttp 修复 P2-1。 + +### D-3: favicon URL 无路径时的构造 + +Python 版用 `urlparse().netloc`,fsys 用首个 `/` 截取 origin,等价;`http://host?query`(无路径直接带查询串)这类畸形输入在 fsys 中 fail-closed 拒绝(Python 可解析),不影响正常调用(前端始终发送完整 URL)。 + +--- + +## 5. 已知限制(记录,不修复) + +### L-1: string_body 无流式传输(P2,架构性) + +libhttp 响应体为 `string_body`,50MB 上限是缓解而非根治(50MB × 4 线程仍有 200MB 峰值)。彻底修复需 libhttp 支持 `file_body` 或 chunked 响应,与 Python 版 NEW-7 的 send_file 流式能力对齐。 + +### L-2: 限速依赖 XFF(P2,继承) + +- libhttp 未向 handler 暴露 peer IP(`Session::remote_ip_` 仅内部可用),XFF 缺失时限速键回退 `"unknown"`,所有直连客户端共享窗口。Python 版有 `request.remote_addr` 兜底,此处为弱化。 +- 绕过 gateway 直连时可伪造 XFF 绕过限速(Python 相同)。生产依赖 gateway/waf 覆写 XFF。 +- 建议:libhttp 在 params 或 request 上下文暴露 peer IP。 + +### L-3: CSP `unsafe-inline`(P3,继承 static NEW-11 / dnfs2 L-7) + +前端(dnfs 特殊节点注入的 HTML/JS)依赖内联脚本,nonce 化需前端架构改造。 + +### L-4: DNS rebinding 理论窗口(P3,继承 static NEW-10) + +`is_private_host` 校验与实际 HTTP 连接之间存在 TOCTOU 窗口(重定向逐跳校验同样存在)。彻底修复需将校验所得 IP 绑定到连接(`CURLOPT_RESOLVE`),当前与 Python 版风险等级一致,可接受。 + +### L-5: favicon 代理无响应缓存 + +每次请求都实时拉取上游(Python 版同样无服务端缓存,仅有浏览器侧 `Cache-Control: max-age=86400`)。高频访问同一链接卡片会产生重复外联。 + +### L-6: Makefile `/opt/homebrew` 路径(继承 dnfs2 惯例) + +macOS/Linux 双开发环境约定,Linux 编译需覆盖 `INCS/LIBS/RPATH`(本次审计编译即如此执行)。与 dnfs2 Makefile 保持逐字节一致,未单独修改。 + +--- + +## 6. 修复文件清单 + +| 文件 | 修改内容 | +|------|---------| +| `fsys/src/fsys.cpp` | F-1: static_root/SSL 相对路径基于 CWD 解析 | +| `fsys/src/fsys.cpp` | F-2: `validate_url_ssrf()` + favicon 手动逐跳重定向校验 | +| `fsys/src/fsys.cpp` | F-3: `url_decode()` + parse_query/catch-all/video 解码 + NUL 拒绝 | +| `fsys/src/fsys.cpp` | F-4: `kMaxServeFileSize=50MB` 上限(413/416) | +| `fsys/src/fsys.cpp` | F-5: `kRateMaxKeys=65536` 限速表驱逐 | +| `fsys/src/fsys.cpp` | F-6: `get_cookie()` 精确按名解析 | +| `fsys/src/fsys.cpp` | F-7: sha256_hex `fetched` 标志修复泄漏 | +| `fsys/src/fsys.cpp` | F-8: `CURLOPT_PROTOCOLS_STR` | +| `fsys/README.md` | `[directory] root` 相对路径语义说明修正 | + +编译验证: `g++ -std=c++20 -O3 -Wall -Werror` 通过,零错误零警告(`-Werror=deprecated-declarations` 下通过)。 + +运行时冒烟测试(confs/db/dnfs 在线,临时端口 19200,与生产 Python 版 9200 对照): + +| 用例 | 结果 | +|------|------| +| `GET /` / `/health` / filelist 根目录与子目录 | ✓ 200,`static_root` 正确解析到 `fsys/public`(F-1) | +| filelist 编码路径 `path=js` / `games%2F` | ✓ 200(F-3 解码生效) | +| 路径遍历 `/%2e%2e/%2e%2e/etc/passwd` | ✓ 403 | +| favicon 编码公网 URL / 内网 URL | ✓ 200 image/x-icon / 403 forbidden host(F-2/F-3) | +| `POST /api/hash` | ✓ 哈希值与 Python 版逐位一致 | +| `POST /api/check_user` | ✓ 与 Python 版行为一致(当前环境 db Users 查询失败,两版同返 500 query failed,非 fsys 缺陷) | +| dnfs 代理 `/get_notes` | ✓ 透传(上游自身 500,直连上游同果,非代理缺陷) | + +--- + +## 7. 安全评级 + +### 评级: A- + +**理由**: +- static 审计 24 项修复全部保留,两处回归(F-2 SSRF 重定向、F-4 大文件内存)发现后立即修复 +- 4 项 P1 全部修复,编译零警告 +- 相比 Python 版新增:请求体限制、TLS 原生支持、限速表驱逐、Cookie 精确解析、401 透传使前端登录跳转生效 +- 扣分项:L-1 流式传输缺失(依赖 libhttp 演进)、L-2 限速依赖 XFF(依赖 libhttp 暴露 peer IP)、L-3 CSP unsafe-inline(前端架构限制) + +**升级路径**(修复以下任一项可评 A): +- libhttp 支持 file_body/chunked → 解除 L-1 +- libhttp 暴露 peer IP → 解除 L-2 +- libhttp 修复 P2-1(OPTIONS Origin 白名单)→ 消除 D-2 diff --git a/fsys/.gitignore b/fsys/.gitignore new file mode 100644 index 0000000..567609b --- /dev/null +++ b/fsys/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/fsys/Dockerfile b/fsys/Dockerfile new file mode 100644 index 0000000..5c41ded --- /dev/null +++ b/fsys/Dockerfile @@ -0,0 +1,88 @@ +# fsys Dockerfile — C++20 静态文件托管服务 +# 多阶段构建:第一阶段编译 libhttp + libmsg + fsys,第二阶段精简运行时 + +# ============================================================================ +# Stage 1: 构建阶段 +# ============================================================================ +FROM ubuntu:24.04 AS builder + +LABEL maintainer="qingsong" +LABEL service="fsys" +LABEL description="Static File Hosting Service (C++20) - 静态文件托管服务" + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + libboost-json-dev \ + libssl-dev \ + libcurl4-openssl-dev \ + make \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /build + +COPY libhttp/ /build/libhttp/ +COPY libmsg/ /build/libmsg/ +COPY fsys/ /build/fsys/ + +# 编译 libmsg +RUN cd /build/libmsg && make clean && make + +# 编译 libhttp +RUN cd /build/libhttp && make clean && make \ + CPP="g++" \ + INCS="-I inc" \ + SERVER_LIBS="-lssl -lcrypto -lpthread" \ + CLIENT_LIBS="-lssl -lcrypto -lcurl -lpthread" + +# 编译 fsys +RUN cd /build/fsys && make clean && make \ + CPP="g++" \ + INCS="-I ../libhttp/inc -I ../libmsg/inc" \ + LIBS="-L ../libhttp/build -L ../libmsg/build -lus_http -lus_httpclient -lus_msg -lboost_json -lssl -lcrypto -lcurl -lpthread" \ + RPATH="-Wl,-rpath,/app/lib" + +# ============================================================================ +# Stage 2: 运行时阶段 +# ============================================================================ +FROM ubuntu:24.04 + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y --no-install-recommends \ + libboost-json1.83.0 \ + libssl3 \ + libcurl4 \ + ca-certificates \ + curl \ + && rm -rf /var/lib/apt/lists/* \ + && useradd -m -u 1000 appuser + +WORKDIR /app + +# 复制构建产物 +COPY --from=builder /build/fsys/build/fsys /app/fsys +COPY --from=builder /build/libhttp/build/libus_http.so /app/lib/libus_http.so +COPY --from=builder /build/libhttp/build/libus_httpclient.so /app/lib/libus_httpclient.so +COPY --from=builder /build/libmsg/build/libus_msg.so /app/lib/libus_msg.so + +# 复制静态文件(public 目录) +COPY static/public/ /app/public/ + +ENV LD_LIBRARY_PATH=/app/lib + +RUN chown -R appuser:appuser /app +USER appuser + +EXPOSE 9200 + +# 健康检查:GET /health 期望 200 +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD curl -sf http://localhost:9200/health -o /dev/null || exit 1 + +ENV LISTEN_ADDR=0.0.0.0 \ + LISTEN_PORT=9200 \ + STATIC_ROOT=/app/public + +CMD ["/app/fsys"] diff --git a/fsys/Makefile b/fsys/Makefile new file mode 100644 index 0000000..1420b80 --- /dev/null +++ b/fsys/Makefile @@ -0,0 +1,26 @@ +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 \ + -lssl -lcrypto -lcurl -lpthread +RPATH = -Wl,-rpath,'$$ORIGIN/../../libhttp/build' \ + -Wl,-rpath,'$$ORIGIN/../../libmsg/build' \ + -Wl,-rpath,/opt/homebrew/lib + +BUILD = build +TARGET = $(BUILD)/fsys +SRC = src/fsys.cpp + +all: $(TARGET) + +$(BUILD): + mkdir -p $(BUILD) + +$(TARGET): $(SRC) | $(BUILD) + $(CPP) $(CPPFLAGS) $(INCS) -o $@ $< $(LIBS) $(RPATH) + +clean: + rm -f $(TARGET) + +.PHONY: all clean diff --git a/fsys/README.md b/fsys/README.md new file mode 100644 index 0000000..c042ccf --- /dev/null +++ b/fsys/README.md @@ -0,0 +1,113 @@ +# fsys — 静态文件托管服务 (C++20) + +基于 libhttp + libmsg 的 C++20 静态文件托管与前端页面服务,监听 `127.0.0.1:9200`。 + +Python/Flask `static` 模块的生产级 C++ 重写,接口 100% 兼容,前端零改动。 + +## 架构 + +``` +waf :443 (TLS 终止 + 安全规则) + │ + ▼ +gateway :8443 (反向代理 + 认证) + │ + ▼ +fsys :9200 (libhttp 异步多线程 + CORS) + │ + ├── public/ 静态文件根目录 + ├── dnfs2 :8006 笔记代理(反向代理) + ├── db2 :8005 用户查询 + 日志写入 + └── confs :8010 配置拉取 +``` + +## 路由 + +### 核心路由 + +| 路径 | 说明 | +|------|------| +| `GET /` | 首页(优先 index.html,回退 __view.html) | +| `GET /*` | 静态文件 / 目录浏览 | +| `GET /api/filelist?path=` | JSON 文件列表,过滤 `__` 前缀系统文件 | +| `GET /video/*` | 视频/音频流媒体(HTTP Range 206 分段) | +| `GET /health` | 健康检查 | + +### 内部工具路由 + +| 路径 | 说明 | +|------|------| +| `POST /api/hash` | SHA-256 密码哈希(限速 20/min/IP) | +| `POST /api/check_user` | 检查用户是否已注册(限速 10/min/IP) | +| `GET /api/favicon?url=` | favicon SSRF 防护代理(512KB 上限,1 天缓存) | + +### dnfs2 代理路由 + +| 路径 | 说明 | +|------|------| +| `POST /get_notes` | 获取树状笔记列表(代理至 dnfs2:8006) | +| `POST /add_note` | 新增笔记(代理至 dnfs2:8006) | +| `POST /delete_note` | 删除笔记(代理至 dnfs2:8006) | +| `POST /update_note` | 更新笔记(代理至 dnfs2:8006) | + +支持 HttpOnly Cookie 模式:`q4_token` Cookie 自动转为 `Authorization: Bearer` 头。 + +## 配置 + +与 `static` 模块完全兼容,使用相同的 `static.ini`(由 confs 服务分发)。 + +| 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` | 工作线程数 | + +## 相比 Python static 的改进 + +| 改进 | 说明 | +|------|------| +| 请求体大小限制 | `setMaxBodySize(10MB)` 防 OOM | +| SSL 原生支持 | libhttp 构造器直接支持,无需回退开发服务器 | +| 异步 IO | Boost.Beast 异步多线程替代 Flask 同步 | +| 消除 Python 依赖 | 不再需要 venv/pip/Flask/waitress | +| 命令行参数 | 支持 `--addr/-a --port/-p --threads/-t` | +| 数值范围校验 | 所有端口/线程数有严格边界检查 | +| 统一日志配置 | 复用主配置 confs 拉取的 db host/port | + +## 运行 + +```bash +bash _set.sh # 编译 +bash _run.sh # 启动 +``` + +## 依赖 + +libhttp, libmsg, boost::json, OpenSSL, libcurl + +## 许可证 + +MIT License diff --git a/fsys/_run.sh b/fsys/_run.sh new file mode 100644 index 0000000..4f93304 --- /dev/null +++ b/fsys/_run.sh @@ -0,0 +1,4 @@ +#!/bin/bash +set -e +cd "$(dirname "$0")" +exec ./build/fsys "$@" diff --git a/fsys/_set.sh b/fsys/_set.sh new file mode 100644 index 0000000..d716258 --- /dev/null +++ b/fsys/_set.sh @@ -0,0 +1,4 @@ +#!/bin/bash +set -e +cd "$(dirname "$0")" +make diff --git a/fsys/public/index.html b/fsys/public/index.html new file mode 100644 index 0000000..3e51790 --- /dev/null +++ b/fsys/public/index.html @@ -0,0 +1,190 @@ + + + + + +fsys 测试页 + + + +
+

fsys 测试页

+
C++ 静态文件托管服务 · 路由/安全功能自检
+ + +
+ +
+

API 自检

+ + + + + + + + + + + + + + + +
GET /health—
GET /api/filelist(根目录)—
GET /api/filelist?path=test%2F(编码子目录)—
GET /api/filelist?path=..%2F..(遍历应 403)—
GET /test/hello.txt(静态文件)—
GET /test/hello.txt?download=true(附件头)—
GET 中文文件名(URL 解码)—
POST /api/hash(sha256("test123"))—
POST /api/hash 非法输入(应 400)—
POST /api/check_user(结构校验)—
GET /api/favicon 内网地址(SSRF 应 403)—
GET /api/favicon 公网地址(需外网)—
安全响应头(CSP/X-Frame-Options…)—
GET /不存在的路径(应 404)—
+
+ + +
+ + + + diff --git a/fsys/public/test/hello.txt b/fsys/public/test/hello.txt new file mode 100644 index 0000000..7d0912a --- /dev/null +++ b/fsys/public/test/hello.txt @@ -0,0 +1 @@ +hello fsys — 这是 fsys 静态文件服务的测试文件。 diff --git a/fsys/public/test/中文文件.txt b/fsys/public/test/中文文件.txt new file mode 100644 index 0000000..a5eb53b --- /dev/null +++ b/fsys/public/test/中文文件.txt @@ -0,0 +1 @@ +这是中文内容:用于验证 URL 解码(UTF-8 编码文件名)是否生效。 diff --git a/fsys/src/fsys.cpp b/fsys/src/fsys.cpp new file mode 100644 index 0000000..d3eece8 --- /dev/null +++ b/fsys/src/fsys.cpp @@ -0,0 +1,1589 @@ +/** fsys — C++20 静态文件托管服务(对齐 Python static 模块) + * + * 基于 libhttp + libmsg + boost::json 实现,接口与 Python static 完全兼容: + * GET / 首页(index.html 或 __view.html) + * GET / 静态文件 / 目录浏览 + * 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 健康检查 + * + * 安全改进(相比 Python static): + * F-1 server.setMaxBodySize(10MB) 限制请求体大小 + * F-2 libhttp 原生 HTTPS 支持,无需回退开发服务器 + * F-3 命令行参数 --addr/-a --port/-p --threads/-t + * F-4 所有端口/线程数有严格边界校验 + * F-5 Boost.Beast 异步多线程替代 Flask 同步 + * F-6 消除 Python/Flask/waitress 依赖 + * F-7 日志客户端复用主配置(confs 拉取的 db host/port) + * + * 依赖: libhttp (HttpServer/HttpClient), libmsg (RpcClient/AsyncQueue), + * boost::json, OpenSSL, libcurl + */ + +#include +#include +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace json = boost::json; +namespace http = boost::beast::http; +namespace fs = std::filesystem; +using namespace pmc::net; + +// ============================================================================ +// 常量 +// ============================================================================ + +static constexpr size_t kMaxBodySize = 10 * 1024 * 1024; + +// 日志 +static constexpr size_t kLogMaxMessage = 8192; +static constexpr size_t kLogMaxExtra = 4096; +static constexpr size_t kLogBatchSize = 50; +static constexpr double kLogFlushInterval = 2.0; +static constexpr size_t kLogQueueMax = 10000; +static constexpr double kLogRpcTimeout = 5.0; + +// favicon 代理 +static constexpr size_t kFaviconMaxBytes = 512 * 1024; +static constexpr long kFaviconTimeout = 5; + +// HTTP 响应大小上限 +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"; + +// ============================================================================ +// 工具函数 +// ============================================================================ + +static std::string utc_now() { + auto now = std::chrono::system_clock::now(); + std::time_t t = std::chrono::system_clock::to_time_t(now); + struct tm tmbuf; + gmtime_r(&t, &tmbuf); + char buf[32]; + std::strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%S", &tmbuf); + return buf; +} + +static int safe_stoi(const std::string& s, const char* field_name) { + try { + size_t pos = 0; + int val = std::stoi(s, &pos); + if (pos != s.size()) { + throw std::runtime_error(std::string("invalid ") + field_name + + ": '" + s + "' (trailing characters)"); + } + return val; + } catch (const std::runtime_error&) { + throw; + } catch (const std::exception& e) { + throw std::runtime_error(std::string("invalid ") + field_name + + ": '" + s + "' (" + e.what() + ")"); + } +} + +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_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(size); + const char* units[] = {"B", "KB", "MB", "GB", "TB"}; + int i = 0; + while (s >= 1024.0 && i < 4) { s /= 1024.0; ++i; } + char buf[32]; + std::snprintf(buf, sizeof(buf), "%.1f%s", s, units[i]); + return buf; +} + +// URL 解码(%XX;plus_is_space=true 时 '+' 解为空格,仅用于查询串;路径解码不转换 '+') +static std::string url_decode(const std::string& s, bool plus_is_space) { + std::string out; + out.reserve(s.size()); + for (size_t i = 0; i < s.size(); ++i) { + char c = s[i]; + if (c == '%' && i + 2 < s.size() && + std::isxdigit(static_cast(s[i + 1])) && + std::isxdigit(static_cast(s[i + 2]))) { + char hex[3] = {s[i + 1], s[i + 2], '\0'}; + out += static_cast(std::strtol(hex, nullptr, 16)); + i += 2; + } else if (c == '+' && plus_is_space) { + out += ' '; + } else { + out += c; + } + } + return out; +} + +// URL 查询参数解析(值做 URL 解码,与 Flask request.args 行为对齐) +static std::unordered_map parse_query(const std::string& target) { + std::unordered_map params; + auto q = target.find('?'); + if (q == std::string::npos) return params; + std::string qs = target.substr(q + 1); + std::istringstream ss(qs); + std::string pair; + while (std::getline(ss, pair, '&')) { + auto eq = pair.find('='); + if (eq == std::string::npos) continue; + params[url_decode(pair.substr(0, eq), true)] = url_decode(pair.substr(eq + 1), true); + } + return params; +} + +// 从请求 target 提取路径(去除查询参数) +static std::string extract_path(const std::string& target) { + auto q = target.find('?'); + return (q == std::string::npos) ? target : target.substr(0, q); +} + +// ============================================================================ +// INI 解析(白名单模式,与 dnfs2 一致) +// ============================================================================ + +using IniMap = std::map>; + +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; +} + +// ============================================================================ +// 配置 +// ============================================================================ + +struct Config { + std::string listen_host = "127.0.0.1"; + int listen_port = 9200; + int threads = 4; + + std::string static_root = "./public"; + + bool ssl_enabled = false; + std::string ssl_cert = "cert.pem"; + std::string ssl_key = "key.pem"; + + std::vector 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; + + std::string dnfs_url = "http://127.0.0.1:8006"; +}; + +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); + } + } + + // 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 兼容) + 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; + if (const char* v = std::getenv("DB_HOST")) cfg.db_host = v; + 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"); + } + + // 6. 校验必需配置项 + if (cfg.listen_host.empty()) { + std::fprintf(stderr, "[fsys] 缺少必需配置: [server] host\n"); + std::exit(1); + } + if (cfg.static_root.empty()) { + std::fprintf(stderr, "[fsys] 缺少必需配置: [directory] root\n"); + std::exit(1); + } + if (cfg.cors_origins.empty()) { + std::fprintf(stderr, "[fsys] 缺少必需配置: [cors] allowed_origins\n"); + std::exit(1); + } + + // 7. 数值范围校验 + 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); + } + if (cfg.threads < 1 || cfg.threads > 256) { + std::fprintf(stderr, "[fsys] invalid threads: %d (must be 1-256)\n", cfg.threads); + std::exit(1); + } + + // 8. 路径规范化:static_root/SSL 相对路径基于当前工作目录 + // (_run.sh 以模块目录为 cwd 启动,与 Python 版 BASE_DIR 语义一致; + // Docker 场景使用绝对路径 STATIC_ROOT,不受影响) + if (!cfg.static_root.empty() && cfg.static_root[0] != '/') { + cfg.static_root = (fs::current_path() / cfg.static_root) + .lexically_normal().string(); + } + if (!cfg.ssl_cert.empty() && cfg.ssl_cert[0] != '/') { + cfg.ssl_cert = (fs::current_path() / cfg.ssl_cert) + .lexically_normal().string(); + cfg.ssl_key = (fs::current_path() / cfg.ssl_key) + .lexically_normal().string(); + } + + 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 类型映射 +// ============================================================================ + +static const std::unordered_map kFileMimeTypes = { + {".html", "text/html"}, {".js", "application/javascript"}, {".css", "text/css"}, + {".json", "application/json"}, {".png", "image/png"}, {".jpg", "image/jpeg"}, + {".jpeg", "image/jpeg"}, {".gif", "image/gif"}, {".txt", "text/plain"}, + {".pdf", "application/pdf"}, {".svg", "image/svg+xml"}, {".ico", "image/x-icon"}, + {".xml", "application/xml"}, {".woff", "font/woff"}, {".woff2", "font/woff2"}, + {".ttf", "font/ttf"}, {".eot", "application/vnd.ms-fontobject"}, + {".md", "text/markdown"}, {".wasm", "application/wasm"}, +}; + +static const std::unordered_map kVideoMimeTypes = { + {".mp4", "video/mp4"}, {".webm", "video/webm"}, {".ogg", "video/ogg"}, + {".avi", "video/x-msvideo"}, {".mov", "video/quicktime"}, {".mkv", "video/x-matroska"}, +}; + +static const std::unordered_map kAudioMimeTypes = { + {".mp3", "audio/mpeg"}, {".wav", "audio/wav"}, + {".flac", "audio/flac"}, {".aac", "audio/aac"}, {".m4a", "audio/mp4"}, +}; + +static std::string get_file_mime(const std::string& path) { + std::string ext = fs::path(path).extension().string(); + std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower); + auto it = kFileMimeTypes.find(ext); + return (it != kFileMimeTypes.end()) ? it->second : "application/octet-stream"; +} + +static bool is_video_file(const std::string& path) { + std::string ext = fs::path(path).extension().string(); + std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower); + return kVideoMimeTypes.count(ext) > 0; +} + +static bool is_audio_file(const std::string& path) { + std::string ext = fs::path(path).extension().string(); + std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower); + // .ogg 同时在 video 和 audio 中,这里按 audio 处理时额外检查 + if (kAudioMimeTypes.count(ext) > 0) return true; + // .ogg 默认归 video,但 audio/ogg 也合法 + if (ext == ".ogg") return true; + return false; +} + +static std::string get_media_mime(const std::string& path) { + std::string ext = fs::path(path).extension().string(); + std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower); + auto vit = kVideoMimeTypes.find(ext); + if (vit != kVideoMimeTypes.end()) return vit->second; + auto ait = kAudioMimeTypes.find(ext); + if (ait != kAudioMimeTypes.end()) return ait->second; + if (ext == ".ogg") return "audio/ogg"; + return "application/octet-stream"; +} + +// ============================================================================ +// 路径安全 +// ============================================================================ + +// 路径遍历防护:返回规范化路径,非法时返回空字符串 +static std::string safe_path_join(const std::string& base, const std::string& user_path) { + // 拒绝空字节截断(解码后的 %00) + if (user_path.find('\0') != std::string::npos) return ""; + if (user_path.empty() || user_path == "/" || user_path.find_first_not_of("/ ") == std::string::npos) { + try { return fs::canonical(base).string(); } + catch (...) { return base; } + } + std::string rel = user_path; + if (!rel.empty() && rel[0] == '/') rel = rel.substr(1); + fs::path full = fs::weakly_canonical(fs::path(base) / rel); + fs::path base_canon; + try { base_canon = fs::canonical(base); } + catch (...) { return ""; } + + std::string full_str = full.string(); + std::string base_str = base_canon.string(); + if (full_str != base_str && + full_str.substr(0, base_str.size() + 1) != base_str + "/") { + return ""; // 路径逃逸 + } + return full_str; +} + +// ============================================================================ +// SSRF 防护:检测私有 IP +// ============================================================================ + +static bool is_ip_private(const struct sockaddr* addr) { + if (addr->sa_family == AF_INET) { + auto* sa = reinterpret_cast(addr); + uint32_t ip = ntohl(sa->sin_addr.s_addr); + uint8_t a = (ip >> 24) & 0xFF; + uint8_t b = (ip >> 16) & 0xFF; + // 127.0.0.0/8 + if (a == 127) return true; + // 10.0.0.0/8 + if (a == 10) return true; + // 172.16.0.0/12 + if (a == 172 && b >= 16 && b <= 31) return true; + // 192.168.0.0/16 + if (a == 192 && b == 168) return true; + // 169.254.0.0/16 (link-local) + if (a == 169 && b == 254) return true; + // 0.0.0.0 + if (ip == 0) return true; + // 224.0.0.0/4 (multicast) + if (a >= 224 && a <= 239) return true; + // 240.0.0.0/4 (reserved) + if (a >= 240) return true; + return false; + } else if (addr->sa_family == AF_INET6) { + auto* sa6 = reinterpret_cast(addr); + const uint8_t* b = sa6->sin6_addr.s6_addr; + // ::1 loopback + if (b[0] == 0 && b[1] == 0 && b[14] == 0 && b[15] == 1) + return true; + // fe80::/10 link-local + if (b[0] == 0xfe && (b[1] & 0xc0) == 0x80) return true; + // fc00::/7 unique-local + if ((b[0] & 0xfe) == 0xfc) return true; + // :: unspecified + bool all_zero = true; + for (int i = 0; i < 16; ++i) if (b[i] != 0) { all_zero = false; break; } + if (all_zero) return true; + // ff00::/8 multicast + if (b[0] == 0xff) return true; + return false; + } + return true; // 未知地址族,拒绝 +} + +static bool is_private_host(const std::string& hostname) { + struct addrinfo hints{}, *res = nullptr; + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + int ret = getaddrinfo(hostname.c_str(), nullptr, &hints, &res); + if (ret != 0 || !res) return true; // 解析失败,fail-closed + + bool private_found = false; + for (auto* p = res; p; p = p->ai_next) { + if (is_ip_private(p->ai_addr)) { + private_found = true; + break; + } + } + freeaddrinfo(res); + return private_found; +} + +// ============================================================================ +// 日志 +// ============================================================================ + +struct LogEntry { + std::string ts; + std::string service; + std::string level; + std::string message; + std::string trace_id; + std::string extra; +}; + +// ============================================================================ +// HTTP 响应辅助 +// ============================================================================ + +static http::response make_secured_response( + http::status status, + const std::string& body, + const std::string& content_type, + const http::request& req) +{ + http::response res; + res.result(status); + res.version(req.version()); + res.set(http::field::content_type, content_type); + res.keep_alive(req.keep_alive()); + + // 安全响应头 + res.set("X-Frame-Options", "SAMEORIGIN"); + res.set("X-XSS-Protection", "1; mode=block"); + res.set("X-Content-Type-Options", "nosniff"); + res.set("Referrer-Policy", "strict-origin-when-cross-origin"); + res.set("Permissions-Policy", "camera=(), microphone=(), geolocation=()"); + res.set("Content-Security-Policy", + "default-src 'self'; script-src 'self' 'unsafe-inline'; " + "style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; " + "font-src 'self'; connect-src 'self'; frame-ancestors 'self'"); + + res.body() = body; + res.prepare_payload(); + return res; +} + +static http::response make_json( + http::status status, const std::string& json_str, + const http::request& req) +{ + return make_secured_response(status, json_str, "application/json", req); +} + +static http::response make_json_obj( + http::status status, const json::object& obj, + const http::request& req) +{ + return make_json(status, json::serialize(obj), req); +} + +static http::response make_error( + http::status status, const std::string& msg, + const http::request& req) +{ + json::object obj; + obj["error"] = msg; + return make_json_obj(status, obj, req); +} + +// ============================================================================ +// FsysServer +// ============================================================================ + +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) + { + // 初始化异步日志队列 + _log_queue = std::make_unique>( + [this](std::vector&& batch) { _send_log_batch(std::move(batch)); }, + AsyncQueueConfig{kLogBatchSize, kLogFlushInterval, kLogQueueMax, 0.5}, + [](const std::exception& e, std::vector&&) { + std::fprintf(stderr, "[fsys] 日志队列异常: %s\n", e.what()); + } + ); + _log_queue->start(); + + // 确保 static_root 目录存在 + std::error_code ec; + fs::create_directories(_cfg.static_root, ec); + if (ec) { + std::fprintf(stderr, "[fsys] 警告: 无法创建目录 %s: %s\n", + _cfg.static_root.c_str(), ec.message().c_str()); + _filesystem_enabled = false; + } + + std::printf("[fsys] 配置来源: confs (static.ini @ %s:%d)\n", + _cfg.confs_host.c_str(), _cfg.confs_port); + std::printf("[fsys] 静态目录: %s\n", _cfg.static_root.c_str()); + } + + ~FsysServer() { + if (_log_queue) _log_queue->shutdown(); + } + + void run(); + +private: + Config _cfg; + RpcClient _rpc; // db RPC(retry_on_refused=true) + RpcClient _log_rpc; // 日志写入 + + std::unique_ptr> _log_queue; + bool _filesystem_enabled = true; + + HttpServer* _server = nullptr; + static std::atomic _should_stop; + + // 限速存储 + std::mutex _rate_mutex; + std::unordered_map> _rate_store; + + static void _signal_handler(int) { _should_stop.store(true); } + + // ---- 日志 -------------------------------------------------------------- + + void _log(const std::string& level, const std::string& service, + const std::string& message, const std::string& trace_id = "", + const std::string& extra = "") { + LogEntry e; + e.ts = utc_now(); + e.service = service; + e.level = level; + e.message = (message.size() > kLogMaxMessage) ? message.substr(0, kLogMaxMessage) : message; + e.trace_id = trace_id; + e.extra = (extra.size() > kLogMaxExtra) ? extra.substr(0, kLogMaxExtra) : extra; + if (_log_queue) _log_queue->push(std::move(e)); + } + + void _send_log_batch(std::vector&& batch) { + for (auto& e : batch) { + try { + json::object values; + values["ts"] = e.ts; + values["service"] = e.service; + values["level"] = e.level; + values["message"] = e.message; + values["trace_id"] = e.trace_id.empty() ? nullptr : json::value(e.trace_id); + values["extra"] = e.extra.empty() ? nullptr : json::value(e.extra); + + json::object payload; + payload["action"] = "insert"; + payload["table"] = "Logs"; + payload["values"] = values; + + _log_rpc.call("execute", payload); + } catch (const std::exception& ex) { + std::fprintf(stderr, "[fsys] 日志写入失败: %s\n", ex.what()); + } + } + } + + // ---- 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& 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 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(timestamps.size()) >= max_requests) return false; + timestamps.push_back(now); + return true; + } + + // ---- CORS -------------------------------------------------------------- + + bool _is_allowed_origin(const std::string& origin) const { + for (const auto& o : _cfg.cors_origins) { + if (o == origin) return true; + } + return false; + } + + void _apply_cors(http::response& res, + const http::request& req) { + auto origin_it = req.find(http::field::origin); + if (origin_it != req.end()) { + std::string origin(origin_it->value()); + if (_is_allowed_origin(origin)) { + res.set("Access-Control-Allow-Origin", origin); + res.set("Vary", "Origin"); + } + } + res.set("Access-Control-Allow-Headers", "Content-Type, Authorization"); + res.set("Access-Control-Allow-Methods", "GET, POST, OPTIONS"); + } + + http::response _cors_preflight( + const http::request& req) + { + http::response res; + res.result(http::status::no_content); + res.version(req.version()); + res.keep_alive(req.keep_alive()); + _apply_cors(res, req); + res.prepare_payload(); + return res; + } + + // ---- 路由 handler 声明 ------------------------------------------------- + + http::response _handle_root( + const http::request& req); + http::response _handle_catch_all( + const http::request& req); + http::response _handle_filelist( + const http::request& req); + http::response _handle_video( + const http::request& req); + http::response _handle_hash( + const http::request& req); + http::response _handle_check_user( + const http::request& req); + http::response _handle_favicon( + const http::request& req); + http::response _handle_dnfs_proxy( + const http::request& req, + const std::string& route); + http::response _handle_health( + const http::request& req); +}; + +// 静态成员定义 +std::atomic FsysServer::_should_stop{false}; + +// ============================================================================ +// run() — 注册路由 + 启动服务器 +// ============================================================================ + +void FsysServer::run() { + std::unique_ptr server_ptr; + + if (_cfg.ssl_enabled && fs::exists(_cfg.ssl_cert) && fs::exists(_cfg.ssl_key)) { + server_ptr = std::make_unique( + _cfg.listen_host, static_cast(_cfg.listen_port), + _cfg.ssl_cert, _cfg.ssl_key, + static_cast(_cfg.threads)); + std::printf("[fsys] HTTPS 模式\n"); + } else { + server_ptr = std::make_unique( + _cfg.listen_host, static_cast(_cfg.listen_port), + static_cast(_cfg.threads)); + } + + HttpServer& server = *server_ptr; + server.setMaxBodySize(kMaxBodySize); + + // 注册具体路由(优先于通配符) + server.get("/", [this](const auto& req, const auto&) { return _handle_root(req); }); + server.get("/api/filelist", [this](const auto& req, const auto&) { return _handle_filelist(req); }); + 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&) { + return _handle_dnfs_proxy(req, route); + }; + }; + server.post("/get_notes", dnfs_handler("get_notes")); + server.post("/add_note", dnfs_handler("add_note")); + server.post("/delete_note", dnfs_handler("delete_note")); + server.post("/update_note", dnfs_handler("update_note")); + + // CORS 预检 + auto options_handler = [this](const auto& req, const auto&) { + return _cors_preflight(req); + }; + server.options("/get_notes", options_handler); + server.options("/add_note", options_handler); + server.options("/delete_note", options_handler); + server.options("/update_note", options_handler); + + // 视频流路由(通配符) + server.get("/video/*", [this](const auto& req, const auto&) { return _handle_video(req); }); + + // 通配符路由(静态文件 / 目录)—— 必须最后注册 + server.get("/*", [this](const auto& req, const auto&) { return _handle_catch_all(req); }); + + // 信号处理(优雅关闭) + _server = &server; + std::signal(SIGINT, &FsysServer::_signal_handler); + std::signal(SIGTERM, &FsysServer::_signal_handler); + + std::thread stop_watcher([this]() { + while (!_should_stop.load()) { + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + } + if (_server) _server->stop(); + }); + + _log("INFO", "fsys", "服务启动,监听 " + std::string(_cfg.ssl_enabled ? "https" : "http") + + "://" + _cfg.listen_host + ":" + std::to_string(_cfg.listen_port) + + "(root=" + _cfg.static_root + ")"); + + server.run(); + + _should_stop.store(true); + if (stop_watcher.joinable()) stop_watcher.join(); + + _log("INFO", "fsys", "服务停止"); + if (_log_queue) _log_queue->shutdown(); +} + +// ============================================================================ +// GET / — 根路径 +// ============================================================================ + +http::response FsysServer::_handle_root( + const http::request& req) +{ + try { + if (!_filesystem_enabled) + return make_error(http::status::forbidden, "\u6587\u4ef6\u7cfb\u7edf\u672a\u542f\u7528", req); + + // 优先 index.html + std::string index_path = _cfg.static_root + "/index.html"; + if (fs::exists(index_path)) { + std::ifstream f(index_path, std::ios::binary); + std::string body((std::istreambuf_iterator(f)), + std::istreambuf_iterator()); + return make_secured_response(http::status::ok, body, "text/html", req); + } + + // 回退 __view.html + std::string view_path = _cfg.static_root + "/" + kViewFile; + if (fs::exists(view_path)) { + std::ifstream f(view_path, std::ios::binary); + std::string body((std::istreambuf_iterator(f)), + std::istreambuf_iterator()); + return make_secured_response(http::status::ok, body, "text/html", req); + } + + return make_error(http::status::internal_server_error, + "\u89c6\u56fe\u6587\u4ef6 " + kViewFile + " \u4e0d\u5b58\u5728", req); + } catch (const std::exception& e) { + _log("ERROR", "fsys", std::string("\u6839\u8def\u5f84\u9519\u8bef: ") + e.what()); + return make_error(http::status::internal_server_error, "\u5185\u90e8\u9519\u8bef", req); + } +} + +// ============================================================================ +// GET /* — 静态文件 / 目录浏览 +// ============================================================================ + +http::response FsysServer::_handle_catch_all( + const http::request& req) +{ + try { + if (!_filesystem_enabled) + return make_error(http::status::forbidden, "\u6587\u4ef6\u7cfb\u7edf\u672a\u542f\u7528", req); + + std::string path = extract_path(std::string(req.target())); + if (path.size() > 1 && path[0] == '/') path = path.substr(1); + path = url_decode(path, /*plus_is_space=*/false); // 浏览器/前端 encodeURIComponent + + std::string full_path = safe_path_join(_cfg.static_root, path); + if (full_path.empty()) + return make_error(http::status::forbidden, "\u65e0\u6548\u7684\u8def\u5f84", req); + + if (!fs::exists(full_path)) + return make_error(http::status::not_found, "\u8def\u5f84\u4e0d\u5b58\u5728", req); + + // 文件处理 + if (fs::is_regular_file(full_path)) { + // 大小上限(string_body 全量读入内存,防大文件 DoS) + if (fs::file_size(full_path) > kMaxServeFileSize) + return make_error(http::status::payload_too_large, "\u6587\u4ef6\u8fc7\u5927", req); + + // 下载模式 + auto params = parse_query(std::string(req.target())); + bool download = (params.count("download") && params["download"] == "true"); + + std::ifstream f(full_path, std::ios::binary); + if (!f) return make_error(http::status::internal_server_error, "\u6587\u4ef6\u8bfb\u53d6\u5931\u8d25", req); + std::string body((std::istreambuf_iterator(f)), + std::istreambuf_iterator()); + + std::string mime; + if (is_video_file(full_path) || is_audio_file(full_path)) { + mime = get_media_mime(full_path); + } else { + mime = get_file_mime(full_path); + } + + auto res = make_secured_response(http::status::ok, body, mime, req); + if (download) { + res.set("Content-Disposition", + "attachment; filename=\"" + fs::path(full_path).filename().string() + "\""); + } + return res; + } + + // 目录处理:返回 __view.html + std::string view_path = _cfg.static_root + "/" + kViewFile; + if (fs::exists(view_path)) { + std::ifstream f(view_path, std::ios::binary); + std::string body((std::istreambuf_iterator(f)), + std::istreambuf_iterator()); + return make_secured_response(http::status::ok, body, "text/html", req); + } + + return make_error(http::status::not_found, "\u76ee\u5f55\u65e0\u6cd5\u6d4f\u89c8", req); + + } catch (const std::exception& e) { + _log("ERROR", "fsys", std::string("\u9759\u6001\u6587\u4ef6\u9519\u8bef: ") + e.what()); + return make_error(http::status::internal_server_error, "\u5185\u90e8\u9519\u8bef", req); + } +} + +// ============================================================================ +// GET /api/filelist — 文件列表 +// ============================================================================ + +http::response FsysServer::_handle_filelist( + const http::request& req) +{ + try { + if (!_filesystem_enabled) + return make_error(http::status::forbidden, "\u6587\u4ef6\u7cfb\u7edf\u672a\u542f\u7528", req); + + auto params = parse_query(std::string(req.target())); + std::string requested_path = params.count("path") ? params["path"] : ""; + + std::string full_path = safe_path_join(_cfg.static_root, requested_path); + if (full_path.empty()) + return make_error(http::status::forbidden, "\u65e0\u6548\u7684\u8def\u5f84", req); + + if (!fs::exists(full_path) || !fs::is_directory(full_path)) + return make_error(http::status::not_found, "\u76ee\u5f55\u4e0d\u5b58\u5728", req); + + json::array items; + for (const auto& entry : fs::directory_iterator(full_path)) { + std::string name = entry.path().filename().string(); + // 过滤以 __ 开头的系统文件 + if (name.size() >= 2 && name[0] == '_' && name[1] == '_') continue; + + bool is_dir = entry.is_directory(); + std::string file_type = is_dir ? "dir" : "file"; + if (!is_dir) { + if (is_video_file(entry.path().string())) file_type = "video"; + else if (is_audio_file(entry.path().string())) file_type = "audio"; + } + + std::string rel_path = requested_path.empty() + ? name : requested_path + "/" + name; + + uintmax_t file_size = 0; + std::string modified; + try { + if (!is_dir) file_size = entry.file_size(); + auto ftime = fs::last_write_time(entry); + auto sctp = std::chrono::time_point_cast( + ftime - fs::file_time_type::clock::now() + std::chrono::system_clock::now()); + std::time_t tt = std::chrono::system_clock::to_time_t(sctp); + struct tm tmbuf; + localtime_r(&tt, &tmbuf); + char tbuf[32]; + std::strftime(tbuf, sizeof(tbuf), "%Y-%m-%d %H:%M:%S", &tmbuf); + modified = tbuf; + } catch (...) { modified = ""; } + + json::object item; + item["name"] = name; + item["type"] = file_type; + item["path"] = rel_path; + item["size"] = is_dir ? 0 : static_cast(file_size); + item["size_formatted"] = is_dir ? "" : format_file_size(file_size); + item["modified"] = modified; + items.push_back(item); + } + + // 排序:目录在前,然后按名称 + std::sort(items.begin(), items.end(), [](const json::value& a, const json::value& b) { + const auto& oa = a.as_object(); + const auto& ob = b.as_object(); + bool a_dir = (std::string(oa.at("type").as_string()) == "dir"); + bool b_dir = (std::string(ob.at("type").as_string()) == "dir"); + if (a_dir != b_dir) return a_dir; + std::string na = std::string(oa.at("name").as_string()); + std::string nb = std::string(ob.at("name").as_string()); + std::transform(na.begin(), na.end(), na.begin(), ::tolower); + std::transform(nb.begin(), nb.end(), nb.begin(), ::tolower); + return na < nb; + }); + + // parent_path 计算 + std::string parent_path; + if (!requested_path.empty()) { + auto slash = requested_path.rfind('/'); + parent_path = (slash == std::string::npos) ? "" : requested_path.substr(0, slash); + } + + json::object result; + result["success"] = true; + result["path"] = requested_path.empty() ? "/" : requested_path; + result["parent_path"] = parent_path; + result["items"] = items; + return make_json_obj(http::status::ok, result, req); + + } catch (const FsysExp& e) { + return make_error(static_cast(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); + } +} + +// ============================================================================ +// GET /video/* — 视频/音频流媒体(HTTP Range 206) +// ============================================================================ + +http::response FsysServer::_handle_video( + const http::request& req) +{ + try { + if (!_filesystem_enabled) + return make_error(http::status::forbidden, "\u6587\u4ef6\u7cfb\u7edf\u672a\u542f\u7528", req); + + // 从 target 提取文件路径(去掉 /video/ 前缀) + std::string target = extract_path(std::string(req.target())); + std::string file_path; + if (target.size() > 7) { // "/video/" = 7 chars + file_path = target.substr(7); + } + file_path = url_decode(file_path, /*plus_is_space=*/false); + + std::string full_path = safe_path_join(_cfg.static_root, file_path); + if (full_path.empty() || !fs::exists(full_path) || !fs::is_regular_file(full_path)) + return make_error(http::status::not_found, "\u6587\u4ef6\u4e0d\u5b58\u5728", req); + + if (!is_video_file(full_path) && !is_audio_file(full_path)) + return make_error(http::status::bad_request, "\u4e0d\u652f\u6301\u7684\u6587\u4ef6\u7c7b\u578b", req); + + uintmax_t file_size = fs::file_size(full_path); + std::string mime = get_media_mime(full_path); + + // 检查 Range 头 + auto range_it = req.find(http::field::range); + if (range_it != req.end()) { + std::string range_val(range_it->value()); + // 解析 "bytes=start-end" + if (range_val.rfind("bytes=", 0) != 0) + return make_error(http::status::bad_request, "\u65e0\u6548\u7684 Range \u8bf7\u6c42", req); + + std::string range_spec = range_val.substr(6); + auto dash = range_spec.find('-'); + if (dash == std::string::npos) + return make_error(http::status::bad_request, "\u65e0\u6548\u7684 Range \u8bf7\u6c42", req); + + int64_t start = 0, end = static_cast(file_size) - 1; + try { + std::string s_start = range_spec.substr(0, dash); + std::string s_end = range_spec.substr(dash + 1); + if (!s_start.empty()) start = std::stoll(s_start); + if (!s_end.empty()) end = std::stoll(s_end); + } catch (...) { + return make_error(http::status::bad_request, "\u65e0\u6548\u7684 Range \u8bf7\u6c42", req); + } + + // 边界校验 + if (start < 0 || start >= static_cast(file_size) || + end >= static_cast(file_size) || end < start) { + return make_error(http::status::range_not_satisfiable, + "\u8bf7\u6c42\u8303\u56f4\u8d85\u51fa\u6587\u4ef6\u5927\u5c0f", req); + } + + int64_t length = end - start + 1; + + // 片段大小上限(防 bytes=0- 全量拉取大文件) + if (static_cast(length) > kMaxServeFileSize) + return make_error(http::status::range_not_satisfiable, + "\u8bf7\u6c42\u8303\u56f4\u8fc7\u5927", req); + + // 读取文件片段 + std::ifstream f(full_path, std::ios::binary); + f.seekg(start); + std::string body(length, '\0'); + f.read(body.data(), length); + + auto res = make_secured_response(http::status::partial_content, body, mime, req); + res.set("Content-Range", "bytes " + std::to_string(start) + "-" + + std::to_string(end) + "/" + std::to_string(file_size)); + res.set("Accept-Ranges", "bytes"); + res.set("Cache-Control", "no-cache"); + return res; + } + + // 无 Range:返回完整文件(大文件超限拒绝,浏览器拉取媒体总会带 Range) + if (file_size > kMaxServeFileSize) + return make_error(http::status::payload_too_large, "\u6587\u4ef6\u8fc7\u5927", req); + + std::ifstream f(full_path, std::ios::binary); + std::string body((std::istreambuf_iterator(f)), + std::istreambuf_iterator()); + auto res = make_secured_response(http::status::ok, body, mime, req); + res.set("Accept-Ranges", "bytes"); + return res; + + } catch (const std::exception& e) { + _log("ERROR", "fsys", std::string("视频流传输错误: ") + e.what()); + return make_error(http::status::internal_server_error, "内部错误", req); + } +} + +// ============================================================================ +// POST /api/hash — SHA-256 密码哈希 +// ============================================================================ + +http::response FsysServer::_handle_hash( + const http::request& 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 FsysServer::_handle_check_user( + const http::request& 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(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 防护代理 +// ============================================================================ + +// URL SSRF 校验:仅 http/https + 端口范围 + 私有 IP 检测(fail-closed) +// 返回 0 合法(origin_out = scheme://hostport);1 bad url;2 bad port;3 forbidden host +static int validate_url_ssrf(const std::string& url, std::string& origin_out) { + if (url.rfind("http://", 0) != 0 && url.rfind("https://", 0) != 0) + return 1; + + auto host_start = url.find("://") + 3; + auto host_end = url.find('/', host_start); + std::string hostport = (host_end == std::string::npos) + ? url.substr(host_start) + : url.substr(host_start, host_end - host_start); + + std::string hostname = hostport; + auto colon = hostport.rfind(':'); + if (colon != std::string::npos) { + hostname = hostport.substr(0, colon); + try { + int port = std::stoi(hostport.substr(colon + 1)); + if (port < 1 || port > 65535) return 2; + } catch (...) { + return 2; + } + } + + if (hostname.empty()) return 1; + if (is_private_host(hostname)) return 3; + + origin_out = url.substr(0, host_end == std::string::npos ? url.size() : host_end); + return 0; +} + +// libcurl 写入回调 +static size_t curl_write_cb(char* ptr, size_t size, size_t nmemb, void* userdata) { + auto* buf = static_cast(userdata); + size_t total = size * nmemb; + if (buf->size() + total > kFaviconMaxBytes + 1) return 0; // 中止:超限 + buf->append(ptr, total); + return total; +} + +http::response FsysServer::_handle_favicon( + const http::request& req) +{ + try { + auto params = parse_query(std::string(req.target())); + std::string target_url = params.count("url") ? params["url"] : ""; + + // 初始 URL 校验:仅 http/https + 端口 + 私有 IP(SSRF 防护) + std::string origin; + int v = validate_url_ssrf(target_url, origin); + if (v == 1) return make_error(http::status::bad_request, "bad url", req); + if (v == 2) return make_error(http::status::bad_request, "bad port", req); + if (v == 3) return make_error(http::status::forbidden, "forbidden host", req); + + std::string current_url = origin + "/favicon.ico"; + + // 手动逐跳重定向:每一跳重新校验目标(防 30x 跳转绕过 SSRF 检查, + // 对齐 Python 版 _FaviconRedirectHandler) + std::string response_body; + std::string content_type; + long http_code = 0; + int redirects = 0; + bool ok = false; + + while (true) { + CURL* curl = curl_easy_init(); + if (!curl) return make_error(http::status::bad_gateway, "fetch failed", req); + + response_body.clear(); + curl_easy_setopt(curl, CURLOPT_URL, current_url.c_str()); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, curl_write_cb); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response_body); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, kFaviconTimeout); + curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, kFaviconTimeout); + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 0L); // 手动逐跳校验 + curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "http,https"); + curl_easy_setopt(curl, CURLOPT_USERAGENT, "qsont-favicon-fetcher/1.0"); + curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L); + + CURLcode res_code = curl_easy_perform(curl); + if (res_code != CURLE_OK) { + curl_easy_cleanup(curl); + _log("INFO", "fsys", "favicon 拉取失败: " + std::string(curl_easy_strerror(res_code)) + + " url=" + target_url.substr(0, 128)); + return make_error(http::status::bad_gateway, "fetch failed", req); + } + + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); + + // 3xx:取重定向目标,重新走 SSRF 校验 + if (http_code >= 300 && http_code < 400) { + char* loc = nullptr; + curl_easy_getinfo(curl, CURLINFO_REDIRECT_URL, &loc); + std::string next = loc ? loc : ""; + curl_easy_cleanup(curl); + if (next.empty() || ++redirects > 3) + return make_error(http::status::bad_gateway, "fetch failed", req); + std::string redir_origin; + if (validate_url_ssrf(next, redir_origin) != 0) + return make_error(http::status::forbidden, "forbidden host", req); + current_url = next; + continue; + } + + char* ct = nullptr; + curl_easy_getinfo(curl, CURLINFO_CONTENT_TYPE, &ct); + if (ct) content_type = ct; + curl_easy_cleanup(curl); + ok = true; + break; + } + + if (!ok || http_code != 200) { + return make_error(http::status::bad_gateway, "fetch failed", req); + } + + if (response_body.size() > kFaviconMaxBytes) { + return make_error(http::status::bad_gateway, "favicon too large", req); + } + + // Content-Type 校验:必须是 image/* + if (content_type.empty() || content_type.rfind("image/", 0) != 0) { + content_type = "image/x-icon"; + } + + auto out = make_secured_response(http::status::ok, response_body, content_type, req); + out.set("Cache-Control", "public, max-age=86400"); + return out; + + } catch (const std::exception& e) { + _log("INFO", "fsys", std::string("favicon 异常: ") + e.what()); + return make_error(http::status::bad_gateway, "fetch failed", req); + } +} + +// Cookie 按名精确解析(分号拆分后按名匹配,避免 x_q4_token= 等子串误匹配) +static std::string get_cookie(const std::string& cookies, const std::string& name) { + size_t pos = 0; + while (pos <= cookies.size()) { + auto sc = cookies.find(';', pos); + std::string pair = cookies.substr( + pos, sc == std::string::npos ? std::string::npos : sc - pos); + auto eq = pair.find('='); + if (eq != std::string::npos) { + std::string k = pair.substr(0, eq); + auto b = k.find_first_not_of(" \t"); + auto e = k.find_last_not_of(" \t"); + if (b != std::string::npos) { + k = k.substr(b, e - b + 1); + if (k == name) return pair.substr(eq + 1); + } + } + if (sc == std::string::npos) break; + pos = sc + 1; + } + return ""; +} + +// ============================================================================ +// POST /get_notes 等 — dnfs2 反向代理 +// ============================================================================ + +http::response FsysServer::_handle_dnfs_proxy( + const http::request& req, + const std::string& route) +{ + try { + // 构建转发请求 + HttpClient http; + http.set_max_response_size(kHttpMaxResponse); + http.set_header("Content-Type: application/json"); + + // Authorization 头 或 Cookie→Bearer 转换 + auto auth_it = req.find(http::field::authorization); + if (auth_it != req.end()) { + http.set_header(std::string("Authorization: ") + std::string(auth_it->value())); + } else { + // 检查 Cookie 中的 q4_token(HttpOnly Cookie 模式:Cookie 转 Bearer) + auto cookie_it = req.find(http::field::cookie); + if (cookie_it != req.end()) { + std::string token = get_cookie(std::string(cookie_it->value()), "q4_token"); + if (!token.empty()) { + http.set_header("Authorization: Bearer " + token); + } + } + } + + std::string upstream_url = _cfg.dnfs_url + "/" + route; + std::string response_body; + + http.post_stream_sync(upstream_url, req.body(), + [&response_body](const std::string& chunk) { + response_body += chunk; + }); + + long code = http.get_response_code(); + auto res = make_json(static_cast(code), response_body, req); + _apply_cors(res, req); + return res; + + } catch (const std::exception& e) { + _log("WARNING", "fsys", "dnfs " + route + " 代理失败: " + e.what()); + json::object err; + err["error"] = "upstream unavailable"; + auto res = make_json_obj(http::status::bad_gateway, err, req); + _apply_cors(res, req); + return res; + } +} + +// ============================================================================ +// GET /health — 健康检查 +// ============================================================================ + +http::response FsysServer::_handle_health( + const http::request& req) +{ + json::object obj; + obj["status"] = "ok"; + return make_json_obj(http::status::ok, obj, req); +} + +// ============================================================================ +// main +// ============================================================================ + +int main(int argc, char* argv[]) { + curl_global_init(CURL_GLOBAL_DEFAULT); + + int ret = 0; + try { + Config cfg = load_config(argc, argv); + + std::printf("[fsys] listen=%s:%d threads=%d\n", + cfg.listen_host.c_str(), cfg.listen_port, cfg.threads); + std::printf("[fsys] static_root=%s ssl=%s\n", + cfg.static_root.c_str(), cfg.ssl_enabled ? "on" : "off"); + std::printf("[fsys] db=%s:%d dnfs=%s\n", + cfg.db_host.c_str(), cfg.db_port, cfg.dnfs_url.c_str()); + std::fflush(stdout); + + FsysServer server(std::move(cfg)); + server.run(); + } catch (const std::exception& e) { + std::fprintf(stderr, "[fsys] fatal: %s\n", e.what()); + ret = 1; + } + curl_global_cleanup(); + return ret; +}