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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
- Production Docker image and Compose deployment with persistent content and database volumes.

### Security
Expand Down
17 changes: 15 additions & 2 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,9 +218,22 @@ sudo nano config
- 联系邮箱
- ICP 与公安备案信息
- 友情链接
- Cookie 提示
- Cookie / 本地存储提示开关与文案
- 访问来源回退名称

Cookie 提示使用以下结构:

```json
{
"cookieNotice": {
"enable": true,
"message": "本站使用本地存储保存语言、主题和终端偏好。"
}
}
```

启用后,尚未选择的访客会在首次进入时于回滚缓冲区末尾看到提示。输入 `y` 表示同意,输入 `n` 或按 `Ctrl+C` 表示拒绝;选择会保存到 localStorage,后续访问不再重复提示。旧版字符串形式的 `cookieNotice` 仍可读取,并按启用状态处理。

## 命令系统

### 访客命令
Expand Down Expand Up @@ -308,7 +321,7 @@ flowchart TD
### 请求与状态流

1. 服务端每次页面请求读取站点配置、文章目录、分类和附件列表。
2. 客户端只把主题、语言和配置 MD5 保存到 localStorage;文章不从 localStorage 恢复,服务端数据始终是权威来源。
2. 客户端只把主题、语言、Cookie 提示选择和配置 MD5 保存到 localStorage;文章不从 localStorage 恢复,服务端数据始终是权威来源。
3. mutation 请求经过同源检查、认证、请求大小限制和 Zod 校验。
4. 文件系统写入成功后同步 SQLite metadata 索引。
5. 客户端在服务端确认成功之后更新文章状态,避免无回滚的乐观更新。
Expand Down
7 changes: 6 additions & 1 deletion app/api/site-config/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
92 changes: 85 additions & 7 deletions components/TerminalBlog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -86,6 +93,7 @@ const uiText = {
editorSummary: "摘要",
editorBody: "正文",
writeFile: "写入文件",
cookieConsentInput: "y / n",
placeholder: "输入 help 命令获取帮助,输入 / 打开命令菜单。",
},
en: {
Expand Down Expand Up @@ -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.",
},
};
Expand Down Expand Up @@ -298,6 +307,7 @@ export default function TerminalBlog({
const [historyIndex, setHistoryIndex] = useState(-1);
const [suggestionIndex, setSuggestionIndex] = useState(-1);
const [pendingPassword, setPendingPassword] = useState<PasswordMode | null>(null);
const [cookiePromptPending, setCookiePromptPending] = useState(false);
const [editor, setEditor] = useState<EditorState | null>(null);
const [pager, setPager] = useState<PagerState | null>(null);
const [categorySlugs, setCategorySlugs] = useState(
Expand All @@ -320,6 +330,7 @@ export default function TerminalBlog({
const scrollbackPositionRef = useRef(0);
const restoreScrollbackRef = useRef(false);
const stagedFilesRef = useRef(new Map<string, File>());
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.
Expand All @@ -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<BlogCategory[]>(
() =>
categorySlugs.map((slug) => {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
};
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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)
Expand All @@ -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;
Expand Down Expand Up @@ -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();
};
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -1850,7 +1924,7 @@ export default function TerminalBlog({
</div>
<div className="command-area">
<form
className={`terminal-input ${pendingPassword ? "password-mode" : ""} ${insertMode ? "insert-mode" : "overwrite-mode"}`}
className={`terminal-input ${pendingPassword ? "password-mode" : ""} ${cookiePromptPending ? "cookie-consent-mode" : ""} ${insertMode ? "insert-mode" : "overwrite-mode"}`}
onSubmit={(event) => {
event.preventDefault();
executeCommand(input);
Expand All @@ -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
/>
<span
Expand Down Expand Up @@ -1912,7 +1990,7 @@ export default function TerminalBlog({
))}
</div>
)}
{paletteItems.length === 0 && input && activeReference && (
{paletteItems.length === 0 && !cookiePromptPending && input && activeReference && (
<div className="argument-hint">
<span>{copy.usage}</span>
<code>{activeReference.syntax}</code>
Expand Down
5 changes: 4 additions & 1 deletion config/site.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,8 @@
{ "label": "Next.js", "href": "https://nextjs.org" },
{ "label": "React", "href": "https://react.dev" }
],
"cookieNotice": "本站使用本地存储保存语言、主题和终端偏好。"
"cookieNotice": {
"enable": true,
"message": "本站使用本地存储保存语言、主题和终端偏好。"
}
}
20 changes: 20 additions & 0 deletions lib/cookie-consent.ts
Original file line number Diff line number Diff line change
@@ -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";
}
37 changes: 33 additions & 4 deletions lib/site-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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<SiteConfig>) : {};
const candidate =
value && typeof value === "object" && !Array.isArray(value)
? (value as Partial<Omit<SiteConfig, "cookieNotice">> & { cookieNotice?: unknown })
: {};
const filings: Partial<SiteConfig["filings"]> =
candidate.filings && typeof candidate.filings === "object" ? candidate.filings : {};
const friendlyLinks = Array.isArray(candidate.friendlyLinks)
Expand All @@ -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<SiteConfig["cookieNotice"]>)
: 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,
Expand All @@ -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,
};
}

Expand Down
Loading
Loading