const DEFAULT_SITE_TITLE = "SproutBlog"; 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 ? "…" : ""}

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

`; } 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}`; }