diff --git a/.gitignore b/.gitignore index 6f017db..519bbb0 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ dist/ # 环境变量 .env .env.local +.env.* +!.env.example .dev.vars # 系统文件 diff --git a/PRIVACY.md b/PRIVACY.md new file mode 100644 index 0000000..499e28c --- /dev/null +++ b/PRIVACY.md @@ -0,0 +1,99 @@ +# 隐私政策 / Privacy Policy + +最后更新 / Last updated: 2026-04-19 + +--- + +## 数据收集声明 + +Monolith 博客在您访问时可能自动收集以下非个人身份信息: + +| 数据类型 | 来源 | 用途 | 存储方式 | +|---------|------|------|---------| +| 访问页面路径 | 请求 URL | 内容统计 | 边缘数据库 | +| 访客来源国家 | Cloudflare `CF-IPCountry` 头 | 流量分析 | 边缘数据库 | +| 来源域名 | `Referer` 头 | 流量分析 | 边缘数据库 | +| 设备类型 | `User-Agent` 解析(仅区分 desktop/mobile/bot) | 响应式优化 | 边缘数据库 | +| 评论者昵称 | 用户主动填写 | 公开展示 | 边缘数据库 | + +**我们不收集:** IP 地址(仅处理为不可逆哈希用于投票去重)、邮箱地址不在公开 API 中返回。 + +## Cookie 使用 + +| Cookie | 用途 | 持续时间 | +|--------|------|---------| +| 认证 Token | 管理员登录 | 7 天 | +| `_gdpr_consent` | 记录隐私同意 | 1 年 | + +站点管理员可通过"扩展与注入"设置添加第三方分析脚本(如 Google Analytics)。此类脚本可能设置额外的 Cookie,需遵守本政策的同意机制。 + +## 第三方脚本同意机制 + +当站点配置了自定义头部/尾部脚本(如分析服务),访客将看到 Cookie 同意横幅。**在您明确同意之前,第三方脚本不会被加载。** 您可以随时撤回同意。 + +## 数据存储与处理 + +- 所有数据存储在 Cloudflare 边缘网络(Workers + D1/R2),位于就近的数据中心 +- 评论者邮箱仅用于管理员审核通知,不会在他的评论旁公开显示 +- 我们不售卖、共享或传输用户数据给第三方 + +## 数据删除请求 + +如您希望删除您的评论或相关数据,请通过以下方式联系: + +- GitHub Issue(公开请求) +- [GitHub Security Advisories](https://github.com/one-ea/Monolith/security/advisories/new)(私密请求) + +我们将在 14 个工作日内处理您的请求。 + +## 政策更新 + +本隐私政策可能不定期更新,重大变更将在博客页面上公告。继续访问本站即表示您同意本政策。 + +--- + +## Data Collection Notice + +Monolith blog may automatically collect the following non-personally identifiable information when you visit: + +| Data Type | Source | Purpose | Storage | +|-----------|--------|---------|---------| +| Page path visited | Request URL | Content analytics | Edge database | +| Visitor country | Cloudflare `CF-IPCountry` header | Traffic analysis | Edge database | +| Referrer domain | `Referer` header | Traffic analysis | Edge database | +| Device type | `User-Agent` parsing (desktop/mobile/bot only) | Responsive optimization | Edge database | +| Commenter nickname | User-provided | Public display | Edge database | + +**We do not collect:** IP addresses (only hashed for vote deduplication), email addresses are never returned in public APIs. + +## Cookie Usage + +| Cookie | Purpose | Duration | +|--------|---------|----------| +| Auth Token | Admin login | 7 days | +| `_gdpr_consent` | Records privacy consent | 1 year | + +Site administrators may add third-party analytics scripts via "Extensions & Injection" settings. Such scripts may set additional cookies and are subject to the consent mechanism described in this policy. + +## Third-Party Script Consent + +When custom header/footer scripts (e.g., analytics) are configured, visitors will see a cookie consent banner. **Third-party scripts are not loaded until you explicitly consent.** You may withdraw consent at any time. + +## Data Storage & Processing + +- All data is stored on the Cloudflare edge network (Workers + D1/R2) in nearest data centers +- Commenter emails are used only for admin review notifications and are never displayed publicly +- We do not sell, share, or transfer user data to third parties + +## Data Deletion Requests + +To request deletion of your comments or related data, please contact us via: + +- GitHub Issue (public request) +- [GitHub Security Advisories](https://github.com/one-ea/Monolith/security/advisories/new) (private request) + +We will process your request within 14 business days. + +## Policy Updates + +This privacy policy may be updated periodically. Major changes will be announced on the blog. Continued use of this site constitutes acceptance of this policy. \ No newline at end of file diff --git a/client/.env.production b/client/.env.production deleted file mode 100644 index 7c35aa6..0000000 --- a/client/.env.production +++ /dev/null @@ -1 +0,0 @@ -VITE_API_URL=https://monolith-server.h005-9d9.workers.dev diff --git a/client/functions/api/[[path]].ts b/client/functions/api/[[path]].ts index ef70c14..46c199a 100644 --- a/client/functions/api/[[path]].ts +++ b/client/functions/api/[[path]].ts @@ -8,21 +8,25 @@ import { export const onRequest: PagesFunction<{ API_BASE: string }> = async (context) => { // 直接处理 CORS 预检请求,不转发 if (context.request.method === "OPTIONS") { + const origin = context.request.headers.get("Origin") || "*"; return new Response(null, { status: 204, headers: { - "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Origin": origin, "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", "Access-Control-Allow-Headers": "Content-Type, Authorization", "Access-Control-Max-Age": "86400", + "Vary": "Origin", }, }); } const backend = getBackendUrl(context.env); if (!backend) { + const origin = context.request.headers.get("Origin") || "*"; return createApiBaseErrorResponse({ - "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Origin": origin, + "Vary": "Origin", }); } @@ -37,8 +41,10 @@ export const onRequest: PagesFunction<{ API_BASE: string }> = async (context) => redirect: "manual", // 不跟随重定向,原样返回 30x }); + const origin = context.request.headers.get("Origin") || "*"; const responseHeaders = new Headers(res.headers); - responseHeaders.set("Access-Control-Allow-Origin", "*"); + responseHeaders.set("Access-Control-Allow-Origin", origin); + responseHeaders.set("Vary", "Origin"); return new Response(res.body, { status: res.status, diff --git a/client/src/app.tsx b/client/src/app.tsx index db73a0f..c4397d3 100644 --- a/client/src/app.tsx +++ b/client/src/app.tsx @@ -1,10 +1,12 @@ import { Route, Switch, useLocation } from "wouter"; import { useEffect, Suspense, lazy } from "react"; +import DOMPurify from "dompurify"; import { Navbar } from "@/components/navbar"; import { Footer } from "@/components/footer"; import { SearchOverlay } from "@/components/search"; import { ProtectedRoute } from "@/components/protected-route"; import { AdminLayout } from "@/components/admin-layout"; +import { CookieConsent, getCookieConsent } from "@/components/cookie-consent"; // 代码分割 (Code Splitting) const HomePage = lazy(() => import("@/pages/home").then((m) => ({ default: m.HomePage }))); @@ -20,21 +22,27 @@ const AdminPages = lazy(() => import("@/pages/admin/pages").then((m) => ({ defau const AdminComments = lazy(() => import("@/pages/admin/comments").then((m) => ({ default: m.AdminComments }))); const AdminMedia = lazy(() => import("@/pages/admin/media").then((m) => ({ default: m.AdminMedia }))); const AdminAnalytics = lazy(() => import("@/pages/admin/analytics").then((m) => ({ default: m.AdminAnalytics }))); +const PrivacyPage = lazy(() => import("@/pages/privacy").then((m) => ({ default: m.PrivacyPage }))); const DynamicPage = lazy(() => import("@/pages/dynamic-page").then((m) => ({ default: m.DynamicPage }))); const NotFoundPage = lazy(() => import("@/pages/not-found").then((m) => ({ default: m.NotFoundPage }))); -/** 将 HTML 字符串安全注入到容器中(支持 script 标签执行) */ +/** 将设置中的 HTML/JS 代码安全注入到页面(仅允许外部脚本 src) */ function injectHtml(container: HTMLElement, html: string) { const temp = document.createElement("div"); - temp.innerHTML = html; + temp.innerHTML = DOMPurify.sanitize(html, { + ADD_TAGS: ["script"], + ADD_ATTR: ["src", "async", "defer"], + FORBID_TAGS: ["style", "iframe", "object", "embed", "form"], + FORBID_ATTR: ["onerror", "onload", "onclick", "onmouseover", "onfocus", "onblur"], + }); Array.from(temp.childNodes).forEach((node) => { if (node instanceof HTMLScriptElement) { - // script 需要重新创建才能执行 + if (!node.src) return; // 禁止内联脚本,只允许带 src 的外部脚本 const script = document.createElement("script"); - if (node.src) script.src = node.src; - else script.textContent = node.textContent; - Array.from(node.attributes).forEach((a) => script.setAttribute(a.name, a.value)); + script.src = node.src; + if (node.hasAttribute("async")) script.async = true; + if (node.hasAttribute("defer")) script.defer = true; container.appendChild(script); } else { container.appendChild(node.cloneNode(true)); @@ -56,23 +64,36 @@ export function App() { const isAdminArea = isAdminRoot && !isEditorPage && !isLoginPage; const isPublicPage = !isAdminRoot; - // 注入自定义 header/footer 代码(仅执行一次) + // 注入自定义 header/footer 代码(需 Cookie 同意后加载第三方脚本) useEffect(() => { fetch("/api/settings/public") .then((r) => r.json()) .then((s) => { - if (s.custom_header) { - const container = document.createElement("div"); - container.id = "monolith-custom-header"; - injectHtml(container, s.custom_header); - // 将子节点移入 head - Array.from(container.childNodes).forEach((n) => document.head.appendChild(n)); - } - if (s.custom_footer) { - const container = document.createElement("div"); - container.id = "monolith-custom-footer"; - injectHtml(container, s.custom_footer); - document.body.appendChild(container); + const hasThirdParty = (s.custom_header && /