Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ dist/
# 环境变量
.env
.env.local
.env.*
!.env.example
.dev.vars

# 系统文件
Expand Down
99 changes: 99 additions & 0 deletions PRIVACY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# 隐私政策 / Privacy Policy

最后更新 / Last updated: 2026-04-19

---

## 数据收集声明

Monolith 博客在您访问时可能自动收集以下非个人身份信息:

| 数据类型 | 来源 | 用途 | 存储方式 |
|---------|------|------|---------|
| 访问页面路径 | 请求 URL | 内容统计 | 边缘数据库 |
| 访客来源国家 | Cloudflare `CF-IPCountry` 头 | 流量分析 | 边缘数据库 |
| 来源域名 | `Referer` 头 | 流量分析 | 边缘数据库 |
| 设备类型 | `User-Agent` 解析(仅区分 desktop/mobile/bot) | 响应式优化 | 边缘数据库 |
| 评论者昵称 | 用户主动填写 | 公开展示 | 边缘数据库 |

**我们不收集:** IP 地址(仅处理为不可逆哈希用于投票去重)、邮箱地址不在公开 API 中返回。
Comment on lines +11 to +19

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

数据收集表未列出"评论者邮箱"条目

第 19 行声明邮箱"不在公开 API 中返回",且第 37 行说明邮箱用于管理员通知,说明系统确实收集并存储邮箱。为符合 GDPR 的透明性要求,建议在数据收集表中显式列出"评论者邮箱"一项(来源:用户主动填写;用途:管理员审核通知;存储:边缘数据库,不公开),避免用户误以为未收集。英文版同理(第 59-67 行)。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@PRIVACY.md` around lines 11 - 19, Add a new data row for "评论者邮箱" to the data
collection table so the policy accurately reflects that emails are collected:
set 数据类型 to 评论者邮箱, 来源 to 用户主动填写, 用途 to 管理员审核通知, 存储方式 to 边缘数据库(不公开); mirror the
same explicit row in the English section (e.g., "Commenter email" — Source:
user-provided; Purpose: admin notification/review; Storage: edge DB, not public)
so both language versions are consistent with the statement that emails are not
returned in the public 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.
1 change: 0 additions & 1 deletion client/.env.production

This file was deleted.

12 changes: 9 additions & 3 deletions client/functions/api/[[path]].ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
});
}

Expand All @@ -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,
Expand Down
61 changes: 42 additions & 19 deletions client/src/app.tsx
Original file line number Diff line number Diff line change
@@ -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 })));
Expand All @@ -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));
Expand All @@ -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 && /<script/i.test(s.custom_header))
|| (s.custom_footer && /<script/i.test(s.custom_footer));

const inject = () => {
if (s.custom_header) {
const container = document.createElement("div");
container.id = "monolith-custom-header";
injectHtml(container, s.custom_header);
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);
}
};

// 无第三方脚本则直接注入;有则等 Cookie 同意
if (!hasThirdParty) {
inject();
} else if (getCookieConsent()) {
inject();
} else {
window.addEventListener("cookie-consent-accepted", inject, { once: true });
}
})
.catch(() => {});
Expand All @@ -93,6 +114,7 @@ export function App() {
<Route path="/posts/:slug" component={PostPage} />
<Route path="/archive" component={ArchivePage} />
<Route path="/about" component={AboutPage} />
<Route path="/privacy" component={PrivacyPage} />
<Route path="/page/:slug" component={DynamicPage} />
<Route>
<NotFoundPage />
Expand All @@ -101,6 +123,7 @@ export function App() {
</Suspense>
</main>
<Footer />
<CookieConsent />
</>
)}

Expand Down
22 changes: 5 additions & 17 deletions client/src/components/comments.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,10 @@ function formatDate(dateStr: string): string {
});
}

/** 通过邮箱生成 Gravatar 头像 URL */
function gravatarUrl(email: string, size = 40): string {
// 简单 hash 用于无邮箱时的默认颜色
if (!email) return `https://api.dicebear.com/7.x/initials/svg?seed=U&size=${size}`;
const trimmed = email.trim().toLowerCase();
return `https://gravatar.com/avatar/${simpleHash(trimmed)}?s=${size}&d=identicon`;
}

function simpleHash(str: string): string {
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash |= 0;
}
return Math.abs(hash).toString(16).padStart(32, "0");
/** 通过昵称生成 DiceBear 头像 URL(不再传输邮箱) */
function avatarUrl(name: string, size = 40): string {
const seed = encodeURIComponent(name.trim() || "U");
return `https://api.dicebear.com/7.x/initials/svg?seed=${seed}&size=${size}`;
}
Comment on lines +13 to 17

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

头像请求会把访客信息泄漏给第三方 DiceBear

本 PR 主线强调 GDPR 合规并为第三方脚本加了 Cookie 同意门禁;但此处每条评论的 <img> 依然直接打到 api.dicebear.com,会泄露访客 IP / UA / Referer,等同未经同意的第三方追踪载体。

建议二选一:

  1. 服务端代理或本地生成:后端写一个 /api/avatar/initials?seed=... 返回 SVG(只要两行 SVG 即可生成 initials),彻底去掉外部依赖;
  2. Cookie 同意门禁:在 getCookieConsent()false 时渲染纯本地的 CSS 占位圆形(当前文件本就有类似 fallback 样式),同意后再懒加载 DiceBear。

方案 1 顺带省掉一次 HTTPS 握手,移动端性能也更好。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/src/components/comments.tsx` around lines 13 - 17, The current
avatarUrl function directly requests DiceBear and leaks visitor data; change
behavior so when getCookieConsent() is false the UI renders the local CSS
fallback (use existing fallback styles instead of an <img>), and when consent is
true either (A) point avatarUrl to a new internal endpoint like
/api/avatar/initials?seed=... (implement a server handler that returns a minimal
SVG for initials) or (B) lazily load the external DiceBear URL only after
consent; update references to avatarUrl and the component that renders the <img>
to check getCookieConsent() and switch to the local placeholder or the
proxied/internal SVG endpoint accordingly (keep function name avatarUrl and
component logic consistent to locate changes).


/* ── 单条评论 ──────────────────────────── */
Expand All @@ -34,7 +22,7 @@ function CommentItem({ comment }: { comment: CommentData }) {
<div className="group flex gap-[12px] py-[16px]">
<div className="shrink-0">
<img
src={gravatarUrl(comment.authorEmail)}
src={avatarUrl(comment.authorName)}
alt={comment.authorName}
className="h-[36px] w-[36px] rounded-full bg-card/30 ring-1 ring-border/20"
loading="lazy"
Expand Down
65 changes: 65 additions & 0 deletions client/src/components/cookie-consent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { useEffect, useState } from "react";

const CONSENT_KEY = "_gdpr_consent";
const CONSENT_EXPIRY_DAYS = 365;

function getConsent(): "accepted" | "rejected" | null {
try {
const raw = localStorage.getItem(CONSENT_KEY);
if (!raw) return null;
const data = JSON.parse(raw);
if (Date.now() > data.expires) {
localStorage.removeItem(CONSENT_KEY);
return null;
}
return data.value as "accepted" | "rejected";
} catch {
return null;
}
}

function setConsent(value: "accepted" | "rejected") {
localStorage.setItem(CONSENT_KEY, JSON.stringify({
value,
expires: Date.now() + CONSENT_EXPIRY_DAYS * 86400000,
}));
}

export function getCookieConsent(): boolean {
return getConsent() === "accepted";
}

export function CookieConsent() {
const [visible, setVisible] = useState(false);

useEffect(() => {
setVisible(getConsent() === null);
}, []);

if (!visible) return null;

return (
<div className="fixed bottom-0 left-0 right-0 z-[9999] p-[12px] animate-fade-in">
<div className="mx-auto max-w-[720px] rounded-xl border border-border/30 bg-card/95 backdrop-blur-md shadow-lg shadow-black/20 px-[20px] py-[16px] flex flex-col sm:flex-row items-start sm:items-center gap-[12px]">
<div className="flex-1 text-[13px] text-muted-foreground/80 leading-[1.6]">
本站使用 Cookie 进行访问统计与第三方脚本加载。继续访问即表示您同意我们的{" "}
<a href="/privacy" className="text-foreground/70 underline underline-offset-2 hover:text-foreground transition-colors">隐私政策</a>。
</div>
<div className="flex gap-[8px] shrink-0">
<button
onClick={() => { setConsent("rejected"); setVisible(false); }}
className="px-[14px] py-[6px] text-[12px] rounded-md border border-border/30 text-muted-foreground/60 hover:text-foreground hover:border-border/50 transition-all"
>
拒绝
</button>
<button
onClick={() => { setConsent("accepted"); window.dispatchEvent(new Event("cookie-consent-accepted")); setVisible(false); }}
className="px-[14px] py-[6px] text-[12px] rounded-md bg-foreground text-background font-medium hover:bg-foreground/80 transition-all"
>
接受
</button>
</div>
</div>
</div>
);
Comment on lines +41 to +64

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

建议增强无障碍标记、SPA 导航与错误容错

  • Line 46: 在基于 wouter 的 SPA 中使用原生 <a href="/privacy"> 会触发整页刷新,丢失应用内状态。应改用 wouter 的 Link
  • Line 50/56: setConsent 直接访问 localStorage.setItem,在隐私/禁用存储模式下会抛出,导致按钮事件中断、横幅永远无法关闭。应包 try/catch,即使写入失败也应关闭横幅。
  • 整个横幅缺少 role="region" / aria-label,按钮语义层仅有中文文本节点,建议补齐 aria-label 与 live region 语义;参考 WCAG,同意横幅宜使用可聚焦 focus trap 或至少明确标记。
♻️ 建议修改
-import { useEffect, useState } from "react";
+import { useEffect, useState } from "react";
+import { Link } from "wouter";
@@
 function setConsent(value: "accepted" | "rejected") {
-  localStorage.setItem(CONSENT_KEY, JSON.stringify({
-    value,
-    expires: Date.now() + CONSENT_EXPIRY_DAYS * 86400000,
-  }));
+  try {
+    localStorage.setItem(CONSENT_KEY, JSON.stringify({
+      value,
+      expires: Date.now() + CONSENT_EXPIRY_DAYS * 86400000,
+    }));
+  } catch {
+    /* 存储不可用时静默失败,横幅仍需关闭 */
+  }
 }
@@
-    <div className="fixed bottom-0 left-0 right-0 z-[9999] p-[12px] animate-fade-in">
+    <div
+      className="fixed bottom-0 left-0 right-0 z-[9999] p-[12px] animate-fade-in"
+      role="region"
+      aria-label="Cookie 同意"
+    >
@@
-          <a href="/privacy" className="text-foreground/70 underline underline-offset-2 hover:text-foreground transition-colors">隐私政策</a>。
+          <Link href="/privacy" className="text-foreground/70 underline underline-offset-2 hover:text-foreground transition-colors">隐私政策</Link>。

根据代码规范:“无障碍访问(aria 标签、键盘导航)”、“响应式布局”。

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/src/components/cookie-consent.tsx` around lines 41 - 64, Replace the
native anchor with wouter's Link for SPA navigation (swap the <a
href="/privacy"> with Link to "/privacy"), wrap any localStorage writes
performed by setConsent (the click handlers that call setConsent and
localStorage.setItem) in try/catch so failures do not prevent setVisible(false)
from running, and add accessibility attributes to the banner container and
controls: give the outer div role="region" and a descriptive
aria-label/aria-live as appropriate, and add explicit aria-label attributes to
the "拒绝" and "接受" buttons; keep the existing custom event name
("cookie-consent-accepted") unchanged.

}
2 changes: 1 addition & 1 deletion client/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,13 +290,13 @@ export type CommentData = {
id: number;
postId: number;
authorName: string;
authorEmail: string;
content: string;
approved: boolean;
createdAt: string;
};

export type AdminComment = CommentData & {
authorEmail: string;
postSlug: string;
postTitle: string;
};
Expand Down
3 changes: 2 additions & 1 deletion client/src/lib/markdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,8 +279,9 @@ renderer.table = function (token: any) {
// 链接:外部链接自动 target="_blank"(兼容 marked v15 token 结构)
renderer.link = function (token: any) {
const href = token.href || '';
// 防止 javascript: URI XSS
if (/^\s*javascript:/i.test(href)) return escapeHtml(token.text || href);
const title = token.title || '';
// 用 parseInline 解析链接文本中的 inline 格式
const text = token.tokens && this.parser
? this.parser.parseInline(token.tokens)
: (token.text || '');
Expand Down
Loading
Loading