diff --git a/.gitignore b/.gitignore index 538af3f..aeed9c9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +dist/ node_modules/ .wrangler/ .dev.vars diff --git a/README.md b/README.md index 4f46c0e..d9c428e 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,59 @@ # SproutBlog -Cloudflare Worker + D1 的极简博客。 +Cloudflare Worker + D1 + 静态资源(Vite/React SPA)。Worker **仅处理 `/api/*`**,前端构建产物输出到 `dist/`,由 `[assets]` 提供;`wrangler deploy` 一次部署 API + 前端。 ## 功能 -- 公共博客页 -- Markdown 渲染 -- 简单后台 CRUD -- 手机和电脑自适应 +- 公共博客(React + React Router) +- Markdown(marked + KaTeX + highlight.js + Mermaid,按需动态加载) +- 简单后台 CRUD(Cookie / Basic Auth 与原先一致) +- 评论(cwd-widget) +- 手机与桌面适配(沿用原样式) ## 初始化 -1. 创建 D1 数据库: - `wrangler d1 create sproutblog` -2. 把生成的 `database_id` 填到 `wrangler.toml` -3. 应用迁移: - `wrangler d1 migrations apply sproutblog --remote` -4. 本地运行: - `npm run dev` +1. 创建 D1:`wrangler d1 create sproutblog`,把 `database_id` 写入 `wrangler.toml`。 +2. 应用迁移:`wrangler d1 migrations apply sproutblog --remote` +3. 安装依赖:`npm install` -## 可选后台密码 +## 开发 -如果要给 `/admin` 加密码,设置 `ADMIN_PASSWORD`: +- 一体化(会先 `vite build` 再同时拉起 Worker 与前端开发服务器): + - `npm run dev` + - Worker:默认 `http://127.0.0.1:8787`(读取 `dist` 静态资源) + - 前端:`http://127.0.0.1:5173`,`/api` 代理到 `8787` +- 仅 Worker(需已有 `dist`):`npm run dev:worker` +- 仅前端 + API 代理:`npm run dev:client` +- 修改 React 后若要在 `8787` 上看到最新静态文件,请再执行 `npm run build`。 -`wrangler secret put ADMIN_PASSWORD` +## 部署 +```bash +npm run deploy +``` + +等同 `npm run build && wrangler deploy`。 + +## 环境变量 + +- `SITE_TITLE` / `SITE_TITLE_EN` / `CWD_API_BASE`:可在 `wrangler.toml` 的 `[vars]` 中配置。 +- `ADMIN_PASSWORD`:生产环境建议使用 `wrangler secret put ADMIN_PASSWORD`,勿提交仓库。 +- 前端可选:`VITE_CWD_SITE_ID`(构建时注入;否则以 `/api/config` 中的 `CWD_SITE_ID` 为准)。 + +## API 说明(节选) + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/config` | 站点公开配置 | +| POST | `/api/admin/session` | 设置后台 Cookie(body: `{ token }`) | +| GET | `/api/posts` | 列表;未登录仅返回已发布文章 | +| GET | `/api/posts/:idOrSlug?bump=1` | 详情;`bump=1` 且匿名访问已发布文章时增加浏览量 | +| POST/PUT/DELETE | `/api/posts` … | 写操作需鉴权(与原先一致) | + +## 目录结构(节选) + +- `src/worker` — Worker 入口 +- `src/api` — 路由与 HTTP 处理 +- `src/db` — D1 访问 +- `client/` — Vite + React 源码,构建至 `dist/` +- `migrations/` — D1 SQL diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..b22cf0b --- /dev/null +++ b/client/index.html @@ -0,0 +1,32 @@ + + + + + + 萌芽小窝 + + + + + + + + +
+ + + diff --git a/client/public/favicon.ico b/client/public/favicon.ico new file mode 100644 index 0000000..42c5b46 Binary files /dev/null and b/client/public/favicon.ico differ diff --git a/client/public/logo.png b/client/public/logo.png new file mode 100644 index 0000000..2e8e9e1 Binary files /dev/null and b/client/public/logo.png differ diff --git a/client/src/App.tsx b/client/src/App.tsx new file mode 100644 index 0000000..bb79698 --- /dev/null +++ b/client/src/App.tsx @@ -0,0 +1,25 @@ +import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; +import { ConfigProvider } from "./ConfigContext"; +import { Layout } from "./Layout"; +import { Home } from "./pages/Home"; +import { PostPage } from "./pages/PostPage"; +import { AdminDashboard, AdminEditor } from "./pages/Admin"; + +export function App() { + return ( + + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + + + + + ); +} diff --git a/client/src/ConfigContext.tsx b/client/src/ConfigContext.tsx new file mode 100644 index 0000000..afb32ef --- /dev/null +++ b/client/src/ConfigContext.tsx @@ -0,0 +1,51 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode +} from "react"; +import type { SiteConfig } from "./api"; +import { fetchConfig } from "./api"; + +const Ctx = createContext(null); + +export function ConfigProvider({ children }: { children: ReactNode }) { + const [cfg, setCfg] = useState(null); + + const load = useCallback(async () => { + try { + setCfg(await fetchConfig()); + } catch { + setCfg({ + siteTitle: "萌芽小窝", + siteTitleEn: "SproutBlog", + cwdApiBase: "https://cwd.api.smyhub.com" + }); + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + const value = useMemo(() => cfg, [cfg]); + + if (!value) { + return ( +
+

加载站点配置…

+
+ ); + } + + return {children}; +} + +export function useSiteConfig(): SiteConfig { + const v = useContext(Ctx); + if (!v) throw new Error("useSiteConfig"); + return v; +} diff --git a/client/src/Layout.tsx b/client/src/Layout.tsx new file mode 100644 index 0000000..0b40aff --- /dev/null +++ b/client/src/Layout.tsx @@ -0,0 +1,40 @@ +import { Link, Outlet } from "react-router-dom"; +import { useSiteConfig } from "./ConfigContext"; +import { AdminGate } from "./components/AdminGate"; +import { GeoFooter } from "./components/GeoFooter"; + +export function Layout() { + const { siteTitle, siteTitleEn } = useSiteConfig(); + + return ( + <> +
+
+
+ + + +
+ + {siteTitle} + + {siteTitleEn} +
+
+
+ +
+
+

萌芽小窝-一个安静的小地方 @2026-至今

+ +
+ + ); +} diff --git a/client/src/api.ts b/client/src/api.ts new file mode 100644 index 0000000..e60ab1c --- /dev/null +++ b/client/src/api.ts @@ -0,0 +1,123 @@ +const API_BASE = (import.meta.env.VITE_API_BASE as string | undefined) ?? ""; + +function apiUrl(path: string): string { + return API_BASE ? `${API_BASE}${path}` : path; +} + +export type SiteConfig = { + siteTitle: string; + siteTitleEn: string; + cwdApiBase: string; + cwdSiteId?: string; +}; + +export type Post = { + id: number; + title: string; + slug: string; + excerpt: string; + content: string; + published: number; + created_at: string; + updated_at: string; + view_count?: number; +}; + +export async function fetchConfig(): Promise { + const r = await fetch(apiUrl("/api/config")); + const j = (await r.json()) as { + ok?: boolean; + config?: SiteConfig; + }; + if (!j.ok || !j.config) throw new Error("config"); + const viteId = import.meta.env.VITE_CWD_SITE_ID; + if (viteId && !j.config.cwdSiteId) { + j.config.cwdSiteId = viteId; + } + return j.config; +} + +export async function fetchPosts( + q?: string, + opts?: { credentials?: RequestCredentials } +): Promise { + const u = new URL(apiUrl("/api/posts"), window.location.origin); + if (q) u.searchParams.set("q", q); + const r = await fetch(u.toString(), { + credentials: opts?.credentials ?? "same-origin" + }); + const j = (await r.json()) as { ok?: boolean; posts?: Post[] }; + if (!r.ok || !j.ok) throw new Error("posts"); + return j.posts || []; +} + +export async function fetchPost( + idOrSlug: string, + bump: boolean, + opts?: { credentials?: RequestCredentials } +): Promise { + const u = new URL(apiUrl(`/api/posts/${encodeURIComponent(idOrSlug)}`), window.location.origin); + if (bump) u.searchParams.set("bump", "1"); + const r = await fetch(u.toString(), { + credentials: opts?.credentials ?? "same-origin" + }); + const j = (await r.json()) as { ok?: boolean; post?: Post; error?: string }; + if (!r.ok || !j.ok || !j.post) throw new Error(j.error || "post"); + return j.post; +} + +export async function postJson( + path: string, + body: unknown, + opts?: { method?: string } +): Promise { + const r = await fetch(apiUrl(path), { + method: opts?.method || "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify(body) + }); + return (await r.json()) as T; +} + +export async function adminLogin(token: string): Promise { + const j = await postJson<{ ok?: boolean }>("/api/admin/session", { token }); + return !!j.ok; +} + +export async function savePost( + data: Record, + id?: number | null +): Promise { + if (id) { + const r = await fetch(apiUrl(`/api/posts/${id}`), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify(data) + }); + const j = (await r.json()) as { ok?: boolean; post?: Post; error?: string }; + if (!r.ok || !j.ok || !j.post) throw new Error(j.error || "save"); + return j.post; + } + const { id: _omit, ...body } = data; + void _omit; + const r = await fetch(apiUrl("/api/posts"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify(body) + }); + const j = (await r.json()) as { ok?: boolean; post?: Post; error?: string }; + if (!r.ok || !j.ok || !j.post) throw new Error(j.error || "save"); + return j.post; +} + +export async function deletePostApi(id: number): Promise { + const r = await fetch(apiUrl(`/api/posts/${id}`), { + method: "DELETE", + credentials: "include" + }); + const j = (await r.json()) as { ok?: boolean }; + if (!r.ok || !j.ok) throw new Error("delete"); +} diff --git a/client/src/components/AdminGate.tsx b/client/src/components/AdminGate.tsx new file mode 100644 index 0000000..546a3c2 --- /dev/null +++ b/client/src/components/AdminGate.tsx @@ -0,0 +1,78 @@ +import { useCallback, useState, type ReactNode } from "react"; +import { useNavigate } from "react-router-dom"; +import { adminLogin } from "../api"; + +export function AdminGate({ children }: { children: ReactNode }) { + const [n, setN] = useState(0); + const [open, setOpen] = useState(false); + const [token, setToken] = useState(""); + const navigate = useNavigate(); + + const onLogoClick = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + const next = n + 1; + if (next >= 5) { + setN(0); + setOpen(true); + return; + } + setN(next); + window.setTimeout(() => setN(0), 4000); + }, + [n] + ); + + const submit = useCallback(async () => { + const ok = await adminLogin(token); + if (ok) { + setOpen(false); + setToken(""); + navigate("/admin"); + return; + } + alert("Token 无效"); + }, [token, navigate]); + + return ( + <> + + + + ); +} diff --git a/client/src/components/CwdComments.tsx b/client/src/components/CwdComments.tsx new file mode 100644 index 0000000..0abf8eb --- /dev/null +++ b/client/src/components/CwdComments.tsx @@ -0,0 +1,134 @@ +import { useEffect } from "react"; +import { useSiteConfig } from "../ConfigContext"; + +declare global { + interface Window { + CWDComments?: new (opts: { + el: string; + apiBaseUrl: string; + postSlug: string; + lang?: string; + siteId?: string; + }) => { mount: () => void }; + __cwdLikeStatusQueryPatched?: boolean; + } +} + +/** 修正 cwd-widget 点赞 GET 使用完整 URL 导致计数为 0 的问题(沿用旧脚本逻辑) */ +function patchCwdLikeGetQuery(apiBaseNorm: string, postSlug: string): void { + if (window.__cwdLikeStatusQueryPatched) return; + window.__cwdLikeStatusQueryPatched = true; + + const base = apiBaseNorm; + const orig = window.fetch.bind(window); + window.fetch = function fetchPatched(input: RequestInfo | URL, init?: RequestInit) { + let url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input.url; + let method = "GET"; + if (init?.method) method = String(init.method).toUpperCase(); + else if (typeof Request !== "undefined" && input instanceof Request) { + method = String(input.method || "GET").toUpperCase(); + } + if ( + url && + url.indexOf(base) === 0 && + url.indexOf("/api/like?") !== -1 && + method === "GET" + ) { + try { + const u = new URL(url); + const ps = u.searchParams.get("post_slug") || ""; + if (ps.indexOf("://") !== -1) { + u.searchParams.set("post_slug", postSlug); + if (typeof input === "string") { + input = u.toString(); + } else if (typeof Request !== "undefined" && input instanceof Request) { + input = new Request(u.toString(), input); + } else if (input instanceof URL) { + input = u; + } + } + } catch { + /* ignore */ + } + } + return orig(input as RequestInfo, init); + }; +} + +export function CwdComments({ postSlug }: { postSlug: string }) { + const { cwdApiBase, cwdSiteId } = useSiteConfig(); + + useEffect(() => { + const apiNorm = (cwdApiBase || "").replace(/\/$/, ""); + patchCwdLikeGetQuery(apiNorm, postSlug); + + function loadScript(src: string): Promise { + return new Promise((resolve, reject) => { + const s = document.createElement("script"); + s.src = src; + s.async = true; + s.onload = () => resolve(); + s.onerror = () => reject(new Error(src)); + document.head.appendChild(s); + }); + } + + let cancelled = false; + const el = document.getElementById("comments"); + if (el) el.innerHTML = ""; + + (async () => { + try { + if (!window.CWDComments) { + await loadScript( + "https://cdn.jsdelivr.net/npm/cwd-widget@0.1.11/dist/cwd.umd.js" + ); + } + if (cancelled || typeof window.CWDComments === "undefined") return; + const opts: { + el: string; + apiBaseUrl: string; + postSlug: string; + lang?: string; + siteId?: string; + } = { + el: "#comments", + apiBaseUrl: cwdApiBase.replace(/\/$/, ""), + postSlug, + lang: "zh-CN" + }; + if (cwdSiteId) opts.siteId = cwdSiteId; + new window.CWDComments(opts).mount(); + } catch { + const node = document.getElementById("comments"); + if (node) node.textContent = "评论插件加载失败。"; + } + })(); + + return () => { + cancelled = true; + const node = document.getElementById("comments"); + if (node) node.innerHTML = ""; + }; + }, [postSlug, cwdApiBase, cwdSiteId]); + + return ( +
+

+ 评论 +

+
+
+ ); +} diff --git a/client/src/components/GeoFooter.tsx b/client/src/components/GeoFooter.tsx new file mode 100644 index 0000000..4e51556 --- /dev/null +++ b/client/src/components/GeoFooter.tsx @@ -0,0 +1,37 @@ +import { useEffect, useState } from "react"; + +const GEO_API = "https://geoip.api.smyhub.com/api"; + +function formatGeo(g: Record | null): string { + if (!g || typeof g !== "object") return ""; + const parts: string[] = []; + if (g.countryName) parts.push(g.countryName); + if (g.regionName) parts.push(g.regionName); + if (g.cityName) parts.push(g.cityName); + return parts.join(" "); +} + +export function GeoFooter() { + const [text, setText] = useState("正在获取访问信息…"); + + useEffect(() => { + fetch(GEO_API, { credentials: "omit", cache: "no-store" }) + .then((r) => { + if (!r.ok) throw new Error("http " + r.status); + return r.json() as Promise<{ ip?: string; geo?: Record }>; + }) + .then((data) => { + const ip = data && data.ip ? String(data.ip) : "—"; + let loc = formatGeo(data.geo || null); + if (!loc) loc = "未知位置"; + setText(`您的访问 IP:${ip} · ${loc}`); + }) + .catch(() => setText("无法获取访问位置信息")); + }, []); + + return ( + + ); +} diff --git a/client/src/components/MarkdownBody.tsx b/client/src/components/MarkdownBody.tsx new file mode 100644 index 0000000..63d94e6 --- /dev/null +++ b/client/src/components/MarkdownBody.tsx @@ -0,0 +1,37 @@ +import { useEffect, useRef } from "react"; +import { renderMarkdownInto } from "../lib/markdown"; +import { initPostToc, teardownPostTocUi } from "../lib/postToc"; + +export function MarkdownBody({ markdown }: { markdown: string }) { + const ref = useRef(null); + + useEffect(() => { + const el = ref.current; + if (!el) return; + teardownPostTocUi(); + if (!markdown.trim()) { + el.innerHTML = ""; + return; + } + el.classList.add("markdown-body", "markdown-pending"); + el.innerHTML = '

加载中…

'; + let cancelled = false; + (async () => { + try { + await renderMarkdownInto(el, markdown); + if (!cancelled) initPostToc(el); + } catch { + if (!cancelled) { + el.innerHTML = "

渲染失败。

"; + el.classList.remove("markdown-pending"); + } + } + })(); + return () => { + cancelled = true; + teardownPostTocUi(); + }; + }, [markdown]); + + return
; +} diff --git a/client/src/lib/markdown.ts b/client/src/lib/markdown.ts new file mode 100644 index 0000000..afd0762 --- /dev/null +++ b/client/src/lib/markdown.ts @@ -0,0 +1,73 @@ +import { marked } from "marked"; +import markedKatex from "marked-katex-extension"; +import hljs from "highlight.js"; + +marked.use( + markedKatex({ + throwOnError: false, + nonStandard: true + }) +); +marked.setOptions({ gfm: true, breaks: true }); + +function decodeHtmlEntities(str: string): string { + return String(str) + .replace(/ /g, "\u00a0") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&#(\d+);/g, (_, n) => { + const code = Number(n); + return code >= 0 && code <= 0x10ffff ? String.fromCharCode(code) : ""; + }) + .replace(/&#x([0-9a-fA-F]+);/g, (_, h) => { + const code = parseInt(h, 16); + return code >= 0 && code <= 0x10ffff ? String.fromCharCode(code) : ""; + }); +} + +function escapeHtml(s: string): string { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function mermaidFencedToDiv(html: string): string { + return html.replace( + /
([\s\S]*?)<\/code><\/pre>/gi,
+    (_, inner: string) => {
+      const raw = decodeHtmlEntities(inner);
+      return `
${escapeHtml(raw)}
`; + } + ); +} + +/** 将 Markdown 渲染进元素(含 KaTeX、高亮、Mermaid) */ +export async function renderMarkdownInto( + el: HTMLElement, + markdown: string +): Promise { + let html = marked.parse(markdown, { async: false }) as string; + if (typeof html !== "string") html = ""; + html = mermaidFencedToDiv(html); + el.innerHTML = html; + el.classList.remove("markdown-pending"); + el.querySelectorAll("pre code").forEach((block) => { + try { + hljs.highlightElement(block as HTMLElement); + } catch { + /* ignore */ + } + }); + const mNodes = el.querySelectorAll(".mermaid"); + if (mNodes.length) { + const mermaid = (await import("mermaid")).default; + mermaid.initialize({ startOnLoad: false, theme: "neutral" }); + await mermaid.run({ nodes: [...mNodes] as HTMLElement[] }); + } +} diff --git a/client/src/lib/postToc.ts b/client/src/lib/postToc.ts new file mode 100644 index 0000000..fc1845c --- /dev/null +++ b/client/src/lib/postToc.ts @@ -0,0 +1,246 @@ +/** + * 文章目录(由旧 post-toc.js 迁移) + */ +const HEADING_SEL = "h2, h3, h4"; + +function headingText(h: Element): string { + return (h.textContent || "").replace(/\s+/g, " ").trim(); +} + +function makeHeadingIds(headings: Element[]): void { + const used = new Set(); + headings.forEach((h) => { + if (h.id && !used.has(h.id)) { + used.add(h.id); + return; + } + const raw = headingText(h); + let base = + raw + .toLowerCase() + .replace(/\s+/g, "-") + .replace(/[^a-z0-9\u2e80-\u9fff-]/gi, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, "") || "section"; + let id = base; + let n = 0; + while (used.has(id) || document.getElementById(id)) id = `${base}-${++n}`; + h.id = id; + used.add(id); + }); +} + +function buildTocOl(headings: Element[]) { + const ol = document.createElement("ol"); + ol.className = "toc-tree"; + const links: HTMLAnchorElement[] = []; + const path: { level: number; li: HTMLLIElement }[] = []; + + for (const h of headings) { + const level = parseInt(h.tagName[1], 10); + if (level < 2 || level > 6) continue; + + const li = document.createElement("li"); + li.className = "toc-item"; + const a = document.createElement("a"); + a.className = "toc-link"; + a.href = "#" + h.id; + a.textContent = headingText(h); + a.dataset.targetId = h.id; + li.appendChild(a); + links.push(a); + + while (path.length && path[path.length - 1].level >= level) path.pop(); + + if (path.length === 0) { + ol.appendChild(li); + } else { + const parentLi = path[path.length - 1].li; + let sub = parentLi.querySelector(":scope > ol.toc-sub"); + if (!sub) { + sub = document.createElement("ol"); + sub.className = "toc-sub"; + parentLi.appendChild(sub); + } + sub.appendChild(li); + } + path.push({ level, li }); + } + + return { ol, links }; +} + +function setActive( + linkGroups: HTMLAnchorElement[][], + activeId: string | null +): void { + for (const links of linkGroups) { + for (const a of links) { + const on = a.dataset.targetId === activeId; + a.classList.toggle("toc-link--active", !!on); + } + } +} + +function bindScrollSpy( + headings: Element[], + linkGroups: HTMLAnchorElement[][], + desktopAside: HTMLElement | null, + mobileFab: HTMLElement | null, + scope: Element | null | undefined +): void { + const OFFSET = 100; + + function update() { + const inArticle = scope ? scope.getBoundingClientRect().bottom > 120 : true; + if (desktopAside) + desktopAside.classList.toggle("post-toc--hidden", !inArticle); + if (mobileFab) mobileFab.classList.toggle("toc-fab--hidden", !inArticle); + + const y = window.scrollY + OFFSET; + let current: string | null = headings[0]?.id || null; + for (const h of headings) { + if (h.getBoundingClientRect().top + window.scrollY <= y) + current = h.id || null; + } + if (current) setActive(linkGroups, current); + } + + let ticking = false; + function onScroll() { + if (ticking) return; + ticking = true; + requestAnimationFrame(() => { + update(); + ticking = false; + }); + } + + window.addEventListener("scroll", onScroll, { passive: true }); + window.addEventListener("resize", onScroll, { passive: true }); + update(); +} + +function bindTocLinks( + container: HTMLElement, + onNavigate?: (() => void) | null +): void { + container.addEventListener("click", (e) => { + const t = e.target as HTMLElement | null; + const a = t?.closest("a.toc-link") as HTMLAnchorElement | null; + if (!a) return; + const id = a.getAttribute("href")?.slice(1); + if (!id) return; + const target = document.getElementById(id); + if (target) { + e.preventDefault(); + target.scrollIntoView({ behavior: "smooth", block: "start" }); + history.replaceState(null, "", "#" + id); + onNavigate?.(); + } + }); +} + +function initDesktop( + aside: HTMLElement, + nav: HTMLElement, + headings: Element[] +): HTMLAnchorElement[] { + const { ol, links } = buildTocOl(headings); + nav.innerHTML = ""; + nav.appendChild(ol); + aside.hidden = false; + bindTocLinks(nav, null); + return links; +} + +function initMobile(headings: Element[]) { + if (document.getElementById("toc-fab")) return { links: [] as HTMLAnchorElement[], fab: null as HTMLElement | null }; + + const { ol, links } = buildTocOl(headings); + + const panel = document.createElement("div"); + panel.id = "toc-panel"; + panel.setAttribute("aria-label", "文章目录"); + panel.setAttribute("role", "navigation"); + const panelTitle = document.createElement("p"); + panelTitle.className = "toc-panel__title"; + panelTitle.textContent = "目录"; + panel.appendChild(panelTitle); + panel.appendChild(ol); + document.body.appendChild(panel); + + const fab = document.createElement("button"); + fab.id = "toc-fab"; + fab.type = "button"; + fab.setAttribute("aria-label", "打开文章目录"); + fab.setAttribute("aria-controls", "toc-panel"); + fab.setAttribute("aria-expanded", "false"); + fab.innerHTML = ` + `; + document.body.appendChild(fab); + + function openPanel() { + panel.classList.add("toc-panel--open"); + fab.setAttribute("aria-expanded", "true"); + fab.setAttribute("aria-label", "关闭文章目录"); + fab.classList.add("toc-fab--open"); + } + function closePanel() { + panel.classList.remove("toc-panel--open"); + fab.setAttribute("aria-expanded", "false"); + fab.setAttribute("aria-label", "打开文章目录"); + fab.classList.remove("toc-fab--open"); + } + + fab.addEventListener("click", () => { + panel.classList.contains("toc-panel--open") ? closePanel() : openPanel(); + }); + + document.addEventListener("keydown", (e) => { + if (e.key === "Escape") closePanel(); + }); + + bindTocLinks(panel, closePanel); + + return { links, fab }; +} + +export function teardownPostTocUi(): void { + document.getElementById("toc-fab")?.remove(); + document.getElementById("toc-panel")?.remove(); +} + +export function initPostToc(markdownBody: HTMLElement | null): void { + const aside = document.getElementById("post-toc"); + const nav = document.getElementById("post-toc-nav"); + if (!markdownBody) return; + + const headings = [...markdownBody.querySelectorAll(HEADING_SEL)]; + if (headings.length === 0) { + if (aside) aside.hidden = true; + return; + } + + makeHeadingIds(headings); + const scope = markdownBody.closest(".post-with-toc"); + + let desktopLinks: HTMLAnchorElement[] = []; + if (aside && nav) { + desktopLinks = initDesktop(aside, nav, headings); + } + + const { links: mobileLinks, fab: mobileElem } = initMobile(headings); + + const fab = mobileElem || document.getElementById("toc-fab"); + bindScrollSpy(headings, [desktopLinks, mobileLinks], aside, fab, scope); + + if (location.hash.length > 1) { + const id = decodeURIComponent(location.hash.slice(1)); + if (headings.some((h) => h.id === id)) { + setActive([desktopLinks, mobileLinks], id); + } + } +} diff --git a/utils.js b/client/src/lib/text.ts similarity index 50% rename from utils.js rename to client/src/lib/text.ts index d7fd3d0..d1ea2c7 100644 --- a/utils.js +++ b/client/src/lib/text.ts @@ -1,37 +1,4 @@ -export function cleanText(value) { - return String(value ?? "").trim(); -} - -export function isTruthy(value) { - return value === true || value === "true" || value === "1" || value === 1 || value === "on" || value === "yes"; -} - -export function escapeHtml(value) { - return String(value ?? "") - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - -export function escapeAttr(value) { - return escapeHtml(value).replace(/`/g, "`"); -} - -export function formatDateTime(value) { - if (!value) return ""; - const date = new Date(value); - if (Number.isNaN(date.getTime())) return escapeHtml(value); - const yyyy = date.getFullYear(); - const mm = String(date.getMonth() + 1).padStart(2, "0"); - const dd = String(date.getDate()).padStart(2, "0"); - const hh = String(date.getHours()).padStart(2, "0"); - const mi = String(date.getMinutes()).padStart(2, "0"); - return `${yyyy}-${mm}-${dd} ${hh}:${mi}`; -} - -export function stripMarkdown(text) { +export function stripMarkdown(text: unknown): string { return String(text ?? "") .replace(/```[\s\S]*?```/g, " ") .replace(/\$\$[\s\S]*?\$\$/g, " ") @@ -47,6 +14,14 @@ export function stripMarkdown(text) { .trim(); } -export function b64EncodeUtf8(str) { - return btoa(unescape(encodeURIComponent(str))); +export function formatDateTime(value: unknown): string { + if (!value) return ""; + const date = new Date(String(value)); + if (Number.isNaN(date.getTime())) return String(value); + const yyyy = date.getFullYear(); + const mm = String(date.getMonth() + 1).padStart(2, "0"); + const dd = String(date.getDate()).padStart(2, "0"); + const hh = String(date.getHours()).padStart(2, "0"); + const mi = String(date.getMinutes()).padStart(2, "0"); + return `${yyyy}-${mm}-${dd} ${hh}:${mi}`; } diff --git a/client/src/main.tsx b/client/src/main.tsx new file mode 100644 index 0000000..4835fb3 --- /dev/null +++ b/client/src/main.tsx @@ -0,0 +1,11 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { App } from "./App"; +import "./styles/app.css"; +import "./styles/markdown.css"; + +createRoot(document.getElementById("root")!).render( + + + +); diff --git a/client/src/pages/Admin.tsx b/client/src/pages/Admin.tsx new file mode 100644 index 0000000..3045d13 --- /dev/null +++ b/client/src/pages/Admin.tsx @@ -0,0 +1,234 @@ +import { FormEvent, useEffect, useState } from "react"; +import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom"; +import { + deletePostApi, + fetchPost, + fetchPosts, + savePost, + type Post +} from "../api"; +import { useSiteConfig } from "../ConfigContext"; + +export function AdminDashboard() { + const { siteTitle } = useSiteConfig(); + const [q, setQ] = useState(""); + const [inputQ, setInputQ] = useState(""); + const [posts, setPosts] = useState([]); + + useEffect(() => { + document.title = `后台 - ${siteTitle}`; + }, [siteTitle]); + + useEffect(() => { + const id = window.setTimeout(() => setQ(inputQ.trim()), 300); + return () => clearTimeout(id); + }, [inputQ]); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const list = await fetchPosts(q || undefined, { + credentials: "include" + }); + if (!cancelled) setPosts(list); + } catch { + if (!cancelled) setPosts([]); + } + })(); + return () => { + cancelled = true; + }; + }, [q]); + + return ( + <> +

+ 返回 +

+

后台

+

+ 新建文章 +

+
+

文章列表

+
e.preventDefault()} + > +

+ +

+
+ {posts.length ? ( + posts.map((post) => ( +
+

{post.title}

+

+ ID: {post.id} | Slug: {post.slug} |{" "} + {post.published ? "已发布" : "草稿"} +

+
+ 编辑 + setPosts((p) => p.filter((x) => x.id !== post.id))} /> +
+
+ )) + ) : ( +

没有找到文章。

+ )} +
+ + ); +} + +function DeleteButton({ + postId, + onDone +}: { + postId: number; + onDone: () => void; +}) { + return ( +
{ + e.preventDefault(); + if (!confirm("确定删除?")) return; + try { + await deletePostApi(postId); + onDone(); + } catch { + alert("删除失败"); + } + }} + > + +
+ ); +} + +export function AdminEditor() { + const { id: idParam } = useParams(); + const [search] = useSearchParams(); + const navigate = useNavigate(); + const { siteTitle } = useSiteConfig(); + const id = idParam ? Number(idParam) : Number(search.get("id") || "") || null; + + const [title, setTitle] = useState(""); + const [slug, setSlug] = useState(""); + const [excerpt, setExcerpt] = useState(""); + const [content, setContent] = useState(""); + const [published, setPublished] = useState(true); + + useEffect(() => { + document.title = `后台 - ${siteTitle}`; + }, [siteTitle]); + + useEffect(() => { + if (!id) { + setTitle(""); + setSlug(""); + setExcerpt(""); + setContent(""); + setPublished(true); + return; + } + let cancelled = false; + (async () => { + try { + const post = await fetchPost(String(id), false, { + credentials: "include" + }); + if (cancelled) return; + setTitle(post.title); + setSlug(post.slug); + setExcerpt(post.excerpt || ""); + setContent(post.content || ""); + setPublished(!!post.published); + } catch { + if (!cancelled) alert("加载文章失败"); + } + })(); + return () => { + cancelled = true; + }; + }, [id]); + + async function onSubmit(e: FormEvent) { + e.preventDefault(); + try { + const payload = { + title, + slug, + excerpt, + content, + published: published ? 1 : 0, + id: id || undefined + }; + const saved = await savePost(payload, id); + navigate(`/admin/edit/${saved.id}?saved=1`, { replace: true }); + } catch (er) { + alert(er instanceof Error ? er.message : "保存失败"); + } + } + + return ( + <> +

+ 返回后台 +

+

{id ? `编辑 #${id}` : "新建文章"}

+
+

+ +

+

+ +

+

+ +

+

+ -

-

- -

-

-
- - -
- -
-

文章列表

-
-

- -

-

-
- ${posts.length ? posts.map(renderAdminCard).join("") : "

没有找到文章。

"} -
- `, env)); -} - -async function handleAdminSave(request, env, url) { - const auth = await requireAdmin(request, env); - if (auth) return auth; - - const data = await readBody(request); - const rawId = data.id ? Number(data.id) : null; - const id = Number.isFinite(rawId) && rawId > 0 ? rawId : null; - const title = cleanText(data.title); - const content = cleanText(data.content); - const excerpt = cleanText(data.excerpt); - const slugBase = cleanText(data.slug) || title; - const published = isTruthy(data.published) ? 1 : 0; - - if (!title || !content) { - return htmlResponse(renderLayout("表单错误", ` -

标题和内容不能为空

-

返回后台

- `, env), 400); - } - - const slug = await uniqueSlug(env.DB, slugify(slugBase), id); - const now = new Date().toISOString(); - - if (id) { - const exists = await getPostById(env.DB, id); - if (!exists) return notFoundPage(env); - await env.DB.prepare(` - UPDATE posts - SET title = ?, slug = ?, excerpt = ?, content = ?, published = ?, updated_at = ? - WHERE id = ? - `).bind(title, slug, excerpt, content, published, now, id).run(); - } else { - const result = await env.DB.prepare(` - INSERT INTO posts (title, slug, excerpt, content, published, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?) - `).bind(title, slug, excerpt, content, published, now, now).run(); - return Response.redirect(new URL(`/admin?id=${result.meta.last_row_id}&saved=1`, url), 303); - } - - return Response.redirect(new URL(`/admin?id=${id}&saved=1`, url), 303); -} - -async function handleAdminDelete(request, env, url) { - const auth = await requireAdmin(request, env); - if (auth) return auth; - - const data = await readBody(request); - const id = Number(data.id); - if (!Number.isFinite(id) || id <= 0) { - return htmlResponse(renderLayout("表单错误", ` -

无效的文章 ID

-

返回后台

- `, env), 400); - } - - await env.DB.prepare("DELETE FROM posts WHERE id = ?").bind(id).run(); - return Response.redirect(new URL(`/admin?deleted=1`, url), 303); -} - -async function handleAdminSession(request, env) { - const password = cleanText(env.ADMIN_PASSWORD); - if (!password) { - return jsonResponse({ ok: false, error: "not_configured" }, 503); - } - - const data = await readBody(request); - const token = cleanText(data.token); - if (token !== password) { - return jsonResponse({ ok: false, error: "invalid" }, 401); - } - - const value = await adminSessionCookieValue(env); - const maxAge = 60 * 60 * 24 * 7; - const secure = new URL(request.url).protocol === "https:"; - const parts = [`sb_admin=${value}`, "Path=/", "HttpOnly", "SameSite=Lax", `Max-Age=${maxAge}`]; - if (secure) parts.push("Secure"); - - return jsonResponse({ ok: true }, 200, { - "Set-Cookie": parts.join("; ") - }); -} - -async function handleApiList(env, url) { - const q = (url.searchParams.get("q") || "").trim(); - const posts = await listPosts(env.DB, { publishedOnly: false, q }); - return jsonResponse({ ok: true, posts }); -} - -async function handleApiRead(env, idOrSlug) { - const post = await readPost(env.DB, idOrSlug); - if (!post) return jsonResponse({ ok: false, error: "Not found" }, 404); - return jsonResponse({ ok: true, post }); -} - -async function handleApiCreate(request, env) { - const auth = await requireAdmin(request, env); - if (auth) return auth; - - const data = await readBody(request); - const created = await createOrUpdatePost(env.DB, data, null); - return jsonResponse({ ok: true, post: created }, 201); -} - -async function handleApiUpdate(request, env, idOrSlug) { - const auth = await requireAdmin(request, env); - if (auth) return auth; - - const data = await readBody(request); - const existing = await readPost(env.DB, idOrSlug); - if (!existing) return jsonResponse({ ok: false, error: "Not found" }, 404); - const updated = await createOrUpdatePost(env.DB, data, existing.id); - return jsonResponse({ ok: true, post: updated }); -} - -async function handleApiDelete(request, env, idOrSlug) { - const auth = await requireAdmin(request, env); - if (auth) return auth; - - const post = await readPost(env.DB, idOrSlug); - if (!post) return jsonResponse({ ok: false, error: "Not found" }, 404); - await env.DB.prepare("DELETE FROM posts WHERE id = ?").bind(post.id).run(); - return jsonResponse({ ok: true, deleted: post.id }); -} - -async function createOrUpdatePost(db, data, currentId) { - const rawId = currentId ? Number(currentId) : null; - const id = Number.isFinite(rawId) && rawId > 0 ? rawId : null; - const title = cleanText(data.title); - const content = cleanText(data.content); - const excerpt = cleanText(data.excerpt); - const slugBase = cleanText(data.slug) || title; - const published = isTruthy(data.published) ? 1 : 0; - - if (!title || !content) { - throw new Error("title 和 content 不能为空"); - } - - const slug = await uniqueSlug(db, slugify(slugBase), id); - const now = new Date().toISOString(); - - if (id) { - await db.prepare(` - UPDATE posts - SET title = ?, slug = ?, excerpt = ?, content = ?, published = ?, updated_at = ? - WHERE id = ? - `).bind(title, slug, excerpt, content, published, now, id).run(); - return await getPostById(db, id); - } - - const result = await db.prepare(` - INSERT INTO posts (title, slug, excerpt, content, published, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?) - `).bind(title, slug, excerpt, content, published, now, now).run(); - return await getPostById(db, result.meta.last_row_id); -} - -async function readPost(db, idOrSlug) { - if (!idOrSlug) return null; - const trimmed = decodeURIComponent(String(idOrSlug)).trim(); - if (/^\d+$/.test(trimmed)) { - return await getPostById(db, Number(trimmed)); - } - return await db.prepare("SELECT * FROM posts WHERE slug = ? LIMIT 1").bind(trimmed).first(); -} - -async function getPostById(db, id) { - return await db.prepare("SELECT * FROM posts WHERE id = ? LIMIT 1").bind(Number(id)).first(); -} - -async function listPosts(db, { publishedOnly = true, q = "" } = {}) { - const params = []; - const where = []; - if (publishedOnly) where.push("published = 1"); - if (q) { - where.push("(title LIKE ? OR slug LIKE ? OR excerpt LIKE ? OR content LIKE ?)"); - const like = `%${q}%`; - params.push(like, like, like, like); - } - const sql = ` - SELECT * FROM posts - ${where.length ? `WHERE ${where.join(" AND ")}` : ""} - ORDER BY published DESC, updated_at DESC, id DESC - `; - const statement = db.prepare(sql); - const { results } = params.length ? await statement.bind(...params).all() : await statement.all(); - return results || []; -} - -async function uniqueSlug(db, baseSlug, currentId) { - let slug = baseSlug || "post"; - let suffix = 2; - - while (true) { - const existing = await db.prepare("SELECT id FROM posts WHERE slug = ? LIMIT 1").bind(slug).first(); - if (!existing || Number(existing.id) === Number(currentId)) return slug; - slug = `${baseSlug || "post"}-${suffix++}`; - } -} - -function slugify(value) { - const raw = cleanText(value); - const slug = raw - .normalize("NFKD") - .replace(/[\u0300-\u036f]/g, "") - .replace(/[^\p{L}\p{N}]+/gu, "-") - .replace(/^-+|-+$/g, "") - .replace(/-+/g, "-") - .toLowerCase(); - return slug || "post"; -} -function normalizePath(pathname) { - if (pathname === "/") return "/"; - return pathname.replace(/\/+$/, ""); -} - -function htmlResponse(html, status = 200, headers = {}) { - return new Response(html, { - status, - headers: { - "content-type": "text/html; charset=UTF-8", - ...headers - } - }); -} - -function jsonResponse(data, status = 200, extraHeaders = {}) { - return new Response(JSON.stringify(data, null, 2), { - status, - headers: { - "content-type": "application/json; charset=UTF-8", - ...corsHeaders(), - ...extraHeaders - } - }); -} - -function notFoundPage(env) { - return htmlResponse(renderLayout("404", ` -

404

-

页面不存在。

-

返回

- `, env), 404); -} - -function corsHeaders() { - return { - "access-control-allow-origin": "*", - "access-control-allow-methods": "GET, POST, PUT, DELETE, OPTIONS", - "access-control-allow-headers": "*" - }; -} - -async function requireAdmin(request, env) { - const password = cleanText(env.ADMIN_PASSWORD); - if (!password) return null; - - const cookies = parseCookie(request.headers.get("Cookie") || ""); - const expected = await adminSessionCookieValue(env); - if (cookies.sb_admin && cookies.sb_admin === expected) { - return null; - } - - const header = request.headers.get("Authorization") || ""; - if (header.startsWith("Basic ")) { - try { - const decoded = atob(header.slice(6)); - const split = decoded.indexOf(":"); - const inputPassword = split >= 0 ? decoded.slice(split + 1) : ""; - if (inputPassword === password) return null; - } catch { - // ignore - } - } - - return unauthorized(); -} - -function parseCookie(header) { - const out = {}; - if (!header) return out; - for (const part of header.split(";")) { - const idx = part.indexOf("="); - if (idx === -1) continue; - const k = part.slice(0, idx).trim(); - const v = part.slice(idx + 1).trim(); - try { - out[k] = decodeURIComponent(v); - } catch { - out[k] = v; - } - } - return out; -} - -async function adminSessionCookieValue(env) { - const password = cleanText(env.ADMIN_PASSWORD); - const enc = new TextEncoder(); - const data = enc.encode(`${password}\0sproutblog_admin_v1`); - const hash = await crypto.subtle.digest("SHA-256", data); - return [...new Uint8Array(hash)].map((b) => b.toString(16).padStart(2, "0")).join(""); -} - -function unauthorized() { - return new Response("Authentication required", { - status: 401, - headers: { - "content-type": "text/plain; charset=UTF-8" - } - }); -} - -async function readBody(request) { - const type = request.headers.get("content-type") || ""; - if (type.includes("application/json")) { - return await request.json(); - } - - if (type.includes("multipart/form-data") || type.includes("application/x-www-form-urlencoded")) { - const form = await request.formData(); - const data = {}; - for (const [key, value] of form.entries()) { - data[key] = value; - } - return data; - } - - return {}; -} -function blankPost() { - return { - id: "", - title: "", - slug: "", - excerpt: "", - content: "", - published: 1 - }; -} - diff --git a/src/api/router.ts b/src/api/router.ts new file mode 100644 index 0000000..f2e1c06 --- /dev/null +++ b/src/api/router.ts @@ -0,0 +1,56 @@ +import { jsonResponse, corsHeaders } from "../lib/http"; +import { normalizePath } from "../lib/slug"; +import { handleConfigGet } from "./routes/config"; +import { handleAdminSessionPost } from "./routes/admin-session"; +import { + handlePostsCollection, + handlePostItem +} from "./routes/posts"; + +export async function handleApi(request: Request, env: Env): Promise { + if (!env.DB) { + return jsonResponse({ ok: false, error: "db_not_configured" }, 500); + } + + if (request.method === "OPTIONS") { + return new Response(null, { headers: corsHeaders() }); + } + + const url = new URL(request.url); + const path = normalizePath(url.pathname); + const segments = path.split("/").filter(Boolean); + + if (segments[0] !== "api") { + return jsonResponse({ ok: false, error: "Not found" }, 404); + } + + const parts = segments.slice(1); + + if ( + parts[0] === "config" && + parts.length === 1 && + request.method === "GET" + ) { + return handleConfigGet(env); + } + + if ( + parts[0] === "admin" && + parts[1] === "session" && + parts.length === 2 && + request.method === "POST" + ) { + return handleAdminSessionPost(request, env); + } + + if (parts[0] === "posts") { + if (parts.length === 1) { + return handlePostsCollection(request, env, url); + } + if (parts.length === 2) { + return handlePostItem(request, env, url, parts[1]); + } + } + + return jsonResponse({ ok: false, error: "Not found" }, 404); +} diff --git a/src/api/routes/admin-session.ts b/src/api/routes/admin-session.ts new file mode 100644 index 0000000..b3ef30d --- /dev/null +++ b/src/api/routes/admin-session.ts @@ -0,0 +1,29 @@ +import { jsonResponse, readBody, getAdminPassword } from "../../lib/http"; +import { cleanText } from "../../lib/text"; +import { adminSessionCookieValue } from "../../lib/auth"; + +export async function handleAdminSessionPost( + request: Request, + env: Env +): Promise { + const password = getAdminPassword(env); + if (!password) { + return jsonResponse({ ok: false, error: "not_configured" }, 503); + } + + const data = await readBody(request); + const token = cleanText(data.token); + if (token !== password) { + return jsonResponse({ ok: false, error: "invalid" }, 401); + } + + const value = await adminSessionCookieValue(env); + const maxAge = 60 * 60 * 24 * 7; + const secure = new URL(request.url).protocol === "https:"; + const parts = [`sb_admin=${value}`, "Path=/", "HttpOnly", "SameSite=Lax", `Max-Age=${maxAge}`]; + if (secure) parts.push("Secure"); + + return jsonResponse({ ok: true }, 200, { + "Set-Cookie": parts.join("; ") + }); +} diff --git a/src/api/routes/config.ts b/src/api/routes/config.ts new file mode 100644 index 0000000..66c4ae2 --- /dev/null +++ b/src/api/routes/config.ts @@ -0,0 +1,17 @@ +import { jsonResponse } from "../../lib/http"; +import { cleanText } from "../../lib/text"; + +export function handleConfigGet(env: Env): Response { + const cwdApiBase = + cleanText(env.CWD_API_BASE) || "https://cwd.api.smyhub.com"; + const cwdSiteId = cleanText(env.CWD_SITE_ID) || ""; + return jsonResponse({ + ok: true, + config: { + siteTitle: cleanText(env.SITE_TITLE) || "萌芽小窝", + siteTitleEn: cleanText(env.SITE_TITLE_EN) || "SproutBlog", + cwdApiBase, + cwdSiteId: cwdSiteId || undefined + } + }); +} diff --git a/src/api/routes/posts.ts b/src/api/routes/posts.ts new file mode 100644 index 0000000..654d7ed --- /dev/null +++ b/src/api/routes/posts.ts @@ -0,0 +1,113 @@ +import { jsonResponse, readBody } from "../../lib/http"; +import { requireAdmin, canAccessDrafts, hasValidAdminSession } from "../../lib/auth"; +import { + listPosts, + readPost, + createOrUpdatePost, + bumpViewCount, + deletePost, + type PostRow +} from "../../db/posts"; + +function numPublished(p: PostRow): number { + return Number(p.published) ? 1 : 0; +} + +async function listForRequest( + request: Request, + env: Env, + url: URL +): Promise { + const q = (url.searchParams.get("q") || "").trim(); + const seeAll = await canAccessDrafts(request, env); + const publishedOnly = !seeAll; + const posts = await listPosts(env.DB, { publishedOnly, q }); + return jsonResponse({ ok: true, posts }); +} + +export async function handlePostsCollection( + request: Request, + env: Env, + url: URL +): Promise { + if (request.method === "GET") { + return listForRequest(request, env, url); + } + + if (request.method === "POST") { + const denied = await requireAdmin(request, env); + if (denied) return denied; + const data = await readBody(request); + try { + const created = await createOrUpdatePost(env.DB, data, null); + return jsonResponse({ ok: true, post: created }, 201); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return jsonResponse({ ok: false, error: msg }, 400); + } + } + + return jsonResponse({ ok: false, error: "Method not allowed" }, 405); +} + +export async function handlePostItem( + request: Request, + env: Env, + url: URL, + idOrSlug: string +): Promise { + const bump = url.searchParams.get("bump") === "1"; + + if (request.method === "GET") { + const post = await readPost(env.DB, idOrSlug); + if (!post) return jsonResponse({ ok: false, error: "Not found" }, 404); + + const draftsOk = await canAccessDrafts(request, env); + if (!draftsOk && numPublished(post) !== 1) { + return jsonResponse({ ok: false, error: "Not found" }, 404); + } + + let row = post; + const shouldBump = + bump && + numPublished(post) === 1 && + !(await hasValidAdminSession(request, env)); + if (shouldBump) { + await bumpViewCount(env.DB, Number(post.id)); + const refreshed = await readPost(env.DB, idOrSlug); + if (refreshed) row = refreshed; + } + + return jsonResponse({ ok: true, post: row }); + } + + if (request.method === "PUT") { + const denied = await requireAdmin(request, env); + if (denied) return denied; + const data = await readBody(request); + const existing = await readPost(env.DB, idOrSlug); + if (!existing) return jsonResponse({ ok: false, error: "Not found" }, 404); + try { + const updated = await createOrUpdatePost( + env.DB, + data, + Number(existing.id) + ); + return jsonResponse({ ok: true, post: updated }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return jsonResponse({ ok: false, error: msg }, 400); + } + } + + if (request.method === "DELETE") { + const denied = await requireAdmin(request, env); + if (denied) return denied; + const post = await readPost(env.DB, idOrSlug); + if (!post) return jsonResponse({ ok: false, error: "Not found" }, 404); + await deletePost(env.DB, Number(post.id)); + return jsonResponse({ ok: true, deleted: post.id }); + } + + return jsonResponse({ ok: false, error: "Method not allowed" }, 405); +} diff --git a/src/bindings.d.ts b/src/bindings.d.ts new file mode 100644 index 0000000..005d59a --- /dev/null +++ b/src/bindings.d.ts @@ -0,0 +1,9 @@ +interface Env { + DB: D1Database; + ASSETS: Fetcher; + SITE_TITLE: string; + SITE_TITLE_EN: string; + ADMIN_PASSWORD?: string; + CWD_API_BASE?: string; + CWD_SITE_ID?: string; +} diff --git a/src/db/posts.ts b/src/db/posts.ts new file mode 100644 index 0000000..279676f --- /dev/null +++ b/src/db/posts.ts @@ -0,0 +1,135 @@ +import { cleanText, isTruthy } from "../lib/text"; +import { slugify } from "../lib/slug"; + +type Db = Env["DB"]; + +export type PostRow = Record; + +export function blankPost(): PostRow { + return { + id: "", + title: "", + slug: "", + excerpt: "", + content: "", + published: 1 + }; +} + +export async function getPostById(db: Db, id: number): Promise { + return (await db + .prepare("SELECT * FROM posts WHERE id = ? LIMIT 1") + .bind(Number(id)) + .first()) as PostRow | null; +} + +export async function readPost(db: Db, idOrSlug: string): Promise { + if (!idOrSlug) return null; + const trimmed = decodeURIComponent(String(idOrSlug)).trim(); + if (/^\d+$/.test(trimmed)) { + return getPostById(db, Number(trimmed)); + } + return (await db + .prepare("SELECT * FROM posts WHERE slug = ? LIMIT 1") + .bind(trimmed) + .first()) as PostRow | null; +} + +export async function listPosts( + db: Db, + { publishedOnly = true, q = "" } = {} +): Promise { + const params: string[] = []; + const where: string[] = []; + if (publishedOnly) where.push("published = 1"); + if (q) { + where.push("(title LIKE ? OR slug LIKE ? OR excerpt LIKE ? OR content LIKE ?)"); + const like = `%${q}%`; + params.push(like, like, like, like); + } + const sql = ` + SELECT * FROM posts + ${where.length ? `WHERE ${where.join(" AND ")}` : ""} + ORDER BY published DESC, updated_at DESC, id DESC + `; + const statement = db.prepare(sql); + const { results } = params.length + ? await statement.bind(...params).all() + : await statement.all(); + return (results || []) as PostRow[]; +} + +export async function uniqueSlug( + db: Db, + baseSlug: string, + currentId: number | null +): Promise { + let slug = baseSlug || "post"; + let suffix = 2; + + while (true) { + const existing = (await db + .prepare("SELECT id FROM posts WHERE slug = ? LIMIT 1") + .bind(slug) + .first()) as { id: number } | null; + if (!existing || Number(existing.id) === Number(currentId)) return slug; + slug = `${baseSlug || "post"}-${suffix++}`; + } +} + +export async function createOrUpdatePost( + db: Db, + data: Record, + currentId: number | null +): Promise { + const rawId = currentId != null ? Number(currentId) : NaN; + const id = Number.isFinite(rawId) && rawId > 0 ? rawId : null; + const title = cleanText(data.title); + const content = cleanText(data.content); + const excerpt = cleanText(data.excerpt); + const slugBase = cleanText(data.slug) || title; + const published = isTruthy(data.published) ? 1 : 0; + + if (!title || !content) { + throw new Error("title 和 content 不能为空"); + } + + const slug = await uniqueSlug(db, slugify(slugBase), id); + const now = new Date().toISOString(); + + if (id) { + await db + .prepare(` + UPDATE posts + SET title = ?, slug = ?, excerpt = ?, content = ?, published = ?, updated_at = ? + WHERE id = ? + `) + .bind(title, slug, excerpt, content, published, now, id) + .run(); + return getPostById(db, id); + } + + const result = await db + .prepare(` + INSERT INTO posts (title, slug, excerpt, content, published, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `) + .bind(title, slug, excerpt, content, published, now, now) + .run(); + const newId = result.meta?.last_row_id; + if (newId == null) return null; + return getPostById(db, Number(newId)); +} + +export async function bumpViewCount(db: Db, postId: number): Promise { + await db + .prepare( + "UPDATE posts SET view_count = COALESCE(view_count, 0) + 1 WHERE id = ?" + ) + .bind(postId) + .run(); +} + +export async function deletePost(db: Db, id: number): Promise { + await db.prepare("DELETE FROM posts WHERE id = ?").bind(id).run(); +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..57e4c75 --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,78 @@ +import { cleanText } from "./text"; +import { getAdminPassword, unauthorized } from "./http"; + +export function parseCookie(header: string): Record { + const out: Record = {}; + if (!header) return out; + for (const part of header.split(";")) { + const idx = part.indexOf("="); + if (idx === -1) continue; + const k = part.slice(0, idx).trim(); + const v = part.slice(idx + 1).trim(); + try { + out[k] = decodeURIComponent(v); + } catch { + out[k] = v; + } + } + return out; +} + +export async function adminSessionCookieValue(env: Env): Promise { + const password = getAdminPassword(env); + const enc = new TextEncoder(); + const data = enc.encode(`${password}\0sproutblog_admin_v1`); + const hash = await crypto.subtle.digest("SHA-256", data); + return [...new Uint8Array(hash)] + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} + +/** + * 需要后台权限的接口:未配置密码则放行;否则校验 Cookie 或 Basic Auth。 + * 返回 null 表示已通过;返回 Response 为 401。 + */ +export async function requireAdmin( + request: Request, + env: Env +): Promise { + const password = getAdminPassword(env); + if (!password) return null; + + const cookies = parseCookie(request.headers.get("Cookie") || ""); + const expected = await adminSessionCookieValue(env); + if (cookies.sb_admin && cookies.sb_admin === expected) { + return null; + } + + const header = request.headers.get("Authorization") || ""; + if (header.startsWith("Basic ")) { + try { + const decoded = atob(header.slice(6)); + const split = decoded.indexOf(":"); + const inputPassword = split >= 0 ? decoded.slice(split + 1) : ""; + if (inputPassword === password) return null; + } catch { + /* ignore */ + } + } + + return unauthorized(); +} + +/** 已配置密码且当前请求具备后台凭证(Cookie / Basic) */ +export async function hasValidAdminSession( + request: Request, + env: Env +): Promise { + const password = getAdminPassword(env); + if (!password) return false; + return (await requireAdmin(request, env)) === null; +} + +/** 是否可列出/阅读草稿(未配置密码时保持原先「全量可见」行为) */ +export async function canAccessDrafts(request: Request, env: Env): Promise { + const password = getAdminPassword(env); + if (!password) return true; + return (await requireAdmin(request, env)) === null; +} diff --git a/src/lib/http.ts b/src/lib/http.ts new file mode 100644 index 0000000..89c4c87 --- /dev/null +++ b/src/lib/http.ts @@ -0,0 +1,63 @@ +import { cleanText } from "./text"; + +export function corsHeaders(): Record { + return { + "access-control-allow-origin": "*", + "access-control-allow-methods": "GET, POST, PUT, DELETE, OPTIONS", + "access-control-allow-headers": "*" + }; +} + +export function jsonResponse( + data: unknown, + status = 200, + extraHeaders: Record = {} +): Response { + return new Response(JSON.stringify(data, null, 2), { + status, + headers: { + "content-type": "application/json; charset=UTF-8", + ...corsHeaders(), + ...extraHeaders + } + }); +} + +export async function readBody(request: Request): Promise> { + const type = request.headers.get("content-type") || ""; + if (type.includes("application/json")) { + try { + return (await request.json()) as Record; + } catch { + return {}; + } + } + + if ( + type.includes("multipart/form-data") || + type.includes("application/x-www-form-urlencoded") + ) { + const form = await request.formData(); + const data: Record = {}; + for (const [key, value] of form.entries()) { + data[key] = value; + } + return data; + } + + return {}; +} + +export function unauthorized(): Response { + return new Response("Authentication required", { + status: 401, + headers: { + "content-type": "text/plain; charset=UTF-8" + } + }); +} + +/** Basic Auth / Cookie 中的密码校验用原始字符串 */ +export function getAdminPassword(env: Pick): string { + return cleanText(env.ADMIN_PASSWORD); +} diff --git a/src/lib/slug.ts b/src/lib/slug.ts new file mode 100644 index 0000000..3c08f1c --- /dev/null +++ b/src/lib/slug.ts @@ -0,0 +1,18 @@ +import { cleanText } from "./text"; + +export function normalizePath(pathname: string): string { + if (pathname === "/") return "/"; + return pathname.replace(/\/+$/, ""); +} + +export function slugify(value: unknown): string { + const raw = cleanText(value); + const slug = raw + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^\p{L}\p{N}]+/gu, "-") + .replace(/^-+|-+$/g, "") + .replace(/-+/g, "-") + .toLowerCase(); + return slug || "post"; +} diff --git a/src/lib/text.ts b/src/lib/text.ts new file mode 100644 index 0000000..762de8d --- /dev/null +++ b/src/lib/text.ts @@ -0,0 +1,16 @@ +/** 文本与表单常用工具(Worker 侧) */ + +export function cleanText(value: unknown): string { + return String(value ?? "").trim(); +} + +export function isTruthy(value: unknown): boolean { + return ( + value === true || + value === "true" || + value === "1" || + value === 1 || + value === "on" || + value === "yes" + ); +} diff --git a/src/worker/index.ts b/src/worker/index.ts new file mode 100644 index 0000000..b3049ba --- /dev/null +++ b/src/worker/index.ts @@ -0,0 +1,15 @@ +import { handleApi } from "../api/router"; + +export default { + async fetch( + request: Request, + env: Env, + _ctx: ExecutionContext + ): Promise { + const url = new URL(request.url); + if (url.pathname.startsWith("/api/")) { + return handleApi(request, env); + } + return env.ASSETS.fetch(request); + } +}; diff --git a/tsconfig.worker.json b/tsconfig.worker.json new file mode 100644 index 0000000..263a559 --- /dev/null +++ b/tsconfig.worker.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "types": ["@cloudflare/workers-types"], + "strict": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src/**/*.ts", "src/**/*.d.ts"] +} diff --git a/worker.js b/worker.js deleted file mode 100644 index c3a2e14..0000000 --- a/worker.js +++ /dev/null @@ -1,3 +0,0 @@ -import app from "./server.js"; - -export default app; diff --git a/wrangler.toml b/wrangler.toml index 3b2cb98..6823be7 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -1,5 +1,5 @@ name = "sproutblog" -main = "worker.js" +main = "src/worker/index.ts" compatibility_date = "2026-04-13" routes = [ @@ -7,7 +7,10 @@ routes = [ ] [assets] -directory = "./public/" +directory = "./dist" +binding = "ASSETS" +not_found_handling = "single-page-application" +run_worker_first = [ "/api/*" ] [vars] SITE_TITLE = "萌芽小窝"