From 10ff96f32bd0b8b7bc3bd99f0d988467a9f0090f Mon Sep 17 00:00:00 2001 From: shumengya Date: Wed, 15 Apr 2026 13:20:51 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/css/app.css | 278 +++++++++++ public/js/app.js | 59 +++ public/js/cwd-comments.js | 78 ++++ public/js/markdown-client.js | 3 +- render.js | 131 ++++++ server.js | 504 ++++++++++++++++++++ utils.js | 52 +++ worker.js | 871 +---------------------------------- wrangler.toml | 3 + 9 files changed, 1109 insertions(+), 870 deletions(-) create mode 100644 public/css/app.css create mode 100644 public/js/app.js create mode 100644 public/js/cwd-comments.js create mode 100644 render.js create mode 100644 server.js create mode 100644 utils.js diff --git a/public/css/app.css b/public/css/app.css new file mode 100644 index 0000000..8537e02 --- /dev/null +++ b/public/css/app.css @@ -0,0 +1,278 @@ +:root { + --page-max: 920px; + --page-pad-x: 24px; + --page-pad-y: 24px; + --page-gap: 24px; + --text: #111; + --muted: #555; + --line: #e8e8e8; +} +html { + margin: 0; + padding: 0; + background: #fff; + color: var(--text); + font-family: "FangSong", serif; + -webkit-text-size-adjust: 100%; + text-size-adjust: 100%; +} +body { + margin: 0; + padding: 0; + background: #fff; + color: var(--text); + font-family: "FangSong", serif; + line-height: 1.7; + overflow-x: hidden; +} +main { + width: min(100%, var(--page-max)); + margin: 0 auto; + padding: var(--page-pad-y) var(--page-pad-x) 32px; + box-sizing: border-box; +} +.site-footer { + width: min(100%, var(--page-max)); + margin: 0 auto; + padding: 20px var(--page-pad-x) 28px; + box-sizing: border-box; + border-top: 1px solid var(--line); + text-align: center; + font-size: 0.88rem; + color: var(--muted); +} +.site-footer p { + margin: 0; + line-height: 1.6; +} +header.site-header { + margin-bottom: var(--page-gap); + padding-bottom: 16px; + border-bottom: 1px solid var(--line); +} +.site-brand { + display: flex; + align-items: center; + gap: 12px; +} +.site-titles { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 2px; + min-width: 0; +} +.site-title-link { + text-decoration: none; + color: inherit; + font-weight: 700; + font-size: 1.25rem; + line-height: 1.2; +} +.site-title-en { + font-size: 0.82rem; + font-weight: 500; + color: var(--muted); + letter-spacing: 0.02em; +} +.site-logo { + flex-shrink: 0; + width: 40px; + height: 40px; + object-fit: contain; + cursor: pointer; +} +.admin-gate { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.35); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; + padding: 16px; + box-sizing: border-box; +} +.admin-gate[hidden] { + display: none !important; +} +.admin-gate-panel { + background: #fff; + padding: 16px 18px; + border-radius: 6px; + border: 1px solid #ccc; + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + width: min(100%, 420px); + box-sizing: border-box; +} +.admin-gate-panel input[type="password"] { + width: 100%; + box-sizing: border-box; + min-width: 0; +} +h1, +h2, +h3 { + line-height: 1.3; + margin: 0 0 12px; + word-break: break-word; +} +article, +section { + margin: 0 0 24px; +} +p { + margin: 0 0 12px; + word-break: break-word; +} +a { + color: inherit; +} +input, +textarea, +button { + font: inherit; + max-width: 100%; +} +input[type="text"], +input[type="password"], +textarea { + width: 100%; + box-sizing: border-box; + min-width: 0; +} +textarea { + min-height: 320px; + resize: vertical; +} +pre { + white-space: pre-wrap; + word-break: break-word; + overflow-x: auto; +} +img { + max-width: 100%; + height: auto; +} +hr { + border: 0; + border-top: 1px solid #ddd; + margin: 24px 0; +} +.post-card { + padding-bottom: 16px; + border-bottom: 1px solid #efefef; +} +.post-card:last-of-type { + padding-bottom: 0; + border-bottom: 0; +} +.post-card .post-excerpt { + margin: 0 0 10px; +} +.post-card .post-meta { + font-size: 0.9em; + color: #555; + margin: 0 0 16px; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 16px; +} +.post-detail-meta { + font-size: 0.95em; + color: #555; + margin: 0 0 16px; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 16px; +} +.post-detail-views { + color: #666; +} +.post-comments { + margin-top: 36px; + padding-top: 28px; + border-top: 1px solid var(--line); +} +.post-comments__title { + font-size: 1.35rem; + margin: 0 0 16px; +} +form { + margin: 0; +} +@media (max-width: 1024px) { + :root { + --page-max: 840px; + --page-pad-x: 20px; + --page-pad-y: 20px; + --page-gap: 20px; + } +} +@media (max-width: 768px) { + :root { + --page-max: 100%; + --page-pad-x: 16px; + --page-pad-y: 16px; + --page-gap: 18px; + } + .site-logo { + width: 36px; + height: 36px; + } + .site-title-link { + font-size: 1.1rem; + } + .post-card .post-meta, + .post-detail-meta { + gap: 6px 12px; + } +} +@media (max-width: 480px) { + :root { + --page-pad-x: 14px; + --page-pad-y: 14px; + --page-gap: 16px; + } + body { + line-height: 1.68; + } + header.site-header { + padding-bottom: 12px; + } + textarea { + min-height: 240px; + } + .site-brand { + gap: 10px; + align-items: flex-start; + } + .site-logo { + width: 32px; + height: 32px; + } + .site-title-link { + font-size: 1rem; + } + .post-card .post-meta, + .post-detail-meta { + font-size: 0.82em; + flex-direction: row; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 10px; + row-gap: 4px; + } + .post-card .post-meta span, + .post-detail-meta span { + white-space: nowrap; + } + .admin-gate-panel { + padding: 14px 14px; + } +} diff --git a/public/js/app.js b/public/js/app.js new file mode 100644 index 0000000..d0eb033 --- /dev/null +++ b/public/js/app.js @@ -0,0 +1,59 @@ +(function () { + var logo = document.querySelector(".site-logo"); + var gate = document.getElementById("admin-gate"); + var inp = document.getElementById("admin-token"); + var btnGo = document.getElementById("admin-token-go"); + var btnCancel = document.getElementById("admin-token-cancel"); + if (!logo || !gate || !inp || !btnGo || !btnCancel) return; + var n = 0; + var resetTimer = null; + function showGate() { + gate.hidden = false; + inp.value = ""; + inp.focus(); + } + function hideGate() { + gate.hidden = true; + } + logo.addEventListener("click", function (e) { + e.preventDefault(); + if (resetTimer) clearTimeout(resetTimer); + n += 1; + if (n >= 5) { + n = 0; + showGate(); + return; + } + resetTimer = setTimeout(function () { + n = 0; + }, 4000); + }); + btnCancel.addEventListener("click", hideGate); + function submit() { + fetch("/admin/session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "same-origin", + body: JSON.stringify({ token: inp.value }) + }) + .then(function (r) { + if (r.ok) { + hideGate(); + location.href = "/admin"; + return; + } + inp.select(); + alert("Token 无效"); + }) + .catch(function () { + alert("网络错误"); + }); + } + btnGo.addEventListener("click", submit); + inp.addEventListener("keydown", function (e) { + if (e.key === "Enter") { + e.preventDefault(); + submit(); + } + }); +})(); diff --git a/public/js/cwd-comments.js b/public/js/cwd-comments.js new file mode 100644 index 0000000..a5a7440 --- /dev/null +++ b/public/js/cwd-comments.js @@ -0,0 +1,78 @@ +(function () { + /** + * cwd-widget(≤0.1.11)里 getLikeStatus 用 post_slug = 完整页面 URL(postUrl), + * 而点赞写入 likePage 用的是 postSlug;后端排行榜按 slug 汇总,导致前台一直显示 0。 + * getPagePv 已写成 postUrl || postSlug,点赞查询未对齐。这里把 GET /api/like 的 + * post_slug 从绝对地址改回与评论区一致的 slug。 + */ + function patchCwdLikeGetQuery(apiBaseNorm, postSlug) { + if (window.__cwdLikeStatusQueryPatched) return; + window.__cwdLikeStatusQueryPatched = true; + + var base = apiBaseNorm; + var orig = window.fetch; + window.fetch = function (input, init) { + var url = + typeof input === "string" + ? input + : input && typeof input.url === "string" + ? input.url + : ""; + var method = "GET"; + if (init && 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 { + var u = new URL(url); + var 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); + } + } + } catch (e) { + /* ignore */ + } + } + return orig.call(this, input, init); + }; + } + + function mount() { + var el = document.getElementById("comments"); + if (!el || typeof CWDComments === "undefined") return; + + var ds = el.dataset; + var apiBaseUrl = ds.apiBase; + var postSlug = ds.postSlug; + if (!apiBaseUrl || !postSlug) return; + + patchCwdLikeGetQuery(apiBaseUrl.replace(/\/$/, ""), postSlug); + + var opts = { + el: "#comments", + apiBaseUrl: apiBaseUrl, + postSlug: postSlug, + lang: ds.lang || "zh-CN" + }; + if (ds.siteId) opts.siteId = ds.siteId; + + new CWDComments(opts).mount(); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", mount); + } else { + mount(); + } +})(); diff --git a/public/js/markdown-client.js b/public/js/markdown-client.js index 838d1b8..42ecf20 100644 --- a/public/js/markdown-client.js +++ b/public/js/markdown-client.js @@ -9,7 +9,8 @@ marked.use( nonStandard: true }) ); -marked.setOptions({ gfm: true, breaks: false }); +// breaks: true — 段内单换行渲染为
,与 Obsidian 等编辑器的换行习惯一致(CommonMark 默认会把单换行当成空格) +marked.setOptions({ gfm: true, breaks: true }); function decodeHtmlEntities(str) { return String(str) diff --git a/render.js b/render.js new file mode 100644 index 0000000..c27406f --- /dev/null +++ b/render.js @@ -0,0 +1,131 @@ +import { + escapeHtml, + escapeAttr, + formatDateTime, + cleanText, + stripMarkdown, + b64EncodeUtf8 +} from "./utils.js"; + +const DEFAULT_SITE_NAME_ZH = "萌芽小窝"; +const DEFAULT_SITE_NAME_EN = "SproutBlog"; + +export function siteNameZh(env) { + return cleanText(env?.SITE_TITLE) || DEFAULT_SITE_NAME_ZH; +} + +export function siteNameEn(env) { + return cleanText(env?.SITE_TITLE_EN) || DEFAULT_SITE_NAME_EN; +} + +export function pageTitle(env, suffix = "") { + const site = siteNameZh(env); + return suffix ? `${site} - ${suffix}` : site; +} + +export function renderLayout(title, body, env, options = {}) { + const siteZh = siteNameZh(env); + const siteEn = siteNameEn(env); + const cwdPostSlug = options.cwdPostSlug ? String(options.cwdPostSlug) : ""; + const cwdApiBase = + cleanText(env?.CWD_API_BASE) || "https://cwd.api.smyhub.com"; + const cwdSiteId = cleanText(env?.CWD_SITE_ID); + const cwdSection = cwdPostSlug + ? ` +
+

评论

+
+
` + : ""; + const mdHead = options.markdownPage + ? ` + + + +` + : ""; + const mdScript = options.markdownPage + ? `\n ` + : ""; + const cwdScripts = cwdPostSlug + ? ` + + ` + : ""; + return ` + + + + + ${escapeHtml(title)} + + + ${mdHead} + + +
+ + ${body} + ${cwdSection} +
+ + + ${mdScript}${cwdScripts} + +`; +} + +export function renderPostCard(post) { + const excerpt = post.excerpt || stripMarkdown(post.content).slice(0, 180); + const views = Number(post.view_count) || 0; + return ` +
+

${escapeHtml(post.title)}

+

${escapeHtml(excerpt)}${excerpt.length >= 180 ? "…" : ""}

+ +
+ `; +} + +export function renderAdminCard(post) { + return ` +
+

${escapeHtml(post.title)}

+

ID: ${post.id} | Slug: ${escapeHtml(post.slug)} | ${post.published ? "已发布" : "草稿"} | ${formatDateTime(post.updated_at)}

+
+ 编辑 +
+ + +
+
+
+ `; +} + +export function markdownPlaceholder(raw) { + const src = String(raw ?? ""); + if (!src.trim()) return ""; + const b64 = b64EncodeUtf8(src); + return `

加载中…

`; +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..d6413df --- /dev/null +++ b/server.js @@ -0,0 +1,504 @@ +import { + renderLayout, + renderPostCard, + renderAdminCard, + markdownPlaceholder, + pageTitle +} from "./render.js"; +import { + escapeHtml, + escapeAttr, + formatDateTime, + cleanText, + isTruthy +} from "./utils.js"; + +export default { + async fetch(request, env) { + const url = new URL(request.url); + const path = normalizePath(url.pathname); + + if (!env.DB) { + return htmlResponse(renderLayout("配置缺失", ` +

DB 绑定未配置

+

请先在 wrangler.toml 里配置 D1 数据库。

+ `, env), 500); + } + + if (request.method === "OPTIONS") { + return new Response(null, { headers: corsHeaders() }); + } + + try { + if (path === "/admin/session" && request.method === "POST") { + return await handleAdminSession(request, env); + } + + if (request.method === "GET") { + if (path === "/") return await handleIndex(env, url); + if (path.startsWith("/post/")) return await handlePost(env, path.slice(6)); + if (path === "/admin" || path === "/admin/new" || path.startsWith("/admin/edit/")) { + return await handleAdminPage(request, env, url, path); + } + if (path === "/api/posts") return await handleApiList(env, url); + if (path.startsWith("/api/posts/")) return await handleApiRead(env, path.slice(11)); + } + + if (path === "/admin/save" && request.method === "POST") { + return await handleAdminSave(request, env, url); + } + + if (path === "/admin/delete" && request.method === "POST") { + return await handleAdminDelete(request, env, url); + } + + if (path === "/api/posts" && request.method === "POST") { + return await handleApiCreate(request, env); + } + + if (path.startsWith("/api/posts/")) { + const idOrSlug = path.slice(11); + if (request.method === "PUT") return await handleApiUpdate(request, env, idOrSlug); + if (request.method === "DELETE") return await handleApiDelete(request, env, idOrSlug); + } + + return notFoundPage(env); + } catch (error) { + return htmlResponse(renderLayout("错误", ` +

发生错误

+
${escapeHtml(error?.stack || error?.message || String(error))}
+ `, env), 500); + } + } +}; +async function handleIndex(env, url) { + const q = (url.searchParams.get("q") || "").trim(); + const posts = await listPosts(env.DB, { publishedOnly: true, q }); + return htmlResponse(renderLayout(pageTitle(env, "首页"), ` + ${q ? `

搜索:${escapeHtml(q)}

` : ""} + ${posts.length ? posts.map(renderPostCard).join("") : "

暂无文章。

"} + `, env)); +} + +async function handlePost(env, slug) { + const post = await env.DB.prepare( + "SELECT * FROM posts WHERE slug = ? AND published = 1 LIMIT 1" + ).bind(decodeURIComponent(slug)).first(); + + if (!post) return notFoundPage(env); + + await env.DB.prepare( + "UPDATE posts SET view_count = COALESCE(view_count, 0) + 1 WHERE id = ?" + ).bind(post.id).run(); + const views = (Number(post.view_count) || 0) + 1; + + return htmlResponse(renderLayout(`${post.title} - ${pageTitle(env)}`, ` +

返回

+
+

${escapeHtml(post.title)}

+

${formatDateTime(post.updated_at)}浏览 ${views}

+ ${markdownPlaceholder(post.content)} +
+ `, env, { markdownPage: true, cwdPostSlug: post.slug })); +} + +async function handleAdminPage(request, env, url, path) { + const auth = await requireAdmin(request, env); + if (auth) return auth; + + let post = blankPost(); + if (path.startsWith("/admin/edit/")) { + const id = Number(path.slice("/admin/edit/".length)); + if (Number.isFinite(id) && id > 0) { + post = await getPostById(env.DB, id) || post; + } + } else if (path === "/admin" && url.searchParams.get("id")) { + const id = Number(url.searchParams.get("id")); + if (Number.isFinite(id) && id > 0) { + post = await getPostById(env.DB, id) || post; + } + } + + const q = (url.searchParams.get("q") || "").trim(); + const posts = await listPosts(env.DB, { publishedOnly: false, q }); + + return htmlResponse(renderLayout(`后台 - ${pageTitle(env)}`, ` +

返回

+

后台

+
+

${post.id ? `编辑 #${post.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/utils.js b/utils.js new file mode 100644 index 0000000..d7fd3d0 --- /dev/null +++ b/utils.js @@ -0,0 +1,52 @@ +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) { + return String(text ?? "") + .replace(/```[\s\S]*?```/g, " ") + .replace(/\$\$[\s\S]*?\$\$/g, " ") + .replace(/\$[^$\n]+\$/g, " ") + .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1") + .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") + .replace(/^\s{0,3}#{1,6}\s+/gm, "") + .replace(/^\s{0,3}>\s?/gm, "") + .replace(/^\s{0,3}[-*+]\s+/gm, "") + .replace(/^\s{0,3}\d+[.)]\s+/gm, "") + .replace(/[*_`~#]/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +export function b64EncodeUtf8(str) { + return btoa(unescape(encodeURIComponent(str))); +} diff --git a/worker.js b/worker.js index 1eba175..c3a2e14 100644 --- a/worker.js +++ b/worker.js @@ -1,870 +1,3 @@ -const DEFAULT_SITE_TITLE = "SproutBlog"; +import app from "./server.js"; -export default { - async fetch(request, env) { - const url = new URL(request.url); - const path = normalizePath(url.pathname); - - if (!env.DB) { - return htmlResponse(renderLayout("配置缺失", ` -

DB 绑定未配置

-

请先在 wrangler.toml 里配置 D1 数据库。

- `, env), 500); - } - - if (request.method === "OPTIONS") { - return new Response(null, { headers: corsHeaders() }); - } - - try { - if (path === "/admin/session" && request.method === "POST") { - return await handleAdminSession(request, env); - } - - if (request.method === "GET") { - if (path === "/") return await handleIndex(env, url); - if (path.startsWith("/post/")) return await handlePost(env, path.slice(6)); - if (path === "/admin" || path === "/admin/new" || path.startsWith("/admin/edit/")) { - return await handleAdminPage(request, env, url, path); - } - if (path === "/api/posts") return await handleApiList(env, url); - if (path.startsWith("/api/posts/")) return await handleApiRead(env, path.slice(11)); - } - - if (path === "/admin/save" && request.method === "POST") { - return await handleAdminSave(request, env, url); - } - - if (path === "/admin/delete" && request.method === "POST") { - return await handleAdminDelete(request, env, url); - } - - if (path === "/api/posts" && request.method === "POST") { - return await handleApiCreate(request, env); - } - - if (path.startsWith("/api/posts/")) { - const idOrSlug = path.slice(11); - if (request.method === "PUT") return await handleApiUpdate(request, env, idOrSlug); - if (request.method === "DELETE") return await handleApiDelete(request, env, idOrSlug); - } - - return notFoundPage(env); - } catch (error) { - return htmlResponse(renderLayout("错误", ` -

发生错误

-
${escapeHtml(error?.stack || error?.message || String(error))}
- `, env), 500); - } - } -}; - -async function handleIndex(env, url) { - const q = (url.searchParams.get("q") || "").trim(); - const posts = await listPosts(env.DB, { publishedOnly: true, q }); - return htmlResponse(renderLayout(pageTitle(env, "首页"), ` - ${q ? `

搜索:${escapeHtml(q)}

` : ""} - ${posts.length ? posts.map(renderPostCard).join("") : "

暂无文章。

"} - `, env)); -} - -async function handlePost(env, slug) { - const post = await env.DB.prepare( - "SELECT * FROM posts WHERE slug = ? AND published = 1 LIMIT 1" - ).bind(decodeURIComponent(slug)).first(); - - if (!post) return notFoundPage(env); - - await env.DB.prepare( - "UPDATE posts SET view_count = COALESCE(view_count, 0) + 1 WHERE id = ?" - ).bind(post.id).run(); - const views = (Number(post.view_count) || 0) + 1; - - return htmlResponse(renderLayout(`${post.title} - ${pageTitle(env)}`, ` -

返回首页

-
-

${escapeHtml(post.title)}

-

${formatDateTime(post.updated_at)}浏览 ${views}

- ${markdownPlaceholder(post.content)} -
- `, env, { markdownPage: true })); -} - -async function handleAdminPage(request, env, url, path) { - const auth = await requireAdmin(request, env); - if (auth) return auth; - - let post = blankPost(); - if (path.startsWith("/admin/edit/")) { - const id = Number(path.slice("/admin/edit/".length)); - if (Number.isFinite(id) && id > 0) { - post = await getPostById(env.DB, id) || post; - } - } else if (path === "/admin" && url.searchParams.get("id")) { - const id = Number(url.searchParams.get("id")); - if (Number.isFinite(id) && id > 0) { - post = await getPostById(env.DB, id) || post; - } - } - - const q = (url.searchParams.get("q") || "").trim(); - const posts = await listPosts(env.DB, { publishedOnly: false, q }); - - return htmlResponse(renderLayout(`后台 - ${pageTitle(env)}`, ` -

返回首页

-

后台

-
-

${post.id ? `编辑 #${post.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 renderPostCard(post) { - const excerpt = post.excerpt || stripMarkdown(post.content).slice(0, 180); - const views = Number(post.view_count) || 0; - return ` -
-

${escapeHtml(post.title)}

-

${escapeHtml(excerpt)}${excerpt.length >= 180 ? "…" : ""}

- -
- `; -} - -function renderAdminCard(post) { - return ` -
-

${escapeHtml(post.title)}

-

ID: ${post.id} | Slug: ${escapeHtml(post.slug)} | ${post.published ? "已发布" : "草稿"} | ${formatDateTime(post.updated_at)}

-
- 编辑 -
- - -
-
-
- `; -} - -function renderLayout(title, body, env, options = {}) { - const site = cleanText(env?.SITE_TITLE) || DEFAULT_SITE_TITLE; - const mdHead = options.markdownPage - ? ` - - - -` - : ""; - const mdScript = options.markdownPage - ? `\n ` - : ""; - return ` - - - - - ${escapeHtml(title)} - - ${mdHead} - - - -
- - ${body} -
- - ${mdScript} - -`; -} - -function b64EncodeUtf8(str) { - return btoa(unescape(encodeURIComponent(str))); -} - -function markdownPlaceholder(raw) { - const src = String(raw ?? ""); - if (!src.trim()) return ""; - const b64 = b64EncodeUtf8(src); - return `

加载中…

`; -} - -function stripMarkdown(text) { - return String(text ?? "") - .replace(/```[\s\S]*?```/g, " ") - .replace(/\$\$[\s\S]*?\$\$/g, " ") - .replace(/\$[^$\n]+\$/g, " ") - .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1") - .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") - .replace(/^\s{0,3}#{1,6}\s+/gm, "") - .replace(/^\s{0,3}>\s?/gm, "") - .replace(/^\s{0,3}[-*+]\s+/gm, "") - .replace(/^\s{0,3}\d+[.)]\s+/gm, "") - .replace(/[*_`~#]/g, " ") - .replace(/\s+/g, " ") - .trim(); -} - -function pageTitle(env, suffix = "") { - const site = cleanText(env.SITE_TITLE) || DEFAULT_SITE_TITLE; - return suffix ? `${site} - ${suffix}` : site; -} - -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 cleanText(value) { - return String(value ?? "").trim(); -} - -function isTruthy(value) { - return value === true || value === "true" || value === "1" || value === 1 || value === "on" || value === "yes"; -} - -function blankPost() { - return { - id: "", - title: "", - slug: "", - excerpt: "", - content: "", - published: 1 - }; -} - -function escapeHtml(value) { - return String(value ?? "") - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - -function escapeAttr(value) { - return escapeHtml(value).replace(/`/g, "`"); -} - -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 default app; diff --git a/wrangler.toml b/wrangler.toml index 8952664..3b2cb98 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -10,7 +10,10 @@ routes = [ directory = "./public/" [vars] +SITE_TITLE = "萌芽小窝" +SITE_TITLE_EN = "SproutBlog" ADMIN_PASSWORD = "shumengya520" +CWD_API_BASE = "https://cwd.api.smyhub.com" [[d1_databases]] binding = "DB"