diff --git a/dnfs2/src/dnfs2.cpp b/dnfs2/src/dnfs2.cpp index ee67d5d..37f4526 100644 --- a/dnfs2/src/dnfs2.cpp +++ b/dnfs2/src/dnfs2.cpp @@ -60,6 +60,10 @@ static constexpr size_t kSpMaxUrl = 2048; static constexpr size_t kSpMaxCode = 512 * 1024; static constexpr size_t kSpMaxData = 3500 * 1024; static constexpr size_t kSpReviewCodePrefix = 4000; +static constexpr size_t kSpMaxCss = 256 * 1024; // style 节点 CSS 上限 +static constexpr size_t kSpMaxThemeVars = 64; // theme 节点变量条数上限 +static constexpr size_t kSpMaxThemeKey = 64; +static constexpr size_t kSpMaxThemeValue = 256; // 日志 static constexpr size_t kLogMaxMessage = 8192; @@ -239,7 +243,7 @@ private: // 特殊节点校验 // ============================================================================ -static const std::vector kSpTypes = {"link", "js", "image", "file"}; +static const std::vector kSpTypes = {"link", "js", "image", "file", "style", "theme"}; // 识别特殊节点信封:合法信封返回 object;普通内容返回 nullptr; // 以 {"_sp" 开头但 JSON 非法视为格式错误(抛异常)。 @@ -271,6 +275,10 @@ static json::value parse_sp(const std::string& content) { static void validate_sp(const json::object& sp) { std::string t = std::string(sp.at("_sp").as_string()); + // persist(style/theme 的生命周期标记)可选,必须是布尔值 + if (sp.contains("persist") && !sp.at("persist").is_bool()) + throw DnfsExp("persist 必须是布尔值", 400); + if (t == "link") { if (!sp.contains("url") || !sp.at("url").is_string()) throw DnfsExp("链接节点缺少 url", 400); @@ -310,6 +318,46 @@ static void validate_sp(const json::object& sp) { throw DnfsExp("文件节点缺少数据", 400); if (data.size() > kSpMaxData) throw DnfsExp("文件过大(上限约 2.6MB)", 400); + } else if (t == "style") { + if (!sp.contains("css") || !sp.at("css").is_string()) + throw DnfsExp("样式节点缺少 css", 400); + std::string css = std::string(sp.at("css").as_string()); + if (css.empty() || css.find_first_not_of(" \t\r\n") == std::string::npos) + throw DnfsExp("样式节点 css 不能为空", 400); + if (css.size() > kSpMaxCss) + throw DnfsExp("样式节点 css 过大(上限 256KB)", 400); + } else if (t == "theme") { + if (!sp.contains("vars") || !sp.at("vars").is_object()) + throw DnfsExp("主题节点缺少 vars 对象", 400); + const auto& vars = sp.at("vars").as_object(); + if (vars.empty()) + throw DnfsExp("主题节点 vars 不能为空", 400); + if (vars.size() > kSpMaxThemeVars) + throw DnfsExp("主题变量过多(上限 64 条)", 400); + auto is_name_char = [](char c) { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') + || (c >= '0' && c <= '9') || c == '-' || c == '_'; + }; + for (const auto& kv : vars) { + std::string key(kv.key()); + // 仅允许 CSS 自定义属性名:-- 前缀 + [A-Za-z0-9_-] + if (key.size() < 3 || key.size() > kSpMaxThemeKey + || key[0] != '-' || key[1] != '-') + throw DnfsExp("主题变量名必须以 -- 开头", 400); + for (char c : key) { + if (!is_name_char(c)) + throw DnfsExp("主题变量名含非法字符", 400); + } + if (!kv.value().is_string()) + throw DnfsExp("主题变量值必须是字符串", 400); + std::string val = std::string(kv.value().as_string()); + if (val.empty() || val.size() > kSpMaxThemeValue) + throw DnfsExp("主题变量值为空或过长(上限 256 字符)", 400); + // 值禁止 {}<>:防止逃逸 :root 变量上下文注入任意规则, + // 保持 theme 相对 style 的"仅换肤"克制定位 + if (val.find_first_of("{}<>") != std::string::npos) + throw DnfsExp("主题变量值含非法字符({}<>)", 400); + } } } @@ -333,6 +381,15 @@ static std::string sp_review_text(const std::string& title, const json::object& if (code.size() > kSpReviewCodePrefix) code = code.substr(0, kSpReviewCodePrefix); parts.push_back(code); } + } else if (t == "style") { + if (sp.contains("css") && sp.at("css").is_string()) { + std::string css = std::string(sp.at("css").as_string()); + if (css.size() > kSpReviewCodePrefix) css = css.substr(0, kSpReviewCodePrefix); + parts.push_back(css); + } + } else if (t == "theme") { + if (sp.contains("vars") && sp.at("vars").is_object()) + parts.push_back(json::serialize(sp.at("vars"))); } else { if (sp.contains("name") && sp.at("name").is_string()) parts.push_back(std::string(sp.at("name").as_string())); diff --git a/fsys/public/dnfs.html b/fsys/public/dnfs.html index 861e540..75927e2 100644 --- a/fsys/public/dnfs.html +++ b/fsys/public/dnfs.html @@ -35,9 +35,12 @@ + + +
diff --git a/fsys/public/js/dnfs.js b/fsys/public/js/dnfs.js index 43cc014..974c9b5 100644 --- a/fsys/public/js/dnfs.js +++ b/fsys/public/js/dnfs.js @@ -445,7 +445,7 @@ function parseSp(content) { } catch (e) { return null; // 非法信封按普通文本展示(后端发布时已拦截,这里是兜底) } - if (obj && typeof obj === 'object' && ['link', 'js', 'image', 'file'].indexOf(obj._sp) !== -1) return obj; + if (obj && typeof obj === 'object' && ['link', 'js', 'image', 'file', 'style', 'theme'].indexOf(obj._sp) !== -1) return obj; return null; } @@ -468,6 +468,7 @@ function renderContent(container, item) { else if (sp._sp === 'js') renderSpJs(container, sp, appendNote); else if (sp._sp === 'image') renderSpImage(container, sp, appendNote); else if (sp._sp === 'file') renderSpFile(container, sp, appendNote); + else if (sp._sp === 'style' || sp._sp === 'theme') renderSpCss(container, sp, appendNote, item); } /* 链接节点:服务端代理拉取的 favicon + 可点击链接(卡片内交互需 stopPropagation) */ @@ -505,8 +506,15 @@ var sp_effects_disabled = (function() { try { return localStorage.getItem('sp_effects_disabled') === '1'; } catch (e) { return false; } })(); /* 清除当前页面已运行的全部特效沙箱(进入新节点 / 关闭特效时调用) */ +/* 清除当前页面已运行的全部特效沙箱与节点作用域注入样式(进入新节点 / 关闭特效时调用)。 + * persist 注入样式(data-persist="1")跨节点保留,由用户面板"还原界面样式"统一清除。 */ function clearAllEffects() { document.querySelectorAll('.sp-effect-frame').forEach(function(f) { f.remove(); }); + document.querySelectorAll('.sp-injected-style:not([data-persist="1"])').forEach(function(s) { s.remove(); }); +} +/* 还原界面样式:清除 style/theme 节点的全部注入样式(含 persist),不清特效沙箱 */ +function clearAllStyles() { + document.querySelectorAll('.sp-injected-style').forEach(function(s) { s.remove(); }); } function renderSpJs(container, sp, appendNote) { var bar = document.createElement('div'); @@ -619,6 +627,108 @@ function renderSpFile(container, sp, appendNote) { appendNote(); } +/* ===== 样式类节点(style/theme):CSS 注入