Files
blackbird/fsys/src/fsys.cpp
T
qing 175fa5d244 refactor(fsys): 移除遗留死接口与快捷登录入口
删除无调用方的 /api/check_user(Users 无 email 列,生产恒 500)与
/api/hash(前端早改直传密码给 ca,无鉴权 SHA-256 端点徒增哈希预言机
风险),连带清理 _rpc/_call_db/FsysExp/sha256_hex/限速设施等失去唯一
使用方的死代码;login.html 移除 yhdet 快捷登录按钮及"或"分隔线
(fsys 无 /yhdet_callback,入口本就失效)。同步两处 README。
2026-08-13 08:08:01 +08:00

1310 lines
52 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/** fsys — C++20 静态文件托管服务(对齐 Python static 模块)
*
* 基于 libhttp + libmsg + boost::json 实现,接口与 Python static 完全兼容:
* GET / 首页(index.html 或 __view.html)
* GET /<path> 静态文件 / 目录浏览
* GET /api/filelist?path= 文件列表 JSON
* GET /video/... 视频/音频流媒体(HTTP Range 206)
* 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 日志客户端复用主配置(CLI 传递的 db host/port)
*
* 依赖: libhttp (HttpServer/HttpClient), libmsg (RpcClient/AsyncQueue),
* boost::json, OpenSSL, libcurl
*/
#include <us/HttpServer.hpp>
#include <us/HttpClient.hpp>
#include <us/RpcClient.hpp>
#include <us/AsyncQueue.hpp>
#include <boost/json.hpp>
#include <boost/program_options.hpp>
#include <curl/curl.h>
#include <algorithm>
#include <atomic>
#include <cctype>
#include <chrono>
#include <csignal>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <ctime>
#include <filesystem>
#include <fstream>
#include <functional>
#include <iostream>
#include <map>
#include <memory>
#include <mutex>
#include <optional>
#include <sstream>
#include <string>
#include <thread>
#include <unordered_map>
#include <unordered_set>
#include <vector>
#include <arpa/inet.h>
#include <netdb.h>
#include <sys/socket.h>
#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;
// ============================================================================
// 常量
// ============================================================================
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 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 format_file_size(uintmax_t size) {
double s = static_cast<double>(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<unsigned char>(s[i + 1])) &&
std::isxdigit(static_cast<unsigned char>(s[i + 2]))) {
char hex[3] = {s[i + 1], s[i + 2], '\0'};
out += static_cast<char>(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<std::string, std::string> parse_query(const std::string& target) {
std::unordered_map<std::string, std::string> 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);
}
// ============================================================================
// 配置
// ============================================================================
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<std::string> cors_origins;
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;
// ── 命令行参数 ──
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);
}
// ── 配置加载: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;
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;
// 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>();
// 3. 校验必需配置项
if (cfg.listen_host.empty()) {
std::fprintf(stderr, "[fsys] 缺少必需配置: --addr\n");
std::exit(1);
}
if (cfg.static_root.empty()) {
std::fprintf(stderr, "[fsys] 缺少必需配置: --root\n");
std::exit(1);
}
if (cfg.cors_origins.empty()) {
std::fprintf(stderr, "[fsys] 缺少必需配置: --cors-origins\n");
std::exit(1);
}
// 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);
}
if (cfg.threads < 1 || cfg.threads > 256) {
std::fprintf(stderr, "[fsys] invalid threads: %d (must be 1-256)\n", cfg.threads);
std::exit(1);
}
// 5. 路径规范化: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;
}
// ============================================================================
// MIME 类型映射
// ============================================================================
static const std::unordered_map<std::string, std::string> 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<std::string, std::string> 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<std::string, std::string> 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<const struct sockaddr_in*>(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<const struct sockaddr_in6*>(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<http::string_body> make_secured_response(
http::status status,
const std::string& body,
const std::string& content_type,
const http::request<http::string_body>& req)
{
http::response<http::string_body> 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<http::string_body> make_json(
http::status status, const std::string& json_str,
const http::request<http::string_body>& req)
{
return make_secured_response(status, json_str, "application/json", req);
}
static http::response<http::string_body> make_json_obj(
http::status status, const json::object& obj,
const http::request<http::string_body>& req)
{
return make_json(status, json::serialize(obj), req);
}
static http::response<http::string_body> make_error(
http::status status, const std::string& msg,
const http::request<http::string_body>& 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)),
_log_rpc(_cfg.db_host, _cfg.db_port, "fsys-log", kLogRpcTimeout,
/*retry_on_refused=*/false)
{
// 初始化异步日志队列
_log_queue = std::make_unique<AsyncQueue<LogEntry>>(
[this](std::vector<LogEntry>&& batch) { _send_log_batch(std::move(batch)); },
AsyncQueueConfig{kLogBatchSize, kLogFlushInterval, kLogQueueMax, 0.5},
[](const std::exception& e, std::vector<LogEntry>&&) {
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] 配置来源: CLI > 环境变量 > 默认值\n");
std::printf("[fsys] 静态目录: %s\n", _cfg.static_root.c_str());
}
~FsysServer() {
if (_log_queue) _log_queue->shutdown();
}
void run();
private:
Config _cfg;
RpcClient _log_rpc; // 日志写入
std::unique_ptr<AsyncQueue<LogEntry>> _log_queue;
bool _filesystem_enabled = true;
HttpServer* _server = nullptr;
static std::atomic<bool> _should_stop;
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<LogEntry>&& 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());
}
}
}
// ---- 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<http::string_body>& res,
const http::request<http::string_body>& 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<http::string_body> _cors_preflight(
const http::request<http::string_body>& req)
{
http::response<http::string_body> 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<http::string_body> _handle_root(
const http::request<http::string_body>& req);
http::response<http::string_body> _handle_catch_all(
const http::request<http::string_body>& req);
http::response<http::string_body> _handle_filelist(
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_favicon(
const http::request<http::string_body>& req);
http::response<http::string_body> _handle_dnfs_proxy(
const http::request<http::string_body>& req,
const std::string& route);
http::response<http::string_body> _handle_health(
const http::request<http::string_body>& req);
};
// 静态成员定义
std::atomic<bool> FsysServer::_should_stop{false};
// ============================================================================
// run() — 注册路由 + 启动服务器
// ============================================================================
void FsysServer::run() {
std::unique_ptr<HttpServer> server_ptr;
if (_cfg.ssl_enabled && fs::exists(_cfg.ssl_cert) && fs::exists(_cfg.ssl_key)) {
server_ptr = std::make_unique<HttpServer>(
_cfg.listen_host, static_cast<unsigned short>(_cfg.listen_port),
_cfg.ssl_cert, _cfg.ssl_key,
static_cast<unsigned int>(_cfg.threads));
std::printf("[fsys] HTTPS 模式\n");
} else {
server_ptr = std::make_unique<HttpServer>(
_cfg.listen_host, static_cast<unsigned short>(_cfg.listen_port),
static_cast<unsigned int>(_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); });
// 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<http::string_body> FsysServer::_handle_root(
const http::request<http::string_body>& 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<char>(f)),
std::istreambuf_iterator<char>());
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<char>(f)),
std::istreambuf_iterator<char>());
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<http::string_body> FsysServer::_handle_catch_all(
const http::request<http::string_body>& 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<char>(f)),
std::istreambuf_iterator<char>());
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<char>(f)),
std::istreambuf_iterator<char>());
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<http::string_body> FsysServer::_handle_filelist(
const http::request<http::string_body>& 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<std::chrono::system_clock::duration>(
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<int64_t>(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 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<http::string_body> FsysServer::_handle_video(
const http::request<http::string_body>& 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<int64_t>(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<int64_t>(file_size) ||
end >= static_cast<int64_t>(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<uintmax_t>(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<char>(f)),
std::istreambuf_iterator<char>());
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);
}
}
// ============================================================================
// 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<std::string*>(userdata);
size_t total = size * nmemb;
if (buf->size() + total > kFaviconMaxBytes + 1) return 0; // 中止:超限
buf->append(ptr, total);
return total;
}
http::response<http::string_body> FsysServer::_handle_favicon(
const http::request<http::string_body>& 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<http::string_body> FsysServer::_handle_dnfs_proxy(
const http::request<http::string_body>& 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<http::status>(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<http::string_body> FsysServer::_handle_health(
const http::request<http::string_body>& 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;
}