删除无调用方的 /api/check_user(Users 无 email 列,生产恒 500)与 /api/hash(前端早改直传密码给 ca,无鉴权 SHA-256 端点徒增哈希预言机 风险),连带清理 _rpc/_call_db/FsysExp/sha256_hex/限速设施等失去唯一 使用方的死代码;login.html 移除 yhdet 快捷登录按钮及"或"分隔线 (fsys 无 /yhdet_callback,入口本就失效)。同步两处 README。
1310 lines
52 KiB
C++
1310 lines
52 KiB
C++
/** 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;
|
||
}
|