/** 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) * 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 #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 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(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); } // ============================================================================ // 配置 // ============================================================================ 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 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()->default_value("fsys"), "实例名") ("log-level", po::value()->default_value("info"), "日志级别") ("addr,a", po::value(), "监听地址") ("port,p", po::value(), "监听端口") ("threads,t", po::value(), "工作线程数") ("root", po::value(), "静态文件根目录") ("ssl-enabled", po::value(), "SSL 开关 (true/false)") ("cert-file", po::value(), "SSL 证书路径") ("key-file", po::value(), "SSL 私钥路径") ("cors-origins", po::value(), "CORS 白名单(逗号分隔)") ("db-host", po::value(), "数据库 RPC 地址") ("db-port", po::value(), "数据库 RPC 端口") ("dnfs-url", po::value(), "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(); if (vm.count("port")) cfg.listen_port = vm["port"].as(); if (vm.count("threads")) cfg.threads = vm["threads"].as(); if (vm.count("root")) cfg.static_root = vm["root"].as(); if (vm.count("ssl-enabled")) { std::string s = vm["ssl-enabled"].as(); cfg.ssl_enabled = (s == "true" || s == "True" || s == "1"); } if (vm.count("cert-file")) cfg.ssl_cert = vm["cert-file"].as(); if (vm.count("key-file")) cfg.ssl_key = vm["key-file"].as(); if (vm.count("cors-origins")) { std::string origins_str = vm["cors-origins"].as(); 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(); if (vm.count("db-port")) cfg.db_port = vm["db-port"].as(); if (vm.count("dnfs-url")) cfg.dnfs_url = vm["dnfs-url"].as(); // 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 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)), _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] 配置来源: 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> _log_queue; bool _filesystem_enabled = true; HttpServer* _server = nullptr; static std::atomic _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&& 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& 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_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); }); // 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 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); } } // ============================================================================ // 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; }