commit 276a7fbc83c0c23f4a516b0a8ee2a7a6c9e1cabb Author: 萌小芽 Date: Tue Apr 14 13:17:55 2026 +0800 Initial import of sproutblog diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..538af3f --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.wrangler/ +.dev.vars +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..4f46c0e --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# SproutBlog + +Cloudflare Worker + D1 的极简博客。 + +## 功能 + +- 公共博客页 +- Markdown 渲染 +- 简单后台 CRUD +- 手机和电脑自适应 + +## 初始化 + +1. 创建 D1 数据库: + `wrangler d1 create sproutblog` +2. 把生成的 `database_id` 填到 `wrangler.toml` +3. 应用迁移: + `wrangler d1 migrations apply sproutblog --remote` +4. 本地运行: + `npm run dev` + +## 可选后台密码 + +如果要给 `/admin` 加密码,设置 `ADMIN_PASSWORD`: + +`wrangler secret put ADMIN_PASSWORD` + diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql new file mode 100644 index 0000000..e3ee1db --- /dev/null +++ b/migrations/0001_init.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS posts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + excerpt TEXT DEFAULT '', + content TEXT NOT NULL, + published INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_posts_published_updated_at +ON posts (published, updated_at DESC); diff --git a/migrations/0002_seed_hello_world.sql b/migrations/0002_seed_hello_world.sql new file mode 100644 index 0000000..72bcc9a --- /dev/null +++ b/migrations/0002_seed_hello_world.sql @@ -0,0 +1,22 @@ +INSERT INTO posts (title, slug, excerpt, content, published, created_at, updated_at) +VALUES ( + 'Hello World', + 'hello-world', + '第一篇测试文章。', + '# Hello World + +这是 SproutBlog 的第一篇文章。 + +它使用标准 Markdown 渲染,并且页面保持纯白、极简。 + +## 说明 + +- 这是一个示例段落 +- 这是一个示例列表 + +> 如果你看到这篇文章,说明 D1 和 Worker 已经正常工作。 +', + 1, + datetime('now'), + datetime('now') +); diff --git a/package.json b/package.json new file mode 100644 index 0000000..0ee7632 --- /dev/null +++ b/package.json @@ -0,0 +1,10 @@ +{ + "name": "sproutblog", + "private": true, + "type": "module", + "scripts": { + "dev": "npx wrangler dev", + "deploy": "npx wrangler deploy", + "typecheck": "node --check worker.js" + } +} diff --git a/worker.js b/worker.js new file mode 100644 index 0000000..88cb05c --- /dev/null +++ b/worker.js @@ -0,0 +1,661 @@ +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 数据库。

+ `), 500); + } + + if (request.method === "OPTIONS") { + return new Response(null, { headers: corsHeaders() }); + } + + try { + 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(); + } catch (error) { + return htmlResponse(renderLayout("错误", ` +

发生错误

+
${escapeHtml(error?.stack || error?.message || String(error))}
+ `), 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, "首页"), ` +
+

${escapeHtml(pageTitle(env))}

+

后台

+
+ ${q ? `

搜索:${escapeHtml(q)}

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

暂无文章。

"} + `)); +} + +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(); + + return htmlResponse(renderLayout(`${post.title} - ${pageTitle(env)}`, ` +

返回首页

+
+

${escapeHtml(post.title)}

+

${formatDateTime(post.updated_at)}

+
${renderMarkdown(post.content)}
+
+ `)); +} + +async function handleAdminPage(request, env, url, path) { + const auth = 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("") : "

没有找到文章。

"} +
+ `)); +} + +async function handleAdminSave(request, env, url) { + const auth = 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("表单错误", ` +

标题和内容不能为空

+

返回后台

+ `), 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(); + 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 = 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

+

返回后台

+ `), 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 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 = 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 = 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 = 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); + return ` +
+

${escapeHtml(post.title)}

+

${formatDateTime(post.updated_at)}

+

${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) { + return ` + + + + + ${escapeHtml(title)} + + + +
+ ${body} +
+ +`; +} + +function renderMarkdown(source) { + const lines = String(source || "").replace(/\r\n?/g, "\n").split("\n"); + let html = ""; + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + const trimmed = line.trim(); + + if (!trimmed) { + i += 1; + continue; + } + + if (trimmed.startsWith("```")) { + const fence = trimmed.slice(3).trim(); + i += 1; + const code = []; + while (i < lines.length && !lines[i].trim().startsWith("```")) { + code.push(lines[i]); + i += 1; + } + if (i < lines.length) i += 1; + html += `
${escapeHtml(code.join("\n"))}
`; + continue; + } + + const heading = trimmed.match(/^(#{1,6})\s+(.*)$/); + if (heading) { + const level = heading[1].length; + html += `${parseInline(heading[2])}`; + i += 1; + continue; + } + + if (/^(\*{3,}|-{3,}|_{3,})$/.test(trimmed)) { + html += "
"; + i += 1; + continue; + } + + if (/^>\s?/.test(trimmed)) { + const quote = []; + while (i < lines.length && /^>\s?/.test(lines[i].trim())) { + quote.push(lines[i].trim().replace(/^>\s?/, "")); + i += 1; + } + html += `
${renderMarkdown(quote.join("\n"))}
`; + continue; + } + + if (/^(\s*[-*+]\s+|\s*\d+[.)]\s+)/.test(line)) { + const ordered = /^\s*\d+[.)]\s+/.test(line); + const tag = ordered ? "ol" : "ul"; + const items = []; + while (i < lines.length && /^(\s*[-*+]\s+|\s*\d+[.)]\s+)/.test(lines[i])) { + items.push(lines[i].replace(/^(\s*[-*+]\s+|\s*\d+[.)]\s+)/, "")); + i += 1; + } + html += `<${tag}>${items.map((item) => `
  • ${parseInline(item)}
  • `).join("")}`; + continue; + } + + const paragraph = []; + while (i < lines.length) { + const current = lines[i]; + const currentTrimmed = current.trim(); + if (!currentTrimmed) break; + if ( + currentTrimmed.startsWith("```") || + /^(#{1,6})\s+/.test(currentTrimmed) || + /^(\*{3,}|-{3,}|_{3,})$/.test(currentTrimmed) || + /^>\s?/.test(currentTrimmed) || + /^(\s*[-*+]\s+|\s*\d+[.)]\s+)/.test(current) + ) { + break; + } + paragraph.push(currentTrimmed); + i += 1; + } + html += `

    ${parseInline(paragraph.join(" "))}

    `; + } + + return html; +} + +function parseInline(text) { + const placeholders = []; + let output = escapeHtml(String(text || "")); + + output = output.replace(/`([^`]+?)`/g, (_, code) => { + placeholders.push(`${escapeHtml(code)}`); + return `\u0000${placeholders.length - 1}\u0000`; + }); + + output = output.replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g, (_, alt, src, title) => { + const attrs = [`alt="${escapeAttr(alt)}"`, `src="${escapeAttr(src)}"`]; + if (title) attrs.push(`title="${escapeAttr(title)}"`); + return ``; + }); + + output = output.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g, (_, label, href, title) => { + const attrs = [`href="${escapeAttr(href)}"`]; + if (title) attrs.push(`title="${escapeAttr(title)}"`); + return `${label}`; + }); + + output = output.replace(/(\*\*|__)(.+?)\1/g, "$2"); + output = output.replace(/(\*|_)([^*_].*?)\1/g, "$2"); + + output = output.replace(/\u0000(\d+)\u0000/g, (_, index) => placeholders[Number(index)] || ""); + return output; +} + +function stripMarkdown(text) { + return String(text || "") + .replace(/```[\s\S]*?```/g, " ") + .replace(/!\[([^\]]*)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g, "$1") + .replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"([^"]*)")?\)/g, "$1") + .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) { + return new Response(JSON.stringify(data, null, 2), { + status, + headers: { + "content-type": "application/json; charset=UTF-8", + ...corsHeaders() + } + }); +} + +function notFoundPage() { + return htmlResponse(renderLayout("404", ` +

    404

    +

    页面不存在。

    +

    返回首页

    + `), 404); +} + +function corsHeaders() { + return { + "access-control-allow-origin": "*", + "access-control-allow-methods": "GET, POST, PUT, DELETE, OPTIONS", + "access-control-allow-headers": "*" + }; +} + +function requireAdmin(request, env) { + const password = cleanText(env.ADMIN_PASSWORD); + if (!password) return null; + + const header = request.headers.get("Authorization") || ""; + if (!header.startsWith("Basic ")) { + return unauthorized(); + } + + 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 unauthorized() { + return new Response("Authentication required", { + status: 401, + headers: { + "WWW-Authenticate": 'Basic realm="SproutBlog admin", 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}`; +} diff --git a/wrangler.toml b/wrangler.toml new file mode 100644 index 0000000..325a1ad --- /dev/null +++ b/wrangler.toml @@ -0,0 +1,12 @@ +name = "sproutblog" +main = "worker.js" +compatibility_date = "2026-04-13" + +[vars] +ADMIN_PASSWORD = "shumengya520" + +[[d1_databases]] +binding = "DB" +database_name = "sproutblog" +database_id = "d4f62f01-7a6c-423b-bc8b-a6535750e44d" +migrations_dir = "migrations"