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(excerpt)}${excerpt.length >= 180 ? "…" : ""}
+
+ 创建于:${formatDateTime(post.created_at)}
+ 最后更新于:${formatDateTime(post.updated_at)}
+ 浏览 ${views}
+
+
+ `;
+}
+
+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)}`, `
- 返回首页
- 后台
-
-
-
-
-
- 文章列表
-
- ${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(excerpt)}${excerpt.length >= 180 ? "…" : ""}
-
- 创建时间:${formatDateTime(post.created_at)}
- 最后更新时间:${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}`;
-}
+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"
评论
+ +