零服务器搭一个「带线上后台」的静态博客

用 CNB 云原生构建 + EdgeOne Pages + Hexo,实现浏览器里点鼠标发文
全程不需要服务器、不需要备案、不需要在本地装任何环境。


0. 这套方案是什么

最终效果

博客首页 https://你的域名/
线上后台 https://你的域名/admin/
发文方式 浏览器打开后台 → 输密码 → 写 → 点发布 → 约 1 分钟上线
手机能用吗 能,后台是响应式页面
服务器 不需要
花费 0

为什么这样搭

静态博客(Hexo)本身没有后台,传统做法要么忍着手敲 Markdown,要么自己买服务器跑 WordPress。这套方案用「静态站 + 一个托管在边缘的云函数」补上了后台能力:

  • 静态站(HTML/CSS/JS)放在 CDN 上 → 快、便宜、免运维、不容易被黑
  • 云函数(几十行 JS)当"中介",帮你把文章写进代码仓库
  • 流水线(CNB)负责构建和发布,你完全不用管

你会得到什么 / 不会得到什么

:文章增删改、标签、分类、归档、搜索、评论位(需另接第三方)、自定义域名、HTTPS。

没有:多用户权限体系、数据库、动态交互(访问统计、登录用户)。静态博客的本质决定了这些要另接第三方服务。


1. 准备清单

账号(3 个,都免费)

平台 用途 地址 注意
CNB 代码托管 + 流水线 + WebIDE cnb.cool 必须先完成实名认证才能建组织,否则创建组织会失败
EdgeOne Pages 静态站托管 + 云函数 pages.edgeone.ai 腾讯云国际版;没备案就选「海外可访问」
域名(可选) 自己的域名 任意域名商 不备案也能用,只是国内访问会绕一下

要拿到手的 3 样东西

名称 从哪拿 长什么样 用在哪
EdgeOne Pages API Token Pages 项目 → 设置 → API Token,创建时选永久有效 一长串 base64,通常以 = 结尾 .cnb.yml 里部署命令的 -t
CNB 访问令牌 CNB → 个人设置 → 访问令牌 一串大小写字母数字 EdgeOne 环境变量 CNB_TOKEN
后台密码 你自己定 随便定 EdgeOne 环境变量 ADMIN_PASSWORD

⚠️ 两个 Token 完全不同、不能混用

  • EdgeOne Pages API Token 用来部署静态站edgeone pages deploy
  • CNB 访问令牌用来读写代码仓库(相当于 git 密码)

这是最容易搞混、也最容易卡住的一点。

CNB 令牌需要的权限

在 CNB 建访问令牌时,勾选:

  • repo-code读写(流水线里要 git push
  • repo-cnb-trigger读写(云函数要触发流水线)

不必勾 repo-basic-inforepo-cnb-history(只有查仓库元信息和构建日志才用得到,不影响功能)。


2. 整体架构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
① 你在浏览器打开  /admin/
输入密码 + 文章内容

② 云函数 POST /api/publish 跑在 EdgeOne 边缘节点
校验密码,把 CNB 令牌从环境变量取出来用
(令牌永远不下发到浏览器)

CNB OpenAPI POST /{repo}/-/build/start
event=api_trigger,文章内容通过 env 传进去

CNB 流水线 main: api_trigger
stage 1: publish-post.sh 写 md 文件 git push
stage 2: build-and-deploy.sh hexo g 组装 dist edgeone pages deploy

EdgeOne Pages CDN 更新 1 分钟后线上可见

为什么要绕这么一圈? 有两个硬约束:

  1. CNB 的 OpenAPI 对仓库文件是只读的 —— 没有"写文件/提交 commit"的接口(详见「踩坑 1」)。
  2. api.cnb.cool 不返回任何 CORS 头 —— 浏览器不能直接调它(详见「踩坑 2」)。

所以必须有一个跑在服务端、能拿密钥的中间人来转发。EdgeOne 自带的云函数正好补上这个位置,不用另外注册 Cloudflare、也不用占用你自己的服务器。


3. 搭建步骤

步骤 1:CNB 建私密仓库

  1. 打开 https://cnb.cool 登录
  2. 右上角 + → 新建仓库
  3. 填三项:
    • 归属组织:你的组织
    • 仓库名:blog
    • 可见性:私密 ← 重要,.cnb.yml 里有 EdgeOne Token
  4. 不要勾"初始化 README",留空仓库

为什么必须私密.cnb.yml 里要写 EdgeOne API Token 才能部署。虽然更安全的做法是用平台的密钥管理功能,但最省事的方案是"仓库私密 + Token 只放在这一个文件里"。

步骤 2:EdgeOne Pages 建项目

  1. 打开 https://pages.edgeone.ai,登录腾讯云国际账号
  2. 新建项目 → 选「直接上传」(不要选 Git 集成,我们走 CLI 部署)
  3. 项目名:你的项目名(比如 myblog)—— 记住它,后面多处要填
  4. 访问区:没备案选「海外可访问」;有备案可选国内
  5. 进项目设置 → API Token → 新建一个永久有效的 Token → 复制保存

步骤 3:初始化 Hexo 工程

在本地(或 CNB WebIDE 里)执行:

1
2
3
4
5
6
7
8
9
10
11
12
# 建目录
mkdir Blog && cd Blog

# 装依赖(package.json 见下方"文件清单")
npm install --registry=https://registry.npmmirror.com

# 从 node_modules 里把主题拷进 themes/(Fluid 主题的标准装法)
mkdir -p themes scaffolds source/_posts
cp -R node_modules/hexo-theme-fluid themes/fluid

# 主题配置文件
cp themes/fluid/_config.yml ../_config.fluid.yml

Blog/package.json 最小内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"name": "blog",
"private": true,
"hexo": { "version": "7.3.0" },
"dependencies": {
"hexo": "^7.3.0",
"hexo-generator-archive": "^2.0.0",
"hexo-generator-category": "^2.0.0",
"hexo-generator-index": "^4.0.0",
"hexo-generator-tag": "^2.0.0",
"hexo-renderer-ejs": "^2.0.0",
"hexo-renderer-marked": "^7.0.0",
"hexo-renderer-stylus": "^3.0.0",
"hexo-server": "^3.0.0",
"hexo-theme-fluid": "^1.9.8"
}
}

Blog/_config.yml 要改的几处

1
2
3
4
5
6
7
8
9
10
11
title: 你的博客名
subtitle: ''
description: 一句话描述
author: 你的名字
language: zh-CN
timezone: 'Asia/Shanghai'

# 换成你的正式域名,影响生成的绝对链接和 RSS
url: https://你的域名

theme: fluid

步骤 4:写根目录的配置文件

4.0 .ide/Dockerfile —— WebIDE 环境(可选)

配了它,就能在浏览器里打开一个完整的 VSCode(带终端、Git 面板、语法高亮),不用在本地装任何环境。改完文件直接在 WebIDE 里 commit + push。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
FROM node:20

# code-server + 常用插件
RUN curl -fsSL https://code-server.dev/install.sh | sh \
&& code-server --install-extension cnbcool.cnb-welcome \
&& code-server --install-extension redhat.vscode-yaml \
&& code-server --install-extension dbaeumer.vscode-eslint \
&& code-server --install-extension waderyan.gitblame \
&& code-server --install-extension mhutchie.git-graph \
&& code-server --install-extension donjayamanne.githistory \
&& code-server --install-extension tencent-cloud.coding-copilot \
&& echo done

# git 等基础工具 + ssh(支持用本地 VSCode Remote-SSH 连进来)
RUN apt-get update && apt-get install -y git wget unzip openssh-server

ENV LANG C.UTF-8
ENV LANGUAGE C.UTF-8

用法:CNB 仓库页点「云原生开发」,第一次会按这个 Dockerfile 构建环境(约 2 分钟),之后直接拉起 WebIDE。

4.1 .cnb.yml —— 流水线定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
# ---------- ① 云原生开发(WebIDE)----------
$:
vscode:
- docker:
build: ./.ide/Dockerfile
runner:
cpus: 4
services:
- vscode
stages:
- name: 预装依赖
script: |
npm install -g hexo-cli || true
if [ -d Blog ]; then cd Blog && npm install || true; fi
- name: 拉起 WebIDE
script: vscode:go

# ---------- ② 代码推送 → 构建并部署 ----------
main:
push:
- name: 构建并部署博客
docker:
image: node:20
volumes:
- /root/.npm:copy-on-write
env:
EDGEONE_API_TOKEN: <EDGEONE_PAGES_API_TOKEN>
EDGEONE_PROJECT: 你的项目名
stages:
- name: 渲染并部署
script: |
sh ./scripts/build-and-deploy.sh

# ---------- ③ 线上后台「发布」按钮 → 写入文章 ----------
api_trigger:
- name: 发布新文章
docker:
image: node:20
volumes:
- /root/.npm:copy-on-write
env:
EDGEONE_API_TOKEN: <EDGEONE_PAGES_API_TOKEN>
EDGEONE_PROJECT: 你的项目名
stages:
- name: 写入文章并推送
script: |
sh ./scripts/publish-post.sh
- name: 渲染并部署
script: |
sh ./scripts/build-and-deploy.sh

要点:

  • api_trigger 是挂在分支名下面的合法事件,和 push 同级
  • 流水线内置的 CNB_TOKENapi_trigger 下拥有 repo-code:rw,所以脚本里能 git push
  • 构建镜像用 node:20

4.2 build.sh —— 组装部署产物

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#!/bin/sh
# 组装部署产物 dist/
set -e

SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
cd "$SCRIPT_DIR"

if [ ! -d Blog/public ]; then
echo "找不到 Blog/public,请先执行 hexo generate"
exit 1
fi

# 旧产物改名而不是删除,避免误删
if [ -d dist ]; then
mv dist "dist_stale_$(date +%s)"
fi

mkdir -p dist
cp -R Blog/public/. dist/ # 博客本体
mkdir -p dist/admin
cp admin/index.html dist/admin/index.html # 线上后台
cp -R cloud-functions dist/cloud-functions # 云函数
cp functions/package.json dist/package.json
cp edgeone.json dist/edgeone.json

echo "===== dist 组装完成 ====="
du -sh dist

4.3 scripts/build-and-deploy.sh —— 渲染 + 部署

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#!/bin/sh
set -e

SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
ROOT=$(cd "$SCRIPT_DIR/.." && pwd)
cd "$ROOT"

if [ ! -d Blog/source ]; then
echo "Blog/ 还没初始化,跳过构建。"
exit 0
fi

if ! command -v hexo >/dev/null 2>&1; then
npm install -g hexo-cli
fi

echo "===== 1/3 渲染静态文件 ====="
(
cd Blog
npm install --no-audit --no-fund
hexo clean
hexo generate
)

echo "===== 2/3 组装 dist ====="
sh ./build.sh

echo "===== 3/3 部署到 EdgeOne Pages ====="
if [ -z "$EDGEONE_API_TOKEN" ]; then
echo "缺少 EDGEONE_API_TOKEN,跳过部署(只构建不发布)。"
exit 0
fi

npx --yes edgeone pages deploy dist \
-n "${EDGEONE_PROJECT:-你的项目名}" \
-t "$EDGEONE_API_TOKEN"

4.4 scripts/publish-post.sh —— 把文章写进仓库

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
#!/bin/sh
set -e

SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
ROOT=$(cd "$SCRIPT_DIR/.." && pwd)
cd "$ROOT"

: "${PUB_TITLE:?没有收到 PUB_TITLE(文章标题)}"
: "${PUB_CONTENT_B64:?没有收到 PUB_CONTENT_B64(文章正文)}"

TARGET_BRANCH="${CNB_BRANCH:-main}"
POST_DIR="Blog/source/_posts"

# 去掉前后空白 / 换行 / 引号,避免破坏 YAML front-matter
clean() { printf '%s' "$1" | tr -d '\r\n' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' | tr '"' "'"; }

# 文件名清洗:保留中文,只把路径危险字符和空白替换掉
SLUG=$(printf '%s' "$PUB_SLUG" \
| tr -d '\r\n' \
| sed 's#[/\\:*?"<>|[:space:]]#-#g; s/-\{2,\}/-/g; s/^[-.]*//; s/[-.]*$//')
[ -n "$SLUG" ] || SLUG="post-$(date +%Y%m%d%H%M%S)"

mkdir -p "$POST_DIR"

# 正文经 base64 传输,换行、引号、中文都不会被破坏
printf '%s' "$PUB_CONTENT_B64" | base64 -d > /tmp/post_body.md

{
printf -- '---\n'
printf 'title: "%s"\n' "$(clean "$PUB_TITLE")"
printf 'date: %s\n' "$(date '+%Y-%m-%d %H:%M:%S')"
if [ -n "$PUB_TAGS" ]; then printf 'tags: [%s]\n' "$(clean "$PUB_TAGS")"; fi
if [ -n "$PUB_CATEGORIES" ]; then printf 'categories: [%s]\n' "$(clean "$PUB_CATEGORIES")"; fi
printf -- '---\n\n'
cat /tmp/post_body.md
} > "$POST_DIR/$SLUG.md"

echo "===== 已写入 $POST_DIR/$SLUG.md ====="

git config user.name "${CNB_BUILD_USER_NICKNAME:-博客}"
git config user.email "${CNB_BUILD_USER_EMAIL:-blog@example.com}"

git add "$POST_DIR/$SLUG.md"
if git diff --cached --quiet; then
echo "内容与仓库中的版本完全一致,无需提交。"
exit 0
fi

git commit -m "post: $(clean "$PUB_TITLE")"

# CNB_TOKEN 是流水线内置临时令牌,api_trigger 下拥有 repo-code:rw
if [ -n "$CNB_REPO_URL_HTTPS" ]; then
REPO_HOST_PATH="${CNB_REPO_URL_HTTPS#https://}"
elif [ -n "$CNB_REPO_SLUG" ]; then
REPO_HOST_PATH="cnb.cool/$CNB_REPO_SLUG"
else
echo "错误:拿不到仓库地址"
exit 1
fi

PUSH_URL="https://cnb:${CNB_TOKEN}@${REPO_HOST_PATH}"
git push "$PUSH_URL" "HEAD:refs/heads/$TARGET_BRANCH"

echo "===== 已推送到 $TARGET_BRANCH 分支 ====="

两个设计要点

  • 正文用 base64 传,避免中文、换行、引号在多层 shell 传递中被破坏
  • publish-post.shgit push 会再次触发 main: push 流水线 → 部署。这是故意的,形成闭环

步骤 5:写云函数(4 个文件)

放到 cloud-functions/ 目录。目录名必须是 cloud-functions(EdgeOne 已把旧的 node-functions 改名)。

5.1 cloud-functions/_cnb.js —— 公共工具(不会注册成路由)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
export const DEFAULT_API_BASE = 'https://api.cnb.cool';
export const DEFAULT_REPO = '你的组织/你的仓库';
export const DEFAULT_BRANCH = 'main';

export function json(body, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: {
'content-type': 'application/json; charset=utf-8',
'cache-control': 'no-store',
},
});
}

export function base64Utf8(str) {
if (typeof Buffer !== 'undefined') {
return Buffer.from(str, 'utf8').toString('base64');
}
const bytes = new TextEncoder().encode(str);
let bin = '';
for (let i = 0; i < bytes.length; i += 1) bin += String.fromCharCode(bytes[i]);
return btoa(bin);
}

export function slugify(input) {
const s = String(input || '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 60);
return s || `post-${Date.now()}`;
}

export function config(env = {}) {
return {
apiBase: String(env.CNB_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, ''),
repo: String(env.CNB_REPO || DEFAULT_REPO),
branch: String(env.CNB_BRANCH || DEFAULT_BRANCH),
token: String(env.CNB_TOKEN || ''),
password: String(env.ADMIN_PASSWORD || ''),
};
}

export async function cnb(cfg, path, init = {}) {
const url = `${cfg.apiBase}/${cfg.repo}/-/${String(path).replace(/^\/+/, '')}`;
const res = await fetch(url, {
...init,
headers: {
Authorization: `Bearer ${cfg.token}`,
// CNB 的内容协商只认单一值:
// "application/vnd.cnb.api+json, application/json" 会直接 406,
// 默认的 */* 也一样被拒。
Accept: 'application/vnd.cnb.api+json',
'Content-Type': 'application/json',
...(init.headers || {}),
},
});
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = { raw: text.slice(0, 500) };
}
return { ok: res.ok, status: res.status, data };
}

反直觉但很重要:EdgeOne 的函数是按文件名注册路由的。_cnb.js 没有导出 onRequest,所以它只是个普通模块,不会变成 /api/_cnb 这样的接口。

5.2 cloud-functions/api/publish.js —— 发布接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
import { json, base64Utf8, slugify, config, cnb } from '../_cnb.js';

export async function onRequestPost(context) {
const { request, env } = context;
const cfg = config(env);

if (!cfg.password) return json({ ok: false, error: '服务端还没配置 ADMIN_PASSWORD 环境变量' }, 500);
if (!cfg.token) return json({ ok: false, error: '服务端还没配置 CNB_TOKEN 环境变量' }, 500);

let body;
try {
body = await request.json();
} catch {
return json({ ok: false, error: '请求体不是合法的 JSON' }, 400);
}

if (String(body.password || '') !== cfg.password) {
return json({ ok: false, error: '管理密码不正确' }, 401);
}

const title = String(body.title || '').trim();
const content = String(body.content || '');
if (!title) return json({ ok: false, error: '文章标题不能为空' }, 400);
if (!content.trim()) return json({ ok: false, error: '文章正文不能为空' }, 400);
if (content.length > 60000) {
return json({ ok: false, error: `正文太长(${content.length} 字),请控制在 6 万字以内` }, 400);
}

const slug = slugify(body.slug || title);

const payload = {
event: 'api_trigger',
branch: cfg.branch,
title: `发布文章: ${title}`,
sync: 'false',
env: {
PUB_TITLE: title,
PUB_SLUG: slug,
PUB_CONTENT_B64: base64Utf8(content),
PUB_TAGS: String(body.tags || ''),
PUB_CATEGORIES: String(body.categories || ''),
},
};

const r = await cnb(cfg, 'build/start', { method: 'POST', body: JSON.stringify(payload) });

if (!r.ok) {
return json({
ok: false,
error: `触发 CNB 构建失败(HTTP ${r.status})`,
hint: '检查 CNB_TOKEN 是否有效,以及令牌是否有 repo-cnb-trigger:rw 权限',
detail: r.data,
}, 502);
}

return json({
ok: true,
slug,
file: `Blog/source/_posts/${slug}.md`,
build: r.data,
message: '已触发发布,约 1~2 分钟后线上更新',
});
}

export async function onRequestGet() {
return json({ ok: false, error: '请用 POST 提交文章' }, 405);
}

5.3 cloud-functions/api/posts.js —— 文章列表(只读)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import { json, config, cnb } from '../_cnb.js';

function normalize(data) {
let arr = [];
if (Array.isArray(data)) {
arr = data;
} else if (data && typeof data === 'object') {
for (const key of ['contents', 'files', 'entries', 'list', 'data', 'items']) {
if (Array.isArray(data[key])) { arr = data[key]; break; }
}
}
return arr
.filter((it) => it && typeof it === 'object')
.map((it) => {
const path = it.path || it.name || '';
return {
name: it.name || path.split('/').pop() || '',
path,
sha: it.sha || it.object_sha || '',
size: typeof it.size === 'number' ? it.size : null,
type: it.type || (it.is_dir ? 'dir' : 'file'),
};
})
.filter((it) => it.name && /\.(md|markdown)$/i.test(it.name))
.sort((a, b) => a.name.localeCompare(b.name));
}

export async function onRequestGet(context) {
const cfg = config(context.env);
if (!cfg.token) return json({ ok: false, error: '服务端还没配置 CNB_TOKEN 环境变量' }, 500);

const r = await cnb(cfg, `git/contents/Blog/source/_posts?ref=${encodeURIComponent(cfg.branch)}`, { method: 'GET' });

if (!r.ok) {
return json({
ok: false,
error: `读取文章列表失败(HTTP ${r.status})`,
hint: '如果返回 404,通常是 Blog/source/_posts 目录还不存在——先在后台发布一篇就会有',
detail: r.data,
}, 502);
}

const posts = normalize(r.data);
return json({ ok: true, count: posts.length, posts });
}

5.4 cloud-functions/api/ping.js —— 自检接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import { json, config, cnb } from '../_cnb.js';

export async function onRequestGet(context) {
const cfg = config(context.env);

const out = {
ok: true,
server: {
hasPassword: Boolean(cfg.password),
hasToken: Boolean(cfg.token),
apiBase: cfg.apiBase,
repo: cfg.repo,
branch: cfg.branch,
},
cnb: null,
};

if (!cfg.token) {
out.ok = false;
out.hint = '还没配置 CNB_TOKEN 环境变量';
return json(out, 200);
}
if (!cfg.password) {
out.ok = false;
out.hint = '还没配置 ADMIN_PASSWORD 环境变量';
return json(out, 200);
}

const r = await cnb(cfg, 'git/head', { method: 'GET' });
out.cnb = { ok: r.ok, status: r.status, data: r.data };

if (!r.ok) {
out.ok = false;
out.hint = 'CNB_TOKEN 无效,或这个令牌没有该仓库的读取权限';
} else {
out.hint = '配置正常,可以进 /admin/ 写文章了';
}
return json(out, 200);
}

5.5 functions/package.json —— 函数运行时声明

1
2
3
4
5
6
7
8
9
10
{
"name": "your-blog",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "静态博客 + 云函数",
"scripts": {
"build": "echo \"静态文件已由流水线构建完成\""
}
}

注意 "type": "module" —— 云函数用的是 ESM 语法(export / import)。

步骤 6:写线上后台页面 admin/index.html

一个单文件页面(HTML + CSS + JS 全在里面,零外部依赖),部署后通过 https://你的域名/admin/ 访问。

核心逻辑只有三块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// ① 登录:密码只存本机 localStorage,真正校验在服务端
localStorage.setItem('admin_pw', pw);

// ② 实时预览:把 Markdown 转成 HTML 显示在右侧
function render(md) {
return md
.replace(/^### (.*)$/gm, '<h3>$1</h3>')
.replace(/^## (.*)$/gm, '<h2>$1</h2>')
.replace(/^# (.*)$/gm, '<h1>$1</h1>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/^- (.*)$/gm, '<li>$1</li>')
.replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2">$1</a>');
}

// ③ 发布:POST 到同源接口,不涉及跨域
const res = await fetch('/api/publish', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ password, title, slug, tags, categories, content }),
});

页面提供 3 个标签页:写文章(标题/文件名/标签/分类/正文 + 实时预览)、文章列表(读仓库)、设置(接口地址、退出登录)。

完整代码较长(含样式),建议直接抄仓库里的 admin/index.html。写的时候注意:如果用 const $ = id => document.getElementById(id) 这种简写,调用时不要#$('#title') 会把 # 当成 id 的一部分,返回 null)。

步骤 7:站点配置 edgeone.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"headers": [
{
"source": "/admin/*",
"headers": [
{ "key": "X-Robots-Tag", "value": "noindex, nofollow" }
]
},
{
"source": "/api/*",
"headers": [
{ "key": "Cache-Control", "value": "no-store, max-age=0" }
]
}
],
"rewrites": [
{ "source": "/admin", "destination": "/admin/index.html" }
]
}
  • /admin/*noindex,不让搜索引擎收录你的后台
  • /api/* 禁缓存,否则自检和发布结果会读到旧值
  • rewrites/admin(不带斜杠)也能打开后台

步骤 8:推代码 + 配环境变量 + 首次部署

8.1 推代码到 CNB

1
2
3
4
5
6
cd 你的项目目录
git init
git add -A
git commit -m "初始化博客"
git remote add origin https://cnb.cool/你的组织/你的仓库.git
git push -u origin main

(如果本机没装 git,也可以用 CNB 网页的"上传文件"功能,或 WebIDE 里的终端。)

8.2 配 4 个环境变量

EdgeOne Pages → 你的项目 → 设置 → 环境变量 → 生产环境,添加:

变量名
ADMIN_PASSWORD 你定的后台密码
CNB_TOKEN 你的 CNB 访问令牌
CNB_REPO 你的组织/你的仓库
CNB_BRANCH main

也可以用 CLI 配(更省事):

1
2
3
4
5
npx edgeone pages link -n 你的项目名 -t <EDGEONE_PAGES_API_TOKEN>
npx edgeone pages env set ADMIN_PASSWORD '你的密码' -t <EDGEONE_PAGES_API_TOKEN> -e production
npx edgeone pages env set CNB_TOKEN '你的CNB令牌' -t <EDGEONE_PAGES_API_TOKEN> -e production
npx edgeone pages env set CNB_REPO '你的组织/你的仓库' -t <EDGEONE_PAGES_API_TOKEN> -e production
npx edgeone pages env set CNB_BRANCH 'main' -t <EDGEONE_PAGES_API_TOKEN> -e production

⚠️ CLI 的 env set 会把变量写进本地 .env 文件,务必确保 .gitignore 里有 .env.edgeone/(见「踩坑 4」)。

8.3 .gitignore(必须)

1
2
3
4
5
6
7
8
9
10
11
12
node_modules/
dist/
dist_stale_*/
_stale/
Blog/db.json
Blog/public/
Blog/.deploy*/

# 密钥相关,绝对不能进仓库
.env
.env.*
.edgeone/

8.4 触发首次部署

推一次代码(或第一次提交就会触发 main: push 流水线),流水线会:

  1. 渲染 Hexo
  2. 组装 dist
  3. edgeone pages deploy 到你的项目

等 1~2 分钟,打开 https://你的项目名.edgeone.dev/ 看效果。

8.5 自检

浏览器打开 https://你的域名/api/ping,应该看到:

1
2
3
4
5
6
{
"ok": true,
"server": { "hasPassword": true, "hasToken": true, "apiBase": "https://api.cnb.cool", "repo": "你的组织/你的仓库", "branch": "main" },
"cnb": { "ok": true, "status": 200 },
"hint": "配置正常,可以进 /admin/ 写文章了"
}
  • hasPassword: falseADMIN_PASSWORD 没配上
  • hasToken: falseCNB_TOKEN 没配上
  • cnb.ok: false 且 status 是 401/403 → 令牌无效或权限不够
  • cnb.ok: false 且 status 是 406 → 代码里的 Accept 头写成了多个值(见「踩坑 3」)

8.6 绑自己的域名(可选)

  1. 域名商处加一条 CNAME,指向 你的项目名.edgeone.dev
  2. EdgeOne Pages 项目里添加自定义域名
  3. Blog/_config.ymlurl,重新推送

4. 发文

方式 A:网页后台(推荐)

1
2
3
4
5
6
打开 https://你的域名/admin/
→ 输入密码
→ 写文章页:填标题 → 写正文(右侧实时预览)
→ 标签/分类可留空;文件名留空会自动生成
→ 点「发布到线上」
→ 等约 1 分钟,刷新博客首页即见

几个细节

  • 草稿:点「存草稿」把内容存在你本机浏览器(localStorage),换设备看不到
  • 文件名:留空时按标题生成,中文会保留;纯符号标题会退化成时间戳
  • 正文长度上限:6 万字
  • 发布后能不能改:可以,同样的文件名再发一次会覆盖;换文件名则是新文章
  • 删文章:后台不支持删除,需要到 CNB 网页里把那篇 .md 删掉(或在 WebIDE 里删),删完会自动触发重新部署

方式 B:让 AI 代发

如果你在用 AI 助手协作,也可以把标题 + 正文给它,让它调发布接口推上去,效果和后台点发布一样。

Markdown 速查

想要的效果 写法
一级标题 # 标题
二级标题 ## 标题
加粗 **加粗**
斜体 *斜体*
无序列表 - 项目
有序列表 1. 项目
链接 [文字](https://...)
图片 ![说明](图片地址)
行内代码 `code`
代码块 ```js 换行 代码 换行 ```
引用 > 引用内容
表格 | 分隔的 Markdown 表格语法
分割线 ---

发文时最常用的三条## 小标题、**加粗**、代码块。其余可以慢慢学。


5. 日常维护

改后台密码

1
npx edgeone pages env set ADMIN_PASSWORD '新密码' -t <EDGEONE_PAGES_API_TOKEN> -e production

不用重新部署,环境变量立即生效(在后台「设置」里点「退出登录」清掉本机旧密码再重进)。

换域名

  1. 域名商加 CNAME → 指向 EdgeOne 项目域名
  2. EdgeOne Pages 里绑定自定义域名
  3. Blog/_config.ymlurl 为新域名
  4. 推送代码(或后台发一篇)触发重新部署

手动本地部署

不想等流水线时,本地构建完直接推:

1
2
3
cd Blog && hexo clean && hexo generate && cd ..
sh ./build.sh
npx edgeone pages deploy dist -n 你的项目名 -t <EDGEONE_PAGES_API_TOKEN> --skip-ai-gateway-sync --json

看构建状态

  • CNB 仓库页 → 构建 → 构建历史
  • 触发构建后可以用 CNB OpenAPI 查状态:GET /{repo}/-/build/status/{构建号}

每次发文的实际耗时

实测一次发布全程 56~68 秒

阶段 耗时
写入文章并推送 0.3 秒
渲染并部署 56~62 秒

6. 踩坑记录

这一节是全文最值钱的部分 —— 下面每一个坑都是实测撞出来的。

坑 1:CNB OpenAPI 只能读不能写

现象:想直接在浏览器里调 CNB API 写文章,接口返回 404。

实测结果

接口 方法 结果
读分支 / 读目录 / 读文件 / 读 commit GET ✅ 200
/-/git/blobs(创建散对象) POST 403(有路由,但组不成 commit)
/-/git/commits(建提交) POST 404 路由不存在
/-/git/contents/{path}(写文件) PUT 404 路由不存在

扫完全量 OpenAPI 文档,唯一和写相关的操作是 CreateBlob没有 CreateCommit / CreateTree / UpdateRef / PutContent

结论:CNB 上写仓库只有一条路 —— git push over HTTPS(用户名 + 令牌当密码)。

解法:把"写文件"这一步交给 CNB 流水线做(流水线里有 git 环境),云函数只负责"触发流水线"。

坑 2:api.cnb.cool 没有 CORS 头

现象:浏览器直接 fetch('https://api.cnb.cool/...') 必被拦。

实测:OPTIONS 预检、带 Origin 的 200 响应、甚至把 Origin 换成 https://cnb.cool 自己 —— 全都没有 Access-Control-Allow-Origin

结论:浏览器不能直连 CNB API。

解法:必须有一个同源的服务端代理。EdgeOne 云函数正好挂在同一个域名下(/api/*),天然同源,零跨域。

坑 3:CNB 只认单一的 Accept 头(406)

现象:自检接口一直返回 cnb.ok: false, status: 406,但用 curl 直接调 CNB 是好的。

根因:云函数里写成了

1
Accept: 'application/vnd.cnb.api+json, application/json'   // ❌ 406

CNB 的内容协商不接受逗号分隔的合并值。实测:

Accept 值 结果
application/vnd.cnb.api+json, application/json ❌ 406
application/vnd.cnb.api+json ✅ 200
application/json ✅ 200
不带 Accept(默认 */* ❌ 406

解法:只给一个值。

1
Accept: 'application/vnd.cnb.api+json'   // ✅

坑 4:EdgeOne CLI 会把密钥写进本地 .env

这是一次真实的安全事故,务必重视。

经过:用 edgeone pages env set 配环境变量时,CLI 顺手把 4 个变量写进了本地 .env。因为 .gitignore 当时没有排除 .env,结果这个文件被推送脚本一起提交进了仓库 —— 后台密码和 CNB 令牌明文进过仓库历史

影响:即使之后把文件删掉,旧提交对象可能仍存在于服务端。

处理

  1. .gitignore 补上 .env.env.*.edgeone/
  2. 重建历史 + 强推,让仓库只剩一个干净提交
  3. 轮换所有泄露过的密钥(换 CNB 令牌 + 换后台密码)
  4. 确认仓库是私密

教训

  • 用任何 CLI 配密钥后,第一件事是检查它有没有在本地留下明文文件
  • .gitignore 要在放密钥之前就配好,不能事后补

坑 5:isomorphic-git 的"快进"不会检出文件

背景:本机没装 git,用纯 JS 的 isomorphic-git 做推送工具。

现象:线上后台发了一篇文章后(远端比本地新),本地再同步时,那篇文章被当成"已删除"提交上去了。

根因

1
await git.merge({ fs, dir, ours, theirs, fastForwardOnly: true });   // ❌ 只移动分支指针

merge({fastForwardOnly:true}) 只更新分支指针,不会把新文件检出到工作区。所以工作区里没有那个文件,statusMatrix 就认为它是"被删除了"。

正确写法

1
2
await git.writeRef({ fs, dir, ref: `refs/heads/${BRANCH}`, value: remoteSha, force: true });
await git.checkout({ fs, dir, ref: BRANCH, force: true }); // ✅ 真正检出

坑 6:函数目录名是 cloud-functions

EdgeOne 早期文档写的是 node-functions,后来改名为 cloud-functions。写错目录名,函数不会生效(表现为 /api/ping 返回静态站的 404 页面)。

另外:手动构建时,要把函数文件和 package.json 一起放进部署产物目录,CLI 才能识别。

坑 7:api_trigger 要挂在分支名下

.cnb.yml 里事件不是顶层键,而是分支名 → 事件的两层结构:

1
2
3
main:
push: [...] # ✅
api_trigger: [...] # ✅

写成顶层 api_trigger: 不会触发。

另外 POST /{repo}/-/build/startevent 字段只能是 api_trigger(或它的前缀),没法用它触发 push 流水线

附:沙箱/网络相关(仅影响本地调试)

  • 某些受限网络环境下访问不了 *.edgeone.dev(curl 直连超时),但 api.cnb.cool 和 EdgeOne 的部署 API 是通的
  • 验证线上内容可以换个通道(比如用能出网的抓取工具),测 POST 类接口可以本地 import 云函数模块直接调用处理器

7. 安全清单

搭完自查一遍:

  • [ ] CNB 仓库设为私密
  • [ ] .gitignore 里有 .env.env.*.edgeone/
  • [ ] .cnb.yml 里的 EdgeOne Token 只在这一个文件里,且仓库私密
  • [ ] EdgeOne 环境变量里配的是 ADMIN_PASSWORD / CNB_TOKEN不要把这些值写进任何前端文件
  • [ ] edgeone.json/admin/*noindex
  • [ ] CNB 令牌权限最小化:只给 repo-coderepo-cnb-trigger,不勾其他
  • [ ] 用 CLI 配完密钥后,检查本地有没有生成 .env 之类的明文文件
  • [ ] 后台密码别用弱密码;怀疑泄露就换(换密码不用重新部署)
  • [ ] 定期轮换 CNB 访问令牌

关于后台安全性的说明:后台密码校验发生在服务端云函数里,密码不会下发到浏览器;CNB 令牌只存在 EdgeOne 环境变量中,前端代码里没有任何密钥。因此即使有人看到你的 /admin/ 页面,没有密码也做不了任何事。


附录:文件清单

项目根目录

文件 作用
.cnb.yml 流水线定义(WebIDE / push 构建 / api_trigger 发布)
.gitignore 排除依赖、产物、密钥文件
build.sh 组装部署产物 dist/
edgeone.json 站点配置(响应头、路由重写)
TUTORIAL.md 本文的 Markdown 全文
.ide/Dockerfile WebIDE 的容器环境(Node 20 + code-server)
Blog/ Hexo 工程(源码 + 主题 + 文章)
admin/index.html 线上后台页面(单文件)
cloud-functions/ 云函数(_cnb.js + api/{publish,posts,ping}.js
functions/package.json 函数运行时声明(type: module
scripts/build-and-deploy.sh 渲染 + 部署
scripts/publish-post.sh 写文章 + git push

Blog/ 里的关键文件

文件 作用
_config.yml 站点配置(标题、作者、url、theme)
_config.fluid.yml 主题配置(样式、导航、评论等,注释很全)
source/_posts/*.md 文章
themes/fluid/ 主题本体
scaffolds/post.md hexo new 时的模板(定义 front-matter 结构)

三个核心文件的关系

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
管理员点发布

├─ admin/index.html ──POST──▶ cloud-functions/api/publish.js
│ │
│ ▼
│ CNB OpenAPI build/start (api_trigger)
│ │
│ ▼
│ scripts/publish-post.sh → 写 md + git push
│ │
└──────────────────────────────────────┤

scripts/build-and-deploy.sh
→ hexo generate → build.sh → dist/
→ edgeone pages deploy

成本与限制

项目 情况
费用 0(CNB 免费额度 + EdgeOne Pages 免费额度)
服务器 不需要
备案 不需要(用海外访问区)
国内访问速度 走海外节点,首屏约 1~3 秒,偶有波动
想要国内秒开 需要域名备案 + 用 EdgeOne 国内版选「国内可访问」
单篇正文上限 6 万字
发布延迟 约 1 分钟

本文记录的每一步都经过实际运行验证。文中所有密钥均为占位符,照做时请替换成你自己的。


零服务器搭一个「带线上后台」的静态博客
https://nixingzhe-blog.edgeone.dev/2026/09/17/zero-server-static-blog-with-admin/
作者
逆行者
发布于
2026年9月17日
许可协议