个人博客写久以后,文章通常不会是唯一内容。我还想放相册、个人介绍,也想给自己做的软件留一个长期入口。
这次我给 AstroPaper 增加了一个项目页,用来展示 iOS 软件 LinkSet 和 macOS 软件闪电右键。页面完成以后,我又接入了 Decap CMS,让文章可以在浏览器里编辑,再由 GitHub 提交触发 Vercel 部署。
页面本身不难,OAuth 才是最容易卡住的地方。Netlify 托管时可以直接使用平台提供的认证服务,Vercel 上没有这层现成代理,需要自己补上授权入口和回调函数。中间还遇到了一次很典型的中文文件名问题,一篇标题带有 100% 的文章让后台直接崩溃。
下面按实际改造顺序写一遍。最终会得到这些东西。
/projects/展示两款独立软件/admin/提供中文内容管理后台/api/decap/auth发起 GitHub OAuth/api/decap/callback接收授权结果- Decap CMS 直接读写 GitHub 仓库
- 每次保存内容后由 Vercel 自动发布
先看一下项目结构
这次新增和修改的文件集中在几个位置。
api/
└── decap/
├── _shared.ts
├── auth.ts
└── callback.ts
public/
├── admin/
│ └── config.yml
└── images/
└── projects/
├── linkset-icon.jpg
├── linkset-preview.jpg
├── quickright-icon.png
└── quickright-preview.jpg
src/
├── components/
│ └── Header.astro
└── pages/
├── admin/
│ └── index.html
└── projects/
└── index.astro
博客继续保持静态输出。只有 OAuth 两个地址运行在 Vercel Functions 中,这样不用为了一个后台登录把整站改成 SSR。
项目页先从内容数据开始
项目页只有两款软件,直接把数据写在 src/pages/projects/index.astro 里已经够用。这里没有急着引入新的内容集合,因为产品名称、平台、卖点和 App Store 地址并不会频繁变化。
const projects = [
{
name: "LinkSet",
chineseName: "链接管理器",
platform: "iPhone · iPad · Mac",
description: "把散落在不同平台的重要链接收进一个清爽的空间。",
features: ["AI 智能分类", "iCloud 多端同步", "重复与失效链接清理"],
icon: "/images/projects/linkset-icon.jpg",
preview: "/images/projects/linkset-preview.jpg",
href: "https://apps.apple.com/cn/app/...",
theme: "linkset",
},
{
name: "闪电右键",
chineseName: "QuickRight",
platform: "macOS",
description: "为 Finder 补上真正顺手的右键菜单。",
features: ["快捷新建文件", "常用 Finder 工具", "轻量且原生"],
icon: "/images/projects/quickright-icon.png",
preview: "/images/projects/quickright-preview.jpg",
href: "https://apps.apple.com/cn/app/...",
theme: "quickright",
},
];
页面再用一次 map 渲染卡片。以后增加新软件,只要补一个对象,不必复制整段 HTML。
<div class="project-list">
{
projects.map(project => (
<article class={`project-card project-card--${project.theme}`}>
<div class="project-copy">
<img src={project.icon} alt={`${project.name} 应用图标`} />
<h2>{project.name}</h2>
<p>{project.description}</p>
<ul aria-label={`${project.name} 主要功能`}>
{project.features.map(feature => (
<li>{feature}</li>
))}
</ul>
<a href={project.href} target="_blank" rel="noopener noreferrer">
在 App Store 查看
</a>
</div>
<div class="project-visual">
<img src={project.preview} alt={project.previewAlt} />
</div>
</article>
))
}
</div>
卡片在桌面端分成文案和预览两栏,窄屏时改成上下排列。LinkSet 的截图接近手机长图,适合居中展示并留出顶部空间。闪电右键是横向的 macOS 窗口截图,直接铺满右侧更自然。两种图片共用同一套卡片结构,只通过 theme 增加少量差异样式。
.project-card {
@apply grid overflow-hidden rounded-2xl border border-skin-line;
@apply sm:grid-cols-[minmax(0,1fr)_minmax(240px,0.86fr)];
}
.project-card--linkset .preview {
@apply left-1/2 top-6 h-[112%] w-auto -translate-x-1/2;
@apply rounded-t-[2rem] object-contain shadow-2xl;
}
@media (max-width: 639px) {
.project-card--linkset .project-visual {
min-height: 23rem;
}
}
这页沿用了 AstroPaper 的 Layout、Header、Breadcrumbs 和 Footer。颜色使用主题已有的 skin 变量,深色模式不用另外维护一套。按钮的位移动画还增加了 prefers-reduced-motion 处理,系统要求减少动态效果时会关闭过渡。
导航栏需要同步认识新页面。先给 Header.astro 的 activeNav 联合类型加入 projects,随后增加入口。
<li>
<a href="/projects/" class={activeNav === "projects" ? "active" : ""}>
项目
</a>
</li>
项目页调用 <Header activeNav="projects" /> 后,现有的波浪下划线就会自动落在“项目”上。桌面导航和移动菜单使用同一份列表,也不用再补第二个入口。
给静态站放进一个内容后台
Decap CMS 的后台可以从一个很薄的 HTML 页面启动。文件放在 src/pages/admin/index.html,Astro 构建后会得到 /admin/index.html。
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<meta name="robots" content="noindex, nofollow" />
<link href="/admin/config.yml" type="text/yaml" rel="cms-config-url" />
<title>内容管理 | HuaTing的博客</title>
</head>
<body>
<noscript>请启用 JavaScript 后使用内容管理后台。</noscript>
<script src="https://cdn.jsdelivr.net/npm/decap-cms@3.1.2/dist/decap-cms.js"></script>
</body>
</html>
我把版本号固定下来,没有直接引用浮动的最新版。这样一次依赖更新不会在毫无提示的情况下改变后台行为。准备升级时,可以先改成新的明确版本,在预览部署里检查登录、文章列表、图片和保存流程。
public/admin/config.yml 决定后台怎样读取和保存内容。它需要同时匹配 GitHub 仓库和 Astro Content Collection。
backend:
name: github
repo: sillyaboy/astro-paper-blog
branch: main
use_graphql: true
base_url: https://www.huating.me
auth_endpoint: api/decap/auth
local_backend: true
site_url: https://www.huating.me
display_url: https://www.huating.me
locale: zh_Hans
media_folder: src/assets/images
public_folder: "@assets/images"
base_url 指向 OAuth 函数所在域名。auth_endpoint 只写认证入口,回调地址由认证函数交给 GitHub。媒体文件继续进入 src/assets/images,正文则保存 Astro 已经使用的别名路径。
本地编辑时可以在 Astro 开发服务之外启动 Decap 的本地代理。
npx decap-server
npm run dev
local_backend: true 只开放本地开发能力,线上仍然走 GitHub OAuth。
让字段符合 Astro 内容模型
原博客的文章 frontmatter 已经有固定结构。Decap 配置中的字段名必须与它一致,否则后台保存一次,就可能写出 Astro 无法通过校验的数据。
下面是一组主要字段。
collections:
- name: blog
label: 文章
folder: src/content/blog
create: true
extension: md
format: frontmatter
identifier_field: title
slug: "{{slug}}"
fields:
- label: 标题
name: title
widget: string
- label: 自定义网址标识
name: slug
widget: string
required: false
- label: 作者
name: author
widget: string
default: Hua Ting
- label: 发布时间
name: pubDatetime
widget: datetime
format: "YYYY-MM-DDTHH:mm:ss.SSSZ"
- label: 描述
name: description
widget: text
- label: 标签
name: tags
widget: list
required: false
- label: 正文
name: body
widget: markdown
日期格式值得单独检查。Astro 的 schema 使用 z.date(),Decap 保存的时间需要包含完整时区信息。YYYY-MM-DDTHH:mm:ss.SSSZ 能把浏览器选择的日期和时间写成明确的 ISO 8601 值。
slug 字段也保留了下来。已有文章继续使用原来的公开网址,编辑标题不会顺手改掉链接。这个小设置能省掉重定向和搜索引擎收录变化带来的麻烦。
GitHub OAuth 要补一段服务端流程
Decap CMS 在浏览器里运行,GitHub OAuth 的 Client Secret 不能放进 config.yml 或前端脚本。Vercel Functions 正好可以承担这段工作。
完整过程很短。
- 后台打开
/api/decap/auth - 函数生成随机
state并写入安全 Cookie - 浏览器跳到 GitHub 授权页
- GitHub 把临时
code送回/api/decap/callback - 回调函数用 Client Secret 换取 access token
- token 通过
window.postMessage交还给 Decap CMS
GitHub OAuth App 中填写下面两个地址。
Homepage URL
https://www.huating.me
Authorization callback URL
https://www.huating.me/api/decap/callback
回调地址要和代码生成的 redirect_uri 完全一致。正式域名带 www 时,这三个地方都使用同一个版本,避免在授权中途依赖重定向。
Vercel 的 Production 环境至少需要两个 Secret。
GITHUB_OAUTH_CLIENT_ID
GITHUB_OAUTH_CLIENT_SECRET
代码还预留了这些可选项。
OAUTH_ALLOWED_ORIGIN=https://www.huating.me
OAUTH_REDIRECT_URI=https://www.huating.me/api/decap/callback
GITHUB_OAUTH_SCOPE=repo
Client Secret 只能保存在 Vercel 环境变量中。不要把它写进 .env 后提交,也不要使用 PUBLIC_ 或 VITE_ 前缀。
repo scope 可以读写账号有权访问的公开与私有仓库,授权范围很宽。如果 CMS 只管理公开仓库,可以评估 public_repo。GitHub OAuth App 不能把传统 OAuth scope 精确限制到单个仓库,后台使用者仍然应该控制在确实有仓库写权限的人之内。
认证入口怎样生成跳转
api/decap/auth.ts 会检查请求方法和 provider,随后生成 32 字节随机值。
const state = createState();
const authorizationUrl = new URL("https://github.com/login/oauth/authorize");
authorizationUrl.searchParams.set(
"client_id",
getRequiredEnv("GITHUB_OAUTH_CLIENT_ID")
);
authorizationUrl.searchParams.set("redirect_uri", getRedirectUri());
authorizationUrl.searchParams.set("scope", "repo");
authorizationUrl.searchParams.set("state", state);
return new Response(null, {
status: 302,
headers: {
Location: authorizationUrl.toString(),
"Set-Cookie": stateCookie(state),
"Cache-Control": "no-store, max-age=0",
},
});
Cookie 使用这些属性。
Path=/api/decap
HttpOnly
Secure
SameSite=Lax
Max-Age=600
浏览器脚本读不到 HttpOnly Cookie,HTTPS 之外也不会发送它。十分钟有效期足够完成一次授权,时间到了就失效。
回调函数怎样把 token 送回后台
api/decap/callback.ts 先比较查询参数中的 state 和 Cookie。缺少 code、Cookie 已过期或两边不同,都会停止换取 token。
const code = requestUrl.searchParams.get("code");
const returnedState = requestUrl.searchParams.get("state");
const expectedState = readStateCookie(request);
if (!code || !returnedState || returnedState !== expectedState) {
return htmlResponse("error", {
error: "GitHub OAuth state validation failed. Please try again.",
});
}
校验通过以后,函数在服务端向 GitHub 交换 token。
const tokenResponse = await fetch(
"https://github.com/login/oauth/access_token",
{
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
client_id: getRequiredEnv("GITHUB_OAUTH_CLIENT_ID"),
client_secret: getRequiredEnv("GITHUB_OAUTH_CLIENT_SECRET"),
code,
redirect_uri: getRedirectUri(),
}),
}
);
Decap CMS 通过弹窗完成授权。回调页收到 token 后,要按它约定的消息格式通知原窗口。
window.opener.postMessage(
`authorization:github:success:${JSON.stringify({
token,
provider: "github",
})}`,
allowedOrigin
);
这里没有把目标来源写成 *。回调页只接受 https://www.huating.me 发来的消息,也只把结果发回这个来源。响应同时带上 no-store、CSP、Referrer-Policy 和 X-Content-Type-Options,用完后清除 state Cookie。
Vercel 不需要额外写重定向规则
Vercel 会把根目录 api 下的 TypeScript 文件识别成 Functions。
api/decap/auth.ts -> /api/decap/auth
api/decap/callback.ts -> /api/decap/callback
这次没有增加 vercel.json。推送到 main 后,GitHub 集成自动创建 Production Deployment,环境变量也在这次新部署中生效。
部署完成后,我按下面的顺序检查。
GET /api/decap/auth
期望结果 302,并跳向 github.com/login/oauth/authorize
GET /api/decap/callback
期望结果 200,并带 no-store 与 CSP 响应头
GET /admin/config.yml
期望结果能看到 base_url 和 auth_endpoint
打开 /admin/
期望结果能登录、读取文章列表并打开编辑器
只看到登录页还不算接入完成。授权弹窗能够回来、文章能打开,保存后 GitHub 也出现提交,整个流程才算走通。
一个百分号让后台崩了
第一次登录后,文章列表刚打开,Decap CMS 3.1.2 抛出了下面的错误。
URIError: Pathname "/collections/blog/entries/Wordpress 负载100% 解决思路"
could not be decoded. This is likely caused by an invalid percent-encoding.
对应的 Markdown 文件名正好是下面这样。
Wordpress 负载100% 解决思路.md
路由解析器把文件名中的裸 % 当成百分号编码开头,后面又没有两个合法的十六进制字符,decodeURIComponent 因此报错。标题可以保留,问题出在文件名。
这篇文章已经有独立的 frontmatter slug。
slug: WordPress-100-Load-Solution
所以我只把源文件改成下面的名字。
wordpress-100-load-solution.md
改完后重新部署,从 /admin/ 首页进入文章列表。后台生成的编辑地址变成了安全路径。
/admin/#/collections/blog/entries/wordpress-100-load-solution
文章公开地址仍然由 frontmatter 中的 slug 决定。
/posts/WordPress-100-Load-Solution/
这个问题给后续内容留下一条很实用的规则。标题可以使用中文和常见标点,文件名尽量使用小写字母、数字与连字符。公开地址交给明确的 slug 管理。这样既照顾阅读,也能避开不同工具对文件路径的解析差异。
最后做一次完整检查
这次改造完成后,我保留了一份短检查表。
- 项目页在桌面和手机宽度下都能正常排列
- 图片带有尺寸、替代文字和懒加载
- 导航能正确显示项目页激活状态
/admin/不会被搜索引擎收录- Decap 字段与 Astro schema 对应
- Client Secret 只存在于 Vercel Production 环境
- OAuth 回调地址与
redirect_uri完全一致 - 授权请求包含随机 state 并校验 Cookie
- OAuth 响应禁止缓存,也限制消息来源
- Markdown 文件名没有裸
%等危险字符 - 推送后检查 Vercel 状态和后台实际编辑流程
项目页给软件留了一个稳定入口,Decap CMS 则把写文章这件事从本地编辑器搬到了浏览器里。两部分都没有改变 AstroPaper 原来的内容组织方式,博客依旧是静态站,GitHub 继续保存全部历史,Vercel 只多运行两个很小的授权函数。
这套结构很适合个人博客。平时几乎没有服务端维护成本,想改页面时仍然可以直接写 Astro,临时在别的电脑上更新文章时也有一个够用的后台。