From a24db5e562860feeabf72b2b0f72fd34fef301bd Mon Sep 17 00:00:00 2001 From: Gabriel Date: Sun, 16 Aug 2026 19:25:02 +0800 Subject: [PATCH 1/3] feat(config): add cookie notice enable setting --- app/api/site-config/route.ts | 7 ++++++- config/site.config.json | 5 ++++- lib/site-config.ts | 37 ++++++++++++++++++++++++++++++++---- tests/site-config.test.ts | 22 +++++++++++++++++++++ 4 files changed, 65 insertions(+), 6 deletions(-) create mode 100644 tests/site-config.test.ts diff --git a/app/api/site-config/route.ts b/app/api/site-config/route.ts index 9492cf3..930be03 100644 --- a/app/api/site-config/route.ts +++ b/app/api/site-config/route.ts @@ -21,7 +21,12 @@ const siteConfigSchema = z .array(z.object({ label: z.string().max(160), href: z.string().url().max(2048) }).strict()) .max(32) .optional(), - cookieNotice: z.string().max(1000).optional(), + cookieNotice: z + .union([ + z.string().max(1000), + z.object({ enable: z.boolean().optional(), message: z.string().max(1000).optional() }).strict(), + ]) + .optional(), }) .strict(); diff --git a/config/site.config.json b/config/site.config.json index 5fe1f92..43c96b3 100644 --- a/config/site.config.json +++ b/config/site.config.json @@ -14,5 +14,8 @@ { "label": "Next.js", "href": "https://nextjs.org" }, { "label": "React", "href": "https://react.dev" } ], - "cookieNotice": "本站使用本地存储保存语言、主题和终端偏好。" + "cookieNotice": { + "enable": true, + "message": "本站使用本地存储保存语言、主题和终端偏好。" + } } diff --git a/lib/site-config.ts b/lib/site-config.ts index f893715..a740d0a 100644 --- a/lib/site-config.ts +++ b/lib/site-config.ts @@ -7,7 +7,10 @@ export type SiteConfig = { sourceFallback: string; filings: { icp: string; police: string }; friendlyLinks: Array<{ label: string; href: string }>; - cookieNotice: string; + cookieNotice: { + enable: boolean; + message: string; + }; }; export const defaultSiteConfig: SiteConfig = { @@ -26,11 +29,17 @@ export const defaultSiteConfig: SiteConfig = { { label: "Next.js", href: "https://nextjs.org" }, { label: "React", href: "https://react.dev" }, ], - cookieNotice: "本站使用本地存储保存语言、主题和终端偏好。", + cookieNotice: { + enable: true, + message: "本站使用本地存储保存语言、主题和终端偏好。", + }, }; export function mergeSiteConfig(value: unknown): SiteConfig { - const candidate = value && typeof value === "object" && !Array.isArray(value) ? (value as Partial) : {}; + const candidate = + value && typeof value === "object" && !Array.isArray(value) + ? (value as Partial> & { cookieNotice?: unknown }) + : {}; const filings: Partial = candidate.filings && typeof candidate.filings === "object" ? candidate.filings : {}; const friendlyLinks = Array.isArray(candidate.friendlyLinks) @@ -39,6 +48,26 @@ export function mergeSiteConfig(value: unknown): SiteConfig { Boolean(link) && typeof link === "object" && typeof link.label === "string" && typeof link.href === "string", ) : []; + const cookieNotice = candidate.cookieNotice; + const cookieNoticeFields = + cookieNotice && typeof cookieNotice === "object" + ? (cookieNotice as Partial) + : undefined; + const normalizedCookieNotice = + typeof cookieNotice === "string" + ? { enable: true, message: cookieNotice } + : cookieNoticeFields + ? { + enable: + typeof cookieNoticeFields.enable === "boolean" + ? cookieNoticeFields.enable + : defaultSiteConfig.cookieNotice.enable, + message: + typeof cookieNoticeFields.message === "string" + ? cookieNoticeFields.message + : defaultSiteConfig.cookieNotice.message, + } + : defaultSiteConfig.cookieNotice; return { blogName: typeof candidate.blogName === "string" ? candidate.blogName : defaultSiteConfig.blogName, contactEmail: typeof candidate.contactEmail === "string" ? candidate.contactEmail : defaultSiteConfig.contactEmail, @@ -53,7 +82,7 @@ export function mergeSiteConfig(value: unknown): SiteConfig { police: typeof filings.police === "string" ? filings.police : defaultSiteConfig.filings.police, }, friendlyLinks: friendlyLinks.length ? friendlyLinks : defaultSiteConfig.friendlyLinks, - cookieNotice: typeof candidate.cookieNotice === "string" ? candidate.cookieNotice : defaultSiteConfig.cookieNotice, + cookieNotice: normalizedCookieNotice, }; } diff --git a/tests/site-config.test.ts b/tests/site-config.test.ts new file mode 100644 index 0000000..70774a6 --- /dev/null +++ b/tests/site-config.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { defaultSiteConfig, mergeSiteConfig } from "../lib/site-config"; + +describe("site configuration", () => { + it("normalizes the cookie notice object", () => { + const config = mergeSiteConfig({ cookieNotice: { enable: false, message: "No storage." } }); + expect(config.cookieNotice).toEqual({ enable: false, message: "No storage." }); + }); + + it("keeps legacy cookie notice strings enabled", () => { + const config = mergeSiteConfig({ cookieNotice: "Legacy notice" }); + expect(config.cookieNotice).toEqual({ enable: true, message: "Legacy notice" }); + }); + + it("fills missing cookie notice fields from defaults", () => { + const config = mergeSiteConfig({ cookieNotice: { enable: false } }); + expect(config.cookieNotice).toEqual({ + enable: false, + message: defaultSiteConfig.cookieNotice.message, + }); + }); +}); From 1fc0c027e135535a1d5565f2576719b2b31fd239 Mon Sep 17 00:00:00 2001 From: Gabriel Date: Sun, 16 Aug 2026 19:25:03 +0800 Subject: [PATCH 2/3] feat(terminal): prompt for first-visit storage consent --- components/TerminalBlog.tsx | 92 +++++++++++++++++++++++++++++++++--- lib/cookie-consent.ts | 20 ++++++++ tests/cookie-consent.test.ts | 30 ++++++++++++ 3 files changed, 135 insertions(+), 7 deletions(-) create mode 100644 lib/cookie-consent.ts create mode 100644 tests/cookie-consent.test.ts diff --git a/components/TerminalBlog.tsx b/components/TerminalBlog.tsx index a650c09..a4f1c82 100644 --- a/components/TerminalBlog.tsx +++ b/components/TerminalBlog.tsx @@ -43,6 +43,13 @@ import { type CommandDefinition, } from "@/lib/command-registry"; import { defaultSiteConfig, type SiteConfig } from "@/lib/site-config"; +import { + COOKIE_CONSENT_STORAGE_KEY, + isCookieConsentCancelShortcut, + parseCookieConsentInput, + parseStoredCookieConsent, + type CookieConsent, +} from "@/lib/cookie-consent"; import { parseFrontmatter, serializeArticleDocument } from "@/lib/article-codec"; import { splitCommand, splitPipeline } from "@/lib/terminal-command-parser"; import { runTextPipeline, runTextStage } from "@/lib/terminal-text-pipeline"; @@ -86,6 +93,7 @@ const uiText = { editorSummary: "摘要", editorBody: "正文", writeFile: "写入文件", + cookieConsentInput: "y / n", placeholder: "输入 help 命令获取帮助,输入 / 打开命令菜单。", }, en: { @@ -121,6 +129,7 @@ const uiText = { editorSummary: "SUMMARY", editorBody: "TRANSMISSION BODY", writeFile: "WRITE FILE", + cookieConsentInput: "y / n", placeholder: "Enter the 'help' command to get helps, enter '/' to invoke the command menu.", }, }; @@ -298,6 +307,7 @@ export default function TerminalBlog({ const [historyIndex, setHistoryIndex] = useState(-1); const [suggestionIndex, setSuggestionIndex] = useState(-1); const [pendingPassword, setPendingPassword] = useState(null); + const [cookiePromptPending, setCookiePromptPending] = useState(false); const [editor, setEditor] = useState(null); const [pager, setPager] = useState(null); const [categorySlugs, setCategorySlugs] = useState( @@ -320,6 +330,7 @@ export default function TerminalBlog({ const scrollbackPositionRef = useRef(0); const restoreScrollbackRef = useRef(false); const stagedFilesRef = useRef(new Map()); + const cookiePromptShownRef = useRef(false); const nextId = useRef(1); const getEntryKey = useCallback((index: number) => entries[index]?.id ?? index, [entries]); // TanStack Virtual owns the measurement lifecycle for variable-height terminal entries. @@ -338,6 +349,14 @@ export default function TerminalBlog({ const copy = uiText[language]; const resolvedTheme = themeMode === "auto" ? systemTheme : themeMode; const say = (zh: string, en: string) => (language === "en" ? en : zh); + const cookieNoticePrompt = [ + siteConfig.cookieNotice.message, + language === "en" + ? "Accept this storage policy? Enter y/n, or press Ctrl+C to decline and stop future prompts." + : "是否同意此存储策略?请输入 y/n,或按 Ctrl+C 拒绝并停止后续提示。", + ] + .filter(Boolean) + .join("\n"); const categories = useMemo( () => categorySlugs.map((slug) => { @@ -424,6 +443,31 @@ export default function TerminalBlog({ } }, [language, themeMode, hydrated]); + useEffect(() => { + if (!hydrated) return; + if (!siteConfig.cookieNotice.enable) { + cookiePromptShownRef.current = false; + setCookiePromptPending(false); + return; + } + if (cookiePromptShownRef.current) return; + cookiePromptShownRef.current = true; + try { + if (parseStoredCookieConsent(window.localStorage.getItem(COOKIE_CONSENT_STORAGE_KEY))) return; + } catch { + // The prompt still works for this session when storage is unavailable. + } + setEntries((previous) => [ + ...previous, + { + id: `entry-${nextId.current++}`, + type: "text", + value: cookieNoticePrompt, + }, + ]); + setCookiePromptPending(true); + }, [cookieNoticePrompt, hydrated, siteConfig.cookieNotice.enable]); + useEffect(() => { document.documentElement.lang = language === "zh" ? "zh-CN" : "en"; document.documentElement.style.colorScheme = resolvedTheme; @@ -488,6 +532,21 @@ export default function TerminalBlog({ const append = (...newEntries: Entry[]) => setEntries((previous) => [...previous, ...newEntries]); + const completeCookieConsent = (consent: CookieConsent, interrupted = false) => { + try { + window.localStorage.setItem(COOKIE_CONSENT_STORAGE_KEY, consent); + } catch { + // The choice remains effective for this mounted session. + } + setCookiePromptPending(false); + setInput(""); + const message = + consent === "accepted" + ? say("已记录同意。", "Consent recorded.") + : say("已记录拒绝,不再显示此提示。", "Declined. This notice will not be shown again."); + append(makeEntry(consent === "accepted" ? "success" : "text", { value: interrupted ? `^C\n${message}` : message })); + }; + const captureScrollback = () => { scrollbackPositionRef.current = outputRef.current?.scrollTop || 0; }; @@ -635,12 +694,23 @@ export default function TerminalBlog({ const executeCommand = (rawValue: string, options: ExecutionOptions = {}) => { const submitted = rawValue.trim(); - if (!submitted && !pendingPassword) return; + if (!submitted && !pendingPassword && !cookiePromptPending) return; const effectiveUser = options.effectiveUser || user; const shouldEchoCommand = options.echoCommand !== false; const shouldRecordHistory = options.recordHistory !== false; playTick(); + if (cookiePromptPending && !options.skipPasswordMode) { + const consent = parseCookieConsentInput(rawValue); + setInput(""); + if (!consent) { + append(makeEntry("error", { value: say("请输入 y 或 n。", "Enter y or n.") })); + } else { + completeCookieConsent(consent); + } + return; + } + if (pendingPassword && !options.skipPasswordMode) { setInput(""); if (pendingPassword.kind === "su") { @@ -1489,6 +1559,7 @@ export default function TerminalBlog({ const hasExactCommand = candidateCommands.some((item) => item.command === commandToken); const paletteItems = !pendingPassword && + !cookiePromptPending && commandOnly && (input.startsWith("/") || commandToken.length > 0) && (!hasExactCommand || candidateCommands.length > 1) @@ -1512,7 +1583,7 @@ export default function TerminalBlog({ useEffect(() => { setSuggestionIndex(-1); - }, [input, pendingPassword, user]); + }, [cookiePromptPending, input, pendingPassword, user]); const acceptSuggestion = (item?: CommandDefinition) => { if (!item) return; @@ -1565,7 +1636,7 @@ export default function TerminalBlog({ }; const clearTerminal = () => { - setEntries([makeEntry("boot")]); + setEntries([makeEntry("boot"), ...(cookiePromptPending ? [makeEntry("text", { value: cookieNoticePrompt })] : [])]); setPendingPassword(null); stagedFilesRef.current.clear(); }; @@ -1599,6 +1670,9 @@ export default function TerminalBlog({ } if (event.ctrlKey && !event.shiftKey && ["c", "v"].includes(event.key.toLowerCase())) { event.preventDefault(); + if (cookiePromptPending && isCookieConsentCancelShortcut(event.key, event.ctrlKey, event.shiftKey)) { + completeCookieConsent("declined", true); + } return; } if (!insertMode && event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) { @@ -1850,7 +1924,7 @@ export default function TerminalBlog({
{ event.preventDefault(); executeCommand(input); @@ -1874,8 +1948,12 @@ export default function TerminalBlog({ autoComplete="off" autoCapitalize="off" spellCheck="false" - aria-label={pendingPassword ? "Password" : "Terminal command"} - placeholder={pendingPassword ? "" : copy.placeholder} + aria-label={ + pendingPassword ? "Password" : cookiePromptPending ? "Cookie consent" : "Terminal command" + } + placeholder={ + pendingPassword ? "" : cookiePromptPending ? copy.cookieConsentInput : copy.placeholder + } autoFocus /> )} - {paletteItems.length === 0 && input && activeReference && ( + {paletteItems.length === 0 && !cookiePromptPending && input && activeReference && (
{copy.usage} {activeReference.syntax} diff --git a/lib/cookie-consent.ts b/lib/cookie-consent.ts new file mode 100644 index 0000000..4af1cf4 --- /dev/null +++ b/lib/cookie-consent.ts @@ -0,0 +1,20 @@ +export const COOKIE_CONSENT_STORAGE_KEY = "terminal-blog-cookie-consent-v1"; + +export type CookieConsent = "accepted" | "declined"; + +export function parseStoredCookieConsent(value: string | null): CookieConsent | null { + if (value === "accepted" || value === "true") return "accepted"; + if (value === "declined" || value === "false") return "declined"; + return null; +} + +export function parseCookieConsentInput(value: string): CookieConsent | null { + const normalized = value.trim().toLowerCase(); + if (normalized === "y" || normalized === "yes") return "accepted"; + if (normalized === "n" || normalized === "no") return "declined"; + return null; +} + +export function isCookieConsentCancelShortcut(key: string, ctrlKey: boolean, shiftKey: boolean) { + return ctrlKey && !shiftKey && key.toLowerCase() === "c"; +} diff --git a/tests/cookie-consent.test.ts b/tests/cookie-consent.test.ts new file mode 100644 index 0000000..994e2f8 --- /dev/null +++ b/tests/cookie-consent.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { + isCookieConsentCancelShortcut, + parseCookieConsentInput, + parseStoredCookieConsent, +} from "../lib/cookie-consent"; + +describe("cookie consent", () => { + it("parses terminal consent input", () => { + expect(parseCookieConsentInput("Y")).toBe("accepted"); + expect(parseCookieConsentInput(" yes ")).toBe("accepted"); + expect(parseCookieConsentInput("n")).toBe("declined"); + expect(parseCookieConsentInput("later")).toBeNull(); + }); + + it("reads current and legacy stored values", () => { + expect(parseStoredCookieConsent("accepted")).toBe("accepted"); + expect(parseStoredCookieConsent("declined")).toBe("declined"); + expect(parseStoredCookieConsent("true")).toBe("accepted"); + expect(parseStoredCookieConsent("false")).toBe("declined"); + expect(parseStoredCookieConsent(null)).toBeNull(); + }); + + it("reserves Ctrl+C for cancelling the prompt", () => { + expect(isCookieConsentCancelShortcut("c", true, false)).toBe(true); + expect(isCookieConsentCancelShortcut("C", true, false)).toBe(true); + expect(isCookieConsentCancelShortcut("c", true, true)).toBe(false); + expect(isCookieConsentCancelShortcut("c", false, false)).toBe(false); + }); +}); From ed14840f6fbab994c713d49d9e3caeb8c6e7f5cb Mon Sep 17 00:00:00 2001 From: Gabriel Date: Sun, 16 Aug 2026 19:25:03 +0800 Subject: [PATCH 3/3] docs(cookie): document consent configuration --- CHANGELOG.md | 1 + README.en.md | 17 +++++++++++++++-- README.md | 17 +++++++++++++++-- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24ec419..8f15f9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - Initial terminal-first blog workspace with article browsing, editing, drafts, uploads, authentication, configuration, and command pipelines. - Bilingual interface, theme persistence, virtual scrollback, and responsive file drawer. - Short `config` virtual path for editing site configuration from the terminal. +- Configurable first-visit cookie and local-storage consent prompt with persistent `y`, `n`, and `Ctrl+C` handling. ### Security diff --git a/README.en.md b/README.en.md index 94e0f50..55917de 100644 --- a/README.en.md +++ b/README.en.md @@ -218,9 +218,22 @@ sudo nano config - Contact email - ICP and public-security filing text - Friendly links -- Cookie notice +- Cookie / local-storage notice toggle and message - Source-address fallback label +The cookie notice uses this structure: + +```json +{ + "cookieNotice": { + "enable": true, + "message": "This site stores language, theme, and terminal preferences locally." + } +} +``` + +When enabled, a visitor without a stored choice sees the notice at the end of the scrollback on first entry. Enter `y` to accept, or enter `n` / press `Ctrl+C` to decline. The choice is stored in localStorage so later visits do not repeat the prompt. Legacy string values for `cookieNotice` remain readable and are treated as enabled. + ## Command System ### Visitor commands @@ -308,7 +321,7 @@ flowchart TD ### Request and state flow 1. Every page request reads current site configuration, articles, categories, and attachments on the server. -2. The client stores only theme, language, and configuration MD5 in localStorage. Articles are never restored from localStorage; server data remains authoritative. +2. The client stores only theme, language, the cookie-notice choice, and configuration MD5 in localStorage. Articles are never restored from localStorage; server data remains authoritative. 3. Mutation requests pass origin, authentication, size, Content-Type, and Zod checks. 4. Successful filesystem changes synchronize the SQLite metadata index. 5. Client article state changes only after server confirmation, avoiding irreversible optimistic updates. diff --git a/README.md b/README.md index c89a4e3..04c0f5d 100644 --- a/README.md +++ b/README.md @@ -219,9 +219,22 @@ sudo nano config - 联系邮箱 - ICP 与公安备案信息 - 友情链接 -- Cookie 提示 +- Cookie / 本地存储提示开关与文案 - 访问来源回退名称 +Cookie 提示使用以下结构: + +```json +{ + "cookieNotice": { + "enable": true, + "message": "本站使用本地存储保存语言、主题和终端偏好。" + } +} +``` + +启用后,尚未选择的访客会在首次进入时于回滚缓冲区末尾看到提示。输入 `y` 表示同意,输入 `n` 或按 `Ctrl+C` 表示拒绝;选择会保存到 localStorage,后续访问不再重复提示。旧版字符串形式的 `cookieNotice` 仍可读取,并按启用状态处理。 + ## 命令系统 ### 访客命令 @@ -309,7 +322,7 @@ flowchart TD ### 请求与状态流 1. 服务端每次页面请求读取站点配置、文章目录、分类和附件列表。 -2. 客户端只把主题、语言和配置 MD5 保存到 localStorage;文章不从 localStorage 恢复,服务端数据始终是权威来源。 +2. 客户端只把主题、语言、Cookie 提示选择和配置 MD5 保存到 localStorage;文章不从 localStorage 恢复,服务端数据始终是权威来源。 3. mutation 请求经过同源检查、认证、请求大小限制和 Zod 校验。 4. 文件系统写入成功后同步 SQLite metadata 索引。 5. 客户端在服务端确认成功之后更新文章状态,避免无回滚的乐观更新。