This commit is contained in:
qing committed 2026-08-07 13:04:21 +08:00
commit 7f358faaf4
7 files changed
+813 -366

No files matched your search

+1
View File
@@ -172,6 +172,7 @@
| [libhttp](libhttp/) | HTTP/WebSocket 库:`HttpServer`(含 WebSocket 服务/代理)、`HttpClient`(含 WebSocket 客户端,基于 Boost.Beast) |
| [libcrypt](libcrypt/) | 加密库:`AES_CBC`、`MyRSA`、`SHA256`、`Base64`、`Crypto_Basic` |
| [libsqlite3](libsqlite3/) | SQLite3 C++ 封装(Header-only) |
| [libmsg](libmsg/) | 消息通信库:pymsg 协议 C++ 实现(4B 大端长度前缀 + UTF-8 载荷),提供 `MsgClient`/`MsgServer`/`AsyncQueue`/`RpcClient`/`RpcServer`,与 Python pymsg 字节级兼容 |
| [libtry](libtry/) | 嵌入式脚本解释器(~741 行 C),手写递归下降词法/语法/执行引擎,支持变量/数组/函数/控制流,宽字符(中文标识符),用于嵌入式操作界面 shell |
### Python 共享组件
+69
View File
@@ -0,0 +1,69 @@
# Prerequisites
*.d
# Compiled Object files
*.slo
*.lo
*.o
*.obj
# Precompiled Headers
*.gch
*.pch
# Linker files
*.ilk
# Debugger Files
*.pdb
# Compiled Dynamic libraries
*.so
*.dylib
*.dll
*.so.*
# Fortran module files
*.mod
*.smod
# Compiled Static libraries
*.lai
*.la
*.a
*.lib
# Executables
*.exe
*.out
*.app
# Build directories
build/
Build/
build-*/
# CMake generated files
CMakeFiles/
CMakeCache.txt
cmake_install.cmake
#Makefile
install_manifest.txt
compile_commands.json
# Temporary files
*.tmp
*.log
*.bak
*.swp
# vcpkg
vcpkg_installed/
# debug information files
*.dwo
# test output & cache
Testing/
.cache/
BIN
View File
Binary file not shown.
+432 -341
View File
@@ -28,7 +28,7 @@ using namespace qing;
namespace json = boost::json;
namespace po = boost::program_options;
constexpr const char *_VER = "0.7.0";
constexpr const char *_VER = "0.8.0";
//////////////////////////////////
#include <sstream>
@@ -341,6 +341,43 @@ bool is_special_header(const std::string &header);
/* 请求路径安全性校验(防路径遍历/协议注入/空字节) */
bool is_safe_target(const std::string& target);
/* ===== Token 吊销机制 ===== */
// Token 验签结果结构体
struct TokenInfo {
bool valid = false;
std::string username;
std::string jti; // Token 唯一标识(用于单 Token 吊销)
int ver = 1; // 用户 Token 版本号(用于全设备吊销)
bool is_admin = false;
int keep_time = 3600;
int refresh_count = 0;
int max_refresh = 10;
std::string iat;
std::string created_at;
int http_status = 401; // 验签失败时的推荐 HTTP 状态码
};
// 生成 128-bit 随机 JTI(hex 字符串,32 字符)
std::string generate_jti();
// 获取用户当前 Token 版本号(不存在则初始化为 1)
int get_user_ver(const std::string& username);
// 吊销单个 Token(将 jti 加入黑名单直到其过期)
void revoke_jti(const std::string& jti, int keep_time);
// 吊销用户所有 Token(递增版本号,使该用户所有旧 Token 失效)
void revoke_all_user_tokens(const std::string& username);
// 从请求中提取原始 Token 字符串(Authorization: Bearer 优先,回退 Cookie q4_token)
// 返回值:原始 "base64_signature" 格式字符串;提取失败返回空串
// out_cookie_token:如果 Token 来自 Cookie,回填 cookie_token 值(供下游 Authorization 注入)
std::string extract_token_from_request(const http::request<http::string_body>& req, std::string& out_cookie_token);
// 统一 Token 验签:RSA 验签 + 过期检查 + jti 黑名单检查 + 版本号检查
// 参数 raw_token:"base64data_signature" 格式(不含 "Bearer " 前缀)
TokenInfo verify_token(const std::string& raw_token);
/* configure items */
static std::string _ADDR;
static int _PORT;
@@ -403,6 +440,26 @@ struct CaptchaEntry {
static std::unordered_map<std::string, CaptchaEntry> _CAPTCHA_STORE;
static std::mutex _CAPTCHA_MTX;
/* Token 吊销机制(P1:Token 吊销缺失修复)
*
* 双轨纯内存方案:
* 1. jti_denylist:单 Token 吊销(logout),key=jti,val=过期时间(steady_clock),到期自动清理
* 2. user_ver_map:用户级版本号(改密/封禁),key=username,val=当前版本号,签发新 Token 时读取
*
* 设计优势:
* - 零 DB 改动:黑名单和版本号全在 gateway 进程内存中
* - 重启自动清空:gateway 每次重启 RSA 密钥对重新生成,旧 Token 签名天然验签失败
* - O(1) 验签:unordered_set/map 查找均摊 O(1),对代理转发性能无影响
* - 内存有界:jti_denylist 定期清理过期条目;user_ver_map 按需清理
*/
struct JtiDenylistEntry {
std::chrono::steady_clock::time_point expire_at;
};
static std::unordered_map<std::string, JtiDenylistEntry> _JTI_DENYLIST;
static std::unordered_map<std::string, int> _USER_VER_MAP;
static std::mutex _TOKEN_REVOKE_MTX;
static constexpr size_t _JTI_DENYLIST_MAX_SIZE = 5000; // 超过此阈值触发批量清理
/* 集中式日志客户端:推送 gateway 运行日志到 db 模块 Logs 表(异步批量,fail-safe) */
static inline LogC& logs() { return LogC::instance(); }
@@ -478,6 +535,230 @@ bool verify_email_code_remote(const std::string& email, const std::string& code)
}
}
/* ===== Token 吊销机制实现 ===== */
std::string generate_jti() {
unsigned char jti_bytes[16];
RAND_bytes(jti_bytes, 16);
std::string jti;
static const char hex[] = "0123456789abcdef";
jti.reserve(32);
for (int i = 0; i < 16; i++) {
jti += hex[jti_bytes[i] >> 4];
jti += hex[jti_bytes[i] & 0x0F];
}
return jti;
}
int get_user_ver(const std::string& username) {
std::lock_guard<std::mutex> lock(_TOKEN_REVOKE_MTX);
auto it = _USER_VER_MAP.find(username);
if (it == _USER_VER_MAP.end()) {
_USER_VER_MAP[username] = 1;
return 1;
}
return it->second;
}
void revoke_jti(const std::string& jti, int keep_time) {
if (jti.empty()) return;
std::lock_guard<std::mutex> lock(_TOKEN_REVOKE_MTX);
auto expire = std::chrono::steady_clock::now() + std::chrono::seconds(keep_time);
_JTI_DENYLIST[jti] = {expire};
// 超过阈值时批量清理过期条目
if (_JTI_DENYLIST.size() > _JTI_DENYLIST_MAX_SIZE) {
auto now = std::chrono::steady_clock::now();
for (auto it = _JTI_DENYLIST.begin(); it != _JTI_DENYLIST.end(); ) {
if (now > it->second.expire_at)
it = _JTI_DENYLIST.erase(it);
else
++it;
}
}
}
void revoke_all_user_tokens(const std::string& username) {
if (username.empty()) return;
std::lock_guard<std::mutex> lock(_TOKEN_REVOKE_MTX);
auto it = _USER_VER_MAP.find(username);
if (it == _USER_VER_MAP.end()) {
_USER_VER_MAP[username] = 2; // 初始版本1,吊销后为2
} else {
it->second++;
}
logs().info("gateway", "用户所有Token已吊销: " + username, std::nullopt,
json::serialize(json::value{{"new_ver", _USER_VER_MAP[username]}}));
}
std::string extract_token_from_request(const http::request<http::string_body>& req, std::string& out_cookie_token) {
out_cookie_token.clear();
std::string raw;
// 1. 优先从 Authorization: Bearer 头获取
auto it_auth = req.base().find("Authorization");
if (it_auth != req.base().end()) {
raw = std::string(it_auth->value());
}
// 2. 回退从 Cookie q4_token 获取
if (raw.empty()) {
auto cit = req.base().find("Cookie");
if (cit != req.base().end()) {
std::string cookie_token = get_cookie_value(std::string(cit->value()), "q4_token");
if (!cookie_token.empty()) {
raw = "Bearer " + cookie_token;
out_cookie_token = cookie_token;
}
}
}
// 去掉 "Bearer " 前缀
if (raw.size() >= 7 && raw.substr(0, 7) == "Bearer ") {
return raw.substr(7);
}
return raw; // 可能为空或不含前缀,由调用方处理
}
// 清理 denylist 中指定 jti(如果已过期则删除)
static void cleanup_jti_if_expired(const std::string& jti) {
auto now = std::chrono::steady_clock::now();
auto it = _JTI_DENYLIST.find(jti);
if (it != _JTI_DENYLIST.end() && now > it->second.expire_at) {
_JTI_DENYLIST.erase(it);
}
}
TokenInfo verify_token(const std::string& raw_token) {
TokenInfo info;
info.valid = false;
if (raw_token.empty()) {
info.http_status = 401;
return info;
}
// 解析 base64data_signature
size_t idx = raw_token.find("_");
if (idx == std::string::npos) {
info.http_status = 400;
return info;
}
std::string data = raw_token.substr(0, idx);
std::string sign = raw_token.substr(idx + 1);
std::string auth;
try {
auth = Base64::base64_decode_to_string(data);
} catch (const std::exception&) {
info.http_status = 400;
return info;
}
// RSA 验签
auto publ = MyRSA::Public_Key::from_pem(_KEYGEN->get_public_key_pem());
if (!publ.Verify(auth, sign)) {
info.http_status = 401;
return info;
}
// 解析 payload
json::value jv_t;
try {
jv_t = json::parse(auth);
} catch (const std::exception&) {
info.http_status = 400;
return info;
}
if (!jv_t.is_object()) {
info.http_status = 400;
return info;
}
json::object& obj = jv_t.as_object();
// 提取字段(兼容旧 Token 缺失 jti/ver 字段的情况)
try {
info.username = json::value_to<std::string>(obj.at("user"));
} catch (...) {
info.http_status = 400;
return info;
}
// 过期检查
auto it_created = obj.find("created_at");
if (it_created != obj.end() && it_created->value().is_string()) {
info.created_at = json::value_to<std::string>(it_created->value());
}
auto it_keep = obj.find("keep_time");
if (it_keep != obj.end() && it_keep->value().is_int64()) {
info.keep_time = static_cast<int>(it_keep->value().as_int64());
}
if (!info.created_at.empty()) {
auto created_tp = LocalTime::parse(info.created_at);
if (created_tp) {
auto now = std::chrono::system_clock::now();
auto expire_tp = *created_tp + std::chrono::seconds(info.keep_time);
if (now > expire_tp) {
info.http_status = 401;
return info;
}
}
}
// is_admin
auto it_admin = obj.find("is_admin");
if (it_admin != obj.end() && it_admin->value().is_bool()) {
info.is_admin = it_admin->value().as_bool();
}
// refresh_count / max_refresh / iat
auto it_rc = obj.find("refresh_count");
if (it_rc != obj.end() && it_rc->value().is_int64())
info.refresh_count = static_cast<int>(it_rc->value().as_int64());
auto it_mr = obj.find("max_refresh");
if (it_mr != obj.end() && it_mr->value().is_int64())
info.max_refresh = static_cast<int>(it_mr->value().as_int64());
auto it_iat = obj.find("iat");
if (it_iat != obj.end() && it_iat->value().is_string())
info.iat = json::value_to<std::string>(it_iat->value());
// jti 黑名单检查(单 Token 吊销)
auto it_jti = obj.find("jti");
if (it_jti != obj.end() && it_jti->value().is_string()) {
info.jti = json::value_to<std::string>(it_jti->value());
std::lock_guard<std::mutex> lock(_TOKEN_REVOKE_MTX);
cleanup_jti_if_expired(info.jti); // 惰性清理
if (_JTI_DENYLIST.count(info.jti)) {
info.http_status = 401;
return info;
}
}
// 版本号检查(全设备吊销:改密后版本号递增)
// 兼容旧 Token 无 ver 字段:视为 ver=1
auto it_ver = obj.find("ver");
int token_ver = 1;
if (it_ver != obj.end() && it_ver->value().is_int64()) {
token_ver = static_cast<int>(it_ver->value().as_int64());
}
info.ver = token_ver;
{
std::lock_guard<std::mutex> lock(_TOKEN_REVOKE_MTX);
auto it_uv = _USER_VER_MAP.find(info.username);
int current_ver = (it_uv != _USER_VER_MAP.end()) ? it_uv->second : 1;
if (token_ver < current_ver) {
info.http_status = 401;
return info;
}
}
// 全部通过
info.valid = true;
info.http_status = 200;
return info;
}
/* -------- 信号处理回调函数 -------- */
void signalHandler(int signum) {
@@ -757,66 +1038,23 @@ static bool verifyWsAdminToken(const http::request<http::string_body>& req,
std::string& out_target) {
out_target.clear();
try {
std::string raw;
auto it = req.base().find(http::field::authorization);
if (it != req.base().end()) {
raw = std::string(it->value());
} else {
auto cit = req.base().find(http::field::cookie);
if (cit != req.base().end()) {
std::string cookie_token = get_cookie_value(std::string(cit->value()), "q4_token");
if (!cookie_token.empty())
raw = "Bearer " + cookie_token;
}
}
if (raw.empty() || raw.substr(0, 7) != "Bearer ")
return false;
/* 提取 Token(Authorization 优先,回退 Cookie) */
std::string cookie_token;
std::string raw_token = extract_token_from_request(req, cookie_token);
if (raw_token.empty()) return false;
raw = raw.substr(7);
size_t idx = raw.find("_");
if (idx == std::string::npos) return false;
std::string data = raw.substr(0, idx);
std::string sign = raw.substr(idx + 1);
std::string auth = Base64::base64_decode_to_string(data);
auto publ = MyRSA::Public_Key::from_pem(_KEYGEN->get_public_key_pem());
if (!publ.Verify(auth, sign)) return false;
json::value jv_t;
try { jv_t = json::parse(auth); }
catch (...) { return false; }
if (!jv_t.is_object()) return false;
json::object& obj = jv_t.as_object();
auto t = json::value_to<std::string>(obj["created_at"]);
auto keep_time_val = json::value_to<int>(obj["keep_time"]);
auto created_tp = LocalTime::parse(t);
if (created_tp) {
auto now = std::chrono::system_clock::now();
auto expire_tp = *created_tp + std::chrono::seconds(keep_time_val);
if (now > expire_tp) return false;
}
/* 统一验签(RSA + 过期 + jti黑名单 + 版本号) */
TokenInfo ti = verify_token(raw_token);
if (!ti.valid) return false;
// 必须是管理员
bool is_admin = false;
if (obj.contains("is_admin")) {
auto& v = obj["is_admin"];
if (v.is_bool()) is_admin = v.as_bool();
}
if (!is_admin) {
if (!ti.is_admin) {
logs().warning("gateway", "WS proxy rejected: non-admin user",
std::nullopt, std::string(req.target()));
return false;
}
out_target = target_url;
// 将客户端请求路径附加到目标URL后(支持子路径)
std::string req_path = std::string(req.target());
auto qpos = req_path.find('?');
if (qpos != std::string::npos) req_path = req_path.substr(0, qpos);
// 不追加路径:代理直接使用配置中指定的完整目标URL
(void)req_path;
return true;
}
catch (const std::exception& e) {
@@ -1150,8 +1388,14 @@ http::response<http::string_body> handleLogin(
* 下游服务(dnfs 等)直接读取该字段豁免所有权校验,无需各自维护名单 */
bool is_admin = std::find(_ADMIN_USERS.begin(), _ADMIN_USERS.end(), username) != _ADMIN_USERS.end();
/* Token 吊销支持:签发新 jti + 当前版本号 */
std::string jti = generate_jti();
int user_ver = get_user_ver(username);
json::value jv_t = {
{"user", username},
{"jti", jti},
{"ver", user_ver},
{"iat", LocalTime::getCurrent()},
{"created_at", LocalTime::getCurrent()},
{"keep_time", _TOKEN_KEEP_TIME},
@@ -1191,7 +1435,7 @@ http::response<http::string_body> handleLogin(
return res;
}
/* 退出登录 /logout:清除 q4_token Cookie */
/* 退出登录 /logout:吊销当前 Token(加入 jti 黑名单)并清除 q4_token Cookie */
http::response<http::string_body> handleLogout(
const http::request<http::string_body>& req,
const std::unordered_map<std::string, std::string>& params)
@@ -1199,6 +1443,18 @@ http::response<http::string_body> handleLogout(
http::response<http::string_body> res;
res.version(req.version());
/* 提取并吊销当前 Token:将 jti 加入黑名单(单设备退出) */
std::string cookie_token;
std::string raw_token = extract_token_from_request(req, cookie_token);
if (!raw_token.empty()) {
TokenInfo ti = verify_token(raw_token);
if (ti.valid && !ti.jti.empty()) {
revoke_jti(ti.jti, ti.keep_time);
logs().info("gateway", "Token 已吊销: " + ti.username, std::nullopt,
json::serialize(json::value{{"jti_prefix", ti.jti.substr(0, 8)}}));
}
}
res.set("Set-Cookie",
"q4_token=; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=0");
res.set("Connection", "close");
@@ -1220,114 +1476,40 @@ http::response<http::string_body> handleWhoami(
http::response<http::string_body> res;
res.version(req.version());
auto _PUBL = MyRSA::Public_Key::from_pem(_KEYGEN->get_public_key_pem());
res.set(http::field::content_type, "application/json");
res.set("Connection", "close");
/* 获取 Token:Authorization 头优先,回退 Cookie */
std::string raw;
auto it_auth = req.base().find("Authorization");
if (it_auth != req.base().end()) {
raw = std::string(it_auth->value());
} else {
auto cit = req.base().find("Cookie");
if (cit != req.base().end()) {
std::string cookie_token = get_cookie_value(std::string(cit->value()), "q4_token");
if (!cookie_token.empty())
raw = "Bearer " + cookie_token;
}
}
/* 提取 Token(Authorization 优先,回退 Cookie) */
std::string cookie_token;
std::string raw_token = extract_token_from_request(req, cookie_token);
/* 无 Token 或格式错误:返回未登录 */
if (raw.empty() || raw.substr(0, 7) != "Bearer ") {
res.set(http::field::content_type, "application/json");
res.set("Connection", "close");
if (raw_token.empty()) {
res.result(http::status::ok);
res.body() = R"({"login": false})";
res.prepare_payload();
return res;
}
raw = raw.substr(7);
size_t idx = raw.find("_");
if (idx == std::string::npos) {
res.set(http::field::content_type, "application/json");
res.set("Connection", "close");
/* 统一验签(RSA + 过期 + jti黑名单 + 版本号) */
TokenInfo ti = verify_token(raw_token);
if (!ti.valid) {
res.result(http::status::ok);
res.body() = R"({"login": false})";
res.prepare_payload();
return res;
}
std::string data = raw.substr(0, idx);
std::string sign = raw.substr(idx + 1);
std::string auth;
try { auth = Base64::base64_decode_to_string(data); }
catch (const std::exception&) {
res.set(http::field::content_type, "application/json");
res.set("Connection", "close");
res.result(http::status::ok);
res.body() = R"({"login": false})";
res.prepare_payload();
return res;
}
/* RSA 验签:失败按未登录处理 */
if (!_PUBL.Verify(auth, sign)) {
res.set(http::field::content_type, "application/json");
res.set("Connection", "close");
res.result(http::status::ok);
res.body() = R"({"login": false})";
res.prepare_payload();
return res;
}
/* 解析 Token payload */
json::value jv_t;
try { jv_t = json::parse(auth); }
catch (const std::exception&) {
res.set(http::field::content_type, "application/json");
res.set("Connection", "close");
res.result(http::status::ok);
res.body() = R"({"login": false})";
res.prepare_payload();
return res;
}
if (!jv_t.is_object()) {
res.set(http::field::content_type, "application/json");
res.set("Connection", "close");
res.result(http::status::ok);
res.body() = R"({"login": false})";
res.prepare_payload();
return res;
}
json::object& obj = jv_t.as_object();
std::string username = json::value_to<std::string>(obj.at("user"));
/* is_admin:旧 Token 未携带时默认为 false */
bool is_admin = false;
auto it_admin = obj.find("is_admin");
if (it_admin != obj.end() && it_admin->value().is_bool())
is_admin = it_admin->value().as_bool();
json::value jv_r = {
{"login", true},
{"user", username},
{"is_admin", is_admin}
{"user", ti.username},
{"is_admin", ti.is_admin}
};
/* 附加 Token 生命周期信息,供前端 Token 管理器估算过期时间 */
auto it_iat = obj.find("iat");
if (it_iat != obj.end() && it_iat->value().is_string())
jv_r.as_object()["iat"] = it_iat->value();
auto it_created = obj.find("created_at");
if (it_created != obj.end() && it_created->value().is_string())
jv_r.as_object()["created_at"] = it_created->value();
auto it_keep = obj.find("keep_time");
if (it_keep != obj.end() && it_keep->value().is_int64())
jv_r.as_object()["keep_time"] = it_keep->value();
if (!ti.iat.empty()) jv_r.as_object()["iat"] = ti.iat;
if (!ti.created_at.empty()) jv_r.as_object()["created_at"] = ti.created_at;
if (ti.keep_time > 0) jv_r.as_object()["keep_time"] = ti.keep_time;
res.set(http::field::content_type, "application/json");
res.set("Connection", "close");
res.result(http::status::ok);
res.body() = json::serialize(jv_r);
res.prepare_payload();
@@ -1354,105 +1536,30 @@ http::response<http::string_body> handleAdminVerify(
res.set(http::field::content_type, "application/json");
res.set("Connection", "close");
auto _PUBL = MyRSA::Public_Key::from_pem(_KEYGEN->get_public_key_pem());
/* 提取 Token(Authorization 优先,回退 Cookie) */
std::string cookie_token;
std::string raw_token = extract_token_from_request(req, cookie_token);
/* 获取 Token:Authorization 头优先,回退 Cookie */
std::string raw;
auto it_auth = req.base().find("Authorization");
if (it_auth != req.base().end()) {
raw = std::string(it_auth->value());
} else {
auto cit = req.base().find("Cookie");
if (cit != req.base().end()) {
std::string cookie_token = get_cookie_value(std::string(cit->value()), "q4_token");
if (!cookie_token.empty())
raw = "Bearer " + cookie_token;
}
}
/* 无 Token 或格式错误:按未授权处理 */
if (raw.empty() || raw.substr(0, 7) != "Bearer ") {
if (raw_token.empty()) {
res.result(http::status::unauthorized);
res.body() = R"({"success": false, "is_admin": false})";
res.prepare_payload();
return res;
}
raw = raw.substr(7);
size_t idx = raw.find("_");
if (idx == std::string::npos) {
/* 统一验签(RSA + 过期 + jti黑名单 + 版本号) */
TokenInfo ti = verify_token(raw_token);
if (!ti.valid) {
res.result(http::status::unauthorized);
res.body() = R"({"success": false, "is_admin": false})";
res.prepare_payload();
return res;
}
std::string data = raw.substr(0, idx);
std::string sign = raw.substr(idx + 1);
std::string auth;
try { auth = Base64::base64_decode_to_string(data); }
catch (const std::exception&) {
res.result(http::status::unauthorized);
res.body() = R"({"success": false, "is_admin": false})";
res.prepare_payload();
return res;
}
/* RSA 验签:失败按未授权处理 */
if (!_PUBL.Verify(auth, sign)) {
res.result(http::status::unauthorized);
res.body() = R"({"success": false, "is_admin": false})";
res.prepare_payload();
return res;
}
/* 解析 Token payload */
json::value jv_t;
try { jv_t = json::parse(auth); }
catch (const std::exception&) {
res.result(http::status::unauthorized);
res.body() = R"({"success": false, "is_admin": false})";
res.prepare_payload();
return res;
}
if (!jv_t.is_object()) {
res.result(http::status::unauthorized);
res.body() = R"({"success": false, "is_admin": false})";
res.prepare_payload();
return res;
}
json::object& obj = jv_t.as_object();
/* 过期检查:created_at + keep_time 超过当前时间按无效处理 */
auto t = json::value_to<std::string>(obj["created_at"]);
auto keep_time_val = json::value_to<int>(obj["keep_time"]);
auto created_tp = LocalTime::parse(t);
if (created_tp) {
auto now = std::chrono::system_clock::now();
auto expire_tp = *created_tp + std::chrono::seconds(keep_time_val);
if (now > expire_tp) {
res.result(http::status::unauthorized);
res.body() = R"({"success": false, "is_admin": false})";
res.prepare_payload();
return res;
}
}
/* 管理员判定:旧 Token 未携带 is_admin 时默认为 false */
bool is_admin = false;
if (obj.contains("is_admin")) {
auto& v = obj["is_admin"];
if (v.is_bool()) is_admin = v.as_bool();
}
std::string username;
try { username = json::value_to<std::string>(obj["user"]); }
catch (...) {}
json::value jv_r = {
{"success", true},
{"is_admin", is_admin},
{"user", username}
{"is_admin", ti.is_admin},
{"user", ti.username}
};
res.result(http::status::ok);
res.body() = json::serialize(jv_r);
@@ -1489,59 +1596,84 @@ http::response<http::string_body> handleRefreshToken(
THROW_EX_CUSTOM(HttpException, 4001, "Too many refresh attempts. Please try again later.",
429, "/refresh_token");
auto _PUBL = MyRSA::Public_Key::from_pem(_KEYGEN->get_public_key_pem());
/* 获取并验证 Token(含 jti 黑名单 + 版本号检查) */
std::string cookie_token;
std::string raw_token = extract_token_from_request(req, cookie_token);
if (raw_token.empty())
THROW_EX_CUSTOM(HttpException, 4002, "No token provided", 400, "/refresh_token");
/* 获取 Token:Authorization 头优先,回退 Cookie */
std::string raw;
auto it_auth = req.base().find("Authorization");
if (it_auth != req.base().end()) {
raw = std::string(it_auth->value());
} else {
auto cit = req.base().find("Cookie");
if (cit != req.base().end()) {
std::string cookie_token = get_cookie_value(std::string(cit->value()), "q4_token");
if (!cookie_token.empty())
raw = "Bearer " + cookie_token;
/* 正常过期(非吊销/版本错误)才允许宽限期刷新:
* - jti 在黑名单(logout/refresh后旧token)→ 401 拒绝
* - 版本号落后(改密后旧token)→ 401 拒绝
* 注意:verify_token 不区分"过期"和"吊销",统一返回 valid=false。
* 我们需要区分"正常过期可刷新"和"被吊销不可刷新"。
* 策略:先做 RSA+黑名单+版本号检查(通过则可刷新),过期检查在后面宽限期逻辑中处理。 */
// 重新做一次不含过期检查的验签(判断是否被吊销)
bool token_revoked = false;
std::string old_jti;
std::string username;
std::string iat_str;
int keep_time = _TOKEN_KEEP_TIME;
int refresh_count = 0;
int max_refresh = _TOKEN_MAX_REFRESH;
bool is_admin = false;
int user_ver = 1;
{
if (raw_token.find("_") == std::string::npos)
THROW_EX_CUSTOM(HttpException, 4002, "Invalid token format", 400, "/refresh_token");
size_t idx2 = raw_token.find("_");
std::string data = raw_token.substr(0, idx2);
std::string sign = raw_token.substr(idx2 + 1);
std::string auth;
try { auth = Base64::base64_decode_to_string(data); }
catch (const std::exception&) {
THROW_EX_CUSTOM(HttpException, 4002, "Invalid token format", 400, "/refresh_token");
}
auto _PUBL = MyRSA::Public_Key::from_pem(_KEYGEN->get_public_key_pem());
if (!_PUBL.Verify(auth, sign))
THROW_EX_CUSTOM(HttpException, 4003, "Invalid token signature", 401, "/refresh_token");
json::value jv_t;
try { jv_t = json::parse(auth); }
catch (const std::exception&) {
THROW_EX_CUSTOM(HttpException, 4004, "Bad token payload", 400, "/refresh_token");
}
if (!jv_t.is_object())
THROW_EX_CUSTOM(HttpException, 4004, "Bad token payload", 400, "/refresh_token");
json::object& obj = jv_t.as_object();
username = json::value_to<std::string>(obj.at("user"));
iat_str = json::value_to<std::string>(obj.at("iat"));
keep_time = json::value_to<int>(obj.at("keep_time"));
refresh_count = json::value_to<int>(obj.at("refresh_count"));
max_refresh = json::value_to<int>(obj.at("max_refresh"));
auto it_admin = obj.find("is_admin");
if (it_admin != obj.end() && it_admin->value().is_bool())
is_admin = it_admin->value().as_bool();
// 检查 jti 黑名单
auto it_jti = obj.find("jti");
if (it_jti != obj.end() && it_jti->value().is_string()) {
old_jti = json::value_to<std::string>(it_jti->value());
std::lock_guard<std::mutex> lock(_TOKEN_REVOKE_MTX);
if (_JTI_DENYLIST.count(old_jti))
token_revoked = true;
}
// 检查版本号
auto it_ver = obj.find("ver");
int token_ver = 1;
if (it_ver != obj.end() && it_ver->value().is_int64())
token_ver = static_cast<int>(it_ver->value().as_int64());
{
std::lock_guard<std::mutex> lock(_TOKEN_REVOKE_MTX);
auto it_uv = _USER_VER_MAP.find(username);
user_ver = (it_uv != _USER_VER_MAP.end()) ? it_uv->second : 1;
if (token_ver < user_ver)
token_revoked = true;
}
}
if (raw.empty())
THROW_EX_CUSTOM(HttpException, 4002, "No token provided", 400, "/refresh_token");
/* 解析 Bearer <base64>_<signature> */
if (raw.substr(0, 7) != "Bearer ")
THROW_EX_CUSTOM(HttpException, 4002, "Invalid token format", 400, "/refresh_token");
raw = raw.substr(7);
size_t idx = raw.find("_");
if (idx == std::string::npos)
THROW_EX_CUSTOM(HttpException, 4002, "Invalid token format", 400, "/refresh_token");
std::string data = raw.substr(0, idx);
std::string sign = raw.substr(idx + 1);
std::string auth = Base64::base64_decode_to_string(data);
/* RSA 验签 */
if (!_PUBL.Verify(auth, sign))
THROW_EX_CUSTOM(HttpException, 4003, "Invalid token signature", 401, "/refresh_token");
/* 解析 Token payload */
json::value jv_t;
try { jv_t = json::parse(auth); }
catch (const std::exception&) {
THROW_EX_CUSTOM(HttpException, 4004, "Bad token payload", 400, "/refresh_token");
}
if (!jv_t.is_object())
THROW_EX_CUSTOM(HttpException, 4004, "Bad token payload", 400, "/refresh_token");
json::object& obj = jv_t.as_object();
/* 提取关键字段 */
std::string username = json::value_to<std::string>(obj.at("user"));
std::string iat_str = json::value_to<std::string>(obj.at("iat"));
int keep_time = json::value_to<int>(obj.at("keep_time"));
int refresh_count = json::value_to<int>(obj.at("refresh_count"));
int max_refresh = json::value_to<int>(obj.at("max_refresh"));
if (token_revoked)
THROW_EX_CUSTOM(HttpException, 4003, "Token has been revoked. Please re-login.",
401, "/refresh_token");
/* 刷新次数检查 */
if (refresh_count >= max_refresh)
@@ -1559,16 +1691,18 @@ http::response<http::string_body> handleRefreshToken(
THROW_EX_CUSTOM(HttpException, 4006, "Token expired beyond refresh window. Please re-login.",
401, "/refresh_token");
/* 保留 is_admin(旧 Token 未携带时默认为 false) */
bool is_admin = false;
auto it_admin = obj.find("is_admin");
if (it_admin != obj.end() && it_admin->value().is_bool())
is_admin = it_admin->value().as_bool();
/* 吊销旧 Token 的 jti(防止旧 refresh_token 被重放) */
if (!old_jti.empty()) {
revoke_jti(old_jti, keep_time * _TOKEN_REFRESH_GRACE_MULTIPLIER);
}
/* 签发新 Token:iat 不变,created_at 更新,refresh_count++ */
/* 签发新 Token:iat 不变,created_at 更新,refresh_count++,新 jti,当前版本号 */
std::string new_jti = generate_jti();
auto _PRIV = MyRSA::Private_Key::from_pem(_KEYGEN->get_private_key_pem());
json::value jv_new = {
{"user", username},
{"jti", new_jti},
{"ver", user_ver},
{"iat", iat_str},
{"created_at", LocalTime::getCurrent()},
{"keep_time", keep_time},
@@ -2070,6 +2204,9 @@ http::response<http::string_body> handleResetPassword(
if (!obj_r["success"].as_bool())
THROW_EX_CUSTOM(HttpException, 2202, "Reset Failed", 500, "/reset_password");
/* 重置密码成功:吊销该用户所有已签发的 Token(版本号递增,全设备失效,需重新登录) */
revoke_all_user_tokens(username);
// 防枚举:用户不存在(rowcount=0)时仍统一返回成功,不泄露邮箱是否已注册
logs().info("gateway", "重置密码成功: " + username, std::nullopt,
json::serialize(json::value{{"ip", client_ip}}));
@@ -2089,18 +2226,11 @@ http::response<http::string_body> handleProxy(
http::response<http::string_body> res;
res.version(req.version());
auto _PUBL = MyRSA::Public_Key::from_pem(_KEYGEN->get_public_key_pem());
std::string t_url = "";
std::string url = "";
std::string auth = "";
std::string cookie_token = ""; /* 从 Cookie 提取的 Token(用于向下游注入 Authorization) */
json::value jv_t;
bool is_public_target = false;
{
auto flag = false;
/* 大写的Target-Url */
auto it = req.base().find("Target-Url");
if (it != req.base().end()) {
@@ -2116,7 +2246,7 @@ http::response<http::string_body> handleProxy(
/* 如果请求没设置Target-Url头,就自动设置为默认的 */
if (t_url == "") {
t_url = _DEFT_URL;
flag = true;
is_public_target = true;
}
// 路径规范化校验(P1-1):防止路径遍历、协议注入、空字节等
@@ -2132,55 +2262,19 @@ http::response<http::string_body> handleProxy(
url += "/";
url += target_path;
std::string raw = "";
it = req.base().find("Authorization");
if (it != req.base().end()) {
raw = std::string(it->value());
} else {
/* 回退:从 HttpOnly Cookie 读取 q4_token(浏览器同源请求自动携带) */
auto cit = req.base().find("Cookie");
if (cit != req.base().end()) {
cookie_token = get_cookie_value(std::string(cit->value()), "q4_token");
if (!cookie_token.empty())
raw = "Bearer " + cookie_token;
}
}
if (raw.empty() && !flag)
THROW_EX_CUSTOM(HttpException, 2301, "Unauthorization", 401, "*");
/* 提取 Token(Authorization 优先,回退 Cookie) */
std::string raw_token = extract_token_from_request(req, cookie_token);
if (!flag) {
/* 仅在需要鉴权的目标上校验凭证。
* 默认目标(公开静态资源)必须跳过校验:
* 浏览器会给每个请求自动携带 q4_token Cookie,
* gateway 重启(内存 RSA 密钥对轮换)或 Token 过期后,
* 旧 Cookie 验签失败会让公开页面也误返回 401 */
if (raw.substr(0, 7) == "Bearer ") {
raw = raw.substr(7, raw.size() - 7);
size_t idx = raw.find("_");
if (idx == std::string::npos)
THROW_EX_CUSTOM(HttpException, 2301, "Unauthorization", 400, "*");
std::string data = raw.substr(0, idx);
std::string sign = raw.substr(idx+1, raw.size()-idx-1);
auth = Base64::base64_decode_to_string(data);
if (!_PUBL.Verify(auth, sign))
THROW_EX_CUSTOM(HttpException, 2301, "Unauthorization", 401, "*");
}
if (!is_public_target) {
/* 需要鉴权的目标:统一验签(RSA + 过期 + jti黑名单 + 版本号) */
if (raw_token.empty())
THROW_EX_CUSTOM(HttpException, 2301, "Unauthorization", 401, "*");
/* 是否过期 */
try { jv_t = json::parse(auth); }
catch (const boost::wrapexcept<boost::system::system_error>& e) {
THROW_EX_CUSTOM(HttpException, 2302, "Bad token", 400, "*");
}
if (!jv_t.is_object()) THROW_EX_CUSTOM(HttpException, 2302, "Bad token", 400, "*");
json::object& obj = jv_t.as_object();
auto t = json::value_to<std::string>(obj["created_at"]);
auto keep_time_val = json::value_to<int>(obj["keep_time"]);
auto created_tp = LocalTime::parse(t);
if (created_tp) {
auto now = std::chrono::system_clock::now();
auto expire_tp = *created_tp + std::chrono::seconds(keep_time_val);
if (now > expire_tp) THROW_EX_CUSTOM(HttpException, 2000, "Authorization Expired", 401, "*");
TokenInfo ti = verify_token(raw_token);
if (!ti.valid) {
if (ti.http_status == 400)
THROW_EX_CUSTOM(HttpException, 2302, "Bad token", 400, "*");
THROW_EX_CUSTOM(HttpException, 2301, "Unauthorization", 401, "*");
}
} else {
/* 公开目标不做凭证校验,也不向下游注入 Authorization */
@@ -2204,11 +2298,8 @@ http::response<http::string_body> handleProxy(
}
/*
* 这是用于读数据的HTTP客户端回调函数
*
* FIXME: 增加长度限制
* FIXME: 试试流式输出
* */
* HTTP客户端回调函数
* 响应体大小限制由 HttpClient 默认 10MB 上限保证(libhttp P1-2 已修复) */
auto callback = [&res](const auto& ret) {
res.body() += ret;
};
+240 -12
View File
@@ -255,7 +255,8 @@ body {
.scroll-hint:hover { color: var(--accent); }
.scroll-hint svg { width: 22px; height: 22px; }
/* ===== 章节标题:eyebrow + 标题 + 渐变短杠 ===== */
/* ===== 章节标题:eyebrow + 标题 + 渐变短杠 + 副标题 ===== */
#features { scroll-margin-top: 5.5rem; } /* 锚点跳转时避开悬浮头部 */
.section-eyebrow {
text-align: center;
margin-top: 4.5rem;
@@ -282,20 +283,32 @@ body {
background: linear-gradient(90deg, var(--accent), var(--accent-dark));
opacity: 0.9;
}
.section-sub {
text-align: center;
margin-top: 0.9rem;
font-size: 0.95rem;
letter-spacing: 0.03em;
color: var(--fg-secondary);
}
/* ===== 特性卡片:序号水印 + 光标光斑 + 图标点亮 ===== */
/* ===== 特性卡片:Bento 网格 + 序号水印 + 光标光斑 + 图标点亮 ===== */
.features {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
grid-template-columns: repeat(3, 1fr);
gap: 1.1rem;
width: 100%;
max-width: 960px;
margin: 2rem auto 0;
counter-reset: feat;
}
/* Bento 节奏:首尾卡片横贯两列,形成 2+1 / 1+2 交错布局 */
.feature-card:nth-child(1),
.feature-card:nth-child(4) { grid-column: span 2; }
.feature-card {
counter-increment: feat;
position: relative;
display: flex;
flex-direction: column;
background: var(--card);
/* 光标光斑:JS 写入 --mx/--my,缺省停在顶部中央 */
background-image: radial-gradient(260px circle at var(--mx, 50%) var(--my, 0%), var(--accent-soft), transparent 75%);
@@ -359,31 +372,60 @@ body {
}
.feature-card h3 { margin-bottom: 0.4rem; font-size: 1.05rem; letter-spacing: 0.01em; }
.feature-card p {
flex: 1; /* 撑开文本区,让底部标签在同行卡片间对齐 */
font-size: 0.88rem;
line-height: 1.75;
color: var(--fg-secondary);
}
/* 底部标签胶囊 */
.feature-tag {
align-self: flex-start;
margin-top: 1.1rem;
padding: 0.22rem 0.7rem;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.08em;
color: var(--accent-dark);
background: var(--accent-soft);
border: 1px solid var(--accent-glow);
border-radius: 999px;
transition: color 0.25s, border-color 0.25s;
}
.feature-card:hover .feature-tag {
color: var(--accent);
border-color: var(--accent);
}
/* ===== 关于本站 ===== */
/* ===== 关于本站:左文右数的编辑感面板 ===== */
.about {
text-align: center;
padding: 1rem 1rem 3.5rem;
max-width: 780px;
width: 100%;
max-width: 960px;
margin: 0 auto;
}
.about .section-eyebrow, .about .section-title { margin-top: 0; }
.about .site-intro {
.about-panel {
position: relative;
display: flex;
align-items: center;
gap: 2.4rem;
text-align: left;
background: var(--card);
border: 1px solid var(--border-light);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
padding: 2rem 2.2rem;
padding: 2.4rem 2.6rem;
margin-top: 2rem;
overflow: hidden;
transition: border-color 0.25s, box-shadow 0.25s;
}
.about-panel:hover {
border-color: var(--border);
box-shadow: var(--shadow-md);
}
/* 左侧品牌渐变条 + 右上角装饰引号 */
.about .site-intro::before {
.about-panel::before {
content: "";
position: absolute;
left: 0;
@@ -393,7 +435,7 @@ body {
background: linear-gradient(180deg, var(--accent), var(--accent-dark));
opacity: 0.85;
}
.about .site-intro::after {
.about-panel::after {
content: "”";
position: absolute;
top: -0.6rem;
@@ -404,11 +446,43 @@ body {
color: var(--accent-soft);
pointer-events: none;
}
.about .site-intro { flex: 1; min-width: 0; }
.site-intro {
font-size: 0.96rem;
line-height: 1.95;
color: var(--fg-secondary);
}
/* 右侧数据列:竖向分隔线 + 渐变数字 */
.about-stats {
list-style: none;
flex: none;
display: flex;
flex-direction: column;
gap: 1.15rem;
padding-left: 2.4rem;
border-left: 1px solid var(--border-light);
}
.about-stats li {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.about-stats b {
font-size: 1.45rem;
font-weight: 800;
line-height: 1.1;
letter-spacing: 0.02em;
background: linear-gradient(120deg, var(--accent), var(--accent-dark));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.about-stats span {
font-size: 0.78rem;
letter-spacing: 0.02em;
color: var(--muted);
white-space: nowrap;
}
/* 快捷链接:胶囊按钮 */
.quick-links {
display: flex;
@@ -445,6 +519,135 @@ body {
}
.quick-links a:hover::after { transform: translateX(3px); }
/* ===== 结尾行动号召:品牌渐变横幅 ===== */
.cta-section {
width: 100%;
max-width: 960px;
margin: 0.5rem auto 0;
padding: 0 1rem;
}
.cta-band {
position: relative;
overflow: hidden;
text-align: center;
padding: 3.4rem 1.5rem 3.6rem;
border-radius: var(--radius-lg);
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-dark) 100%);
color: #fff;
box-shadow: 0 14px 34px rgba(232, 133, 58, 0.28);
}
/* 底部点阵纹理 + 顶部高光线,提升质感 */
.cta-band::before {
content: "";
position: absolute;
inset: 0;
background-image: radial-gradient(rgba(255, 255, 255, 0.22) 1px, transparent 1px);
background-size: 22px 22px;
-webkit-mask-image: radial-gradient(ellipse 75% 80% at 50% 110%, #000 20%, transparent 72%);
mask-image: radial-gradient(ellipse 75% 80% at 50% 110%, #000 20%, transparent 72%);
pointer-events: none;
}
.cta-band::after {
content: "";
position: absolute;
left: 0;
right: 0;
top: 0;
height: 1px;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.55), transparent);
}
/* 品牌节点树水印 */
.cta-tree {
position: absolute;
right: -28px;
bottom: -18px;
width: min(380px, 62%);
height: auto;
color: #fff;
opacity: 0.14;
transform: rotate(-6deg);
pointer-events: none;
}
.cta-title {
position: relative;
font-size: clamp(1.45rem, 3.6vw, 1.9rem);
font-weight: 800;
letter-spacing: 0.03em;
}
.cta-sub {
position: relative;
margin-top: 0.65rem;
font-size: 0.95rem;
letter-spacing: 0.04em;
opacity: 0.92;
}
.btn-cta {
position: relative;
margin-top: 1.7rem;
background: #fff;
color: var(--accent-dark);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.14);
}
.btn-cta:hover {
transform: translateY(-2px);
box-shadow: 0 10px 22px rgba(0, 0, 0, 0.18);
}
/* 深色模式:渐变底色变浅,文字与按钮反转为深棕 */
@media (prefers-color-scheme: dark) {
.cta-band {
color: #38220f;
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.45);
}
.cta-band::before {
background-image: radial-gradient(rgba(56, 34, 15, 0.16) 1px, transparent 1px);
}
.cta-band::after {
background: linear-gradient(90deg, transparent, rgba(56, 34, 15, 0.35), transparent);
}
.cta-tree { color: #38220f; opacity: 0.16; }
.btn-cta { background: #38220f; color: #f7ead9; }
}
/* ===== 页脚:品牌 + 导航 + 备案 ===== */
.footer {
width: 100%;
max-width: 960px;
padding: 0 1rem;
}
.footer-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.85rem;
margin-top: 3rem;
padding: 1.8rem 0 0.4rem;
border-top: 1px solid var(--border-light);
}
.footer-brand {
display: flex;
align-items: center;
gap: 0.45rem;
font-size: 0.95rem;
font-weight: 700;
letter-spacing: 0.02em;
color: var(--fg);
}
.footer-brand svg { color: var(--accent); }
.footer-nav {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.35rem 1.5rem;
}
.footer-nav a {
font-size: 0.84rem;
color: var(--fg-secondary);
text-decoration: none;
transition: color 0.15s;
}
.footer-nav a:hover { color: var(--accent); }
.footer-icp { margin-top: 0.15rem; }
/* ===== 入场动画(首屏元素,页面加载即播) ===== */
@keyframes fade-up {
from { opacity: 0; transform: translateY(14px); }
@@ -524,8 +727,9 @@ body {
.hero::after { width: 440px; height: 440px; opacity: 0.45; }
.hero-tree { width: 130%; opacity: 0.12; }
.hero-title { letter-spacing: 0.02em; }
.features { gap: 0.9rem; }
.features { grid-template-columns: repeat(2, 1fr); gap: 0.9rem; }
.feature-card { padding: 1.5rem 1.25rem; }
.about-panel { gap: 1.6rem; padding: 2rem 1.8rem; }
}
/* 手机:全宽布局、紧凑排版 */
@@ -559,12 +763,36 @@ body {
.section-eyebrow { margin-top: 2.75rem; }
.section-title { font-size: 1.3rem; }
.features { margin-top: 1.25rem; grid-template-columns: 1fr; }
/* 单列布局下取消 Bento 跨列,避免撑出隐式第二列 */
.feature-card:nth-child(1),
.feature-card:nth-child(4) { grid-column: auto; }
.feature-card:hover { transform: none; } /* 触屏取消悬浮位移 */
.about { padding: 0.5rem 0.5rem 2.5rem; }
.about .site-intro { padding: 1.5rem 1.4rem; text-align: left; }
.about .site-intro::after { font-size: 3.5rem; right: 0.8rem; }
.about-panel {
flex-direction: column;
align-items: stretch;
gap: 1.4rem;
padding: 1.5rem 1.4rem;
}
.about-panel::after { font-size: 3.5rem; right: 0.8rem; }
/* 数据列转为横向排列,分隔线移到顶部 */
.about-stats {
flex-direction: row;
justify-content: space-between;
gap: 0.8rem;
padding-left: 0;
padding-top: 1.3rem;
border-left: none;
border-top: 1px solid var(--border-light);
}
.about-stats span { white-space: normal; }
.quick-links { gap: 0.6rem 0.8rem; margin-top: 1.5rem; }
.cta-section { padding: 0 0.25rem; }
.cta-band { padding: 2.6rem 1.2rem 2.8rem; }
.cta-tree { width: 78%; opacity: 0.1; }
.footer-inner { margin-top: 2.2rem; }
}
/* 超小屏 */
+65 -9
View File
@@ -57,7 +57,7 @@
<h2 class="hero-title">让知识像树一样<br><span class="accent-text">自然生长</span></h2>
<p class="hero-sub">每个节点都可以生长出子节点,像树枝一样层层延展</p>
<div class="hero-actions">
<button id="ctaBtn" class="btn btn-primary btn-lg">开始记录</button>
<button class="btn btn-primary btn-lg" data-cta>开始记录</button>
<a class="btn btn-ghost btn-lg" href="#features">了解更多</a>
</div>
</div>
@@ -70,13 +70,15 @@
<section id="features">
<p class="section-eyebrow">核心特性</p>
<h2 class="section-title">为什么选择树状笔记</h2>
<p class="section-sub">从灵感到体系,四个理由让知识在树上自然生长</p>
<div class="features">
<div class="feature-card">
<div class="feature-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="10.51" x2="15.42" y2="6.51"/><line x1="8.59" y1="13.49" x2="15.42" y2="17.49"/></svg>
</div>
<h3>树状结构组织</h3>
<p>每个节点都能生长出子节点,像树枝般层层延展,让零散的想法自然汇聚成体系。</p>
<p>每个节点都能生长出子节点,像树枝般层层延展,让零散的想法自然汇聚成体系,长成一棵属于你的知识树。</p>
<span class="feature-tag">无限层级</span>
</div>
<div class="feature-card">
<div class="feature-icon" aria-hidden="true">
@@ -84,6 +86,7 @@
</div>
<h3>Markdown 写作</h3>
<p>原生支持 Markdown 渲染,代码块、引用、列表一应俱全,专注内容本身。</p>
<span class="feature-tag">专注写作</span>
</div>
<div class="feature-card">
<div class="feature-icon" aria-hidden="true">
@@ -91,13 +94,15 @@
</div>
<h3>多媒体分享</h3>
<p>链接卡片、图片直显、文件下载,多种内容形态一站承载,分享更自由。</p>
<span class="feature-tag">自由分享</span>
</div>
<div class="feature-card">
<div class="feature-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
</div>
<h3>深色模式</h3>
<p>跟随系统偏好自动切换明暗主题,日夜皆宜的舒适阅读体验。</p>
<p>跟随系统偏好自动切换明暗主题,深夜记录也不刺眼,日夜皆宜的舒适阅读体验。</p>
<span class="feature-tag">日夜皆宜</span>
</div>
</div>
</section>
@@ -105,21 +110,72 @@
<section class="about">
<p class="section-eyebrow">我们的理念</p>
<h2 class="section-title">关于本站</h2>
<p class="site-intro">
DNFS 树状笔记是一个以树状结构组织内容的笔记社区:每个节点都可以生长出子节点,
像树枝一样层层延展,让知识自然成体系。支持 Markdown 文本、链接卡片、图片与文件分享,
注册登录后即可创建属于你自己的知识树。
</p>
<div class="about-panel">
<p class="site-intro">
DNFS 树状笔记是一个以树状结构组织内容的笔记社区:每个节点都可以生长出子节点,
像树枝一样层层延展,让知识自然成体系。支持 Markdown 文本、链接卡片、图片与文件分享,
注册登录后即可创建属于你自己的知识树。
</p>
<ul class="about-stats">
<li><b>∞</b><span>节点层级无上限</span></li>
<li><b>MD</b><span>原生 Markdown 渲染</span></li>
<li><b>4+</b><span>内容形态一站承载</span></li>
</ul>
</div>
<div class="quick-links">
<a href="allcolor.html">调色板</a>
<a href="games/">小游戏</a>
<a href="LICENSE.html">用户使用条例</a>
</div>
</section>
<section class="cta-section" aria-label="立即开始">
<div class="cta-band">
<!-- 品牌装饰:节点树水印(纯装饰) -->
<svg class="cta-tree" viewBox="0 0 640 360" fill="none" aria-hidden="true">
<g stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M320 344 L320 262"/>
<path d="M320 262 C 282 236, 238 214, 202 184"/>
<path d="M320 262 C 358 236, 402 214, 438 184"/>
<path d="M202 184 C 178 156, 156 134, 142 104"/>
<path d="M202 184 C 222 156, 244 134, 258 104"/>
<path d="M438 184 C 414 156, 396 134, 382 104"/>
<path d="M438 184 C 462 156, 484 134, 498 104"/>
</g>
<g fill="currentColor">
<circle cx="320" cy="344" r="6"/>
<circle cx="320" cy="262" r="4.5"/>
<circle cx="202" cy="184" r="4.5"/>
<circle cx="438" cy="184" r="4.5"/>
<circle cx="142" cy="104" r="4"/>
<circle cx="258" cy="104" r="4"/>
<circle cx="382" cy="104" r="4"/>
<circle cx="498" cy="104" r="4"/>
</g>
</svg>
<h2 class="cta-title">准备好让知识生长了吗?</h2>
<p class="cta-sub">注册一个账号,种下你的第一棵知识树</p>
<button class="btn btn-cta btn-lg" data-cta>立即开始</button>
</div>
</section>
</main>
<footer class="footer">
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">蜀ICP备2024107064号-2</a>
<div class="footer-inner">
<div class="footer-brand">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="10.51" x2="15.42" y2="6.51"/><line x1="8.59" y1="13.49" x2="15.42" y2="17.49"/></svg>
<span>DNFS 树状笔记</span>
</div>
<nav class="footer-nav" aria-label="页脚导航">
<a href="dnfs.html">树状笔记</a>
<a href="allcolor.html">调色板</a>
<a href="games/">小游戏</a>
<a href="LICENSE.html">用户使用条例</a>
</nav>
<p class="footer-icp">
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener">蜀ICP备2024107064号-2</a>
</p>
</div>
</footer>
<script src="js/token_manager.js"></script>
<script src="js/index.js"></script>
+6 -4
View File
@@ -13,7 +13,7 @@
const logoutBtn = document.getElementById('logoutBtn');
const loginBtn = document.getElementById('loginBtn');
const registerBtn = document.getElementById('registerBtn');
const ctaBtn = document.getElementById('ctaBtn');
const ctaBtns = document.querySelectorAll('[data-cta]'); // 首屏与页尾 CTA 按钮
const logoEl = document.getElementById('logo');
let loggedIn = false;
@@ -24,7 +24,9 @@
enterBtn.addEventListener('click', () => go('dnfs.html'));
loginBtn.addEventListener('click', () => go('login.html'));
registerBtn.addEventListener('click', () => go('register.html'));
ctaBtn.addEventListener('click', () => go(loggedIn ? 'dnfs.html' : 'login.html'));
ctaBtns.forEach(function (btn) {
btn.addEventListener('click', () => go(loggedIn ? 'dnfs.html' : 'login.html'));
});
async function checkLogin() {
try {
@@ -40,7 +42,7 @@
logoutBtn.style.display = '';
loginBtn.style.display = 'none';
registerBtn.style.display = 'none';
ctaBtn.textContent = '进入我的知识树';
ctaBtns.forEach(function (btn) { btn.textContent = '进入我的知识树'; });
}
} catch (e) {
console.warn('index: 登录态检测失败', e.message);
@@ -67,7 +69,7 @@
/* 滚动进入视口渐显:由 JS 添加 .reveal 隐藏初始态,无 JS / 减弱动效时内容天然可见 */
if (!reduceMotion && 'IntersectionObserver' in window) {
const targets = document.querySelectorAll('.section-eyebrow, .section-title, .feature-card, .about .site-intro, .quick-links');
const targets = document.querySelectorAll('.section-eyebrow, .section-title, .section-sub, .feature-card, .about .site-intro, .about-stats, .quick-links, .cta-band');
targets.forEach(function (el, i) {
el.classList.add('reveal');
el.style.transitionDelay = (i % 4) * 70 + 'ms';