Skip to content

Repository files navigation

YUNA 协会博客系统

这是燕山大学大学生网络信息协会(YUNA)的协会博客系统。项目部署在 Cloudflare Pages 上,使用 Pages Functions 提供后端接口,D1 保存文章和长期数据,R2 保存图片、PDF 等文件资源,Zitadel 负责登录鉴权。

这个仓库只保存网站框架、页面逻辑、样式和数据库迁移脚本;实际文章、固定页面内容、协会成员、名人堂和首页图库等数据都进入 D1。图片、头像、PDF 等二进制资源进入 R2。

功能概览

  • 首页展示协会头图、最新文章、部门入口和底部相关服务。
  • 普通文章使用 Markdown 编辑器维护,正文存入 D1。
  • 协会成员按届数、部门、职位结构化维护。
  • 名人堂独立维护头像、名称、职位、履历和联系方式。
  • 固定页面内容可以从数据库读取和编辑。
  • 后台支持数据库导出和强制导入,方便整站数据迁移。
  • 图片和授课资料上传直接写入 R2,页面通过 /media/... 访问。
  • R2 上传支持小文件直传和大文件分片上传。
  • 文章或固定 Markdown 页面发生改动后,会自动同步 Markdown 快照到私有 GitHub 仓库。
  • Zitadel OIDC 登录后进入管理后台。

技术栈

  • Cloudflare Pages:静态页面托管。
  • Cloudflare Pages Functions:接口、鉴权、媒体读取。
  • Cloudflare D1:文章 Markdown、站点结构化数据、会话、备份。
  • Cloudflare R2:图片、头像、PDF 等二进制资源。
  • Zitadel:OIDC 登录、用户身份和项目角色来源。
  • TypeScript:Functions 类型检查。
  • 原生 HTML/CSS/JavaScript:前台和后台界面。

目录结构

public/                 前台页面、后台页面、静态资源
public/admin/           管理后台
public/styles.css       全局样式
public/app.js           前台交互逻辑
functions/              Cloudflare Pages Functions
functions/_shared/      鉴权、会话、D1、R2、HTTP 等共享逻辑
migrations/             D1 数据库迁移脚本
scripts/                旧内容迁移、媒体迁移、内容检查脚本
wrangler.toml           Cloudflare 绑定和公开环境变量
.dev.vars.example       本地 secret 示例

数据存储规则

D1 存储:

  • 文章标题、摘要、状态、主要作者、协同作者、作者链接、头像、发布时间和 Markdown 正文。
  • 固定页面 Markdown 内容。
  • 协会成员、名人堂、首页图库等长期结构化数据。
  • 后台登录会话。
  • 站点记录的增量备份。

R2 存储:

  • 文章内图片。
  • 作者头像、成员头像、名人堂头像。
  • 首页背景图。
  • 授课资料、PDF、压缩包等资料文件。

仓库不再保存实际文章内容。重新部署 Pages 不会自动修改 D1 数据,也不会自动执行数据库迁移。

本地启动

安装依赖:

npm install

复制本地 secret 示例:

copy .dev.vars.example .dev.vars

.dev.vars 中填写:

ZITADEL_CLIENT_SECRET=Zitadel Web 应用的 client secret
SESSION_SECRET=至少 32 字节的随机字符串
R2_MIGRATION_TOKEN=仅旧媒体迁移脚本写入 R2 时需要
R2_MIGRATION_PREFIXES=activates
GITHUB_BACKUP_TOKEN=GitHub fine-grained token,仅 Markdown 备份需要

初始化本地 D1:

npm run db:migrate:local

启动本地预览:

npm run dev

打开:

http://localhost:8788
http://localhost:8788/admin/

wrangler pages dev 会同时启动静态页面、Functions、D1 和 R2 绑定。只打开 HTML 文件无法完整预览后台和接口功能。

Cloudflare 资源

当前绑定名固定为:

[[d1_databases]]
binding = "BLOG_DB"
database_name = "cloudflare_markdown_blog"

[[r2_buckets]]
binding = "BLOG_BUCKET"
bucket_name = "cloudflare-markdown-blog"

如果是新环境,需要先创建 D1 和 R2:

npx wrangler d1 create cloudflare_markdown_blog
npx wrangler r2 bucket create cloudflare-markdown-blog

然后把 D1 返回的 database_id 写回 wrangler.toml

线上执行 D1 迁移:

npm run db:migrate

注意:Pages 重新构建不会自动执行 D1 迁移。只要 migrations/ 新增了脚本,就需要手动执行一次 npm run db:migrate

生产环境 Secrets

Cloudflare Pages 项目名以控制台为准。当前线上项目使用过 yuna-team-new,设置 secret 时建议显式指定项目名:

npx wrangler pages secret put ZITADEL_CLIENT_SECRET --project-name yuna-team-new
npx wrangler pages secret put SESSION_SECRET --project-name yuna-team-new
npx wrangler pages secret put FALLBACK_ADMIN_USER --project-name yuna-team-new
npx wrangler pages secret put FALLBACK_ADMIN_PASSWORD --project-name yuna-team-new
npx wrangler pages secret put R2_MIGRATION_TOKEN --project-name yuna-team-new
npx wrangler pages secret put GITHUB_BACKUP_TOKEN --project-name yuna-team-new

说明:

  • ZITADEL_CLIENT_SECRET:Zitadel Web 应用的客户端密钥。
  • SESSION_SECRET:用于签名登录会话 Cookie,必须是随机长字符串。
  • FALLBACK_ADMIN_USER / FALLBACK_ADMIN_PASSWORD:备用账密登录凭据,见下方“备用账密登录”。
  • R2_MIGRATION_TOKEN:只用于旧媒体迁移脚本写入 R2,不具备后台管理权限。
  • GITHUB_BACKUP_TOKEN:写入私有 GitHub 备份仓库的 token。

wrangler.toml 中的公开变量:

PUBLIC_BASE_URL = "https://yuna.liugu.cc"
ZITADEL_ISSUER = "https://sso.welain.com"
ZITADEL_CLIENT_ID = "replace-with-zitadel-client-id"
ZITADEL_REDIRECT_PATH = "/auth/callback"
SSO_ALLOWED_HOSTS = ""
CONTROL_GROUP = "yuna-docs-edit"
R2_MIGRATION_PREFIXES = "activates"
GITHUB_BACKUP_REPO = ""
GITHUB_BACKUP_BRANCH = "main"
GITHUB_BACKUP_PATH = "yuna-blog"

GitHub Markdown 备份配置:

  • GITHUB_BACKUP_REPO:目标仓库,格式为 owner/repo
  • GITHUB_BACKUP_BRANCH:目标分支,默认 main,需要提前存在。
  • GITHUB_BACKUP_PATH:写入仓库内的目录前缀,默认 yuna-blog
  • GITHUB_BACKUP_TOKEN:GitHub fine-grained personal access token,至少需要目标仓库的 Contents: Read and write 权限。

保存文章、删除文章、编辑固定 Markdown 页面、导入数据库或迁移旧文章后,系统会在响应返回后自动同步 D1 里的 Markdown 快照到 GitHub。配置缺失时会跳过同步,不影响正常写入。

备份仓库建议使用独立私有仓库,不要把它绑定到 Cloudflare Pages 项目。这个同步只会向 GitHub 写入 Markdown 快照,不会调用 Cloudflare 部署;如果目标仓库本身被 Pages 监听,GitHub 提交仍然会触发 Pages 构建。

Zitadel 配置

https://sso.welain.com 中创建 Web 类型 OIDC 应用,认证方式选择 Client Secret Basic。

Issuer:

https://sso.welain.com

回调地址默认为:

https://yuna.liugu.cc/auth/callback

站点支持通过多个 CNAME 域名访问。把额外域名写进 SSO_ALLOWED_HOSTS(逗号分隔,主机名或完整 origin 均可)后,登录会回调到用户实际访问的域名:

SSO_ALLOWED_HOSTS = "docs.example.com, blog.example.org"

同时要把每个域名的回调地址加入 Zitadel 应用的 Redirect URIs,例如 https://docs.example.com/auth/callback。不在名单内的域名会退回 PUBLIC_BASE_URL 的规范回调地址。

裸域(如 yuna.team)无法直接绑定为 Pages 自定义域时,可经外部 CDN(如阿里云 CDN)反代回源 yuna-team-new.pages.dev。此时需要在 CDN 上配置:

  • 回源 HOST:yuna-team-new.pages.dev,回源协议 HTTPS。
  • 自定义回源请求头:X-Forwarded-Host: yuna.team(Worker 只信任允许名单内的值,用于解析登录回调域名和 CSRF 同源判断)。
  • /api/*/auth/* 必须不缓存,且回源时透传 Cookie、查询串,响应中的 Set-Cookie 不能被剥离,否则登录态无法建立。

本地开发时,如果需要完整测试登录,也需要在 Zitadel 应用中加入本地回调地址:

http://localhost:8788/auth/callback

后台权限规则:

  • CONTROL_GROUP 保留原变量名,但现在表示唯一控制权限角色。
  • 登录用户必须拥有 CONTROL_GROUP 对应的 Zitadel 项目角色,才可以进入后台、管理文章、维护成员和名人堂、编辑固定 Markdown 页面、上传后台资源。
  • 未登录用户,以及已登录但不在该组内的用户,都没有控制权限。
  • CONTROL_GROUP 为空时,没有任何登录用户拥有控制权限。

在 Zitadel 项目设置中开启 Assert Roles on Authentication,创建与 CONTROL_GROUP 同名的项目角色,并通过 Role Assignments 把该角色分配给管理员。Zitadel 的普通组织成员关系或其他系统中的“用户组”不会自动成为项目角色。角色信息会在登录时写入会话;角色变更后,用户需要退出并重新登录。

备用账密登录

Zitadel 网关不可用时,后台入口页(/admin-login.html)提供备用账号密码登录,接口为 POST /api/auth/password-login

  • 仅当 FALLBACK_ADMIN_USERFALLBACK_ADMIN_PASSWORD 两个 secret 都已配置时启用;缺任一个接口返回 404。
  • 凭据核对通过后创建的会话与 OIDC 登录完全同构,用户组直接写入 CONTROL_GROUP,拥有全部后台权限;退出登录、会话过期逻辑一致。
  • 密码务必使用高强度随机串(建议 24 位以上)。校验失败统一延迟约 0.8 秒并返回相同文案,不区分账号或密码错误。
  • 这是应急通道:SSO 恢复后建议轮换或清空这两个 secret。

调试权限时,登录后访问:

/api/auth/me

返回里的 user.groups 是从 Zitadel userinfo 角色 claim 提取并写入会话的项目角色(字段名为兼容旧会话而保留),authz.controlGroupMatched 会显示当前配置的角色名是否命中。

部署

方式一:通过 Cloudflare Pages 连接 Git 仓库。

  • 构建命令:可以留空,或使用 npm install
  • 输出目录:public
  • Functions 目录:functions
  • D1 绑定名:BLOG_DB
  • R2 绑定名:BLOG_BUCKET

方式二:使用 Wrangler 手动部署:

npm run deploy

部署前确认:

npm run typecheck
npm run db:migrate

管理后台

后台地址:

/admin/

当前后台包含:

  • 文章管理:创建、编辑、发布、保存草稿、删除文章。
  • 协会成员:按届数、部门、职位维护成员。
  • 名人堂:维护头像、名称、职位、履历和联系方式。
  • 站点维护:手动同步 Markdown 备份、导出数据库、强制导入数据库、维护首页图库。
  • 文章编辑器:维护主要作者及多位协同作者的署名、链接与头像,上传正文图片和附件资料,并自动插入 Markdown 链接。

数据库导出会生成完整 JSON 备份。数据库导入会以导入内容为准强制覆盖对应数据,操作前建议先导出一份当前数据。

资料文件保存在 R2。文章编辑器上传附件时,8MB 以内走普通上传,超过 8MB 自动按 8MB 分片上传并在 R2 合并。

数据迁移

旧 Markdown 固定页面迁移到 D1:

$env:PUBLIC_BASE_URL="https://yuna.liugu.cc"
$env:ADMIN_SESSION_COOKIE="登录后台后的 yuna_session Cookie 值"
$env:CONTENT_SOURCE_REF="旧内容所在的 git ref,例如 9e4fd12^"
npm run db:migrate-pages

旧动态文章迁移到 D1:

$env:PUBLIC_BASE_URL="https://yuna.liugu.cc"
$env:ADMIN_SESSION_COOKIE="登录后台后的 yuna_session Cookie 值"
npm run db:migrate-posts

旧授课资料、PDF、压缩包等文件迁移到 R2:

$env:SITE_BASE_URL="https://yuna.liugu.cc"
$env:R2_MIGRATION_TOKEN="与 Cloudflare Pages 中一致的 R2_MIGRATION_TOKEN"
$env:SOURCE_ROOT="D:\System\Desktop\yuna\yuna.team\docs"
npm run media:migrate

media:migrate 只迁移旧站 public/activates 下的授课资料。小文件直接上传,大文件自动使用 R2 multipart 分片上传。R2_MIGRATION_TOKEN 只允许写入 R2_MIGRATION_PREFIXES 配置的媒体前缀,默认是 activates

这些迁移脚本只用于旧内容搬迁,日常写文章和传图片直接在后台完成。

常用命令

npm run dev               # 本地启动 Pages + Functions
npm run deploy            # 部署到 Cloudflare Pages
npm run db:migrate:local  # 本地 D1 迁移
npm run db:migrate        # 线上 D1 迁移
npm run typecheck         # TypeScript 类型检查
npm run content:check     # 检查内容链接
npm run db:migrate-pages  # 旧固定页面迁移到 D1
npm run db:migrate-posts  # 旧文章迁移到 D1
npm run media:migrate     # 旧媒体文件迁移到 R2

接口概览

公开接口:

  • GET /api/posts:获取已发布文章列表。
  • GET /api/posts/:slug:获取文章详情和 Markdown 正文。
  • GET /api/site/:key:获取固定页面或结构化站点记录。
  • GET /media/:path:读取 R2 媒体文件。

鉴权接口:

  • GET /api/auth/login:跳转 Zitadel 登录。
  • POST /api/auth/password-login:备用账密登录(需配置 fallback secrets)。
  • GET /auth/callback:OIDC 回调。
  • POST /api/auth/logout:退出登录。
  • GET /api/auth/me:获取当前登录用户。

后台接口:

  • POST /api/posts:创建文章。
  • PUT /api/posts/:slug:更新文章。
  • DELETE /api/posts/:slug:删除文章。
  • PUT /api/admin/media/:path:上传媒体到 R2。
  • POST /api/admin/uploads/init:初始化 R2 分片上传。
  • PUT /api/admin/uploads/part:上传一个 R2 分片。
  • POST /api/admin/uploads/complete:完成 R2 分片上传。
  • POST /api/admin/uploads/abort:取消 R2 分片上传。
  • POST /api/admin/github-sync:手动同步 D1 Markdown 快照到 GitHub。
  • PUT /api/admin/site/:key:更新站点记录。
  • GET /api/admin/export?download=1:导出数据库数据。
  • POST /api/admin/import:导入数据库数据。

安全说明

  • 不要把 ZITADEL_CLIENT_SECRETSESSION_SECRETR2_MIGRATION_TOKENGITHUB_BACKUP_TOKEN 提交进仓库。
  • SESSION_SECRET 修改后,已有登录会话会失效。
  • R2 文件通过后端接口输出,非图片类型会按下载文件处理。
  • 联系方式链接会限制协议,避免写入危险链接。
  • 数据库导入是覆盖型操作,执行前先确认导入文件来源可信。
  • 公开接口不输出成员登录邮箱:文章的 author_email、站点记录的 updated_by 只保留在数据库和管理端导出里。
  • 所有写请求(POST/PUT/DELETE)在中间件统一校验 Origin 头,跨站请求直接拒绝;无 Origin 的脚本客户端仍需各接口自身的鉴权。
  • public/_headers 下发全站安全响应头(CSP、X-Frame-Options、HSTS 等),只覆盖静态资源;接口响应的安全头在代码中单独设置。
  • 文章阅读计数按浏览器 Cookie 去重(30 分钟内同一篇不重复累计),计数写入在响应后异步执行。

About

Club website and Markdown blog for YUNA, built on Cloudflare Pages Functions with D1, R2, and Zitadel OIDC.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages