30 Commits
Author SHA1 Message Date
wangairnan b1ea89ba3d fix(ci): 发版重跑幂等:刷新发布说明并替换旧附件
Release / release (push) Successful in 5m16s
同 tag 重跑时 POST 冲突走查询回退,需 PATCH 以本次构建为准补写
发布说明,并先清空旧附件再重传,避免同名副本。
2026-09-09 23:31:26 +08:00
wangairnan 9b0f2785cf fix(ci): 修复 release id 提取的 node 语法错误
Release / release (push) Successful in 5m15s
node -e 少写一个右括号导致 Release 创建后资产上传前失败。
改经环境变量传入响应 JSON,不再用 stdin 拼串;
已存在的同 tag release 走查询回退,重跑即可补传产物。
2026-09-09 23:24:13 +08:00
wangairnan ccc858bfdd fix(ci): 标签名与 token 改经表达式注入
Release / release (push) Failing after 4m17s
该 runner(Forgejo 系 v3.3.2)未导出 GITEA_REF_NAME 等别名环境变量
(checkout 因空 pathspec 失败)。改为 job 级 env 显式表达式注入:
TAG=${{ github.ref_name }},token 按 GITHUB_TOKEN/GITEA_TOKEN/FORGEJO_TOKEN 依次回退。
2026-09-09 23:17:18 +08:00
wangairnan 2be5d4d25b fix(ci): 发版工作流改为零 action 纯 run 实现
Release / release (push) Failing after 13s
runner 实例地址注册为 localhost:3000,job 容器内不可达,导致所有
JS 类 action(checkout/setup-go/release)无法工作。改为:
- git clone 公网域名检出,tarball 安装 Go(go.dev 失败回退中国镜像)
- 容器固定 node:24-bookworm(runner 默认镜像无 node)
- curl 调 Gitea API 创建 Release 并上传产物,npm/Go 模块代理双层回退
2026-09-09 23:09:21 +08:00
wangairnan 95cac4688d fix(ci): 发版 job 显式使用 node 容器镜像
Release / release (push) Failing after 2m8s
runner 默认镜像无 node,checkout/setup-go/release 等 JS 类 action
第一步即失败(Cannot find: node in PATH)。改用 node:24-bookworm
镜像(自带 node 与 git),并省去 setup-node 步骤。
2026-09-09 23:00:32 +08:00
wangairnan ad4a87fc9f feat(ci): 基于 v* 标签的多架构自动发版
Release / release (push) Failing after 8s
- 新增 .gitea/workflows/release.yml:推送 v* 标签触发,构建前端嵌入后
  交叉编译 linux/darwin(amd64+arm64),生成变更日志并创建 Gitea Release
- 新增 scripts/build-release.sh:make dist 与 CI 共用的产物构建脚本,
  tar.gz 打包 + SHA256SUMS;不含 windows(internal/store 依赖 Unix Umask)
- Makefile 增加 dist 目标;版本注入沿用 git describe + ldflags(pn version)
2026-09-09 22:46:12 +08:00
wangairnan 92d89717c5 refactor(web): 残留自制控件统一替换为 antd 组件
- 状态点自制徽标改用 Badge,回收站剩余天数紧急态改用红色 Tag
- 危险按钮改用 danger 属性并补充语言,text+danger 不套实心红样式
- 后台搜索框改 antd Input 前缀形态,删除手写容器与聚焦环样式
- 加载态统一 Spin/Skeleton(Table 用 loading),空态统一 Empty
- Segmented 选项计数改用 Badge count,弱化为 muted 灰
- 首页分页说明改 Pagination showTotal,笔记错误态改 Empty 承接布局
- 代码块复制按钮改 antd Button 并增加「已复制」反馈,修复悬浮不显示
- 博客/后台图标按钮与溢出菜单触发器统一为 antd Button type=text
2026-09-09 15:37:36 +08:00
wangairnan 969d537aee fix(web): 后台侧栏「新建笔记」上移至首位,移除管理员信息中的「站点所有者」
侧栏 Menu 与移动端 tabbar 同步调换,编辑页高亮新建入口的逻辑不变。
2026-09-09 12:25:10 +08:00
wangairnan 614bfe2852 fix(web): 后台侧栏选中项改黑底反白,修复明暗模式下选中态看不清
自定义主题 colorPrimary 为近黑/近白,antd Menu 选中项默认的
primary 淡色底与侧栏底色对比不足;选中/hover 态改用 sidebar
token(黑底反白/浅灰底),并覆盖此前对未选中文字色的误覆盖。
2026-09-09 12:09:33 +08:00
wangairnan 5bac272f6d feat: 前台标签接入 antd 组件、列表封面图、favicon 随站点 Logo 及侧栏收窄
- 前台列表/详情/标签页标签统一 antd Tag;标签页改 CheckableTagGroup 单选
  (选中态由组件承接,再点已选标签回到全部);移除首页标签筛选条,
  标签入口收敛至顶部「标签」页
- 公开列表新增 cover 字段(取正文第一张站内图,store.FirstImageRef),
  首页条目左侧显示封面缩略图,无图条目保持纯文字
- favicon 随站点 Logo:webui.Meta.Icon 服务端注入 index.html,
  前端 useSiteTitle 兜底(dev 模板占位符场景)并即时生效;
  设置页「重置默认」清空 Logo 后回退内置 /favicon.svg
- 后台侧栏改 antd Menu(inline 分组),宽度 256→208、窄侧栏档 224→184
2026-09-09 11:51:06 +08:00
wangairnan 5457aeaa33 feat: 新增发布日期自选、站点 Logo、回收站清空等十项改进
- 编辑页标题改 filled 变体;标签改 tags 选择器(可勾选既有/输入新建)
- 笔记发布日期可自选(schema v2 新增 published_at 并回填),前台展示发布日期
- 登录页移除「请输入管理密码以继续」「连续失败将被暂时锁定」文案
- 站点设置每页条数收敛为 10/20/30/50 选择器,后端白名单同源校验
- 站点设置两卡片宽屏左右/窄屏上下;修改密码按钮改常规大小
- 后台各页顶栏固定高度,切换页面不再抖动
- 站点 Logo:settings 新增 site_logo(站内路径),gc/孤儿清单豁免 Logo 图片,
  博客 header 站点名前展示,设置页支持上传/更换/清除
- 回收站右上角一键清空(DELETE /api/admin/trash,含确认弹窗)
- pn init 写入 Markdown 语法示例文档(slug welcome,公开)与程序生成的示例图片
2026-09-09 11:10:30 +08:00
wangairnan 87d9bab5dc feat(web): 整体替换 UI 框架为 Ant Design 6 并接入自定义主题
以 antd ConfigProvider + antd-style 承载 zinc 风格自定义主题(明暗双套 token),
button/input/select/notification 用 createStyles 定制;三态主题经
darkAlgorithm 无刷新切换;use-toast 改基于 antd notification;
移除 shadcn/ui 组件目录与 radix 依赖;样式桥接层保证排版与多端布局不变。
2026-09-09 10:35:24 +08:00
wangairnan 5a78de6aa6 feat(web): 后台主题切换与移动端溢出菜单,设置保存按钮上移 topbar
- 后台 topbar 右侧常驻主题切换按钮(复用博客端三态循环逻辑),
  tooltip 新增 tip-below 朝下变体适配近视口顶部的 topbar
- <768px 侧栏隐藏时主题/返回博客/退出登录收进「···」溢出菜单;
  博客 <640px 首页/标签收进 header 溢出菜单(带 active 高亮)
- 新增通用 MoreMenu 组件:点击外部/Escape/选中项自动关闭,
  面板用 --popover 令牌 + 0.15s 淡入,与现有卡片风格一致
- 站点设置保存按钮移至 topbar 标题右侧,删除底部 sticky 保存条
- 修复两处回归:tip-below 气泡居中锚定越界产生页面横向滚动条
  (改右对齐);pn-site-menu-btn 基础 display 规则源码顺序在
  640px MQ 之后导致移动端菜单按钮不显示(前移至博客 header 区)
- 已用 Playwright 在 1440×900 / 390×844 实测后台四页与博客
  菜单展开/跳转均无溢出
2026-09-09 00:12:21 +08:00
wangairnan 646a49a26e feat(web): 页脚品牌与备案信息、首页文案精简及导航/侧栏交互优化
- 页脚「纯文本驱动」改为 Powered by Pure Note(链至仓库地址),新增
  公安备案展示:国徽图标 + 备案号链至 beian.mps.gov.cn,备案号可在
  后台设置(站点描述下一项)配置,留空不显示
- settings 白名单新增 beian_no 键(trim、≤200 字符、可清空),/api/site
  一并下发;TestSettingsWhitelist 补生效/清空/超长用例
- 去除首页 hero 文案区(PURE NOTE · 极简笔记/记录,而不打扰。等),
  同步清理失效样式并微调顶部留白
- 博客 header 首页/标签菜单补 House/Tags 图标,与管理按钮同风格
- 管理侧栏「返回博客/退出登录」浓缩为图标按钮置于用户行右侧,CSS
  悬浮 tooltip(top-center、带箭头,原生 title 延迟高不采用),返回
  博客改新窗口打开
2026-09-08 23:40:57 +08:00
wangairnan 13ad202924 chore(web): 重新生成 package-lock 统一官方源并更新次要依赖 2026-09-08 23:05:15 +08:00
wangairnan f16f583978 docs: 补充 README 界面功能、Safari 登录注意事项与前端架构
- 功能清单补「界面」条目:9 页面响应式 SPA 与三态主题
- 开发模式说明为何明文 HTTP 必须带 --dev(WebKit 无 Secure Cookie
  localhost 豁免)及生产 HTTPS 的自动加固行为
- 架构树展开 web/ 前端子目录(pages/components/lib)
2026-09-08 21:46:31 +08:00
wangairnan dda5ad1e5a fix(web): 登录表单补充隐藏用户名字段以兼容 Safari 密码管理器
Safari/WebKit 在明文 HTTP 下不保存 Secure Cookie(无 localhost 豁免,
Chrome/Firefox 有),__Host- 会话被静默丢弃导致登录成功后仍弹回登录页;
本机调试需以 --dev 启动(pn_session 无 Secure,loopback-only 守卫,§7.3-3)。
纯密码表单另令 Safari 保存密码弹窗要求手动输入用户名:补充 sr-only 用户名
输入(autoComplete=username,只读预填 admin),弹窗不再阻断登录。
新增 login.test.tsx 回归测试锁定用户名/密码字段的自动填充标记。
2026-09-08 21:42:52 +08:00
wangairnan 51f528f516 feat(web): 重构 9 页面界面并统一设计令牌与响应式布局
- index.css 以 Tailwind 4 @theme 落地设计令牌,全部页面按新设计稿重写
  样式:首页/笔记详情/标签页/登录/后台列表/编辑/回收站/设置/404
- Layout 重构导航与主题切换;新增 Toast 轻提示组件
- 移除 vite 手动 codeSplitting:分组 chunk 会被提升进入口静态依赖并对
  HTML 发出 modulepreload,lazy 路由的 CodeMirror 在所有页面被预加载
  (Lighthouse unused-javascript 549KiB 的根因)
- vite dev 代理补 changeOrigin:false,保留浏览器原始 Host,避免后端
  Origin 校验误判跨源 403
2026-09-08 21:41:05 +08:00
wangairnan 412821684b docs: 新增第 2 轮评审报告、D36 决策记录与 serve 旧词收尾
review-round2.md 全量修复清单;decisions.md 澄清 D6、新增 D36;design/acceptance 残留 serve → start(P2-14)。
2026-09-08 17:32:57 +08:00
wangairnan b94e9b1721 fix(web): 修复图片上传、自动保存竞态、登录竞态并统一失效公共缓存
FormData 不强设 Content-Type(P1-2),补 api 客户端回归测试; 自动保存成功仅失效列表与公共键,防详情 refetch 回滚输入(P1-4); login/logout 以请求序号丢弃迟到 /api/me 响应,401 统一回调跳登录(P2-15); 首页不硬编码 page_size,写操作后统一 invalidatePublic,TagPage 补 error 分支(P2-15)。
2026-09-08 17:32:57 +08:00
wangairnan 3105b0415c fix(middleware): Origin 同源校验增加 scheme 比对
http/https 不再视为同源;可信反代后采信 X-Forwarded-Proto(round2 P2-11),补测试。
2026-09-08 17:32:57 +08:00
wangairnan 1272d680a8 fix(httpapi): no-store 接线、图片缓存头时序、解压炸弹上限与 RSS 空日期
/api/admin/* 挂 NoStore、/api/me 内联 no-store(P1-3); 图片缓存头移至数据读取成功后,404 不携带 public immutable(P2-9); 上传先 DecodeConfig 限制像素 ≤2^25 再解码(P2-12); 无公开笔记时省略 lastBuildDate(P2-10)。
2026-09-08 17:32:52 +08:00
wangairnan ba7d5dd01f fix(auth): VerifyPassword 校验 PHC 参数上下界
t/p=0 会使 argon2.IDKey panic、m 超大可 OOM;越界一律返回 false(round2 P2-8),补畸形参数用例。
2026-09-08 17:32:52 +08:00
wangairnan a35e7fdfd3 fix(cmd): 维护子命令改经 OpenData 打开,--allow-newer 仅限 start/init
passwd/backup/gc 不再触发迁移与 user_version 守卫,回滚旧二进制场景备份仍可用(round2 P1-5,落实 D6)。
2026-09-08 17:32:52 +08:00
wangairnan 132c4a1ba2 fix(store): 修正邻接查询、GC 竞态、备份权限与设置解析,新增 OpenData 纯数据打开
邻接查询 tie-break 与 pinned 排序键与列表序对齐,补表驱动测试(round2 P1-1); GC DELETE 带条件复查,防 SELECT 与 DELETE 间恢复/引用竞态(P2-6); 备份经 umask 收紧创建即 0600,消除 chmod 前暴露窗口(P2-7); page_size 改 strconv.Atoi 全文解析,拒部分解析脏值(P2-13); OpenData 不迁移、不做版本守卫(D6/P1-5)。
2026-09-08 17:32:47 +08:00
wangairnan a885b52f18 docs: 新增提交规范(Conventional Commits)并链接至 README
明确 type/scope/subject/body/footer 规则与提交拆分约定;README 文档索引加入链接。
2026-09-08 17:32:44 +08:00
wangairnan 27ec9badf2 前端构建告警清理:happy-dom 替代 jsdom、批准 esbuild 安装脚本、CodeMirror 与重路由懒加载分包 2026-09-08 17:01:36 +08:00
wangairnan edfc787e1a 二进制及自命令命名统一简化 2026-09-08 17:01:09 +08:00
wangairnan 6bfe20ee00 文档与 usage:--data-dir 默认 ./data,示例去除冗余参数 2026-09-08 16:13:36 +08:00
wangairnan 2d02756fa3 新增 reset-password 子命令:免旧口令重设管理员口令并吊销全部会话 2026-09-08 16:10:16 +08:00
72 changed files with 7033 additions and 1465 deletions
+109
View File
@@ -0,0 +1,109 @@
# 发版工作流:推送 v* 标签 → 构建前端并嵌入 → 交叉编译多平台二进制
# → 生成发布说明 → 通过 Gitea API 创建 Release 并上传产物。
#
# 发版操作:
# git tag -a v0.0.1 -m "v0.0.1"
# git push origin v0.0.1
#
# 实现说明:刻意不使用任何 JS 类 action(checkout/setup-*/release-*)——
# 自托管 runner 常把实例地址注册成 localhost,job 容器内不可达,
# 且默认容器镜像可能没有 node。此处全程纯 run 步骤 + 公网域名访问实例,
# 容器固定 node:24-bookworm(自带 node 与 git,npm 构建前端所需)。
name: Release
on:
push:
tags:
- 'v*'
permissions:
contents: write
env:
SITE: https://git.airnan.cn
REPO: wangairnan/pure-note
jobs:
release:
runs-on: ubuntu-latest
container:
image: node:24-bookworm
env:
# 这台 runner 只保证 GITHUB_* 兼容层(GITEA_REF_NAME 等别名不可靠),
# 一律经表达式显式注入,不直接读 GITEA_* 环境变量
TAG: ${{ github.ref_name }}
TOKEN: ${{ secrets.GITHUB_TOKEN || secrets.GITEA_TOKEN || secrets.FORGEJO_TOKEN }}
steps:
# 完整历史:供 git log 生成上一个 tag 以来的变更日志
- name: 检出代码
run: |
git clone "$SITE/$REPO.git" .
git checkout --detach "$TAG"
git log --oneline -1
- name: 安装 Go(版本取自 go.mod)
run: |
set -euo pipefail
GOVER=$(awk '$1 == "go" {print $2}' go.mod)
echo "==> 安装 Go $GOVER"
# go.dev 在部分网络不可达,回退官方中国镜像
curl -fsSL "https://go.dev/dl/go${GOVER}.linux-amd64.tar.gz" -o /tmp/go.tgz \
|| curl -fsSL "https://golang.google.cn/dl/go${GOVER}.linux-amd64.tar.gz" -o /tmp/go.tgz
tar -C /usr/local -xzf /tmp/go.tgz
/usr/local/go/bin/go version
- name: 构建前端并嵌入(web/dist → internal/webui/dist)
run: make web sync-assets || (npm config set registry https://registry.npmmirror.com && make web sync-assets)
- name: 交叉编译发布产物
run: |
set -euo pipefail
export PATH=/usr/local/go/bin:$PATH
# 模块代理双层回退:官方代理不可达时走 goproxy.cn
export GOPROXY=https://proxy.golang.org,https://goproxy.cn,direct
scripts/build-release.sh "$TAG"
# 首个 tag 无上一版本,变更日志段自动省略
- name: 生成发布说明
run: |
set -euo pipefail
prev=$(git describe --tags --abbrev=0 "HEAD^" 2>/dev/null || true)
short=$(git rev-parse --short HEAD)
{
echo "## pn ${TAG}"
echo
echo "- commit: \`$short\`"
echo "- 构建时间: $(date -u '+%Y-%m-%dT%H:%M:%SZ')"
if [ -n "$prev" ]; then
echo
echo "### 自 ${prev} 以来的变更"
echo
git log --oneline --no-decorate "${prev}..HEAD" | sed 's/^/- /'
fi
} > release-notes.md
cat release-notes.md
# 重跑幂等:release 已存在时更新说明、清旧附件后重传
- name: 创建 Release 并上传产物
run: |
set -euo pipefail
API="$SITE/api/v1/repos/$REPO"
AUTH="Authorization: token ${TOKEN}"
REQ=$(node -e 'const fs=require("fs");process.stdout.write(JSON.stringify({tag_name:process.env.TAG,name:"pn "+process.env.TAG,body:fs.readFileSync("release-notes.md","utf8")}))')
# 同 tag release 已存在(如失败重跑)时回退为查询
REL=$(curl -fsS -X POST "$API/releases" -H "$AUTH" -H 'Content-Type: application/json' -d "$REQ") \
|| REL=$(curl -fsS "$API/releases/tags/${TAG}" -H "$AUTH")
RID=$(REL="$REL" node -e 'process.stdout.write(String(JSON.parse(process.env.REL).id))')
echo "==> release id=$RID"
# 发布说明以本次构建为准(回退路径下 POST 已冲突,须补写)
curl -fsS -X PATCH "$API/releases/$RID" -H "$AUTH" -H 'Content-Type: application/json' -d "$REQ" >/dev/null
# 清除旧附件,避免重跑产生同名副本
ASSETS=$(curl -fsS "$API/releases/$RID/assets" -H "$AUTH")
ASSETS="$ASSETS" node -e 'JSON.parse(process.env.ASSETS).forEach(a=>console.log(a.id))' | while read -r aid; do
curl -fsS -X DELETE "$API/releases/$RID/assets/$aid" -H "$AUTH" >/dev/null
done
for f in dist/*; do
echo "==> 上传 $(basename "$f")"
curl -fsS -X POST "$API/releases/$RID/assets?name=$(basename "$f")" -H "$AUTH" -F "attachment=@$f" >/dev/null
done
echo "==> 完成:$SITE/$REPO/releases/tag/${TAG}"
+5 -2
View File
@@ -10,8 +10,11 @@ internal/webui/dist/*
!internal/webui/dist/.gitkeep
# 本地构建的二进制
/pure-note
/pure-note-linux-*
/pn
/pn-linux-*
# 发布产物(make dist / CI 发版构建输出)
/dist/
# 系统文件
.DS_Store
+14 -5
View File
@@ -3,19 +3,23 @@
# 常用目标:
# make web 构建前端(npm ci + vite build → web/dist)
# make sync-assets 拷贝 web/dist → internal/webui/dist(go:embed 约束,§8.3-1)
# make build 全量构建(前端 + 单二进制)
# make build 全量构建(前端 + 单二进制 pn)
# make linux 交叉编译 linux/amd64
# make dist 全平台发布产物 → dist/(VERSION ?= git describe,与 CI 发版共用脚本)
# make test Go 测试 + 前端测试
# make smoke 真实构建冒烟测试(§8.3-6)
# make dev 双进程热重载(air + vite dev)
# make vulncheck govulncheck
#
# 发版:git tag -a v0.0.1 -m "v0.0.1" && git push origin v0.0.1
# (推送 v* 标签触发 .gitea/workflows/release.yml 自动构建并创建 Release)
VERSION ?= $(shell git describe --tags --always 2>/dev/null || echo dev)
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo none)
DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildDate=$(DATE)
.PHONY: web sync-assets build linux test smoke dev vulncheck clean
.PHONY: web sync-assets build linux dist test smoke dev vulncheck clean
web:
cd web && npm ci && npm run build
@@ -27,11 +31,15 @@ sync-assets:
touch internal/webui/dist/.gitkeep
build: web sync-assets
CGO_ENABLED=0 go build -trimpath -ldflags="$(LDFLAGS)" -o pure-note ./cmd/pure-note
CGO_ENABLED=0 go build -trimpath -ldflags="$(LDFLAGS)" -o pn ./cmd/pn
linux: web sync-assets
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="$(LDFLAGS)" \
-o pure-note-linux-amd64 ./cmd/pure-note
-o pn-linux-amd64 ./cmd/pn
# 全平台发布产物(版本注入逻辑与 CI 发版共用 scripts/build-release.sh)
dist: web sync-assets
scripts/build-release.sh "$(VERSION)"
test:
go vet ./...
@@ -50,4 +58,5 @@ vulncheck:
govulncheck ./...
clean:
rm -f pure-note pure-note-linux-amd64
rm -f pn pn-linux-amd64
rm -rf dist
+49 -18
View File
@@ -3,15 +3,16 @@
极简高安全私人笔记 + 博客。最终交付 = **一个二进制程序 + 一个数据文件(目录)**:
无数据库服务、无缓存服务、无 Node 运行时、无外部依赖。
> 设计文档:[docs/design.md](docs/design.md)(v1.1);实施决策记录:[docs/decisions.md](docs/decisions.md)
> 设计文档:[docs/design.md](docs/design.md)(v1.1);实施决策记录:[docs/decisions.md](docs/decisions.md);提交规范:[docs/contributing.md](docs/contributing.md)
## 功能
- **笔记**:Markdown CRUD、粘贴/拖拽图片上传(≤5MB、魔数校验、BLOB 入库去重)、公开/私有两态、置顶、标签
- **博客**:首页(置顶优先 + 分页 + 标签云)、详情(上一篇/下一篇)、标签页、RSS、sitemap、SEO meta 注入(仅公开内容)
- **笔记**:Markdown CRUD、粘贴/拖拽图片上传(≤5MB、魔数校验、BLOB 入库去重)、公开/私有两态、置顶、标签、发布日期可自选
- **博客**:首页(置顶优先 + 分页 + 标签云)、详情(上一篇/下一篇)、标签页、RSS、sitemap、SEO meta 注入(仅公开内容);站点名称前可显示自定义 Logo
- **管理**:单管理员口令登录(Argon2id)、会话 7 天滑动续期(CSRF 轮换保持不变)、防爆破限流(per-IP + per-账号)、改密
- **回收站**:删除 = 软删除,30 天内可恢复,`gc` 到期物理清除
- **运维**:`init` / `serve` / `backup`(在线一致快照)/ `gc`(默认 dry-run)/ `version`
- **回收站**:删除 = 软删除,30 天内可恢复,可一键清空,`gc` 到期物理清除
- **界面**:9 页面响应式 SPA(React 19 + Tailwind 4 + Ant Design 6),深色/浅色/跟随系统三态主题
- **运维**:`init`(含 Markdown 示例文档与示例图片种子)/ `passwd`(重设口令并吊销全部会话)/ `start` / `backup`(在线一致快照)/ `gc`(默认 dry-run)/ `version`(亦可 `-v`)
## 快速开始
@@ -19,12 +20,12 @@
# 1. 构建单二进制(前端 + 后端)
make build
# 2. 初始化(设置管理员口令与站点标题)
./pure-note init --data-dir ./data
# 非交互:PN_ADMIN_PASSWORD=xxx PN_SITE_TITLE=yyy ./pure-note init --data-dir ./data
# 2. 初始化(设置管理员口令与站点标题,自动写入一篇 Markdown 示例文档)
./pn init
# 非交互:PN_ADMIN_PASSWORD=xxx PN_SITE_TITLE=yyy ./pn init
# 3. 启动
./pure-note serve --addr 127.0.0.1:8080 --data-dir ./data
./pn start --addr 127.0.0.1:8080
# 4. 访问
# 博客: http://127.0.0.1:8080/
@@ -34,11 +35,20 @@ make build
开发模式(本机调试,Cookie 允许非 Secure,仅限 loopback 监听):
```bash
./pure-note serve --dev --addr 127.0.0.1:8080 --data-dir ./data
./pn start --dev --addr 127.0.0.1:8080
# 前端热重载(另开终端):
cd web && npm install && npm run dev # /api 代理到 127.0.0.1:8080
```
> **为什么必须带 `--dev`**:明文 HTTP 下 Safari/WebKit 不保存 `Secure` Cookie(Chrome/Firefox 有 localhost 豁免,Safari 没有),不带 `--dev` 时 `__Host-` 会话被静默丢弃,Safari 登录成功后仍会被弹回登录页。`--dev` 改用无 `Secure` 的 `pn_session` Cookie 并强制仅监听 loopback(§7.3-3);生产走 HTTPS 不带 `--dev`,自动恢复 `__Host-` + `Secure` 完整加固。
忘记口令(CLI 可达即具备服务器权限,可直接重设;重设后全部会话被吊销):
```bash
./pn passwd
# 非交互:PN_ADMIN_PASSWORD=xxx ./pn passwd
```
## 测试
```bash
@@ -53,25 +63,41 @@ slug 冲突策略、设置白名单(永不泄露口令哈希)、meta 注入
## 部署(生产)
```bash
make linux # 产出 pure-note-linux-amd64
sudo cp pure-note-linux-amd64 /opt/pure-note/pure-note
sudo -u purenote ./pure-note init --data-dir /opt/pure-note/data
sudo cp deploy/pure-note.service deploy/pure-note-maint.{service,timer} /etc/systemd/system/
sudo systemctl enable --now pure-note pure-note-maint.timer
make linux # 产出 pn-linux-amd64
sudo cp pn-linux-amd64 /opt/pure-note/pn
sudo -u purenote ./pn init --data-dir /opt/pure-note/data
sudo cp deploy/pn.service deploy/pn-maint.{service,timer} /etc/systemd/system/
sudo systemctl enable --now pn pn-maint.timer
```
反向代理用 Caddy(自动 TLS),示例见 [deploy/Caddyfile](deploy/Caddyfile);
启动需带 `--behind-proxy`(取 X-Forwarded-For 最右条目)。
**升级 SOP**:`pure-note backup` → 停服 → 换二进制 → 起服(迁移自动执行;
**升级 SOP**:`pn backup` → 停服 → 换二进制 → 起服(迁移自动执行;
库版本高于代码支持范围时拒绝启动,`--allow-newer` 显式放行)。
**恢复 SOP**:停服 → 删除 `data/pure-note.db-wal` 与 `-shm` → 以备份文件替换 `pure-note.db` → 起服。
**恢复 SOP**:停服 → 删除 `data/pn.db-wal` 与 `-shm` → 以备份文件替换 `pn.db` → 起服。
## 版本与发版
版本号以 git tag 为唯一来源(语义化版本,从 `v0.0.1` 开始),构建时经 ldflags 注入,
`./pn version` 可查看:
```bash
git tag -a v0.0.1 -m "v0.0.1"
git push origin v0.0.1 # 推送 v* 标签即触发自动发版
```
推送 `v*` 标签后,Gitea Actions([.gitea/workflows/release.yml](.gitea/workflows/release.yml))
自动构建前端并嵌入,交叉编译 linux/darwin(amd64 + arm64)共 4 个产物,
连同 SHA256SUMS 与上一版本以来的变更日志一起发布到仓库的 Release 页面。
本地验证发布产物:`make dist`(版本缺省 `git describe --tags --always`,输出到 `dist/`)。
## 架构
```
cmd/pure-note/ CLI(serve/init/backup/gc/version)
cmd/pn/ CLI(start/init/passwd/backup/gc/version)
internal/config/ 命令行解析(--dev loopback 守卫)
internal/store/ SQLite(modernc 纯 Go 驱动):迁移(user_version) + DAO + 备份 + gc
internal/auth/ Argon2id(PHC) + 随机 token
@@ -80,6 +106,11 @@ internal/middleware/ 安全头 / 日志 / 限流 / Origin 校验 / MaxBytes
internal/httpapi/ 路由与 handler(公共 / 认证 / 管理 / feed)
internal/webui/ go:embed 前端产物 + SPA fallback + index.html meta 注入
web/ React 19 + Vite + Tailwind 4 前端
├─ src/pages/ 9 页面:首页 / 笔记详情 / 标签页 / 登录 / 后台列表 / 编辑 / 回收站 / 设置 / 404
├─ src/components/ Layout(导航与主题切换)、Editor、MarkdownViewer
├─ src/theme/ shadcnTheme(antd ConfigProvider 主题:品牌 token + 明暗算法 + 桥接样式)
├─ src/hooks/ use-toast(基于 antd notification 的操作反馈)
└─ src/lib/ api(CSRF 注入)/ auth(会话上下文)/ theme(三态主题)/ sanitize / invalidate
deploy/ systemd 单元 ×3 + Caddyfile
```
+71 -20
View File
@@ -1,4 +1,4 @@
// pure-note 单二进制入口:serve / init / backup / gc / version(§7.6)。
// pn 单二进制入口:start / init / passwd / backup / gc / version(§7.6)。
package main
import (
@@ -38,16 +38,18 @@ func main() {
}
var err error
switch os.Args[1] {
case "serve":
err = runServe(os.Args[2:])
case "start":
err = runStart(os.Args[2:])
case "init":
err = runInit(os.Args[2:])
case "passwd":
err = runResetPassword(os.Args[2:])
case "backup":
err = runBackup(os.Args[2:])
case "gc":
err = runGC(os.Args[2:])
case "version":
fmt.Printf("pure-note %s (commit=%s, built=%s)\n", version, commit, buildDate)
case "version", "-v", "--version":
fmt.Printf("pn %s (commit=%s, built=%s)\n", version, commit, buildDate)
case "help", "-h", "--help":
usage()
default:
@@ -62,14 +64,17 @@ func main() {
}
func usage() {
fmt.Print(`pure-note — 极简高安全私人笔记 + 博客
fmt.Print(`pn — 极简高安全私人笔记 + 博客
用法:
pure-note serve [--addr :8080] [--data-dir ./data] [--behind-proxy] [--dev] [--allow-newer]
pure-note init [--data-dir ./data]
pure-note backup [--data-dir ./data] [输出路径]
pure-note gc [--data-dir ./data] [--commit] # 默认 dry-run
pure-note version
pn start [--addr :8080] [--behind-proxy] [--dev] [--allow-newer]
pn init
pn passwd # 重设管理员口令并吊销全部会话
pn backup [输出路径]
pn gc [--commit] # 默认 dry-run
pn version # 亦可 -v / --version
# --data-dir 可指定数据目录,默认 ./data
`)
}
@@ -93,9 +98,9 @@ func newLogger(cfg *config.Config) *slog.Logger {
return slog.New(h)
}
// runServe 启动 HTTP 服务(§7.2 服务器参数 + §10.5 优雅停机)。
func runServe(args []string) error {
cfg, err := config.ParseServe(args)
// runStart 启动 HTTP 服务(§7.2 服务器参数 + §10.5 优雅停机)。
func runStart(args []string) error {
cfg, err := config.ParseStart(args)
if err != nil {
return err
}
@@ -113,7 +118,7 @@ func runServe(args []string) error {
if _, ok, err := st.GetSetting("admin_password_hash"); err != nil {
return err
} else if !ok {
return errors.New("尚未初始化管理员口令:请先执行 `pure-note init --data-dir " + cfg.DataDir + "`")
return errors.New("尚未初始化管理员口令:请先执行 `pn init --data-dir " + cfg.DataDir + "`")
}
srv := httpapi.New(st, cfg, log)
@@ -152,7 +157,7 @@ func runServe(args []string) error {
defer cancel()
errCh := make(chan error, 1)
go func() {
log.Info("pure-note 已启动", "addr", cfg.Addr, "data_dir", cfg.DataDir, "dev", cfg.Dev)
log.Info("pn 已启动", "addr", cfg.Addr, "data_dir", cfg.DataDir, "dev", cfg.Dev)
errCh <- httpServer.ListenAndServe()
}()
@@ -221,7 +226,53 @@ func runInit(args []string) error {
if err := st.SetSetting(store.KeySiteTitle, title); err != nil {
return err
}
fmt.Println("初始化完成。现在可以启动:pure-note serve --data-dir", cfg.DataDir)
// 种子内容:一篇 Markdown 语法示例文档(公开)+ 自动生成的示例图片
if _, err := seedWelcome(st); err != nil {
return fmt.Errorf("写入初始文档失败: %w", err)
}
fmt.Println("初始化完成。现在可以启动:pn start --data-dir", cfg.DataDir)
return nil
}
// runResetPassword 重设管理员口令:CLI 可达即具备服务器权限,允许直接覆盖。
// 口令来源与 init 一致:环境变量 PN_ADMIN_PASSWORD 或交互输入(避免出现在 ps 参数里)。
// 重设成功后吊销全部会话,强制所有端重新登录。
func runResetPassword(args []string) error {
cfg, _, err := config.ParseMaint("passwd", args)
if err != nil {
return err
}
st, err := store.OpenData(cfg.DBPath())
if err != nil {
return err
}
defer st.Close()
var password string
if env := os.Getenv("PN_ADMIN_PASSWORD"); env != "" {
password = env
fmt.Fprintln(os.Stderr, "已从环境变量 PN_ADMIN_PASSWORD 读取口令")
} else {
password, err = promptPassword()
if err != nil {
return err
}
}
if err := auth.CheckPasswordStrength(password); err != nil {
return err
}
hash, err := auth.HashPassword(password)
if err != nil {
return err
}
if err := st.SetSetting(store.KeyAdminPasswordHash, hash); err != nil {
return err
}
revoked, err := st.DeleteAllSessions()
if err != nil {
return err
}
fmt.Printf("口令已重设;已吊销 %d 个会话,所有端需重新登录。\n", revoked)
return nil
}
@@ -264,9 +315,9 @@ func runBackup(args []string) error {
dest = rest[0]
}
if dest == "" {
dest = filepath.Join(cfg.DataDir, fmt.Sprintf("pure-note-backup-%s.db", time.Now().Format("20060102-150405")))
dest = filepath.Join(cfg.DataDir, fmt.Sprintf("pn-backup-%s.db", time.Now().Format("20060102-150405")))
}
st, err := store.Open(cfg.DBPath(), false)
st, err := store.OpenData(cfg.DBPath())
if err != nil {
return err
}
@@ -290,7 +341,7 @@ func runGC(args []string) error {
commit = true
}
}
st, err := store.Open(cfg.DBPath(), false)
st, err := store.OpenData(cfg.DBPath())
if err != nil {
return err
}
+183
View File
@@ -0,0 +1,183 @@
// init 种子内容:一篇展示常用 Markdown 语法的初始文档 + 一张程序生成的示例图片。
package main
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"image"
"image/color"
"image/png"
"time"
"pure-note/internal/store"
)
// welcomeSlug 固定 slug,便于用户识别与重装后覆盖判断(init 幂等由口令守卫保证)。
const welcomeSlug = "welcome"
// seedWelcome 写入初始文档(公开)与其引用的示例图片;返回写入的笔记 id。
func seedWelcome(st *store.Store) (int64, error) {
imgID, err := seedWelcomeImage(st)
if err != nil {
return 0, fmt.Errorf("写入示例图片: %w", err)
}
content := welcomeMarkdown(imgID)
now := time.Now().Unix()
n := &store.Note{
Slug: welcomeSlug,
Title: "欢迎使用 Pure Note:Markdown 速览",
Summary: "一篇入门笔记,覆盖日常写作会用到的全部 Markdown 语法:标题、列表、引用、代码、表格与图片。",
Content: content,
Status: "public",
Tags: []string{"Markdown", "入门"},
PublishedAt: now,
CreatedAt: now,
UpdatedAt: now,
}
id, err := st.CreateNote(n)
if err != nil {
return 0, err
}
if err := st.RebuildImageRefs(id, content); err != nil {
return 0, err
}
return id, nil
}
// welcomeMarkdown 初始文档正文:覆盖常用语法,并引用示例图片。
func welcomeMarkdown(imgID int64) string {
return fmt.Sprintf(`这是一篇初始化时自动创建的示例笔记,覆盖日常写作会用到的绝大部分 Markdown 语法。随时可以编辑或删除它。
## 基础排版
普通段落之间用一个空行分隔。行内语法有:**加粗**、*斜体*、***粗斜体***、~~删除线~~,以及行内代码 `+"`pn start`"+`。也可以直接粘贴链接:<https://github.com>
## 标题层级
### 三级标题
#### 四级标题
标题会进入文章大纲,正文建议从二级标题用起。
## 列表
无序列表:
- 支持嵌套
- 子项缩进两个空格
- 快捷键 `+"`Ctrl/⌘ + S`"+` 可手动保存
有序列表:
1. 在编辑器里写 Markdown
2. 粘贴图片直接上传
3. 点击发布即可访问
任务列表:
- [x] 初始化站点
- [x] 阅读本文档
- [ ] 写第一篇笔记
## 引用
> 简单,可靠,只有一个数据文件。
> —— Pure Note 设计哲学
## 代码块
代码块支持语法高亮:
`+"```go"+`
package main
import "fmt"
func main() {
fmt.Println("Hello, Pure Note!")
}
`+"```"+`
`+"```"+`javascript
const note = { title: "你好", tags: ["Markdown"] };
console.log(`+"`开始写作:${note.title}`"+`);
`+"```"+`
## 表格
| 语法 | 用途 | 示例 |
| --- | --- | --- |
| **文本** | 加粗 | **重要** |
| `+"`代码`"+` | 行内代码 | `+"`npm run dev`"+` |
| [文本](url) | 超链接 | [首页](/) |
## 图片
在编辑器里直接粘贴或拖入图片即可上传(≤5MB),下面这张就是初始化时自动生成的示例图:
![示例图片](/api/images/%d)
## 其他
---
私有笔记仅自己可见,公开笔记会出现在首页与 RSS 中;删除的笔记进入回收站保留 30 天。祝写作愉快。
`, imgID)
}
// seedWelcomeImage 生成一张对角渐变 PNG(zinc 色系),入库后返回图片 id。
func seedWelcomeImage(st *store.Store) (int64, error) {
data, err := gradientPNG()
if err != nil {
return 0, err
}
return st.UpsertImage(sha256Hex(data), "image/png", len(data), data, time.Now().Unix())
}
// gradientPNG 绘制 960×360 对角渐变:zinc-950 → zinc-400,叠加一条高光斜带。
func gradientPNG() ([]byte, error) {
const w, h = 960, 360
img := image.NewNRGBA(image.Rect(0, 0, w, h))
from := color.RGBA{R: 24, G: 24, B: 27, A: 255} // #18181b
to := color.RGBA{R: 161, G: 161, B: 170, A: 255} // #a1a1aa
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
t := (float64(x)/float64(w-1) + float64(y)/float64(h-1)) / 2
// 高光斜带:在 t≈0.55 附近提亮,形成柔和的光泽过渡
band := 0.16 * float64(1) / (1 + pow2((t-0.55)*9))
c := color.RGBA{
R: lerp(from.R, to.R, t+band),
G: lerp(from.G, to.G, t+band),
B: lerp(from.B, to.B, t+band),
A: 255,
}
img.Set(x, y, c)
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func lerp(a, b uint8, t float64) uint8 {
if t < 0 {
t = 0
} else if t > 1 {
t = 1
}
return uint8(float64(a) + (float64(b)-float64(a))*t)
}
func pow2(x float64) float64 { return x * x }
func sha256Hex(b []byte) string {
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}
+11
View File
@@ -0,0 +1,11 @@
# /etc/systemd/system/pn-maint.service
# 每日维护:先 gc(--commit)后备份(§10.3)
[Unit]
Description=Pure Note daily maintenance (gc + backup)
After=pn.service
[Service]
Type=oneshot
User=purenote
ExecStart=/opt/pure-note/pn gc --data-dir /opt/pure-note/data --commit
ExecStart=/opt/pure-note/pn backup --data-dir /opt/pure-note/data /backup/pn-%%F.db
@@ -1,4 +1,4 @@
# /etc/systemd/system/pure-note-maint.timer
# /etc/systemd/system/pn-maint.timer
[Unit]
Description=Daily Pure Note maintenance (gc + backup)
@@ -1,4 +1,4 @@
# /etc/systemd/system/pure-note.service
# /etc/systemd/system/pn.service
[Unit]
Description=Pure Note - minimalist high-security private notes + blog
After=network.target
@@ -6,7 +6,7 @@ After=network.target
[Service]
User=purenote
WorkingDirectory=/opt/pure-note
ExecStart=/opt/pure-note/pure-note serve --addr 127.0.0.1:8080 \
ExecStart=/opt/pure-note/pn start --addr 127.0.0.1:8080 \
--data-dir /opt/pure-note/data --behind-proxy
Restart=on-failure
RestartSec=5
-11
View File
@@ -1,11 +0,0 @@
# /etc/systemd/system/pure-note-maint.service
# 每日维护:先 gc(--commit)后备份(§10.3)
[Unit]
Description=Pure Note daily maintenance (gc + backup)
After=pure-note.service
[Service]
Type=oneshot
User=purenote
ExecStart=/opt/pure-note/pure-note gc --data-dir /opt/pure-note/data --commit
ExecStart=/opt/pure-note/pure-note backup --data-dir /opt/pure-note/data /backup/pure-note-%%F.db
+5 -5
View File
@@ -27,7 +27,7 @@
| 回收站恢复用例;gc dry-run→commit;7 天内孤儿图片不删 | ✅ | `TestTrashLifecycleAndGC` + CLI 实测(dry-run 报告、commit 执行、宽限期保留) |
| user_version 越界启动被拒;--allow-newer 放行 | ✅ | `TestMigrationNewerRejected` + CLI 实测(拒启 exit=1;--allow-newer 后 /api/health 200) |
| 构建冒烟测试通过 | ✅ | `make smoke`(npm ci → vite build → sync-assets → CGO_ENABLED=0 交叉构建 → 起服务 19 项断言:SPA 资源 200+MIME、meta 注入、安全头、私有不可见) |
| Caddy 仅开放 80/443、程序监听 127.0.0.1 | ✅(配置交付) | deploy/Caddyfile + pure-note.service(`--addr 127.0.0.1:8080 --behind-proxy`)——本机无生产部署环境,按配置文件验收 |
| Caddy 仅开放 80/443、程序监听 127.0.0.1 | ✅(配置交付) | deploy/Caddyfile + pn.service(`--addr 127.0.0.1:8080 --behind-proxy`)——本机无生产部署环境,按配置文件验收 |
## 三、§13 测试策略落地
@@ -50,7 +50,7 @@
| 里程碑 | 验收门 | 状态 |
| --- | --- | --- |
| M0 骨架 | `make build` 产出可运行二进制;serve 起服务;/api/health 200 | ✅ |
| M0 骨架 | `make build` 产出可运行二进制;start 起服务;/api/health 200 | ✅ |
| M1 公开浏览 | 笔记/标签 API + 博客三页 + 渲染管线 + RSS/sitemap + meta 注入;公开侧可见性用例;构建冒烟 | ✅ |
| M2 管理端 | 登录/会话/CSRF + CRUD + 回收站 + 图片上传 + 编辑器 + 改密;完整矩阵 + 各组用例随功能交付 | ✅ |
| M3 安全加固 | §9.3 全项通过(见上表);CSP nonce 硬化按设计留 M3+ 待办 | ✅ |
@@ -70,11 +70,11 @@
## 六、交付物清单
```
pure-note(单二进制,CGO_ENABLED=0,go:embed 前端产物)
├── cmd/pure-note/ serve / init / backup / gc / version
pn(单二进制,CGO_ENABLED=0,go:embed 前端产物)
├── cmd/pn/ start / init / passwd / backup / gc / version
├── internal/{config,store,auth,markdown,middleware,httpapi,webui}
├── web/ React 19 + Vite 8 + Tailwind 4(vitest 6 用例)
├── deploy/ pure-note.service、pure-note-maint.{service,timer}、Caddyfile
├── deploy/ pn.service、pn-maint.{service,timer}、Caddyfile
├── scripts/smoke.sh 构建冒烟(make smoke)
├── Makefile / README.md / .gitignore
└── docs/{design.md, review-round1.md, decisions.md, acceptance.md}
+75
View File
@@ -0,0 +1,75 @@
# 提交规范(Conventional Commits)
本仓库提交信息遵循 [Conventional Commits 1.0.0](https://www.conventionalcommits.org/zh-hans/v1.0.0/),
格式如下:
```
<type>(<scope>): <subject>
<body>
<footer>
```
## type(必填)
| type | 用途 |
| --- | --- |
| `feat` | 新功能(用户可感知的行为变化) |
| `fix` | 缺陷修复(bug、安全漏洞、评审发现的功能缺陷) |
| `docs` | 仅文档(设计/决策/评审报告/README/注释性文档) |
| `refactor` | 重构(不改行为、不修缺陷的代码整理) |
| `perf` | 性能优化 |
| `test` | 仅测试代码(新增/修正用例与测试基建) |
| `build` | 构建系统或外部依赖(Makefile、go.mod、npm、打包脚本) |
| `ci` | 持续集成配置 |
| `chore` | 其他杂项(不触及 src 与 test) |
| `revert` | 回滚某次提交(subject 为被回滚提交的摘要,footer 注明 hash) |
## scope(可选)
按模块/目录取值,多个模块的横切改动可省略 scope 或在 body 中说明:
`cmd` / `config` / `store` / `auth` / `markdown` / `middleware` / `httpapi` / `webui` / `web` / `deploy` / `docs`
## subject 规则
- 中文,动词开头(如「修正」「新增」「移除」「收尾」);
- 不超过 50 字;
- 不加句号;描述「做了什么」,而非「做了什么活」。
## body(可选)
- 一行一要点,说明「为什么改」与「影响范围」;
- 引用来源:评审条目(如 `round2 P1-2`)、设计章节(如 `§9.2`)、issue 编号。
## footer(可选)
- `BREAKING CHANGE:` 开头(或 type 后加 `!`)注明破坏性变更;
- 回滚注明被回滚提交的 hash。
## 示例
```
fix(store): 修正上一篇/下一篇邻接查询 tie-break 与 pinned 排序键
同秒多篇更新时 id 比较方向与列表序相反导致漏链/错链;
邻接语义纳入 pinned,与 ListPublicNotes 排序键一致。
round2 P1-1
```
```
feat(cmd): 新增 passwd 子命令免旧口令重设管理员口令
重设成功后吊销全部会话;口令经 PN_ADMIN_PASSWORD 或交互输入。
```
```
docs: 新增提交规范并链接至 README
```
## 提交拆分
- 一个提交只做一件事(文档、功能、修复、测试分开);
- 修复缺陷时**同提交**附带其回归测试;
- 不提交构建产物、密钥与本地配置(`pn` 二进制、`data/`、`.env` 等已在 `.gitignore`)。
+4 -3
View File
@@ -10,7 +10,7 @@
| --- | --- | --- |
| D1 | Go 工具链使用 **1.26.6**(设计写 1.27.1) | 本机已装版本。所用特性(1.22+ ServeMux 方法/通配符路由、slog、go:embed)全部满足,无升级必要 |
| D2 | 依赖版本按设计锁定值解析:modernc.org/sqlite v1.58.0、goldmark v1.8.6、bluemonday v1.0.27、x/crypto v0.56.0、x/time v0.15.0 | 设计附录 A「实际开发时以 go mod tidy 解析到的版本为准」——解析结果与设计核实值完全一致 |
| D3 | 新增第 6 个直接依赖 **golang.org/x/term v0.45.0** | `pure-note init` 交互式口令输入需要关闭回显。设计 §2.2 预算「直接依赖 ≤6(Go 侧 5 个)」,6 个在预算内;`PN_ADMIN_PASSWORD` 环境变量仍是非交互主通道 |
| D3 | 新增第 6 个直接依赖 **golang.org/x/term v0.45.0** | `pn init` 交互式口令输入需要关闭回显。设计 §2.2 预算「直接依赖 ≤6(Go 侧 5 个)」,6 个在预算内;`PN_ADMIN_PASSWORD` 环境变量仍是非交互主通道 |
| D4 | 前端 TypeScript 用 **5.9**(设计允许 7.x 或回退 5.9) | 直接采用设计声明的保守回退路径,规避第三方类型兼容风险(CodeMirror/react-markdown 生态) |
| D5 | 前端其余依赖与设计一致:react 19.2.8、vite 8.2.2、tailwind 4.3.3、react-router 8.3.1、@tanstack/react-query 5.102.8、react-markdown 10.1.0 等,npm 解析 0 漏洞 | `npm install` 实测 |
@@ -18,7 +18,7 @@
| # | 决策 | 依据 |
| --- | --- | --- |
| D6 | `backup` / `gc` 子命令**不执行迁移、不做 user_version 守卫**(只有 `serve`/`init` 迁移) | 维护命令应是纯数据操作:在陌生(更新)schema 上跑迁移反而危险。设计 §10.4 的守卫语境是「启动服务」 |
| D6 | `backup` / `gc` / `passwd` 子命令**不执行迁移、不做 user_version 守卫**(只有 `start`/`init` 迁移) | 维护命令应是纯数据操作:在陌生(更新)schema 上跑迁移反而危险。设计 §10.4 的守卫语境是「启动服务」 |
| D7 | `init` 在口令已存在时**拒绝并提示走管理界面改密**(无 `--force`) | 防止误操作覆盖口令;单管理员场景下改密有专门界面(§7.1 POST /api/admin/password) |
| D8 | `GET /api/site` 公开端点(返回 site_title/site_desc/page_size 白名单三键) | 设计 §8.1 首页要显示「站点标题」,但 §7.1 公共 API 无设置出口;`/api/admin/settings` 需要会话。新增白名单只读端点,**永不含 admin_password_hash**(测试守护) |
| D9 | `GET /api/notes/{slug}` 响应含 `prev`/`next`(相邻公开笔记 slug+title) | §8.1 详情页要求「上一篇/下一篇」;放在详情响应里避免前端额外请求与分页边界问题。排序与公开列表一致(updated_at DESC, id DESC) |
@@ -50,7 +50,7 @@
| # | 决策 | 依据 |
| --- | --- | --- |
| D27 | §13 各测试组全部落为 Go 集成测试(httptest + 临时目录真实 SQLite):可见性矩阵(主体 × 状态 × 出口表驱动)、迁移守卫、登录/改密/会话轮换、CSRF、上传、回收站+gc、slug、设置白名单;webui 用 fstest.MapFS 单测 meta 转义/缓存头/fallback | 设计「其他测试组」要求 httptest + 临时目录真实 SQLite |
| D28 | 构建冒烟 = `scripts/smoke.sh`(`make smoke`):真实构建 → init → serve → 断言 SPA script/link 200 + 正确 MIME、meta 注入、安全头、私有不可见 | §8.3-6「真实构建 → 启动二进制 → 请求任一公开 slug 页面」;比 Go test 内嵌前端产物更贴近 CI 语义 |
| D28 | 构建冒烟 = `scripts/smoke.sh`(`make smoke`):真实构建 → init → start → 断言 SPA script/link 200 + 正确 MIME、meta 注入、安全头、私有不可见 | §8.3-6「真实构建 → 启动二进制 → 请求任一公开 slug 页面」;比 Go test 内嵌前端产物更贴近 CI 语义 |
| D29 | 测试注入限流器:`httpapi.NewWithLimiters` 允许测试替换高容量桶;登录限流测试单独用真实参数构造器 | 避免全局限流 429 干扰矩阵测试,同时保留限流本身的专项测试(两全) |
| D30 | 夹具图片用「合法 PNG + IEND 后差异化尾部」绕开 sha256 去重合并——去重本身另用同字节上传断言 | 实测发现同字节图片被去重合并为同一行(正确行为,§14 已预告「去重会合并」),矩阵需要四张不同图 |
@@ -63,3 +63,4 @@
| D33 | webui 资产缓存:`assets/`(内容 hash 命名)immutable 一年;其他静态文件 1h;index.html no-cache;缺失资产 404 不回退 HTML | §8.3-5 + 防止把 JS 404 伪装成 SPA 页面造成误判 |
| D34 | 无内嵌 index.html(M0 占位)时 HTML 路径返回 503 占位说明页 | 部署缺产物属于配置错误,503 比 200 空页更诚实 |
| D35 | `robots.txt` 额外 `Disallow: /admin` | §7.1 仅要求「允许全部 + sitemap 指向」;Disallow /admin 是 SEO 常规增强,不构成功能偏离 |
| D36 | 第 2 轮评审(review-round2.md)修复随附的实现决策:维护命令经 `store.OpenData` 打开(落实 D6,`--allow-newer` 相应从维护子命令移除);Origin 校验增加 scheme 比对(可信反代后采信 X-Forwarded-Proto);邻接查询纳入 pinned 排序键;上传解码前以 `DecodeConfig` 限制像素 ≤2²⁵;备份经 umask 收紧使创建即 0600;前端写操作后统一失效公共查询缓存 | review-round2.md P1-1/P1-5/P2-11/P2-12/P2-7/P2-15 |
+45 -39
View File
@@ -58,8 +58,8 @@
| F3 | 图片 | 编辑器内粘贴/拖拽上传(≤5MB);Markdown 中引用;图片可见性 = 引用它的笔记可见性的并集;去重存储 |
| F4 | 博客 | 匿名访问:首页(公开列表+分页+标签)、详情页、标签页;RSS Feed;sitemap.xml;SEO 基础(仅公开笔记注入 title/description/og meta) |
| F5 | 认证 | 单管理员密码登录/登出/**改密**;会话失效/续期;登录与改密防爆破 |
| F6 | 站点设置 | 站点标题、副标题、每页条数(白名单字段,绝不含口令哈希) |
| F7 | 回收站 | 删除 = 软删除进入回收站;30 天内可恢复;`gc` 到期物理清除 |
| F6 | 站点设置 | 站点标题、副标题、每页条数(10/20/30/50)、站点 Logo(白名单字段,绝不含口令哈希) |
| F7 | 回收站 | 删除 = 软删除进入回收站;30 天内可恢复;可一键清空;`gc` 到期物理清除 |
| F8 | 运维 | 单二进制启动;`--data-dir` 指定数据目录;在线备份子命令;`gc` 子命令(默认 dry-run);版本号 |
### 2.2 非功能需求
@@ -100,13 +100,13 @@
| 框架 | React | 19.2.8 | 生态最大、md 渲染/编辑器组件最丰富 | — |
| 语言 | TypeScript | 7.x(不动则 5.9) | 原生编译器版已可用;若第三方库兼容有问题回退 5.9 | — |
| 构建 | Vite | 8.2.2 | 事实标准;部署在域名根,`base` 用默认值 `'/'`(相对 base 与 SPA 深链冲突,见 §8.3 与审评报告 P0-1) | — |
| 样式/组件 | Tailwind CSS 4 + **shadcn/ui** | 4.3.3 / CLI 4.21.0 | CSS-first 无样板配置;shadcn 复制式组件零运行时、克制美观,正合内容型博客;按需叠加 Radix 原语 | Ant Design 6(重、后台味浓)、Chakra 3、HeroUI、Radix Themes |
| 样式/组件 | Tailwind CSS 4 + **Ant Design 6** | 4.3.3 / 6.6.3 | Tailwind 负责页面排版与设计 token;交互组件统一用 antd(Button/Input/Table/Modal/Dropdown/Tooltip/notification 等),经 ConfigProvider 自定义主题对齐黑白灰品牌 token 并随三态主题切换明暗算法;v6 原生支持 React 19 | shadcn/ui + Radix(曾采用,组件需自维护,替换为 antd 降低长期维护成本)、Chakra 3、HeroUI |
| 路由 | react-router | 8.3.1 | library mode 成熟,博客+后台两个区足够 | TanStack Router 1.x(类型安全更强,但本项目路由简单) |
| 数据请求 | TanStack Query | 5.102.8 | 十几 kB 换来统一 loading/缓存/重试,值得 | 裸 fetch + 自写 hook |
| Markdown 渲染 | react-markdown + remark-gfm + rehype-sanitize + **rehype-highlight** | 10.1.0 / 4.0.1 / 6.0.0 / 7.0.2 | 组合标准;高亮选 rehype-highlight(**纯 class 输出、零内联样式**)而非 shiki(inline style),与严格 CSP 兼容 | shiki 4.4.3(准确度最高但产 inline style)、prism-react-renderer(近停更,不推荐) |
| Markdown 编辑器 | **CodeMirror 6**(@uiw/react-codemirror + @codemirror/lang-markdown) | 4.25.11 / 6.5.2 | 源码编辑 + 分屏实时预览,零魔法、可预期;图片粘贴自写 handler(≤50 行) | Tiptap 3.31(体验派,WYSIWYG+成熟图片上传,文档全);MDXEditor(重);ByteMD(字节已弃坑)、Vditor 4(体积大、心智旧) |
| 状态管理 | React Query + Context(**不引入** zustand) | — | 会话态 + 服务端缓存已覆盖;确需跨页 UI 态再加 zustand(≈1kB) | redux(不必要) |
| 图标 | lucide-react | 1.42.0 | 活跃、风格统一、与 shadcn 默认匹配 | — |
| 图标 | lucide-react | 1.42.0 | 活跃、风格统一(antd 组件内亦复用) | — |
### 3.3 关键决策记录(ADR 摘要)
@@ -129,7 +129,7 @@
│ HTTPS (Caddy 自动 TLS, 反代, 追加密名 XFF)
▼
┌──────────────────────────┐
│ pure-note 单一二进制 (Go) │
│ pn 单一二进制 (Go) │
│ │
│ net/http + ServeMux │
│ ├─ 公共 API /api/* │──┐
@@ -145,7 +145,7 @@
│ database/sql (SetMaxOpenConns=1)
▼
┌──────────────────────────┐
│ data/pure-note.db (SQLite)│
│ data/pn.db (SQLite) │
│ notes(含 deleted_at) / │
│ images(BLOB) / image_refs /│
│ sessions / settings │
@@ -176,8 +176,8 @@ pure-note/
│ ├── design.md # 本文档(v1.1)
│ └── review-round1.md # 第 1 轮审评报告(修订依据,留档)
├── cmd/
│ └── pure-note/
│ └── main.go # 入口 + 命令行(serve/init/backup/gc/version)
│ └── pn/
│ └── main.go # 入口 + 命令行(start/init/passwd/backup/gc/version)
├── internal/
│ ├── config/ # 环境变量/flag 解析
│ ├── store/ # SQLite 打开、迁移(user_version 上界校验)、DAO
@@ -194,14 +194,16 @@ pure-note/
│ └── src/
│ ├── main.tsx / App.tsx / router.tsx
│ ├── lib/ # api client、auth、csrf、格式化
│ ├── components/ # shadcn/ui 生成目录 + 业务组件
│ ├── theme/ # antd ConfigProvider 主题(品牌 token + 明暗算法)
│ ├── components/ # 业务组件
│ │ ├── markdown/ # MarkdownViewer(渲染管线,可替换)
│ │ └── editor/ # Editor(CodeMirror)+ ImagePaste + Toolbar
│ ├── hooks/ # use-toast(antd notification 封装)
│ ├── features/ # notes / admin 业务 hooks
│ └── pages/ # Home / Note / Tags / AdminLogin / AdminList / AdminTrash / AdminEdit
└── deploy/
├── pure-note.service # systemd 服务单元
├── pure-note-maint.timer # 每日 gc + 备份 timer
├── pn.service # systemd 服务单元
├── pn-maint.timer # 每日 gc + 备份 timer
└── Caddyfile # 反代示例
```
@@ -213,7 +215,7 @@ pure-note/
```sql
-- 连接串(DSN)统一注入以下 pragma:
-- file:data/pure-note.db?_pragma=journal_mode(WAL)
-- file:data/pn.db?_pragma=journal_mode(WAL)
-- &_pragma=busy_timeout(5000)
-- &_pragma=foreign_keys(1)
-- &_pragma=synchronous(NORMAL)
@@ -230,7 +232,8 @@ CREATE TABLE IF NOT EXISTS notes (
pinned INTEGER NOT NULL DEFAULT 0,
deleted_at INTEGER, -- NULL=正常;非空=回收站(软删除)
created_at INTEGER NOT NULL, -- Unix 秒
updated_at INTEGER NOT NULL
updated_at INTEGER NOT NULL,
published_at INTEGER -- 发布日期(可自选);v2 迁移回填为 created_at
);
CREATE INDEX IF NOT EXISTS idx_notes_public
ON notes (status, pinned, updated_at DESC);
@@ -265,7 +268,7 @@ CREATE TABLE IF NOT EXISTS settings (
);
-- 键白名单(§7.1 SettingsDTO 同源):
-- admin_password_hash : PHC 串 $argon2id$v=19$m=19456,t=2,p=1$<b64salt>$<b64hash>
-- site_title / site_desc / page_size
-- site_title / site_desc / page_size / beian_no / site_logo(站内绝对路径)
-- admin_password_hash 永不进入任何 API 响应、不可经 settings 接口写入(§9.3)
```
@@ -281,7 +284,7 @@ CREATE TABLE IF NOT EXISTS settings (
| 迁移 | `PRAGMA user_version` 版本化,顺序执行内嵌迁移脚本;**仅追加式**(禁止删列/重命名/改类型);启动时校验 `user_version ≤ 代码支持的最高版本`,超出拒绝启动(`--allow-newer` 显式放行,§10.4) |
| 并发 | `db.SetMaxOpenConns(1)` — 单写者串行化,WAL 下个人规模足够;规模上来后按「多读者连接池」扩展 |
### 6.3 数据清理(`pure-note gc`)
### 6.3 数据清理(`pn gc`)
默认 `--dry-run` 只输出清理计划,`--commit` 才执行;systemd timer 每日调用一次(§10.3):
@@ -324,10 +327,11 @@ CREATE TABLE IF NOT EXISTS settings (
| PUT | `/api/admin/notes/{id}` | 更新(事务内重建 image_refs);slug 冲突返回 409 + 字段级错误 |
| DELETE | `/api/admin/notes/{id}` | **软删除**:置 `deleted_at` 进入回收站 |
| GET | `/api/admin/trash` | 回收站列表 |
| DELETE | `/api/admin/trash` | **清空回收站**:物理删除全部软删除笔记(refs 级联) |
| POST | `/api/admin/trash/{id}/restore` | 恢复:清空 `deleted_at`(slug 仍被自身占用,无冲突;外部占用则在 30 天内不可能,见 §6.2) |
| POST | `/api/admin/images` | multipart 上传 → `{id, url}`;≤5MB |
| GET | `/api/admin/images?orphan=1` | 0 引用图片清单(删除前检视,实际清除由 gc 执行) |
| GET/PUT | `/api/admin/settings` | **SettingsDTO 白名单**(site_title/site_desc/page_size),读永不序列化 `admin_password_hash`、写只收白名单键 |
| GET/PUT | `/api/admin/settings` | **SettingsDTO 白名单**(site_title/site_desc/page_size/beian_no/site_logo;page_size 仅 10/20/30/50),读永不序列化 `admin_password_hash`、写只收白名单键 |
| POST | `/api/admin/password` | `{old_password, new_password}`;校验旧密码(常量时间)、新密码 ≥12 字符;复用登录限流 |
### 7.2 中间件链与服务器参数
@@ -348,14 +352,15 @@ SecurityHeaders(含 HSTS,§9.2)
### 7.3 认证与会话
1. **初始化**:`pure-note init`(交互式设密码,或环境变量 `PN_ADMIN_PASSWORD` 非交互)→ Argon2id(m=19456, t=2, p=1, salt=16B 随机, keyLen=32B) **以 PHC 串 `$argon2id$v=19$m=19456,t=2,p=1$<b64salt>$<b64hash>` 存库**——参数随哈希走,未来调参可继续校验旧口令;
1. **初始化**:`pn init`(交互式设密码,或环境变量 `PN_ADMIN_PASSWORD` 非交互)→ Argon2id(m=19456, t=2, p=1, salt=16B 随机, keyLen=32B) **以 PHC 串 `$argon2id$v=19$m=19456,t=2,p=1$<b64salt>$<b64hash>` 存库**——参数随哈希走,未来调参可继续校验旧口令;
2. **登录**:Origin/Referer→Host 校验通过后,per-IP 10 次/5 分钟 + 账号维度 5 次/10 分钟令牌桶(只计失败尝试);校验用 `subtle.ConstantTimeCompare`;失败统一返回 401「用户名或密码错误」不泄露差异;
3. **会话 Cookie**:`__Host-pn_session` = 256bit 随机 token;属性 `Secure; HttpOnly; SameSite=Lax; Path=/; Max-Age=7d`(`__Host-` 前缀自带 Secure + 根路径约束);
4. **CSRF**:会话行内存随机 `csrf_token`,登录响应下发、前端仅存内存并附到所有 `/api/admin/*` 变更请求的 `X-CSRF-Token` 头;**刷新页面后经 `GET /api/me` 重取**;明确禁止任何 localStorage 持久化;服务端同时校验 Origin/Referer 与 Host 匹配;
5. **轮换**:活跃访问距过期 < 3 天时重建会话行、旋转 Cookie token,**csrf_token 保持不变**(前端无感);
6. **登出**:删除会话行;
7. **改密**:`POST /api/admin/password` 校验旧密码后写新 PHC 哈希;改密不失效当前会话(可选:改密后删除其他会话——单管理员场景仅本会话,不强制);
8. **开发模式**:`--dev` 允许非 Secure Cookie(否则 `__Host-` 在 `http://局域网IP` 下无法登录);**强制约束:仅当监听地址为 loopback 时允许启动,防止误部署到生产**。
8. **口令重置(CLI)**:`pn passwd` 免旧口令直接覆盖哈希并**吊销全部会话**——前提是具备服务器访问权限(读写数据目录即可改库,CLI 提供规范入口优于手工改库);口令来源与 `init` 一致,避免出现在 argv;
9. **开发模式**:`--dev` 允许非 Secure Cookie(否则 `__Host-` 在 `http://局域网IP` 下无法登录);**强制约束:仅当监听地址为 loopback 时允许启动,防止误部署到生产**。
### 7.4 图片上传与访问
@@ -375,11 +380,12 @@ SecurityHeaders(含 HSTS,§9.2)
| 子命令 / 开关 | 说明 |
| --- | --- |
| `pure-note serve` | 启动;`--addr :8080`、`--data-dir ./data`、`--log-level`、`--log-format text|json`、`--behind-proxy`(声明位于可信反代之后,启用 XFF 处理)、`--dev`(loopback-only,§7.3-8)、`--allow-newer`(跳过 schema 版本上界校验,§10.4);环境变量:`PN_ADMIN_PASSWORD` |
| `pure-note init` | 首次初始化:设口令(Argon2id+PHC)、站点标题 |
| `pure-note backup [path]` | 在线备份:`VACUUM INTO`(一致快照,不停服);默认输出 0600 权限 |
| `pure-note gc` | 回收站过期清除 + 孤儿图片清除 + 会话清理;**默认 `--dry-run`**,`--commit` 才执行(§6.3) |
| `pure-note version` | 版本号(build 时注入 commit/时间) |
| `pn start` | 启动;`--addr :8080`、`--data-dir`(默认 `./data`)、`--log-level`、`--log-format text|json`、`--behind-proxy`(声明位于可信反代之后,启用 XFF 处理)、`--dev`(loopback-only,§7.3-8)、`--allow-newer`(跳过 schema 版本上界校验,§10.4);环境变量:`PN_ADMIN_PASSWORD` |
| `pn init` | 首次初始化:设口令(Argon2id+PHC)、站点标题;并写入一篇 Markdown 语法示例文档(公开,slug `welcome`)及程序生成的示例图片 |
| `pn passwd` | 重设管理员口令(覆盖旧哈希)并吊销全部会话;口令来源同 `init`(环境变量 `PN_ADMIN_PASSWORD` 或交互输入,不经 argv 避免 ps 泄露)。CLI 可达即具备服务器权限,允许直接重设 |
| `pn backup [path]` | 在线备份:`VACUUM INTO`(一致快照,不停服);默认输出 0600 权限 |
| `pn gc` | 回收站过期清除 + 孤儿图片清除 + 会话清理;**默认 `--dry-run`**,`--commit` 才执行(§6.3) |
| `pn version` | 版本号(build 时注入 commit/时间);亦支持 `-v` / `--version` |
---
@@ -394,8 +400,8 @@ SecurityHeaders(含 HSTS,§9.2)
| `/tags/:tag` | 标签页 | 该标签下公开笔记 |
| `/admin/login` | 登录页 | 口令登录、防爆破提示 |
| `/admin` | 管理列表 | 全部笔记(公/私标签可见)、状态开关、新建、删除(入回收站) |
| `/admin/trash` | 回收站 | 已删列表、恢复 |
| `/admin/notes/new` `/admin/notes/:id/edit` | 编辑器 | CodeMirror 源码编辑 + 实时预览(分屏/切换)、元信息侧栏(标题/slug/标签/摘要/公开开关/置顶)、图片粘贴上传 |
| `/admin/trash` | 回收站 | 已删列表、恢复、一键清空 |
| `/admin/notes/new` `/admin/notes/:id/edit` | 编辑器 | CodeMirror 源码编辑 + 实时预览(分屏/切换)、元信息侧栏(标题/slug/标签选择器/发布日期/摘要/公开开关/置顶)、图片粘贴上传 |
| `*` | 404 | 统一兜底 |
### 8.2 核心技术要点
@@ -520,20 +526,20 @@ sync-assets:
build: web sync-assets
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" \
-o pure-note ./cmd/pure-note
-o pn ./cmd/pn
linux: web sync-assets
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath \
-ldflags="-s -w" -o pure-note-linux-amd64 ./cmd/pure-note
-ldflags="-s -w" -o pn-linux-amd64 ./cmd/pn
dev: # 双端热重载:air(Go)+ vite dev(proxy /api → 127.0.0.1:8080)+ serve --dev
dev: # 双端热重载:air(Go)+ vite dev(proxy /api → 127.0.0.1:8080)+ start --dev
air & cd web && npm run dev
```
### 10.2 运行
```ini
# deploy/pure-note.service
# deploy/pn.service
[Unit]
Description=Pure Note
After=network.target
@@ -541,7 +547,7 @@ After=network.target
[Service]
User=purenote
WorkingDirectory=/opt/pure-note
ExecStart=/opt/pure-note/pure-note serve --addr 127.0.0.1:8080 \
ExecStart=/opt/pure-note/pn start --addr 127.0.0.1:8080 \
--data-dir /opt/pure-note/data --behind-proxy
Restart=on-failure
NoNewPrivileges=true
@@ -562,14 +568,14 @@ example.com {
}
```
> 幂等启动:`serve` 时若 settings 无口令哈希则拒绝启动并提示先跑 `init`;`user_version` 越界同样拒绝(§10.4)。
> 幂等启动:`start` 时若 settings 无口令哈希则拒绝启动并提示先跑 `init`;`user_version` 越界同样拒绝(§10.4)。
### 10.3 备份与恢复
**调度(systemd timer,替代 root cron)**
```ini
# deploy/pure-note-maint.timer
# deploy/pn-maint.timer
[Unit]
Description=Daily Pure Note maintenance (gc + backup)
@@ -581,19 +587,19 @@ Persistent=true
WantedBy=timers.target
```
timer 触发 `pure-note-maint.service`:先 `pure-note gc --commit`,后 `pure-note backup /backup/pure-note-$(date +\%F).db`。
timer 触发 `pn-maint.service`:先 `pn gc --commit`,后 `pn backup /backup/pn-$(date +\%F).db`。
**要点**
- 备份进程独立于服务进程运行,不受 unit 沙箱约束;`VACUUM INTO` 在 WAL 下与 serve 并发安全(在线一致快照);
- 备份进程独立于服务进程运行,不受 unit 沙箱约束;`VACUUM INTO` 在 WAL 下与 start 并发安全(在线一致快照);
- `/backup` 目录属主 `purenote`、权限 0700;备份文件**chmod 0600**(内含全部私有笔记、图片、口令哈希、会话哈希的明文副本);
- 保留 30 天;**语义声明**:备份保留期内被删除的数据物理上仍存在于备份中;
- 异地同步(可选):`rclone` 远端启用 **crypt**(或先 `age` 加密再上传),禁止明文上云;
- **恢复 SOP**:停服 → 删除 `data/pure-note.db-wal` 与 `-shm`(残留会污染还原)→ 以备份文件替换 `pure-note.db` → 起服。
- **恢复 SOP**:停服 → 删除 `data/pn.db-wal` 与 `-shm`(残留会污染还原)→ 以备份文件替换 `pn.db` → 起服。
### 10.4 升级
1. **升级前第一步固定为 `pure-note backup`**(写入 SOP);
1. **升级前第一步固定为 `pn backup`**(写入 SOP);
2. 停服 → 替换二进制 → 起服触发迁移:迁移仅追加式(不删列/重命名/改类型),`user_version` 顺序递增;
3. **启动守卫**:代码校验 `user_version ≤ 本版本支持的最高版本`,超出**拒绝启动**并提示,`--allow-newer` 显式放行(防止旧二进制静默在陌生 schema 上读写);
4. 回滚:若迁移尚未执行(版本一致)可直接换回旧二进制;若已执行且不满足追加式约束,回滚 = 从步骤 1 的备份还原数据文件后再换回旧二进制。
@@ -623,7 +629,7 @@ timer 触发 `pure-note-maint.service`:先 `pure-note gc --commit`,后 `pure
| 里程碑 | 内容 | 测试/验收门 |
| --- | --- | --- |
| M0 骨架 | Go 模块 + SQLite 迁移(user_version 守卫)+ 配置子命令 + 中间件链 + embed 构链(拷贝 + 占位 dist)+ `make build`/`make dev` | `make build` 产出可运行二进制;`serve` 起服务;`/api/health` 200 |
| M0 骨架 | Go 模块 + SQLite 迁移(user_version 守卫)+ 配置子命令 + 中间件链 + embed 构链(拷贝 + 占位 dist)+ `make build`/`make dev` | `make build` 产出可运行二进制;`start` 起服务;`/api/health` 200 |
| M1 公开浏览 | 笔记/标签 API + React 博客三页 + Markdown 渲染管线 + RSS/sitemap + 元信息注入(可见性规则) | 可见性矩阵**公开侧**用例起步;**构建冒烟测试**入 CI |
| M2 管理端 | 登录/会话/CSRF(`/api/me` 重取)+ 笔记 CRUD + 回收站 + 图片上传(魔数/缓存头分流)+ 编辑器 + 改密 | **表驱动可见性矩阵完整**(§13)+ 限流/CSRF/魔数/回收站用例**随功能同 PR 交付** |
| M3 安全加固 | 全量 §9.3 检查单、CSP 确认(记录 nonce 硬化待办)、CI 齐备(vet+vulncheck+测试+冒烟) | §9.3 全项通过 |
@@ -703,7 +709,7 @@ timer 触发 `pure-note-maint.service`:先 `pure-note gc --commit`,后 `pure
| react / react-dom | 19.2.8 | UI 框架 |
| vite / @vitejs/plugin-react | 8.2.2 | 构建(base 默认 `'/'`) |
| tailwindcss + @tailwindcss/vite + @tailwindcss/typography | 4.3.3 | 样式体系 |
| shadcn/ui(CLI)+ Radix primitives | 4.21.0 | 组件库 |
| antd + antd-style | 6.6.3 / 4.1.0 | 组件库(ConfigProvider 自定义主题 + createStyles 桥接) |
| react-router | 8.3.1 | 路由 |
| @tanstack/react-query | 5.102.8 | 服务端状态 |
| react-markdown + remark-gfm + rehype-sanitize + rehype-highlight | 10.1.0 / 4.0.1 / 6.0.0 / 7.0.2 | Markdown 渲染与防 XSS |
@@ -717,7 +723,7 @@ timer 触发 `pure-note-maint.service`:先 `pure-note gc --commit`,后 `pure
- https://go.dev/dl/ · https://pkg.go.dev/modernc.org/sqlite
- https://github.com/yuin/goldmark · https://github.com/microcosm-cc/bluemonday
- https://github.com/facebook/react · https://github.com/vitejs/vite
- https://github.com/shadcn-ui/ui · https://github.com/remix-run/react-router
- https://github.com/ant-design/ant-design · https://github.com/remix-run/react-router
- https://github.com/remarkjs/react-markdown · https://github.com/rehypejs/rehype-sanitize
- https://github.com/ueberdosis/tiptap · https://github.com/uiwjs/react-codemirror
+2 -2
View File
@@ -139,7 +139,7 @@ T10 点名了 API/RSS/sitemap/图片,唯独漏了 tags;API 表里只写「
### P1-13 升级回滚不严谨(§10.4)
「回滚 = 换回旧二进制(迁移可逆或旧版本可忽略新表)」不成立:`user_version` 是单向整数,旧二进制看到更高版本**不会报错**,会静默在陌生 schema 上读写,把可恢复事故变成不可恢复。「向前兼容」一词也用反了。
**修复**:启动时校验 `user_version ≤ 代码支持的最高版本`,超出拒绝启动(留 `--allow-newer` 旗标);迁移策略声明为「仅追加式」(禁止删列/重命名/改类型);升级 SOP 第一步固定为 `pure-note backup`。
**修复**:启动时校验 `user_version ≤ 代码支持的最高版本`,超出拒绝启动(留 `--allow-newer` 旗标);迁移策略声明为「仅追加式」(禁止删列/重命名/改类型);升级 SOP 第一步固定为 `pn backup`。
### P1-14 测试策略缺失(§12 / §9.3)
@@ -149,7 +149,7 @@ T10 点名了 API/RSS/sitemap/图片,唯独漏了 tags;API 表里只写「
### P1-15 备份文件敏感性与恢复 SOP(§10.3)
备份 = 全量私有笔记 + 图片 BLOB + 口令哈希 + 会话哈希的**明文副本**。crontab 若以 root 运行、umask 022,备份文件 0644,同机任意本地用户可读全部私密内容;rclone 异地同步也是明文。恢复步骤未要求删除残留 `-wal`/`-shm`(旧 WAL 会污染还原后的库);「备份保留 30 天」与「删除笔记」的数据语义关系未声明。
**修复**:备份文件 chmod 0600 / 专用低权用户;异地前强制加密(age / rclone crypt);恢复 SOP 补「删除 `pure-note.db-wal` 与 `-shm`」;声明备份保留与删除的关系。
**修复**:备份文件 chmod 0600 / 专用低权用户;异地前强制加密(age / rclone crypt);恢复 SOP 补「删除 `pn.db-wal` 与 `-shm`」;声明备份保留与删除的关系。
---
+128
View File
@@ -0,0 +1,128 @@
# 《Pure Note v1.1 实现》审评报告(第 2 轮)
> 审评对象:docs/design.md v1.1 的完整实现(Go 后端 + React 前端 + 构建/部署链),含二进制/子命令改名(pure-note → pn)后的全量代码
> 审评日期:2026-09-08
> 审评方法:三路并行代码评审(store/数据层、httpapi/中间件、前端),关键发现全部人工复核源码与文档(decisions.md D6 等)确认后成稿
> 基线:docs/review-round1.md(v1.0 设计评审)——其 P0/P1 修复要求已在 v1.1 设计中落地,本轮核对实现是否兑现
---
## 一、总体结论
**无 P0。** 默认配置下未发现远程可利用的越权或注入漏洞:SQL 全部参数化、可见性矩阵(列表/详情/标签/RSS/sitemap/图片/meta 注入)落实到位、CSRF 双保险(token + Origin/Referer 严格模式,覆盖 login/logout)、`__Host-` Cookie、PHC 口令哈希与会话摘要、meta 注入经 html/template 转义——round1 的 4 个 P0 均已在实现层封死。
但存在 **5 个 P1 功能缺陷**:其中 1 个导致前端功能完全不可用却被测试漏过(图片上传)、1 个邻接导航查询逻辑写反、1 个文档承诺(D6)与实现矛盾(升级/回滚场景备份不可用)。另有 10 项 P2 加固项。
| 级别 | 定义 | 数量 |
| --- | --- | --- |
| P0 | 远程可利用的越权/注入/私密内容泄露 | **0** |
| P1 | 功能缺陷/契约破坏/文档承诺与实现矛盾 | **5** |
| P2 | 加固与健壮性 | **10**(表内合并条目) |
---
## 二、发现汇总
| # | 级别 | 位置 | 问题一句话 |
| --- | --- | --- | --- |
| 1 | P1 | internal/store/notes.go:188-210 | 上一篇/下一篇邻接查询 tie-break 写反且忽略 pinned,同秒更新漏链/错链,无测试覆盖 |
| 2 | P1 | web/src/lib/api.ts:39-41 | api() 对 FormData 强制 `Content-Type: application/json`,前端图片上传完全不可用 |
| 3 | P1 | internal/httpapi/server.go:102 | `/api/admin/*` 与 `/api/me` 缺 `Cache-Control: no-store`(中间件注释宣称已覆盖) |
| 4 | P1 | web/src/pages/AdminEdit.tsx:113 | 自动保存成功后 invalidate 连详情查询一起失效,refetch 回滚正在输入的内容并抑制下一轮自动保存 |
| 5 | P1 | cmd/pn/main.go:241/316/340 | 维护子命令总是迁移且带守卫、`--allow-newer` 被解析但忽略,与 D6 矛盾;回滚场景连备份都无法执行 |
| 6 | P2 | store/maint.go:36-98 | GC SELECT 与 DELETE 之间竞态:恢复/新引用的活数据会被物理删除 |
| 7 | P2 | store/maint.go:109-119 | 备份先以默认 umask 创建后 chmod 0600,私有内容短暂暴露窗口 |
| 8 | P2 | auth/auth.go:57-68 | VerifyPassword 不校验 PHC 参数上下界,畸形哈希可 panic/OOM |
| 9 | P2 | httpapi/public.go:161-174 | 图片数据行竞态缺失时 404 携带 public immutable 一年 |
| 10 | P2 | httpapi/feed.go:52-75 | 无公开笔记时 lastBuildDate 输出公元 1 年(非法 RSS 日期) |
| 11 | P2 | middleware/ratelimit.go:41 | sameOrigin 只比 host 不比 scheme |
| 12 | P2 | httpapi/admin.go:344-349 | image.Decode 无尺寸上限,<5MB 解压炸弹可 OOM 整站 |
| 13 | P2 | store/settings.go:64-67 | Sscanf("%d") 部分解析("10abc"→10),脏 page_size 被静默采用 |
| 14 | P2 | docs(design:567/590/628、acceptance:53、decisions D6/D28) | 改名后残留 5 处 `serve` 旧词;D6 内容与实现矛盾(见 P1-5) |
| 15 | P2 | web/src 多处 | Home 硬编码 page_size 使站点设置失效;auth.tsx 登录竞态覆盖 CSRF;401 无全局跳转;公共缓存失效缺失等 |
---
## 三、P1 详细说明
### P1-1 上一篇/下一篇邻接查询 tie-break 写反(store/notes.go:188-210)
**问题**:公开列表序为 `ORDER BY pinned DESC, updated_at DESC, id DESC`(notes.go:169)。邻接语义应在 (updated_at, id) 字典序上取紧邻项,但:
- prev(列表中更早一篇)写 `(updated_at > ? OR (updated_at = ? AND id < ?)) ORDER BY updated_at ASC, id DESC`——`id` 比较方向与列表序(id DESC)相反;同秒组内 `id DESC` 取到的是组内顶端而非紧邻项;
- next 同理(`id > ?` + `id ASC`)。
**后果**:同一秒有两篇以上笔记更新/创建(个人站点连续保存极常见,updated_at 为秒级粒度)时 prev/next 漏链或错链;且查询完全不参与 pinned 排序,置顶笔记附近跳错条目。调用方 public.go:103-104 用 `_` 吞掉错误,静默失效。该函数**无任何测试覆盖**(已确认)。
**修复**:prev 改为 `(updated_at > ? OR (updated_at = ? AND id > ?)) ORDER BY updated_at ASC, id ASC LIMIT 1`;next 改为 `(updated_at < ? OR (updated_at = ? AND id < ?)) ORDER BY updated_at DESC, id DESC LIMIT 1`;补表驱动测试(含同秒多篇、置顶参与排序的语义确认,必要时把 pinned 纳入邻接定义)。
### P1-2 前端图片上传完全不可用(web/src/lib/api.ts:39-41)
**问题**:`api()` 对所有带 body 的请求无条件补 `Content-Type: application/json`。Editor.tsx:22 的 `uploadImage` 用 FormData(注释明确「让浏览器自动设置 Content-Type(含 boundary)」),被此逻辑覆盖为 application/json → 后端 `ParseMultipartForm` 失败 → 400,粘贴/拖拽上传功能不可用。
**为什么测试全绿**:Go 集成测试直连 API 手工构造正确 multipart 头;验收浏览器走查未覆盖上传动作;前端无该路径的测试。
**修复**:`if (options.body && !(options.body instanceof FormData) && !headers.has('Content-Type'))`;补一个前端测试或至少把上传动作纳入浏览器走查清单。
### P1-3 /api/admin/* 与 /api/me 缺 no-store(server.go:102、public.go:21-28)
**问题**:middleware.go:129-136 注释宣称 NoStore「为 /api/admin/* 与 /api/auth/* 响应统一附加」,实际接线仅 `/api/auth/`(server.go:86)挂了 NoStore,`mux.Handle("/api/admin/", s.requireAdmin(adminMux))`(server.go:102)没有。`/api/me`(已认证时返回 csrf_token)同样无。
**后果**:登出后经 bfcache/历史回退(共用设备场景)可回看管理数据(笔记全文、回收站列表、settings、CSRF token);与设计 §9.2 目标直接冲突。
**修复**:`mux.Handle("/api/admin/", middleware.NoStore(s.requireAdmin(adminMux)))`;`/api/me` 已认证分支响应前补 `Cache-Control: no-store`(或对 /api/me 整路由统一 no-store)。
### P1-4 自动保存竞态回滚用户输入(AdminEdit.tsx:108-116、62-76)
**问题**:保存成功 `qc.invalidateQueries({ queryKey: ['admin'] })` 前缀匹配连当前编辑中的详情查询(key 形如 `['admin','note',id]`)一起失效 → refetch 返回服务端快照 → `useEffect([existing])` 执行 `setForm(...)` 把保存期间用户继续输入的内容回滚,并把 `dirtyRef.current = false`,抑制下一轮自动保存。每次自动保存都经历一次「输入被吞」窗口。
**修复**:onSuccess 仅 invalidate 列表键(如 `['admin','notes']`)与回收站键,不失效当前详情;或 refetch 落地时比较 `updated_at`/内容未变才允许重置 form。
### P1-5 维护子命令与 D6 矛盾:迁移与守卫始终执行、--allow-newer 被忽略(cmd/pn/main.go:241/316/340)
**问题**:decisions.md D6 承诺「backup/gc 不执行迁移、不做 user_version 守卫(纯数据操作)」,但实现中 passwd/backup/gc 一律 `store.Open(cfg.DBPath(), false)`,而 `store.Open` 无条件 `migrate()`(store.go:105)且 `false` 关闭 `--allow-newer`。`config.ParseMaint` 解析了 `--allow-newer` 却从未使用。
**后果**:
1. 用户升级到更高 schema 后回滚旧二进制,**`pn backup` 直接被守卫拒绝**——与「升级 SOP 第一步先备份」(review-round1 P1-13 修复)的语义冲突;
2. 错误 `--data-dir` 会静默创建并迁移出一个新库(维护命令不再是无副作用的纯数据操作);
3. 传入 `--allow-newer` 无任何效果且无提示。
**修复**:按 D6 拆分「打开 + 迁移(serve/init)」与「仅数据操作打开(passwd/backup/gc,跳过迁移与守卫)」,或至少让三个子命令真正透传 `cfg.AllowNewer`;同步修订 D6/代码注释,消除文档与实现二选一的漂移。
---
## 四、P2 简述
| # | 位置 | 修复建议 |
| --- | --- | --- |
| 6 | store/maint.go:36-98 | GC commit 阶段 DELETE 带原条件复查:笔记 `WHERE id=? AND deleted_at < ?`,图片 `DELETE ... WHERE id=? AND NOT EXISTS(SELECT 1 FROM image_refs WHERE image_id=?)`(或整体单事务)——防 SELECT 与 DELETE 之间恢复/新引用导致活数据被物理删除 |
| 7 | store/maint.go:109-119 | 备份文件以 0600 创建(先 `os.OpenFile(O_CREATE|O_EXCL, 0600)` 占位或临时目录生成后 rename),消除「先以 umask 创建后 chmod」的暴露窗口与存在性竞态 |
| 8 | auth/auth.go:57-68 | 解析 PHC 后校验 `1 ≤ t ≤ 10`、`1 ≤ p ≤ 8`、`m ≤ 1<<20`(KiB)、`len(want) == 32`,不合法直接返回 false——防 t/p=0 触发 argon2 panic、m 巨大 OOM(库被篡改场景) |
| 9 | httpapi/public.go:161-174 | 缓存头移到 `GetImageData` 成功之后设置;数据行缺失的 404 不带 public immutable(防恢复后仍被缓存 404 一年) |
| 10 | httpapi/feed.go:52-75 | 无公开笔记时 lastBuildDate 用当前时间或省略字段,不输出 `Mon, 01 Jan 0001` |
| 11 | middleware/ratelimit.go:41 | `sameOrigin` 同时比较 scheme(或至少把 http/https 视为不同源);与 HSTS 形成双层防护 |
| 12 | httpapi/admin.go:344-349 | 解码校验前先 `image.DecodeConfig` 校验宽高/像素总数上限(如 ≤8192×8192 或总像素 ≤2^26),防 <5MB 高压缩图解压炸弹 OOM |
| 13 | store/settings.go:64-67 | `Sscanf("%d")` 后校验「串已完整消费」(如 `%d%c` 探尾或 strconv.Atoi 全文),拒 "10abc" 类脏值 |
| 14 | docs 多处 | 改名收尾:design.md:567/590/628、acceptance.md:53 的 `serve` → `start`;decisions.md D6/D28 同步(D28 的 `serve` 改 `start`;D6 待 P1-5 修复后按新行为重写) |
| 15 | web/src 多处 | Home.tsx:10 改用 `/api/site` 的 page_size;auth.tsx:26-45 给 refresh 加请求序号/登录态变化判定,丢弃迟到匿名响应(StrictMode 双挂载放大窗口);api.ts 401 回调接入 AuthContext 触发跳转;写操作成功后同步 invalidate `['notes']`/`['tags']`/`['note']` 公共键;TagPage 补 error 分支;上传路径加前端测试 |
---
## 五、核实通过(维持不动)
1. **可见性单一可信点**:列表/标签/RSS/sitemap 统一走 `PublicNoteFilter`(store/notes.go:152),详情/图片/meta 出口 status + deleted_at 双层裁决,私有与不存在统一 404 防枚举,id 越界/负数均 404;
2. **SQL 注入面**:全部 `?` 参数化;仅 `VACUUM INTO` 路径经 `escapeSQLString`(引号加倍,SQLite 语义正确)与内部常量 PRAGMA 两处拼接,均无注入面;
3. **认证与 CSRF**:`__Host-` + Secure + HttpOnly + Lax、登录重建会话防固定、Origin/Referer 严格模式(缺头即拒)覆盖 login/logout、token 仅内存、改密校验旧口令且会话保持;
4. **口令与令牌**:Argon2id PHC 串(参数随哈希走)、会话库存 SHA-256 摘要、常量时间比较;
5. **上传防线**:声明类型白名单 → 魔数(SVG 拒绝)→ PNG/JPEG/GIF 再解码 → ≤5MB 哨兵 → sha256 去重;
6. **输出转义**:meta 注入经 html/template、JSON 默认 HTML 转义、RSS 经 encoding/xml + goldmark(非 unsafe) + bluemonday 双层清洗;
7. **迁移与并发**:user_version 上界守卫、仅追加式、DDL 与版本号同事务幂等;WAL + busy_timeout(5000) + synchronous(NORMAL) + foreign_keys(1) + `SetMaxOpenConns(1)`;
8. **构建/部署链**:Vite `base:'/'` + sync-assets + 指纹缓存 + 冒烟脚本(19 项断言)全链路打通;
9. **重命名**:pure-note → pn 的代码/脚本/deploy/README 残留已清理干净,本轮仅剩 docs 中 5 处 `serve` 旧词(P2-14);
10. **测试资产**:可见性矩阵、CSRF、限流、上传、回收站/gc、slug、设置白名单、meta 转义、迁移守卫均有自动化用例;`go vet`/`go test`/vitest/`make smoke` 全绿。
---
## 六、结论
v1.1 实现的骨架与安全设计经受住了第二轮对抗性评审,round1 的全部 P0 修复要求已在实现层兑现,无新增 P0。需优先处理的是 5 个 P1:其中 P1-1(邻接导航)与 P1-2(图片上传)是用户可直接感知的功能性缺陷,P1-3(no-store)与 P1-5(维护命令契约)是安全/运维承诺的缺口,P1-4(自动保存竞态)影响核心编辑体验。P1 修复后建议随本轮把 P2 清单中低成本项(8/9/10/13/14)一并落地,并补上 P1-1 的邻接查询测试。
+14 -2
View File
@@ -40,7 +40,15 @@ func HashPassword(password string) (string, error) {
), nil
}
// VerifyPassword 按 PHC 串内参数重派生并常量时间比较。任何解析失败均返回 false。
// PHC 参数上下界(防畸形哈希触发 argon2 panic 或 OOM,评审 round2 P2-8):
// t/p 过小使 argon2.IDKey panic,m 过大直接耗尽内存。
const (
maxArgonTime = 10
maxArgonThreads = 8
maxArgonMemory = 1 << 20 // KiB(1 GiB),远大于现行 19456
)
// VerifyPassword 按 PHC 串内参数重派生并常量时间比较。任何解析失败或参数越界均返回 false。
func VerifyPassword(encoded, password string) bool {
parts := strings.Split(encoded, "$")
// ["", "argon2id", "v=19", "m=..,t=..,p=..", salt, hash]
@@ -57,12 +65,16 @@ func VerifyPassword(encoded, password string) bool {
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &m, &t, &p); err != nil {
return false
}
if t < 1 || t > maxArgonTime || p < 1 || p > maxArgonThreads ||
m < 8 || m > maxArgonMemory {
return false
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return false
}
want, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
if err != nil || len(want) != KeyLen {
return false
}
got := argon2.IDKey([]byte(password), salt, t, m, p, uint32(len(want)))
+20
View File
@@ -31,6 +31,26 @@ func TestPasswordHashRoundtrip(t *testing.T) {
}
}
// TestVerifyPasswordMalformedParams 畸形 PHC 参数安全返回 false:
// t/p=0 会使 argon2.IDKey panic、m 巨大会 OOM(评审 round2 P2-8)。
func TestVerifyPasswordMalformedParams(t *testing.T) {
salt := "AAAAAAAAAAAAAAAAAAAAAA" // 16 字节 base64(raw std)
hash := "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" // 32 字节
bad := []string{
"$argon2id$v=19$m=19456,t=0,p=1$" + salt + "$" + hash,
"$argon2id$v=19$m=19456,t=2,p=0$" + salt + "$" + hash,
"$argon2id$v=19$m=999999999,t=2,p=1$" + salt + "$" + hash,
"$argon2id$v=19$m=19456,t=999,p=1$" + salt + "$" + hash,
"$argon2id$v=19$m=19456,t=2,p=99$" + salt + "$" + hash,
"$argon2id$v=19$m=19456,t=2,p=1$" + salt + "$AAAA", // 哈希长度异常
}
for _, h := range bad {
if VerifyPassword(h, "x") {
t.Errorf("畸形参数不应通过: %s", h)
}
}
}
func TestTokenAndStrength(t *testing.T) {
tok, err := NewToken()
if err != nil {
+9 -7
View File
@@ -20,11 +20,10 @@ type Config struct {
}
// DBPath SQLite 数据库文件路径。
func (c *Config) DBPath() string { return c.DataDir + "/pure-note.db" }
func (c *Config) DBPath() string { return c.DataDir + "/pn.db" }
func addCommonFlags(fs *flag.FlagSet, c *Config) {
fs.StringVar(&c.DataDir, "data-dir", "./data", "数据目录(SQLite 数据库所在)")
fs.BoolVar(&c.AllowNewer, "allow-newer", false, "允许在更新的数据库 schema 版本上运行(跳过版本上界守卫)")
}
func addServeFlags(fs *flag.FlagSet, c *Config) {
@@ -34,12 +33,13 @@ func addServeFlags(fs *flag.FlagSet, c *Config) {
fs.StringVar(&c.LogFormat, "log-format", "text", "日志格式(text|json)")
fs.BoolVar(&c.BehindProxy, "behind-proxy", false, "位于可信反向代理之后(取 X-Forwarded-For 最右条目作为客户端 IP)")
fs.BoolVar(&c.Dev, "dev", false, "开发模式:允许非 Secure Cookie,仅允许监听 loopback 地址")
fs.BoolVar(&c.AllowNewer, "allow-newer", false, "允许在更新的数据库 schema 版本上运行(跳过版本上界守卫)")
}
// ParseServe 解析 `pure-note serve` 参数。
func ParseServe(args []string) (*Config, error) {
// ParseStart 解析 `pn start` 参数。
func ParseStart(args []string) (*Config, error) {
c := &Config{}
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
fs := flag.NewFlagSet("start", flag.ContinueOnError)
addServeFlags(fs, c)
if err := fs.Parse(args); err != nil {
return nil, err
@@ -50,18 +50,20 @@ func ParseServe(args []string) (*Config, error) {
return c, nil
}
// ParseInit 解析 `pure-note init` 参数。
// ParseInit 解析 `pn init` 参数。
func ParseInit(args []string) (*Config, error) {
c := &Config{}
fs := flag.NewFlagSet("init", flag.ContinueOnError)
addCommonFlags(fs, c)
fs.BoolVar(&c.AllowNewer, "allow-newer", false, "允许在更新的数据库 schema 版本上运行(跳过版本上界守卫)")
if err := fs.Parse(args); err != nil {
return nil, err
}
return c, nil
}
// ParseMaint 解析 `pure-note backup` / `pure-note gc` 参数(均只需数据目录)。
// ParseMaint 解析 `pn backup` / `pn gc` / `pn passwd` 参数(仅数据目录;
// 维护命令经 store.OpenData 打开,不迁移、无版本守卫,故无 --allow-newer,D6)。
// 返回 flag 解析后的剩余位置参数(如 backup 的输出路径)。
func ParseMaint(cmd string, args []string) (*Config, []string, error) {
c := &Config{}
+5 -5
View File
@@ -4,20 +4,20 @@ import "testing"
func TestDevLoopbackGuard(t *testing.T) {
// --dev 强制 loopback(§7.3-8)
if _, err := ParseServe([]string{"--dev", "--addr", ":8080"}); err == nil {
if _, err := ParseStart([]string{"--dev", "--addr", ":8080"}); err == nil {
t.Error("--dev + 0.0.0.0 应拒绝启动")
}
if _, err := ParseServe([]string{"--dev", "--addr", "192.168.1.5:8080"}); err == nil {
if _, err := ParseStart([]string{"--dev", "--addr", "192.168.1.5:8080"}); err == nil {
t.Error("--dev + 局域网地址应拒绝启动")
}
if _, err := ParseServe([]string{"--dev", "--addr", "127.0.0.1:8080"}); err != nil {
if _, err := ParseStart([]string{"--dev", "--addr", "127.0.0.1:8080"}); err != nil {
t.Errorf("--dev + 127.0.0.1 应允许: %v", err)
}
if _, err := ParseServe([]string{"--dev", "--addr", "localhost:8080"}); err != nil {
if _, err := ParseStart([]string{"--dev", "--addr", "localhost:8080"}); err != nil {
t.Errorf("--dev + localhost 应允许: %v", err)
}
// 非 dev 不限制
if _, err := ParseServe([]string{"--addr", ":8080"}); err != nil {
if _, err := ParseStart([]string{"--addr", ":8080"}); err != nil {
t.Errorf("非 dev 任意地址应允许: %v", err)
}
}
+84 -23
View File
@@ -27,16 +27,17 @@ import (
// adminNoteItem 管理列表项(不含全文)。
type adminNoteItem struct {
ID int64 `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
Summary string `json:"summary"`
Status string `json:"status"`
Tags []string `json:"tags"`
Pinned bool `json:"pinned"`
DeletedAt *int64 `json:"deleted_at,omitempty"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
ID int64 `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
Summary string `json:"summary"`
Status string `json:"status"`
Tags []string `json:"tags"`
Pinned bool `json:"pinned"`
DeletedAt *int64 `json:"deleted_at,omitempty"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
PublishedAt int64 `json:"published_at"`
}
func toAdminItem(n *store.Note) adminNoteItem {
@@ -44,6 +45,7 @@ func toAdminItem(n *store.Note) adminNoteItem {
ID: n.ID, Slug: n.Slug, Title: n.Title, Summary: n.Summary,
Status: n.Status, Tags: n.Tags, Pinned: n.Pinned,
DeletedAt: n.DeletedAt, CreatedAt: n.CreatedAt, UpdatedAt: n.UpdatedAt,
PublishedAt: n.PublishedAt,
}
}
@@ -61,15 +63,16 @@ func (s *Server) handleAdminNotes(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": len(items)})
}
// notePayload 笔记写请求体。
// notePayload 笔记写请求体。PublishedAt 为发布日期(Unix 秒);nil = 创建取当前时刻 / 更新保持不变。
type notePayload struct {
Title string `json:"title"`
Slug string `json:"slug"`
Summary string `json:"summary"`
Content string `json:"content"`
Status string `json:"status"`
Tags []string `json:"tags"`
Pinned bool `json:"pinned"`
Title string `json:"title"`
Slug string `json:"slug"`
Summary string `json:"summary"`
Content string `json:"content"`
Status string `json:"status"`
Tags []string `json:"tags"`
Pinned bool `json:"pinned"`
PublishedAt *int64 `json:"published_at"`
}
func (p *notePayload) validate() (string, string) { // 返回 (错误码, 消息)
@@ -106,10 +109,14 @@ func (s *Server) handleAdminNoteCreate(w http.ResponseWriter, r *http.Request) {
if strings.TrimSpace(summary) == "" {
summary = markdown.Summary(p.Content, 200)
}
publishedAt := now
if p.PublishedAt != nil && *p.PublishedAt > 0 {
publishedAt = *p.PublishedAt
}
n := &store.Note{
Slug: slug, Title: strings.TrimSpace(p.Title), Summary: summary,
Content: p.Content, Status: p.Status, Tags: store.NormalizeTags(p.Tags),
Pinned: p.Pinned, CreatedAt: now, UpdatedAt: now,
Pinned: p.Pinned, PublishedAt: publishedAt, CreatedAt: now, UpdatedAt: now,
}
id, err := s.st.CreateNote(n)
if err != nil {
@@ -203,6 +210,9 @@ func (s *Server) handleAdminNoteUpdate(w http.ResponseWriter, r *http.Request) {
n.Tags = store.NormalizeTags(p.Tags)
n.Pinned = p.Pinned
n.Content = p.Content
if p.PublishedAt != nil && *p.PublishedAt > 0 {
n.PublishedAt = *p.PublishedAt
}
if strings.TrimSpace(p.Summary) == "" {
n.Summary = markdown.Summary(p.Content, 200)
} else {
@@ -253,6 +263,17 @@ func (s *Server) handleAdminTrash(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"items": items, "total": len(items)})
}
// handleAdminTrashEmpty DELETE /api/admin/trash:清空回收站(物理删除全部,§6.2)。
func (s *Server) handleAdminTrashEmpty(w http.ResponseWriter, r *http.Request) {
n, err := s.st.EmptyTrash()
if err != nil {
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
return
}
s.log.Info("admin_action", "op", "trash.empty", "count", n)
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "deleted": n})
}
// handleAdminTrashRestore POST /api/admin/trash/{id}/restore:恢复(清空 deleted_at)。
func (s *Server) handleAdminTrashRestore(w http.ResponseWriter, r *http.Request) {
id, ok := pathID(r)
@@ -274,6 +295,10 @@ func (s *Server) handleAdminTrashRestore(w http.ResponseWriter, r *http.Request)
// ---- 图片上传(§7.4)----
// maxImagePixels 解码像素总数上限(1<<25 ≈ 8K 分辨率 7680×4320 ≈ 3.3×10⁷),
// 防高压缩比小体积图片解码后内存放大(解压炸弹)。
const maxImagePixels = 1 << 25
var allowedUploadTypes = map[string]string{
"image/png": ".png",
"image/jpeg": ".jpg",
@@ -340,8 +365,19 @@ func (s *Server) handleAdminImageUpload(w http.ResponseWriter, r *http.Request)
writeError(w, http.StatusUnsupportedMediaType, "unsupported_media", "文件内容不是受支持的图片(魔数校验失败,SVG 一律拒绝)")
return
}
// 解码校验(PNG/JPEG/GIF;WebP 由魔数保证)——拦截截断/伪造的图片流
// 解码校验(PNG/JPEG/GIF;WebP 由魔数保证)——拦截截断/伪造的图片流。
// 先 DecodeConfig 限制像素总数:防 ≤5MB 高压缩比图片解码后撑爆内存
// (解压炸弹 OOM,评审 round2 P2-12)。
if magicMime != "image/webp" {
cfg, _, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil {
writeError(w, http.StatusUnsupportedMediaType, "unsupported_media", "图片解码失败")
return
}
if cfg.Width <= 0 || cfg.Height <= 0 || int64(cfg.Width)*int64(cfg.Height) > maxImagePixels {
writeError(w, http.StatusRequestEntityTooLarge, "too_large", "图片像素总数超过上限")
return
}
if _, _, err := image.Decode(bytes.NewReader(data)); err != nil {
writeError(w, http.StatusUnsupportedMediaType, "unsupported_media", "图片解码失败")
return
@@ -381,6 +417,8 @@ type settingsDTO struct {
SiteTitle *string `json:"site_title"`
SiteDesc *string `json:"site_desc"`
PageSize *int `json:"page_size"`
BeianNo *string `json:"beian_no"`
SiteLogo *string `json:"site_logo"`
}
// handleAdminSettingsGet GET /api/admin/settings:白名单三键;永不序列化 admin_password_hash。
@@ -399,7 +437,7 @@ func (s *Server) handleAdminSettingsPut(w http.ResponseWriter, r *http.Request)
dec.DisallowUnknownFields()
var dto settingsDTO
if err := dec.Decode(&dto); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "包含未知字段或类型不合法(白名单:site_title/site_desc/page_size)")
writeError(w, http.StatusBadRequest, "bad_request", "包含未知字段或类型不合法(白名单:site_title/site_desc/page_size/beian_no/site_logo)")
return
}
if dto.SiteTitle != nil {
@@ -424,8 +462,8 @@ func (s *Server) handleAdminSettingsPut(w http.ResponseWriter, r *http.Request)
}
}
if dto.PageSize != nil {
if *dto.PageSize < 1 || *dto.PageSize > 100 {
writeError(w, http.StatusBadRequest, "bad_request", "page_size ∈ [1,100]")
if !store.ValidPageSize(*dto.PageSize) {
writeError(w, http.StatusBadRequest, "bad_request", "page_size 仅支持 10/20/30/50")
return
}
if err := s.st.SetSetting(store.KeyPageSize, strconv.Itoa(*dto.PageSize)); err != nil {
@@ -433,6 +471,29 @@ func (s *Server) handleAdminSettingsPut(w http.ResponseWriter, r *http.Request)
return
}
}
if dto.BeianNo != nil {
v := strings.TrimSpace(*dto.BeianNo)
if len([]rune(v)) > 200 {
writeError(w, http.StatusBadRequest, "bad_request", "备案号 ≤200 字符")
return
}
if err := s.st.SetSetting(store.KeyBeianNo, v); err != nil {
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
return
}
}
if dto.SiteLogo != nil {
// 仅收站内绝对路径(如 /api/images/1):CSP img-src 'self',外链图片本来就无法展示
v := strings.TrimSpace(*dto.SiteLogo)
if v != "" && (len(v) > 500 || strings.ContainsAny(v, " \t\r\n\"'\\") || !strings.HasPrefix(v, "/")) {
writeError(w, http.StatusBadRequest, "bad_request", "站点 Logo 须为站内绝对路径(如 /api/images/1)或留空")
return
}
if err := s.st.SetSetting(store.KeySiteLogo, v); err != nil {
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
return
}
}
// admin_password_hash 不可经此接口写入(§9.3):不在白名单结构体中,天然拒绝。
s.log.Info("admin_action", "op", "settings.update")
ss, err := s.st.GetSiteSettings()
+30 -7
View File
@@ -284,20 +284,43 @@ func TestSettingsWhitelist(t *testing.T) {
}
// PUT 合法键
resp, _ = e.do(admin, http.MethodPut, "/api/admin/settings",
[]byte(`{"site_title":"新标题","site_desc":"描述","page_size":5}`), e.adminHeaders())
[]byte(`{"site_title":"新标题","site_desc":"描述","page_size":20,"beian_no":"京公网安备12345678901号"}`), e.adminHeaders())
if resp.StatusCode != 200 {
t.Errorf("合法 PUT 应 200: %d", resp.StatusCode)
}
// beian_no 经 /api/site 可读且生效
_, body = e.get(e.client(), "/api/site")
if !strings.Contains(string(body), `"beian_no":"京公网安备12345678901号"`) {
t.Errorf("beian_no 设置应生效: %s", body)
}
// beian_no 可清空(空串 = 不展示)
resp, _ = e.do(admin, http.MethodPut, "/api/admin/settings",
[]byte(`{"beian_no":""}`), e.adminHeaders())
if resp.StatusCode != 200 {
t.Errorf("beian_no 清空应 200: %d", resp.StatusCode)
}
_, body = e.get(e.client(), "/api/site")
if !strings.Contains(string(body), `"beian_no":""`) {
t.Errorf("beian_no 应可清空: %s", body)
}
// beian_no 过长拒绝
resp, _ = e.do(admin, http.MethodPut, "/api/admin/settings",
[]byte(`{"beian_no":"`+strings.Repeat("备", 201)+`"}`), e.adminHeaders())
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("beian_no 超长应 400: %d", resp.StatusCode)
}
// page_size 生效为公开列表默认
_, body = e.get(e.client(), "/api/notes")
if !strings.Contains(string(body), `"page_size":5`) {
if !strings.Contains(string(body), `"page_size":20`) {
t.Errorf("page_size 设置应生效: %s", body)
}
// page_size 越界
resp, _ = e.do(admin, http.MethodPut, "/api/admin/settings",
[]byte(`{"page_size":0}`), e.adminHeaders())
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("page_size=0 应 400: %d", resp.StatusCode)
// page_size 越界/非候选项
for _, bad := range []string{"0", "5", "101"} {
resp, _ = e.do(admin, http.MethodPut, "/api/admin/settings",
[]byte(`{"page_size":`+bad+`}`), e.adminHeaders())
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("page_size=%s 应 400: %d", bad, resp.StatusCode)
}
}
}
+1 -1
View File
@@ -39,7 +39,7 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
return
}
if !ok {
writeError(w, http.StatusInternalServerError, "not_initialized", "尚未初始化管理员口令,请先执行 pure-note init")
writeError(w, http.StatusInternalServerError, "not_initialized", "尚未初始化管理员口令,请先执行 pn init")
return
}
+9 -4
View File
@@ -29,7 +29,7 @@ type rssItem struct {
Title string `xml:"title"`
Link string `xml:"link"`
GUID string `xml:"guid"`
PubDate string `xml:"pubDate"` // RFC 822(RSS 2.0 规范)
PubDate string `xml:"pubDate"` // RFC 822(RSS 2.0 规范)
Description string `xml:"description"` // 已清洗的 HTML(经 encoding/xml 自动转义)
}
@@ -65,6 +65,11 @@ func (s *Server) handleRSS(w http.ResponseWriter, r *http.Request) {
Description: html,
})
}
// 无公开笔记时省略 lastBuildDate(零值 Format 会产出公元 1 年的非法日期)
lastBuildStr := ""
if !lastBuild.IsZero() {
lastBuildStr = lastBuild.Format(time.RFC1123Z)
}
feed := rssFeed{
Version: "2.0",
Channel: rssChannel{
@@ -72,7 +77,7 @@ func (s *Server) handleRSS(w http.ResponseWriter, r *http.Request) {
Link: base + "/",
Description: ss.SiteDesc,
Language: "zh-CN",
LastBuild: lastBuild.Format(time.RFC1123Z),
LastBuild: lastBuildStr,
Items: items,
},
}
@@ -87,8 +92,8 @@ func (s *Server) handleRSS(w http.ResponseWriter, r *http.Request) {
// ---- sitemap(仅公开笔记)----
type urlSet struct {
XMLName xml.Name `xml:"urlset"`
XMLNS string `xml:"xmlns,attr"`
XMLName xml.Name `xml:"urlset"`
XMLNS string `xml:"xmlns,attr"`
URLs []siteURL `xml:"url"`
}
+32
View File
@@ -0,0 +1,32 @@
package httpapi
import "testing"
// TestNoStoreHeaders 认证与管理端点响应禁缓存(§9.2,评审 round2 P1-3):
// 防登出后 bfcache/历史回退回看管理数据与 CSRF token。
func TestNoStoreHeaders(t *testing.T) {
e := newEnv(t)
// 匿名 /api/me(响应随会话态变化)
resp, _ := e.get(e.client(), "/api/me")
if cc := resp.Header.Get("Cache-Control"); cc != "no-store" {
t.Errorf("匿名 /api/me 期望 Cache-Control: no-store,实际 %q", cc)
}
// 登录后 /api/me(含 csrf_token)与 /api/admin/notes
c := e.loginAdmin()
resp, _ = e.get(c, "/api/me")
if cc := resp.Header.Get("Cache-Control"); cc != "no-store" {
t.Errorf("已认证 /api/me 期望 Cache-Control: no-store,实际 %q", cc)
}
resp, _ = e.get(c, "/api/admin/notes")
if cc := resp.Header.Get("Cache-Control"); cc != "no-store" {
t.Errorf("/api/admin/notes 期望 Cache-Control: no-store,实际 %q", cc)
}
// /api/auth/* 维持 no-store
resp, _ = e.do(c, "POST", "/api/auth/logout", nil, nil)
if cc := resp.Header.Get("Cache-Control"); cc != "no-store" {
t.Errorf("/api/auth/logout 期望 Cache-Control: no-store,实际 %q", cc)
}
}
+41 -21
View File
@@ -18,7 +18,9 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
}
// handleMe GET /api/me:匿名 {authenticated:false};已认证 {authenticated:true, csrf_token}。
// 响应随会话态变化且含 CSRF token → no-store。
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
if sess, ok := s.sessionFrom(r); ok {
s.maybeRotate(w, sess)
writeJSON(w, http.StatusOK, map[string]any{"authenticated": true, "csrf_token": sess.CSRFToken})
@@ -38,15 +40,17 @@ func (s *Server) handleSiteInfo(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, ss)
}
// publicNoteItem 公开列表项(元信息,不含全文)。
// publicNoteItem 公开列表项(元信息 + 封面图,不含全文)。
type publicNoteItem struct {
Slug string `json:"slug"`
Title string `json:"title"`
Summary string `json:"summary"`
Tags []string `json:"tags"`
Pinned bool `json:"pinned"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
Slug string `json:"slug"`
Title string `json:"title"`
Summary string `json:"summary"`
Tags []string `json:"tags"`
Pinned bool `json:"pinned"`
Cover string `json:"cover,omitempty"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
PublishedAt int64 `json:"published_at"`
}
// handlePublicNotes GET /api/notes:公开笔记列表(可见性过滤在查询层,§9.1-T10)。
@@ -68,7 +72,8 @@ func (s *Server) handlePublicNotes(w http.ResponseWriter, r *http.Request) {
items = append(items, publicNoteItem{
Slug: n.Slug, Title: n.Title, Summary: n.Summary,
Tags: n.Tags, Pinned: n.Pinned,
CreatedAt: n.CreatedAt, UpdatedAt: n.UpdatedAt,
Cover: store.FirstImageRef(n.Content),
CreatedAt: n.CreatedAt, UpdatedAt: n.UpdatedAt, PublishedAt: n.PublishedAt,
})
}
writeJSON(w, http.StatusOK, map[string]any{
@@ -100,15 +105,26 @@ func (s *Server) handlePublicNote(w http.ResponseWriter, r *http.Request) {
return
}
}
prevSlug, prevTitle, _ := s.st.AdjacentPublicNote(note, "prev")
nextSlug, nextTitle, _ := s.st.AdjacentPublicNote(note, "next")
prevSlug, prevTitle, err := s.st.AdjacentPublicNote(note, "prev")
if err != nil {
s.log.Error("查询上一篇失败", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
return
}
nextSlug, nextTitle, err := s.st.AdjacentPublicNote(note, "next")
if err != nil {
s.log.Error("查询下一篇失败", "err", err)
writeError(w, http.StatusInternalServerError, "internal", "内部错误")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"id": note.ID, "slug": note.Slug, "title": note.Title,
"summary": note.Summary, "content": note.Content,
"status": note.Status, "tags": note.Tags, "pinned": note.Pinned,
"created_at": note.CreatedAt, "updated_at": note.UpdatedAt,
"prev": siblingOrEmpty(prevSlug, prevTitle),
"next": siblingOrEmpty(nextSlug, nextTitle),
"published_at": note.PublishedAt,
"prev": siblingOrEmpty(prevSlug, prevTitle),
"next": siblingOrEmpty(nextSlug, nextTitle),
})
}
@@ -133,7 +149,8 @@ func (s *Server) handleTags(w http.ResponseWriter, r *http.Request) {
}
// handleImage GET /api/images/{id}:并集可见性;未授权与不存在统一 404;
// 缓存头按可见性分流(§7.4)。
// 缓存头按可见性分流(§7.4)。缓存头在数据读取成功后才设置,
// 错误路径不携带公开缓存指令(防 404 被 CDN 缓存一年)。
func (s *Server) handleImage(w http.ResponseWriter, r *http.Request) {
idStr := r.PathValue("id")
id, err := strconv.ParseInt(idStr, 10, 64)
@@ -156,10 +173,18 @@ func (s *Server) handleImage(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "not_found", "图片不存在")
return
}
}
// 先取数据:数据行竞态缺失时返回的 404 不携带任何缓存指令
data, err := s.st.GetImageData(id)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "图片不存在")
return
}
if public {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
// 非公开图:每次请求重新判定,禁止缓存
w.Header().Set("Cache-Control", "private, no-store")
} else {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
}
w.Header().Set("Content-Type", img.MIME)
w.Header().Set("X-Content-Type-Options", "nosniff")
@@ -168,11 +193,6 @@ func (s *Server) handleImage(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotModified)
return
}
data, err := s.st.GetImageData(id)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", "图片不存在")
return
}
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
_, _ = w.Write(data)
}
+24 -13
View File
@@ -29,12 +29,12 @@ const (
// Server HTTP 服务。
type Server struct {
st *store.Store
cfg *config.Config
log *slog.Logger
global *middleware.Limiter // 全局宽松限流(per-IP)
loginIP *middleware.Limiter // 登录/改密 per-IP:10 次/5 分钟
loginAcct *middleware.Limiter // 登录/改密 per-账号:5 次/10 分钟
st *store.Store
cfg *config.Config
log *slog.Logger
global *middleware.Limiter // 全局宽松限流(per-IP)
loginIP *middleware.Limiter // 登录/改密 per-IP:10 次/5 分钟
loginAcct *middleware.Limiter // 登录/改密 per-账号:5 次/10 分钟
}
// New 构造 Server(生产限流参数,§7.2)。
@@ -93,13 +93,15 @@ func (s *Server) Handler(ui http.Handler) http.Handler {
adminMux.Handle("PUT /api/admin/notes/{id}", middleware.MaxBytes(maxNoteBody)(http.HandlerFunc(s.handleAdminNoteUpdate)))
adminMux.HandleFunc("DELETE /api/admin/notes/{id}", s.handleAdminNoteDelete)
adminMux.HandleFunc("GET /api/admin/trash", s.handleAdminTrash)
adminMux.HandleFunc("DELETE /api/admin/trash", s.handleAdminTrashEmpty)
adminMux.HandleFunc("POST /api/admin/trash/{id}/restore", s.handleAdminTrashRestore)
adminMux.Handle("POST /api/admin/images", middleware.MaxBytes(maxUploadBody)(http.HandlerFunc(s.handleAdminImageUpload)))
adminMux.HandleFunc("GET /api/admin/images", s.handleAdminImages)
adminMux.HandleFunc("GET /api/admin/settings", s.handleAdminSettingsGet)
adminMux.Handle("PUT /api/admin/settings", middleware.MaxBytes(maxAuthBody)(http.HandlerFunc(s.handleAdminSettingsPut)))
adminMux.Handle("POST /api/admin/password", middleware.MaxBytes(maxAuthBody)(http.HandlerFunc(s.handleAdminPassword)))
mux.Handle("/api/admin/", s.requireAdmin(adminMux))
// NoStore:管理数据(笔记全文/回收站/settings)禁缓存,防登出后 bfcache 回看(§9.2)
mux.Handle("/api/admin/", middleware.NoStore(s.requireAdmin(adminMux)))
// ---- SPA(兜底,须最后注册)----
if ui != nil {
@@ -290,13 +292,14 @@ func (s *Server) ResolveMeta(r *http.Request) webui.Meta {
ss = &store.SiteSettings{SiteTitle: store.DefaultSiteTitle}
}
m := webui.Meta{
Title: ss.SiteTitle,
Description: ss.SiteDesc,
OGTitle: ss.SiteTitle,
Title: ss.SiteTitle,
Description: ss.SiteDesc,
OGTitle: ss.SiteTitle,
OGDescription: ss.SiteDesc,
OGType: "website",
SiteName: ss.SiteTitle,
OGURL: baseURL(r) + "/",
OGType: "website",
SiteName: ss.SiteTitle,
OGURL: baseURL(r) + "/",
Icon: favicon(ss.SiteLogo),
}
slug := metaSlug(r.URL.Path)
if slug == "" {
@@ -316,6 +319,14 @@ func (s *Server) ResolveMeta(r *http.Request) webui.Meta {
return s.noteMeta(r, m, note)
}
// favicon favicon 地址:站点 Logo 优先,未设置回退内置图标。
func favicon(siteLogo string) string {
if siteLogo != "" {
return siteLogo
}
return "/favicon.svg"
}
func (s *Server) noteMeta(r *http.Request, base webui.Meta, n *store.Note) webui.Meta {
desc := n.Summary
if desc == "" {
+1 -1
View File
@@ -53,7 +53,7 @@ func newEnvCustom(t *testing.T, global, loginIP, loginAcct *middleware.Limiter)
loginAcct = middleware.NewLimiter(1e9, 1<<20, 1<<20, time.Minute)
}
dir := t.TempDir()
st, err := store.Open(dir+"/pure-note.db", false)
st, err := store.Open(dir+"/pn.db", false)
if err != nil {
t.Fatalf("打开测试库失败: %v", err)
}
+71
View File
@@ -0,0 +1,71 @@
package httpapi
import (
"bytes"
"encoding/binary"
"hash/crc32"
"image"
"net/http"
"testing"
)
// pngWithDims 构造仅含文件签名 + IHDR 的 PNG 头(DecodeConfig 只解析头部即可
// 得到宽高,无需真实像素数据;CRC 按 PNG 规范计算)。
func pngWithDims(w, h uint32) []byte {
ihdr := make([]byte, 13)
binary.BigEndian.PutUint32(ihdr[0:4], w)
binary.BigEndian.PutUint32(ihdr[4:8], h)
// 8bit / truecolor / deflate / adaptive / no interlace
ihdr[8], ihdr[9], ihdr[10], ihdr[11], ihdr[12] = 8, 2, 0, 0, 0
chunk := bytes.NewBuffer(nil)
_ = binary.Write(chunk, binary.BigEndian, uint32(len(ihdr)))
chunk.WriteString("IHDR")
chunk.Write(ihdr)
_ = binary.Write(chunk, binary.BigEndian, crc32.ChecksumIEEE(chunk.Bytes()[4:]))
out := bytes.NewBuffer(nil)
out.Write([]byte{0x89, 'P', 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A})
out.Write(chunk.Bytes())
return out.Bytes()
}
// TestUploadPixelBomb 解压炸弹防御(评审 round2 P2-12):小体积超大尺寸图片
// 在 DecodeConfig 阶段被 413 拒绝,不进入全量 Decode。
func TestUploadPixelBomb(t *testing.T) {
// 头部自证合法:DecodeConfig 可解析出宽高
bomb := pngWithDims(20000, 20000) // 4 亿像素 > 1<<25
if _, _, err := image.DecodeConfig(bytes.NewReader(bomb)); err != nil {
t.Fatalf("夹具应可解析出尺寸: %v", err)
}
// 正常小图不受影响
if _, _, err := image.DecodeConfig(bytes.NewReader(png1x1)); err != nil {
t.Fatalf("1x1 夹具应合法: %v", err)
}
e := newEnv(t)
c := e.loginAdmin()
// 手工 multipart(uploadPNG 辅助对非 201 会 Fatal)
var body bytes.Buffer
boundary := "bombboundary456"
body.WriteString("--" + boundary + "\r\n")
body.WriteString(`Content-Disposition: form-data; name="file"; filename="bomb.png"` + "\r\n")
body.WriteString("Content-Type: image/png\r\n\r\n")
body.Write(bomb)
body.WriteString("\r\n--" + boundary + "--\r\n")
req, err := http.NewRequest(http.MethodPost, e.ts.URL+"/api/admin/images", &body)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "multipart/form-data; boundary="+boundary)
req.Header.Set("Origin", e.ts.URL)
req.Header.Set("X-CSRF-Token", e.csrf)
resp, err := c.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusRequestEntityTooLarge {
t.Fatalf("超大像素图片应 413,实际 %d", resp.StatusCode)
}
}
+6 -6
View File
@@ -154,12 +154,12 @@ func TestVisibilityMatrix(t *testing.T) {
// ---- 图片出口(并集语义 + 缓存头分流,§6.2/§7.4)----
imageCases := []struct {
name string
id int64
anonCode int
anonCache string
adminCode int
adminCache string
name string
id int64
anonCode int
anonCache string
adminCode int
adminCache string
}{
{"公开图", f.imgPub, 200, "public, max-age=31536000, immutable", 200, "public, max-age=31536000, immutable"},
{"私有图", f.imgPriv, 404, "", 200, "private, no-store"},
+50
View File
@@ -0,0 +1,50 @@
package middleware
import (
"crypto/tls"
"net/http"
"net/http/httptest"
"testing"
)
// TestSameOriginSchemeSameOrigin 校验 host 与 scheme 双比对(评审 round2 P2-11):
// 仅 host 相同、scheme 不同的 Origin(如对 https 站点的 http://host)必须拒绝。
func TestSameOriginScheme(t *testing.T) {
mk := func(origin string, tlsConn bool, behindProxy bool, xfp string) *http.Request {
r := httptest.NewRequest(http.MethodPost, "http://example.com/api/auth/login", nil)
if origin != "" {
r.Header.Set("Origin", origin)
}
if tlsConn {
r.TLS = &tls.ConnectionState{}
}
if behindProxy {
r = WithBehindProxy(r, true)
}
if xfp != "" {
r.Header.Set("X-Forwarded-Proto", xfp)
}
return r
}
cases := []struct {
name string
r *http.Request
want bool
}{
{"http 直连 + http Origin", mk("http://example.com", false, false, ""), true},
{"http 直连 + https Origin(scheme 不匹配)", mk("https://example.com", false, false, ""), false},
{"https 直连 + https Origin", mk("https://example.com", true, false, ""), true},
{"https 直连 + http Origin(scheme 不匹配)", mk("http://example.com", true, false, ""), false},
{"反代 XFP=https + https Origin", mk("https://example.com", false, true, "https"), true},
{"反代 XFP=https + http Origin(scheme 不匹配)", mk("http://example.com", false, true, "https"), false},
{"反代未带 XFP 视为 http + http Origin", mk("http://example.com", false, true, ""), true},
{"host 不同", mk("http://evil.com", false, false, ""), false},
{"缺 Origin/Referer", mk("", false, false, ""), false},
}
for _, tc := range cases {
if got := sameOrigin(tc.r); got != tc.want {
t.Errorf("%s: 期望 %v,实际 %v", tc.name, tc.want, got)
}
}
}
+10 -3
View File
@@ -25,7 +25,8 @@ func OriginCheck(next http.Handler) http.Handler {
})
}
// sameOrigin 校验 Origin(或 Referer)的 host 与请求 Host 一致。
// sameOrigin 校验 Origin(或 Referer)与请求同源:host 一致且 scheme 与请求
// 实际 scheme 一致(TLS 直连为 https;可信反代后取 X-Forwarded-Proto,评审 round2 P2-11)。
func sameOrigin(r *http.Request) bool {
raw := r.Header.Get("Origin")
if raw == "" {
@@ -35,10 +36,16 @@ func sameOrigin(r *http.Request) bool {
return false
}
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
if err != nil || u.Host == "" || u.Scheme == "" {
return false
}
return strings.EqualFold(u.Host, r.Host)
scheme := "http"
if r.TLS != nil {
scheme = "https"
} else if trueBehindProxy(r) && strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") {
scheme = "https"
}
return strings.EqualFold(u.Scheme, scheme) && strings.EqualFold(u.Host, r.Host)
}
func writeErr(w http.ResponseWriter, status int, code, msg string) {
+13 -1
View File
@@ -64,6 +64,16 @@ func (s *Store) ImageIsPublic(id int64) (bool, error) {
// imageRefRe 扫描 content 中对 /api/images/{id} 的引用(§6.2 引用维护)。
var imageRefRe = regexp.MustCompile(`/api/images/(\d+)`)
// FirstImageRef 返回 content 中按出现顺序的第一张站内图片路径(无则空串)。
// 供公开列表推导封面图使用。
func FirstImageRef(content string) string {
m := imageRefRe.FindStringSubmatch(content)
if len(m) < 2 {
return ""
}
return m[0]
}
// RebuildImageRefs 在**同一事务**内重建笔记的图片引用:
// 删除该笔记全部 refs → 正则扫描 content → 重建(§6.2)。
func (s *Store) RebuildImageRefs(noteID int64, content string) error {
@@ -101,11 +111,13 @@ func rebuildRefsTx(tx *sql.Tx, noteID int64, content string) error {
}
// ListOrphanImages 零引用图片清单(GET /api/admin/images?orphan=1 与 gc 检视用)。
// 站点 Logo 引用的图片虽无笔记引用仍受保护,不视为孤儿。
func (s *Store) ListOrphanImages() ([]Image, error) {
rows, err := s.db.Query(`
SELECT i.id, i.sha256, i.mime, i.size, i.created_at FROM images i
WHERE NOT EXISTS (SELECT 1 FROM image_refs r WHERE r.image_id = i.id)
ORDER BY i.created_at DESC`)
AND i.id <> ?
ORDER BY i.created_at DESC`, s.logoImageID())
if err != nil {
return nil, err
}
+30 -6
View File
@@ -3,6 +3,7 @@ package store
import (
"fmt"
"os"
"syscall"
"time"
)
@@ -53,12 +54,14 @@ func (s *Store) GC(now time.Time, dryRun bool) (*GCReport, error) {
}
rows.Close()
// 孤儿图片:0 引用且超过宽限期
// 孤儿图片:0 引用且超过宽限期;站点 Logo 引用的图片受保护
imageCutoff := now.Add(-OrphanGrace).Unix()
logoID := s.logoImageID()
orphanRows, err := s.db.Query(`
SELECT i.id, i.sha256, i.mime, i.size, i.created_at FROM images i
WHERE i.created_at < ? AND NOT EXISTS (SELECT 1 FROM image_refs r WHERE r.image_id = i.id)
ORDER BY i.created_at`, imageCutoff)
AND i.id <> ?
ORDER BY i.created_at`, imageCutoff, logoID)
if err != nil {
return nil, err
}
@@ -86,15 +89,30 @@ func (s *Store) GC(now time.Time, dryRun bool) (*GCReport, error) {
return rep, nil
}
// 条件复查删除(防 TOCTOU:SELECT 与 DELETE 之间管理员恢复笔记/图片被
// 重新引用时,不删活数据;评审 round2 P2-6)
for _, en := range rep.ExpiredNotes {
if err := s.DeleteNoteForever(en.ID); err != nil {
res, err := s.db.Exec(
`DELETE FROM notes WHERE id=? AND deleted_at IS NOT NULL AND deleted_at < ?`,
en.ID, noteCutoff)
if err != nil {
return nil, fmt.Errorf("物理删除笔记 %d: %w", en.ID, err)
}
if rows, _ := res.RowsAffected(); rows == 0 {
continue // 期间已被恢复,跳过
}
}
for _, img := range rep.OrphanImages {
if err := s.DeleteImage(img.ID); err != nil {
// 条件复查含 Logo 保护:SELECT 与 DELETE 之间 Logo 可能指向该图
res, err := s.db.Exec(
`DELETE FROM images WHERE id=? AND NOT EXISTS (SELECT 1 FROM image_refs WHERE image_id=?) AND id<>?`,
img.ID, img.ID, logoID)
if err != nil {
return nil, fmt.Errorf("删除孤儿图片 %d: %w", img.ID, err)
}
if rows, _ := res.RowsAffected(); rows == 0 {
continue // 期间已被引用,跳过
}
}
n, err := s.DeleteExpiredSessions(now.Unix())
if err != nil {
@@ -109,11 +127,17 @@ func (s *Store) Backup(destPath string) error {
if _, err := os.Stat(destPath); err == nil {
return fmt.Errorf("目标文件已存在: %s", destPath)
}
// 备份含全部私密内容:临时收紧 umask,确保 VACUUM INTO 创建即 0600
// (否则以默认 umask(常 0644)创建、事后 chmod 存在暴露窗口,评审 round2 P2-7)。
// backup 为 CLI 单线程进程,进程级 umask 无并发副作用。
old := syscall.Umask(0o077)
// VACUUM INTO 不接受参数绑定,路径经单引号转义(无参数化通道时的最小注入面)。
if _, err := s.db.Exec("VACUUM INTO " + escapeSQLString(destPath)); err != nil {
_, err := s.db.Exec("VACUUM INTO " + escapeSQLString(destPath))
syscall.Umask(old)
if err != nil {
return err
}
// 备份含全部私钥内容:强制 0600(§10.3)
// 双保险:显式收紧(幂等)
if err := os.Chmod(destPath, 0o600); err != nil {
return err
}
+46 -30
View File
@@ -9,31 +9,34 @@ import (
)
// Note 笔记实体。DeletedAt 非 nil 表示处于回收站(软删除)。
// PublishedAt 为对外展示的发布日期(可自选,§8.2);排序仍按 updated_at。
type Note struct {
ID int64 `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
Summary string `json:"summary"`
Content string `json:"content,omitempty"`
Status string `json:"status"`
Tags []string `json:"tags"`
Pinned bool `json:"pinned"`
DeletedAt *int64 `json:"deleted_at,omitempty"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
ID int64 `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
Summary string `json:"summary"`
Content string `json:"content,omitempty"`
Status string `json:"status"`
Tags []string `json:"tags"`
Pinned bool `json:"pinned"`
DeletedAt *int64 `json:"deleted_at,omitempty"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
PublishedAt int64 `json:"published_at"`
}
// ErrNotFound 统一的「不存在」错误。
var ErrNotFound = errors.New("not found")
const noteColumns = "id, slug, title, summary, content, status, tags, pinned, deleted_at, created_at, updated_at"
// published_at 经 v2 迁移回填,理论上恒非 NULL;COALESCE 兜底历史异常行。
const noteColumns = "id, slug, title, summary, content, status, tags, pinned, deleted_at, created_at, updated_at, COALESCE(published_at, created_at) AS published_at"
func scanNote(scan func(dest ...any) error) (*Note, error) {
var n Note
var tagsJSON string
var pinned int
var content string
if err := scan(&n.ID, &n.Slug, &n.Title, &n.Summary, &content, &n.Status, &tagsJSON, &pinned, &n.DeletedAt, &n.CreatedAt, &n.UpdatedAt); err != nil {
if err := scan(&n.ID, &n.Slug, &n.Title, &n.Summary, &content, &n.Status, &tagsJSON, &pinned, &n.DeletedAt, &n.CreatedAt, &n.UpdatedAt, &n.PublishedAt); err != nil {
return nil, err
}
n.Content = content
@@ -54,9 +57,9 @@ func (s *Store) CreateNote(n *Note) (int64, error) {
return 0, err
}
res, err := s.db.Exec(
`INSERT INTO notes (slug, title, summary, content, status, tags, pinned, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
n.Slug, n.Title, n.Summary, n.Content, n.Status, tags, boolToInt(n.Pinned), n.CreatedAt, n.UpdatedAt)
`INSERT INTO notes (slug, title, summary, content, status, tags, pinned, published_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
n.Slug, n.Title, n.Summary, n.Content, n.Status, tags, boolToInt(n.Pinned), n.PublishedAt, n.CreatedAt, n.UpdatedAt)
if err != nil {
return 0, err
}
@@ -70,8 +73,8 @@ func (s *Store) UpdateNote(n *Note) error {
return err
}
res, err := s.db.Exec(
`UPDATE notes SET title=?, summary=?, content=?, status=?, tags=?, pinned=?, updated_at=? WHERE id=? AND deleted_at IS NULL`,
n.Title, n.Summary, n.Content, n.Status, tags, boolToInt(n.Pinned), n.UpdatedAt, n.ID)
`UPDATE notes SET title=?, summary=?, content=?, status=?, tags=?, pinned=?, published_at=?, updated_at=? WHERE id=? AND deleted_at IS NULL`,
n.Title, n.Summary, n.Content, n.Status, tags, boolToInt(n.Pinned), n.PublishedAt, n.UpdatedAt, n.ID)
if err != nil {
return err
}
@@ -164,7 +167,8 @@ func (s *Store) ListPublicNotes(page, pageSize int, tag string) ([]Note, int, er
if err := s.db.QueryRow(`SELECT COUNT(*) FROM notes n WHERE `+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
q := `SELECT n.id, n.slug, n.title, n.summary, '' AS content, n.status, n.tags, n.pinned, n.deleted_at, n.created_at, n.updated_at
// 含全文:列表页需从正文推导封面图(FirstImageRef),个人规模条数有限可接受
q := `SELECT n.id, n.slug, n.title, n.summary, n.content, n.status, n.tags, n.pinned, n.deleted_at, n.created_at, n.updated_at, COALESCE(n.published_at, n.created_at) AS published_at
FROM notes n WHERE ` + where + `
ORDER BY n.pinned DESC, n.updated_at DESC, n.id DESC LIMIT ? OFFSET ?`
rows, err := s.db.Query(q, append(args, pageSize, (page-1)*pageSize)...)
@@ -183,23 +187,26 @@ func (s *Store) ListPublicNotes(page, pageSize int, tag string) ([]Note, int, er
return out, total, rows.Err()
}
// AdjacentPublicNote 返回公开序列中与 n 相邻的上一篇/下一篇
// (按列表序 updated_at DESC, id DESC)。仅返回 slug 与 title。
// AdjacentPublicNote 返回公开列表中与 n 相邻的上一篇/下一篇。
// 列表序为 pinned DESC, updated_at DESC, id DESC(notes.go ListPublicNotes),
// prev = 列表中紧邻 n 之前的一篇(元组 (pinned, updated_at, id) 字典序更大侧的最小项),
// next = 紧邻之后的一篇(字典序更小侧的最大项)。仅返回 slug 与 title。
func (s *Store) AdjacentPublicNote(n *Note, dir string) (slug, title string, err error) {
p := boolToInt(n.Pinned)
var q string
switch dir {
case "prev": // 列表中更早的一篇
case "prev": // 列表中位于 n 之前的一篇
q = `SELECT slug, title FROM notes WHERE ` + PublicNoteFilter + `
AND (updated_at > ? OR (updated_at = ? AND id < ?))
ORDER BY updated_at ASC, id DESC LIMIT 1`
case "next": // 列表中更新的一篇
AND (pinned > ? OR (pinned = ? AND (updated_at > ? OR (updated_at = ? AND id > ?))))
ORDER BY pinned ASC, updated_at ASC, id ASC LIMIT 1`
case "next": // 列表中位于 n 之后的一篇
q = `SELECT slug, title FROM notes WHERE ` + PublicNoteFilter + `
AND (updated_at < ? OR (updated_at = ? AND id > ?))
ORDER BY updated_at DESC, id ASC LIMIT 1`
AND (pinned < ? OR (pinned = ? AND (updated_at < ? OR (updated_at = ? AND id < ?))))
ORDER BY pinned DESC, updated_at DESC, id DESC LIMIT 1`
default:
return "", "", fmt.Errorf("dir 必须为 prev|next")
}
row := s.db.QueryRow(q, n.UpdatedAt, n.UpdatedAt, n.ID)
row := s.db.QueryRow(q, p, p, n.UpdatedAt, n.UpdatedAt, n.ID)
if err := row.Scan(&slug, &title); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return "", "", nil
@@ -231,7 +238,7 @@ func (s *Store) ListPublicNotesFull(limit int) ([]Note, error) {
// ListAdminNotes 全部正常笔记(含私有,不含回收站),不含全文。
func (s *Store) ListAdminNotes() ([]Note, error) {
q := `SELECT id, slug, title, summary, '' AS content, status, tags, pinned, deleted_at, created_at, updated_at
q := `SELECT id, slug, title, summary, '' AS content, status, tags, pinned, deleted_at, created_at, updated_at, COALESCE(published_at, created_at) AS published_at
FROM notes WHERE deleted_at IS NULL
ORDER BY pinned DESC, updated_at DESC, id DESC`
return s.queryNotes(q)
@@ -239,12 +246,21 @@ func (s *Store) ListAdminNotes() ([]Note, error) {
// ListTrash 回收站列表(软删除中),不含全文。
func (s *Store) ListTrash() ([]Note, error) {
q := `SELECT id, slug, title, summary, '' AS content, status, tags, pinned, deleted_at, created_at, updated_at
q := `SELECT id, slug, title, summary, '' AS content, status, tags, pinned, deleted_at, created_at, updated_at, COALESCE(published_at, created_at) AS published_at
FROM notes WHERE deleted_at IS NOT NULL
ORDER BY deleted_at DESC`
return s.queryNotes(q)
}
// EmptyTrash 物理删除回收站全部笔记(refs 级联,需 foreign_keys=1),返回删除数。
func (s *Store) EmptyTrash() (int64, error) {
res, err := s.db.Exec(`DELETE FROM notes WHERE deleted_at IS NOT NULL`)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
// ListExpiredTrash 过期待物理删除的笔记 id(gc 用)。
func (s *Store) ListExpiredTrash(before int64) ([]int64, error) {
rows, err := s.db.Query(`SELECT id FROM notes WHERE deleted_at IS NOT NULL AND deleted_at < ?`, before)
+9
View File
@@ -76,3 +76,12 @@ func (s *Store) DeleteExpiredSessions(now int64) (int64, error) {
}
return res.RowsAffected()
}
// DeleteAllSessions 吊销全部会话(口令重置后强制所有端重新登录)。
func (s *Store) DeleteAllSessions() (int64, error) {
res, err := s.db.Exec(`DELETE FROM sessions`)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
+46 -5
View File
@@ -3,7 +3,8 @@ package store
import (
"database/sql"
"errors"
"fmt"
"strconv"
"strings"
)
// Settings 键白名单(§7.1 SettingsDTO 同源;admin_password_hash 永不进入 API 响应)。
@@ -12,6 +13,8 @@ const (
KeySiteTitle = "site_title"
KeySiteDesc = "site_desc"
KeyPageSize = "page_size"
KeyBeianNo = "beian_no"
KeySiteLogo = "site_logo"
)
// GetSetting 读取单个设置。
@@ -36,6 +39,8 @@ type SiteSettings struct {
SiteTitle string `json:"site_title"`
SiteDesc string `json:"site_desc"`
PageSize int `json:"page_size"`
BeianNo string `json:"beian_no"`
SiteLogo string `json:"site_logo"`
}
// Defaults,未初始化时兜底。
@@ -43,11 +48,21 @@ const (
DefaultSiteTitle = "Pure Note"
DefaultSiteDesc = ""
DefaultPageSize = 10
DefaultBeianNo = ""
)
// GetSiteSettings 读取站点设置(白名单三键,带默认值)。
// ValidPageSize 每页条数可选项(前端选择器同源:10/20/30/50)。
func ValidPageSize(n int) bool {
switch n {
case 10, 20, 30, 50:
return true
}
return false
}
// GetSiteSettings 读取站点设置(白名单键,带默认值)。
func (s *Store) GetSiteSettings() (*SiteSettings, error) {
ss := &SiteSettings{SiteTitle: DefaultSiteTitle, SiteDesc: DefaultSiteDesc, PageSize: DefaultPageSize}
ss := &SiteSettings{SiteTitle: DefaultSiteTitle, SiteDesc: DefaultSiteDesc, PageSize: DefaultPageSize, BeianNo: DefaultBeianNo}
if v, ok, err := s.GetSetting(KeySiteTitle); err != nil {
return nil, err
} else if ok && v != "" {
@@ -61,10 +76,36 @@ func (s *Store) GetSiteSettings() (*SiteSettings, error) {
if v, ok, err := s.GetSetting(KeyPageSize); err != nil {
return nil, err
} else if ok {
var n int
if _, err := fmt.Sscanf(v, "%d", &n); err == nil && n >= 1 && n <= 100 {
// strconv.Atoi 全文解析:拒绝 "10abc" 类部分解析的脏值(评审 round2 P2-13);
// 脏值/越界值回退默认,而非落入任意 [1,100]
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && ValidPageSize(n) {
ss.PageSize = n
}
}
if v, ok, err := s.GetSetting(KeyBeianNo); err != nil {
return nil, err
} else if ok {
ss.BeianNo = strings.TrimSpace(v)
}
if v, ok, err := s.GetSetting(KeySiteLogo); err != nil {
return nil, err
} else if ok {
ss.SiteLogo = strings.TrimSpace(v)
}
return ss, nil
}
// logoImageID 从 site_logo 设置解析受 GC 保护的图片 id(无 logo 或非本站图片路径返回 0)。
func (s *Store) logoImageID() int64 {
v, ok, err := s.GetSetting(KeySiteLogo)
if err != nil || !ok || v == "" {
return 0
}
if m := imageRefRe.FindStringSubmatch(v); m != nil {
var id int64
if _, err := fmtSscanInt(m[1], &id); err == nil && id > 0 {
return id
}
}
return 0
}
+34 -10
View File
@@ -69,6 +69,11 @@ CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`,
// v2: 笔记发布日期(可自选);存量笔记回填为 created_at
`
ALTER TABLE notes ADD COLUMN published_at INTEGER;
UPDATE notes SET published_at = created_at WHERE published_at IS NULL;
`,
}
@@ -80,8 +85,32 @@ type Store struct {
db *sql.DB
}
// Open 打开数据库并执行迁移。dbPath 为文件绝对/相对路径。
// Open 打开数据库并执行迁移(start/init 用)。dbPath 为文件绝对/相对路径。
// allowNewer 跳过 user_version 上界守卫(§10.4)。
func Open(dbPath string, allowNewer bool) (*Store, error) {
db, err := openDB(dbPath)
if err != nil {
return nil, err
}
if err := migrateDB(db, allowNewer); err != nil {
db.Close()
return nil, err
}
return &Store{db: db}, nil
}
// OpenData 打开数据库仅做数据操作:**不迁移、不做版本守卫**
// (passwd/backup/gc 维护子命令,D6:在更新 schema 的库上备份/清理
// 不应被守卫阻断,也不应产生迁移副作用)。
func OpenData(dbPath string) (*Store, error) {
db, err := openDB(dbPath)
if err != nil {
return nil, err
}
return &Store{db: db}, nil
}
func openDB(dbPath string) (*sql.DB, error) {
// SQLite URI 不接受相对路径
abs, err := filepath.Abs(dbPath)
if err != nil {
@@ -101,24 +130,19 @@ func Open(dbPath string, allowNewer bool) (*Store, error) {
}
// 单写者串行化(§6.2)
db.SetMaxOpenConns(1)
s := &Store{db: db}
if err := s.migrate(allowNewer); err != nil {
db.Close()
return nil, err
}
return s, nil
return db, nil
}
func (s *Store) migrate(allowNewer bool) error {
func migrateDB(db *sql.DB, allowNewer bool) error {
var v int
if err := s.db.QueryRow("PRAGMA user_version").Scan(&v); err != nil {
if err := db.QueryRow("PRAGMA user_version").Scan(&v); err != nil {
return err
}
if v > MaxSchemaVersion && !allowNewer {
return fmt.Errorf("%w: 库版本 %d > 支持上限 %d", ErrSchemaNewer, v, MaxSchemaVersion)
}
for i := v; i < MaxSchemaVersion; i++ {
tx, err := s.db.Begin()
tx, err := db.Begin()
if err != nil {
return err
}
+94 -2
View File
@@ -9,7 +9,7 @@ import (
// TestMigrationsFromEmpty 空库 → 最新版本;重复打开幂等(§13 迁移组)。
func TestMigrationsFromEmpty(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "pure-note.db")
dbPath := filepath.Join(dir, "pn.db")
s1, err := Open(dbPath, false)
if err != nil {
t.Fatalf("首次打开失败: %v", err)
@@ -41,7 +41,7 @@ func TestMigrationsFromEmpty(t *testing.T) {
// TestMigrationNewerRejected user_version 越界拒绝启动;--allow-newer 放行(§10.4)。
func TestMigrationNewerRejected(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "pure-note.db")
dbPath := filepath.Join(dir, "pn.db")
s1, err := Open(dbPath, false)
if err != nil {
t.Fatal(err)
@@ -97,6 +97,98 @@ func TestForeignKeysCascade(t *testing.T) {
}
}
// TestOpenDataNoMigrate 维护命令打开方式:不迁移、不做版本守卫(D6,评审 round2 P1-5)。
func TestOpenDataNoMigrate(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "pn.db")
// 全新路径:OpenData 不创建 schema
s, err := OpenData(dbPath)
if err != nil {
t.Fatal(err)
}
v, err := s.SchemaVersion()
if err != nil {
t.Fatal(err)
}
if v != 0 {
t.Fatalf("OpenData 不应执行迁移,user_version=%d", v)
}
var one int
if err := s.db.QueryRow("SELECT 1 FROM notes LIMIT 1").Scan(&one); err == nil {
t.Fatal("OpenData 不应创建表")
}
s.Close()
// 更高 schema 版本:OpenData 不被守卫拒绝(升级/回滚场景备份仍可用)
s2, err := Open(dbPath, false)
if err != nil {
t.Fatal(err)
}
if _, err := s2.DB().Exec("PRAGMA user_version = 99"); err != nil {
t.Fatal(err)
}
s2.Close()
if _, err := Open(dbPath, false); !errors.Is(err, ErrSchemaNewer) {
t.Fatalf("Open 仍应被守卫拒绝,实际 %v", err)
}
s3, err := OpenData(dbPath)
if err != nil {
t.Fatalf("OpenData 不做版本守卫,应放行: %v", err)
}
s3.Close()
}
// TestAdjacentPublicNote 邻接查询:列表序 pinned DESC, updated_at DESC, id DESC
// 的紧邻语义(评审 round2 P1-1:同秒 tie-break 与 pinned 参与排序;仅统计公开)。
func TestAdjacentPublicNote(t *testing.T) {
s := openTestStore(t)
mk := func(slug string, at int64, pinned bool, status string) {
t.Helper()
if _, err := s.CreateNote(&Note{Slug: slug, Title: slug, Status: status,
Tags: []string{}, Pinned: pinned, CreatedAt: at, UpdatedAt: at}); err != nil {
t.Fatal(err)
}
}
mk("a", 100, false, "public") // id=1
mk("b", 100, false, "public") // id=2(与 a 同秒,列表中排在 a 之前)
mk("c", 80, false, "public") // id=3
mk("p", 50, true, "public") // id=4 置顶 → 列表首
mk("priv", 200, false, "private") // 更新的私有笔记:不得出现在邻接中
get := func(slug string) *Note {
n, err := s.GetNoteBySlug(slug)
if err != nil {
t.Fatal(err)
}
return n
}
// 公开列表序:p > b > a > c(priv 被过滤)
cases := []struct{ slug, prev, next string }{
{"p", "", "b"}, // 置顶首篇
{"b", "p", "a"},
{"a", "b", "c"}, // a 与 b 同秒:prev 应取同秒中紧邻的 b(id 更大),而非跳过
{"c", "a", ""},
}
for _, tc := range cases {
prevSlug, _, err := s.AdjacentPublicNote(get(tc.slug), "prev")
if err != nil {
t.Fatalf("%s prev: %v", tc.slug, err)
}
nextSlug, _, err := s.AdjacentPublicNote(get(tc.slug), "next")
if err != nil {
t.Fatalf("%s next: %v", tc.slug, err)
}
if prevSlug != tc.prev || nextSlug != tc.next {
t.Errorf("%s: 期望 prev=%q next=%q,实际 prev=%q next=%q",
tc.slug, tc.prev, tc.next, prevSlug, nextSlug)
}
}
if _, _, err := s.AdjacentPublicNote(get("a"), "bad"); err == nil {
t.Error("非法 dir 应返回错误")
}
}
func openTestStore(t *testing.T) *Store {
t.Helper()
s, err := Open(filepath.Join(t.TempDir(), "test.db"), false)
+1
View File
@@ -30,6 +30,7 @@ type Meta struct {
OGURL string
OGImage string
SiteName string
Icon string // favicon:站点 Logo,未设置回退 /favicon.svg
}
// UI 静态资源服务。
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# 发布产物构建:交叉编译全部目标平台 → dist/,打包并生成 SHA256SUMS。
# 本地 `make dist` 与 Gitea Actions 发版工作流共用本脚本,保证产物一致。
#
# 用法: scripts/build-release.sh [版本号]
# 版本号缺省取 `git describe --tags --always`(无 tag 时回退 dev)。
# 前置条件:internal/webui/dist 已就绪(make web sync-assets)。
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
VERSION="${1:-$(git describe --tags --always 2>/dev/null || echo dev)}"
COMMIT="$(git rev-parse --short HEAD 2>/dev/null || echo none)"
DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
LDFLAGS="-s -w -X main.version=$VERSION -X main.commit=$COMMIT -X main.buildDate=$DATE"
# 纯静态(CGO_ENABLED=0,见 Makefile),单机即可交叉编译全部目标。
# 不含 windows:internal/store 对 syscall.Umask 等 Unix API 有硬依赖(备份权限加固)。
TARGETS=(
linux/amd64
linux/arm64
darwin/amd64
darwin/arm64
)
rm -rf dist
mkdir -p dist
for t in "${TARGETS[@]}"; do
os="${t%/*}" arch="${t#*/}"
name="pn-${VERSION}-${os}-${arch}"
echo "==> 构建 $name"
CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" \
go build -trimpath -ldflags="$LDFLAGS" -o "dist/$name" ./cmd/pn
# tar.gz 保留可执行位
(cd dist && tar czf "$name.tar.gz" "$name" && rm "$name")
done
# 生成校验和(Linux 为 sha256sum,macOS 回退 shasum)
echo "==> 生成 SHA256SUMS"
(
cd dist
if command -v sha256sum >/dev/null 2>&1; then
sha256sum -- ./* > SHA256SUMS
else
shasum -a 256 -- ./* > SHA256SUMS
fi
)
echo "==> 完成:dist/"
ls -lh dist/
+2 -2
View File
@@ -5,7 +5,7 @@
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
BIN="$ROOT/pure-note"
BIN="$ROOT/pn"
TMP="$(mktemp -d)"
PORT="${SMOKE_PORT:-18099}"
BASE="http://127.0.0.1:$PORT"
@@ -22,7 +22,7 @@ echo "[smoke] 初始化数据目录"
PN_ADMIN_PASSWORD=smoke-password-123 PN_SITE_TITLE="Smoke 站" "$BIN" init --data-dir "$TMP/data"
echo "[smoke] 启动服务 :$PORT"
"$BIN" serve --data-dir "$TMP/data" --addr "127.0.0.1:$PORT" --dev >"$TMP/serve.log" 2>&1 &
"$BIN" start --data-dir "$TMP/data" --addr "127.0.0.1:$PORT" --dev >"$TMP/serve.log" 2>&1 &
SRV_PID=$!
for _ in $(seq 1 50); do
curl -sf "$BASE/api/health" >/dev/null 2>&1 && break
+2 -1
View File
@@ -2,7 +2,8 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<!-- favicon 随站点 Logo(服务端注入);dev 环境由前端 features/site 兜底替换 -->
<link rel="icon" href="{{.Icon}}" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!--
以下占位符由服务端 internal/webui 以 html/template 渲染并自动转义(§8.3-4)。
+1468 -454
View File
File diff suppressed because it is too large Load Diff
+8 -2
View File
@@ -14,6 +14,9 @@
"@codemirror/lang-markdown": "^6.5.2",
"@tanstack/react-query": "^5.102.8",
"@uiw/react-codemirror": "^4.25.11",
"antd": "^6.6.3",
"antd-style": "^4.1.0",
"clsx": "^2.1.1",
"lucide-react": "^1.42.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
@@ -31,11 +34,14 @@
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.0",
"happy-dom": "^20.14.0",
"highlight.js": "^11.11.1",
"tailwindcss": "^4.3.3",
"typescript": "^5.9.2",
"vite": "^8.2.2",
"vitest": "^3.2.4",
"jsdom": "^26.1.0"
"vitest": "^3.2.4"
},
"allowScripts": {
"esbuild@0.28.2": true
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

+54 -20
View File
@@ -1,22 +1,51 @@
import { Navigate, Route, Routes } from 'react-router'
import { lazy, Suspense, useEffect, useState } from 'react'
import type { ReactNode } from 'react'
import { Navigate, Route, Routes } from 'react-router'
import { App as AntdApp, ConfigProvider, Spin } from 'antd'
import zhCN from 'antd/locale/zh_CN'
import { useAuth } from './lib/auth'
import { useTheme } from './lib/theme'
import useShadcnTheme from './theme/shadcnTheme'
import { BlogLayout, AdminLayout } from './components/Layout'
import Home from './pages/Home'
import Note from './pages/Note'
import TagPage from './pages/TagPage'
import Login from './pages/Login'
import AdminList from './pages/AdminList'
import AdminTrash from './pages/AdminTrash'
import AdminEdit from './pages/AdminEdit'
import AdminSettings from './pages/AdminSettings'
import NotFound from './pages/NotFound'
// 重依赖路由懒加载:Note 带 markdown 渲染管线,AdminEdit 额外带 CodeMirror,
// 拆出主包以避免单 chunk 过大(vite 500kB 告警)
const Note = lazy(() => import('./pages/Note'))
const AdminEdit = lazy(() => import('./pages/AdminEdit'))
// antd ConfigProvider:跟随应用三态主题切换 light/dark 算法(system 监听系统变化)
function AntdProvider({ children }: { children: ReactNode }) {
const { mode } = useTheme()
const [systemDark, setSystemDark] = useState(
() => window.matchMedia('(prefers-color-scheme: dark)').matches,
)
useEffect(() => {
const mq = window.matchMedia('(prefers-color-scheme: dark)')
const onChange = (e: MediaQueryListEvent) => setSystemDark(e.matches)
mq.addEventListener('change', onChange)
return () => mq.removeEventListener('change', onChange)
}, [])
const dark = mode === 'dark' || (mode === 'system' && systemDark)
const configProps = useShadcnTheme(dark)
return (
<ConfigProvider locale={zhCN} {...configProps}>
<AntdApp>{children}</AntdApp>
</ConfigProvider>
)
}
// 登录守卫:401 统一跳转 /admin/login(§8.2)
function RequireAuth({ children }: { children: ReactNode }) {
const { authenticated, loading } = useAuth()
if (loading) {
return <div className="flex min-h-screen items-center justify-center text-zinc-500">加载中…</div>
return <div className="pn-loading"><Spin size="large" /></div>
}
if (!authenticated) {
return <Navigate to="/admin/login" replace />
@@ -26,21 +55,26 @@ function RequireAuth({ children }: { children: ReactNode }) {
export default function App() {
return (
<Routes>
<Route element={<BlogLayout />}>
<Route path="/" element={<Home />} />
<Route path="/notes/:slug" element={<Note />} />
<Route path="/tags/:tag" element={<TagPage />} />
</Route>
<Route path="/admin/login" element={<Login />} />
<Route element={<RequireAuth><AdminLayout /></RequireAuth>}>
<Route path="/admin" element={<AdminList />} />
<Route path="/admin/trash" element={<AdminTrash />} />
<Route path="/admin/settings" element={<AdminSettings />} />
<Route path="/admin/notes/new" element={<AdminEdit />} />
<Route path="/admin/notes/:id/edit" element={<AdminEdit />} />
</Route>
<Route path="*" element={<NotFound />} />
</Routes>
<AntdProvider>
<Suspense fallback={<div className="pn-loading"><Spin size="large" /></div>}>
<Routes>
<Route element={<BlogLayout />}>
<Route path="/" element={<Home />} />
<Route path="/notes/:slug" element={<Note />} />
<Route path="/tags" element={<TagPage />} />
<Route path="/tags/:tag" element={<TagPage />} />
</Route>
<Route path="/admin/login" element={<Login />} />
<Route element={<RequireAuth><AdminLayout /></RequireAuth>}>
<Route path="/admin" element={<AdminList />} />
<Route path="/admin/trash" element={<AdminTrash />} />
<Route path="/admin/settings" element={<AdminSettings />} />
<Route path="/admin/notes/new" element={<AdminEdit />} />
<Route path="/admin/notes/:id/edit" element={<AdminEdit />} />
</Route>
<Route path="*" element={<NotFound />} />
</Routes>
</Suspense>
</AntdProvider>
)
}
+13 -6
View File
@@ -3,6 +3,7 @@ import CodeMirror from '@uiw/react-codemirror'
import { markdown, markdownLanguage } from '@codemirror/lang-markdown'
import { EditorView } from '@codemirror/view'
import { api, ApiError } from '../lib/api'
import { useTheme } from '../lib/theme'
/**
* Editor(编辑态,§8.2):CodeMirror 6 源码编辑 + 图片粘贴/拖拽上传。
@@ -22,6 +23,12 @@ async function uploadImage(file: File): Promise<UploadResult> {
return api<UploadResult>('/api/admin/images', { method: 'POST', body: form })
}
function isDarkMode(mode: 'light' | 'dark' | 'system'): boolean {
if (mode === 'dark') return true
if (mode === 'light') return false
return typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches
}
export default function Editor({
value,
onChange,
@@ -35,6 +42,7 @@ export default function Editor({
}) {
const viewRef = useRef<{ view?: EditorView } | null>(null)
const [dragOver, setDragOver] = useState(false)
const { mode } = useTheme()
const extensions = useMemo(
() => [
@@ -81,9 +89,8 @@ export default function Editor({
return (
<div
className={`relative h-full overflow-hidden rounded-lg border ${
dragOver ? 'border-blue-500 bg-blue-50 dark:bg-blue-950' : 'border-zinc-200 dark:border-zinc-800'
}`}
className={dragOver ? 'pn-cm-drag' : undefined}
style={{ position: 'relative', height: '100%', overflow: 'hidden', borderRadius: 'var(--radius-sm)' }}
onDragOver={(e) => {
e.preventDefault()
setDragOver(true)
@@ -100,9 +107,9 @@ export default function Editor({
onChange={onChange}
extensions={extensions}
basicSetup={{ lineNumbers: true, foldGutter: false, highlightActiveLine: true }}
theme="light"
theme={isDarkMode(mode) ? 'dark' : 'light'}
height="100%"
style={{ height: '100%' }}
style={{ height: '100%', minHeight: 420 }}
onCreateEditor={(view) => {
viewRef.current = { view }
}}
@@ -115,7 +122,7 @@ export default function Editor({
}}
/>
{dragOver && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-blue-500/10 text-sm font-medium text-blue-600">
<div className="pn-cm-drag-overlay">
松开以上传图片(≤5MB)
</div>
)}
+303 -83
View File
@@ -1,101 +1,321 @@
import { Link, NavLink, Outlet } from 'react-router'
import { Moon, Sun, Monitor } from 'lucide-react'
import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router'
import {
Moon, Sun, Monitor, ExternalLink, House, PenLine, MoreHorizontal, Tags, Trash2, Settings, LogOut,
} from 'lucide-react'
import { Button, Dropdown, Menu, Tooltip } from 'antd'
import type { MenuProps } from 'antd'
import { useSiteTitle } from '../features/site'
import { useTheme } from '../lib/theme'
import { useAuth } from '../lib/auth'
export function SiteHeader() {
const { data } = useSiteTitle()
const { mode, setMode } = useTheme()
/* ── 溢出菜单:antd Dropdown(外点关闭/Esc/焦点管理由 antd 承接) ── */
function MoreMenu({ triggerClassName, triggerLabel, items }: {
triggerClassName: string
triggerLabel: string
items: MenuProps['items']
}) {
return (
<header className="border-b border-zinc-200 dark:border-zinc-800">
<div className="mx-auto flex max-w-3xl items-center justify-between px-4 py-4">
<Link to="/" className="text-xl font-bold tracking-tight hover:opacity-80">
{data?.site_title ?? 'Pure Note'}
</Link>
<div className="flex items-center gap-1">
<ThemeButton mode={mode} setMode={setMode} />
// wrapper:与设计稿一致占一个 flex 槽位(display:none 触发器不产生 gap,
// 曾致桌面导航整体横移 4px)
<div className="pn-moremenu">
<Dropdown trigger={['click']} placement="bottomRight" menu={{ items }}>
<Button type="text" className={triggerClassName} aria-label={triggerLabel} icon={<MoreHorizontal size={15} />} />
</Dropdown>
</div>
)
}
/* 菜单内路由项:激活态样式见 index.css 的 .pn-dd-item-active 桥接规则 */
function moreNavItem(to: string, end: boolean, icon: React.ReactNode, label: string, active: boolean): NonNullable<MenuProps['items']>[number] {
return {
key: to,
className: active ? 'pn-dd-item-active' : undefined,
label: (
<NavLink to={to} end={end} className="pn-dd-link">
{icon}
{label}
</NavLink>
),
}
}
/* ── 博客端:居中单栏 720px 布局(博客系列设计稿) ─────────── */
function ThemeButton({ className = 'pn-site-navlink' }: { className?: string }) {
const { mode, setMode } = useTheme()
const next = mode === 'light' ? 'dark' : mode === 'dark' ? 'system' : 'light'
const title = `主题:${mode === 'light' ? '浅色' : mode === 'dark' ? '深色' : '跟随系统'}(点击切换)`
return (
<Button
type="text"
className={className}
title={title}
aria-label={title}
icon={mode === 'light' ? <Sun size={14} /> : mode === 'dark' ? <Moon size={14} /> : <Monitor size={14} />}
onClick={() => setMode(next)}
/>
)
}
export function BlogLayout() {
const { data } = useSiteTitle()
const title = data?.site_title ?? 'Pure Note'
const logo = data?.site_logo ?? ''
const location = useLocation()
return (
<div className="pn-site-page">
<header className="pn-site-header">
<div className="pn-site-header-inner">
<Link to="/" className="pn-site-wordmark">
{logo && <img className="pn-site-logo" src={logo} alt="" aria-hidden />}
{title}
</Link>
<nav className="pn-site-nav" aria-label="站点导航">
<NavLink to="/" end className="pn-site-navlink hide-sm">
<House size={14} />首页
</NavLink>
<NavLink to="/tags" className="pn-site-navlink hide-sm">
<Tags size={14} />标签
</NavLink>
<NavLink to="/admin" className="pn-site-navlink">
<ExternalLink size={14} />管理
</NavLink>
<ThemeButton />
{/* <640px 首页/标签收进溢出菜单 */}
<MoreMenu
triggerClassName="pn-site-navlink pn-site-menu-btn"
triggerLabel="更多导航"
items={[
moreNavItem('/', true, <House size={15} />, '首页', location.pathname === '/'),
moreNavItem('/tags', false, <Tags size={15} />, '标签', location.pathname.startsWith('/tags')),
]}
/>
</nav>
</div>
</header>
<main className="pn-site-main">
<Outlet />
</main>
<footer className="pn-site-footer">
<div className="pn-site-footer-inner">
<p className="pn-site-footer-line">
© {new Date().getFullYear()} {title} · Powered by{' '}
<a href="https://git.airnan.cn/wangairnan/pure-note" target="_blank" rel="noreferrer">Pure Note</a>
{' '}· <a href="/feed.xml">RSS</a>
</p>
{data?.beian_no && (
<a
className="pn-site-beian"
href="https://beian.mps.gov.cn/"
target="_blank"
rel="noreferrer"
>
<img src="/national_emblem.png" alt="" aria-hidden />
{data.beian_no}
</a>
)}
</div>
</footer>
</div>
)
}
/* ── 管理端:sidebar + topbar + 移动端 tabbar(dashboard 设计稿) ── */
function AdminThemeButton() {
const { mode, setMode } = useTheme()
const next = mode === 'light' ? 'dark' : mode === 'dark' ? 'system' : 'light'
const label = `主题:${mode === 'light' ? '浅色' : mode === 'dark' ? '深色' : '跟随系统'}(点击切换)`
return (
<Tooltip title={label} placement="bottom">
<Button
type="text"
className="pn-admin-iconbtn pn-admin-desktop-act"
aria-label={label}
icon={mode === 'light' ? <Sun size={14} /> : mode === 'dark' ? <Moon size={14} /> : <Monitor size={14} />}
onClick={() => setMode(next)}
/>
</Tooltip>
)
}
export function AdminTopbar({ title, children }: { title: string; children?: React.ReactNode }) {
const { logout } = useAuth()
const navigate = useNavigate()
const { mode, setMode } = useTheme()
const next = mode === 'light' ? 'dark' : mode === 'dark' ? 'system' : 'light'
const modeLabel = mode === 'light' ? '浅色' : mode === 'dark' ? '深色' : '跟随系统'
return (
<header className="pn-admin-topbar">
<h1>{title}</h1>
<div className="pn-admin-actions">
{children}
<AdminThemeButton />
{/* <768px 侧栏隐藏、topbar 空间有限:主题/返回博客/退出登录收进溢出菜单 */}
<MoreMenu
triggerClassName="pn-admin-iconbtn pn-admin-mobile-act"
triggerLabel="更多操作"
items={[
{
key: 'theme',
onClick: () => setMode(next),
label: (
<>
{mode === 'light' ? <Sun size={15} /> : mode === 'dark' ? <Moon size={15} /> : <Monitor size={15} />}
主题:{modeLabel}
</>
),
},
{
key: 'blog',
label: (
<a href="/" target="_blank" rel="noreferrer">
<ExternalLink size={15} />返回博客
</a>
),
},
{
key: 'logout',
onClick: () => void logout().then(() => navigate('/')),
label: (
<>
<LogOut size={15} />退出登录
</>
),
},
]}
/>
</div>
</header>
)
}
function ThemeButton({
mode,
setMode,
}: {
mode: 'light' | 'dark' | 'system'
setMode: (m: 'light' | 'dark' | 'system') => void
}) {
const next = mode === 'light' ? 'dark' : mode === 'dark' ? 'system' : 'light'
const title = `主题:${mode === 'light' ? '浅色' : mode === 'dark' ? '深色' : '跟随系统'}(点击切换)`
return (
<button
type="button"
title={title}
onClick={() => setMode(next)}
className="rounded p-2 text-zinc-500 hover:bg-zinc-100 hover:text-zinc-900 dark:hover:bg-zinc-800 dark:hover:text-zinc-100"
>
{mode === 'light' ? <Sun size={16} /> : mode === 'dark' ? <Moon size={16} /> : <Monitor size={16} />}
</button>
)
}
export function SiteFooter() {
return (
<footer className="mt-16 border-t border-zinc-200 py-8 text-center text-xs text-zinc-400 dark:border-zinc-800">
<a href="/feed.xml" className="hover:text-zinc-600">RSS</a>
<span className="mx-2">·</span>
<a href="/sitemap.xml" className="hover:text-zinc-600">Sitemap</a>
</footer>
)
}
export function BlogLayout() {
return (
<div className="min-h-screen">
<SiteHeader />
<main className="mx-auto max-w-3xl px-4 py-8">
<Outlet />
</main>
<SiteFooter />
</div>
)
}
const adminLinks = [
{ to: '/admin', label: '笔记', end: true },
{ to: '/admin/trash', label: '回收站', end: true },
{ to: '/admin/settings', label: '设置', end: true },
const adminNav = [
{ to: '/admin/notes/new', label: '新建笔记', icon: PenLine, end: false, match: 'prefix' as const },
{ to: '/admin', label: '笔记管理', icon: House, end: true, match: 'exact' as const },
{ to: '/admin/trash', label: '回收站', icon: Trash2, end: true, match: 'exact' as const },
{ to: '/admin/settings', label: '设置', icon: Settings, end: true, match: 'exact' as const },
]
const adminMenuPathByKey: Record<string, string> = {
notes: '/admin',
new: '/admin/notes/new',
trash: '/admin/trash',
settings: '/admin/settings',
}
function AdminSidebar() {
const { logout } = useAuth()
const navigate = useNavigate()
const location = useLocation()
// 新建/编辑共用「新建笔记」高亮(设计稿:编辑页高亮新建入口)
const editing = location.pathname.startsWith('/admin/notes')
const selectedKey = editing
? 'new'
: location.pathname === '/admin'
? 'notes'
: location.pathname.startsWith('/admin/trash')
? 'trash'
: location.pathname.startsWith('/admin/settings')
? 'settings'
: ''
return (
<aside className="pn-admin-sidebar">
<Link to="/admin" className="pn-admin-brand">
<span className="pn-admin-brand-mark">P</span>
<span className="pn-admin-brand-copy">
<span className="pn-admin-eyebrow">PURE NOTE</span>
<strong>管理后台</strong>
</span>
</Link>
<Menu
mode="inline"
className="pn-admin-menu"
selectedKeys={selectedKey ? [selectedKey] : []}
onClick={({ key }) => navigate(adminMenuPathByKey[key])}
items={[
{
key: 'g-content',
type: 'group',
label: '内容',
children: [
{ key: 'new', icon: <PenLine size={15} />, label: '新建笔记' },
{ key: 'notes', icon: <House size={15} />, label: '笔记管理' },
],
},
{
key: 'g-system',
type: 'group',
label: '系统',
children: [
{ key: 'trash', icon: <Trash2 size={15} />, label: '回收站' },
{ key: 'settings', icon: <Settings size={15} />, label: '设置' },
],
},
]}
/>
<div className="pn-admin-foot">
<div className="pn-admin-user">
<span className="pn-admin-avatar">管</span>
<span className="pn-admin-user-meta">
<span className="pn-admin-user-name">管理员</span>
</span>
<span className="pn-admin-user-actions">
<Tooltip title="返回博客" placement="bottom">
<Button
type="text"
className="pn-admin-iconbtn"
href="/"
target="_blank"
rel="noreferrer"
aria-label="返回博客(新窗口打开)"
icon={<ExternalLink size={15} />}
/>
</Tooltip>
<Tooltip title="退出登录" placement="bottom">
<Button
type="text"
className="pn-admin-iconbtn"
aria-label="退出登录"
icon={<LogOut size={15} />}
onClick={() => void logout().then(() => navigate('/'))}
/>
</Tooltip>
</span>
</div>
</div>
</aside>
)
}
function AdminTabbar() {
const location = useLocation()
const editing = location.pathname.startsWith('/admin/notes')
return (
<nav className="pn-admin-tabbar" aria-label="后台导航">
{adminNav.map(({ to, label, icon: Icon, match }) => {
const active = match === 'exact' ? location.pathname === to : editing
return (
<Link key={to} to={to} className={active ? 'is-active' : undefined} aria-current={active ? 'page' : undefined}>
<Icon size={18} /><span>{label === '笔记管理' ? '笔记' : label === '新建笔记' ? '新建' : label}</span>
</Link>
)
})}
</nav>
)
}
export function AdminLayout() {
return (
<div className="min-h-screen">
<SiteHeader />
<div className="mx-auto max-w-5xl px-4">
<nav className="flex gap-1 border-b border-zinc-200 py-2 dark:border-zinc-800">
{adminLinks.map((l) => (
<NavLink
key={l.to}
to={l.to}
end={l.end}
className={({ isActive }) =>
`rounded px-3 py-1.5 text-sm ${
isActive
? 'bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900'
: 'text-zinc-600 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:bg-zinc-800'
}`
}
>
{l.label}
</NavLink>
))}
</nav>
<main className="py-6">
<Outlet />
</main>
<div className="pn-admin-shell">
<AdminSidebar />
<div className="pn-admin-main">
<Outlet />
</div>
<AdminTabbar />
</div>
)
}
+23 -10
View File
@@ -1,11 +1,13 @@
import { memo } from 'react'
import { memo, useState } from 'react'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight'
import { Button } from 'antd'
import { sanitizePlugin } from '../lib/sanitize'
/**
* MarkdownViewer:浏览态渲染管线(§8.2),组件边界独立、可整体替换。
* 排版样式由 index.css 的 .pn-note-body 提供(设计稿正文排版契约)。
*
* Markdown 原文
* → remark-gfm(表格/任务列表/删除线/自动链接)
@@ -16,7 +18,7 @@ import { sanitizePlugin } from '../lib/sanitize'
*/
function CodeBlock(props: React.HTMLAttributes<HTMLPreElement>) {
return (
<div className="group relative">
<div>
<pre {...props} />
<CopyButton />
</div>
@@ -24,24 +26,35 @@ function CodeBlock(props: React.HTMLAttributes<HTMLPreElement>) {
}
function CopyButton() {
// 复制按钮在点击时读取最近兄弟 <pre> 的文本;不注入任何 HTML
// 复制按钮在点击时读取最近兄弟 <pre> 的文本;不注入任何 HTML。
// MarkdownViewer 会在无 App 上下文的单测中渲染,反馈用本地状态而非 message。
const [copied, setCopied] = useState(false)
return (
<button
type="button"
className="absolute right-2 top-2 rounded border border-zinc-300 bg-white px-2 py-0.5 text-xs opacity-0 transition group-hover:opacity-100 dark:border-zinc-700 dark:bg-zinc-900"
<Button
size="small"
className={copied ? 'pn-copy-btn is-copied' : 'pn-copy-btn'}
onClick={(e) => {
const pre = (e.currentTarget.parentElement as HTMLElement).querySelector('pre')
if (pre) void navigator.clipboard.writeText(pre.textContent ?? '')
const text = pre?.textContent ?? ''
if (!text) return
try {
void navigator.clipboard.writeText(text).then(() => {
setCopied(true)
window.setTimeout(() => setCopied(false), 1600)
})
} catch {
/* clipboard 不可用(非安全上下文等)时静默 */
}
}}
>
复制
</button>
{copied ? '已复制' : '复制'}
</Button>
)
}
const MarkdownViewer = memo(function MarkdownViewer({ source }: { source: string }) {
return (
<div className="prose prose-zinc max-w-none dark:prose-invert prose-pre:bg-zinc-100 dark:prose-pre:bg-zinc-900">
<div className="pn-note-body">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[sanitizePlugin, rehypeHighlight]}
+25 -1
View File
@@ -1,11 +1,35 @@
import { useEffect } from 'react'
import { useQuery } from '@tanstack/react-query'
import { api } from '../lib/api'
import type { SiteSettings } from '../lib/api'
export function useSiteTitle() {
return useQuery({
const { data } = useQuery({
queryKey: ['site'],
queryFn: () => api<SiteSettings>('/api/site'),
staleTime: 5 * 60_000,
})
// favicon 随站点 Logo:生产由服务端注入 index.html,此处兜底(dev 模板占位符
// 不会被渲染)并保证设置变更后 SPA 内即时生效
useEffect(() => {
const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]')
if (link) link.href = data?.site_logo || '/favicon.svg'
}, [data?.site_logo])
return { data }
}
/**
* 设置浏览器标签页标题。
* - 生产环境首帧由服务端注入首页标题;SPA 路由切换后由此 hook 接管。
* - dev 环境 index.html 占位符 `{{.Title}}` 也依赖此 hook 替换。
* pageTitle 缺省时仅显示站点名。
*/
export function useDocumentTitle(pageTitle?: string) {
const { data } = useSiteTitle()
const siteName = data?.site_title ?? 'Pure Note'
useEffect(() => {
document.title = pageTitle ? `${pageTitle} · ${siteName}` : siteName
}, [pageTitle, siteName])
}
+25
View File
@@ -0,0 +1,25 @@
import { App } from 'antd'
/**
* 操作反馈:基于 antd notification(经 ConfigProvider 主题 + App 上下文)。
* 保留旧 toast 契约——成功/错误两种、2.6s 自动消失、右下角(移动端底部通栏)。
*/
type ToastInput = {
description: string
variant?: 'success' | 'error'
}
export function useToast() {
const notification = App.useApp().notification
return {
toast: ({ description, variant }: ToastInput) => {
const notify = variant === 'error' ? notification.error : notification.success
notify({
message: '',
description,
placement: 'bottomRight',
duration: 2.6,
})
},
}
}
+1963 -7
View File
File diff suppressed because it is too large Load Diff
+17 -2
View File
@@ -25,6 +25,13 @@ export function getCsrfToken(): string | null {
return csrfToken
}
// 401 统一回调:由 AuthProvider 注册(置未登录态 → RequireAuth 跳 /admin/login)
let unauthorizedHandler: (() => void) | null = null
export function setUnauthorizedHandler(fn: (() => void) | null) {
unauthorizedHandler = fn
}
interface Envelope<T> {
data?: T
error?: { code: string; message: string; field?: string }
@@ -36,7 +43,9 @@ export async function api<T>(
): Promise<T> {
const method = (options.method ?? 'GET').toUpperCase()
const headers = new Headers(options.headers)
if (options.body && !headers.has('Content-Type')) {
// FormData 由浏览器自动生成含 boundary 的 multipart Content-Type,不可覆写
// (曾因此破坏图片上传,评审 round2 P1-2)
if (options.body && !(options.body instanceof FormData) && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json')
}
if (method !== 'GET' && method !== 'HEAD' && csrfToken) {
@@ -54,8 +63,9 @@ export async function api<T>(
if (!res.ok) {
const err = body?.error
if (res.status === 401) {
// 登录态失效:清内存态,统一由 AuthContext 跳登录页
// 登录态失效:清内存态并通知全局回调(AuthProvider 跳登录页)
setCsrfToken(null)
unauthorizedHandler?.()
}
throw new ApiError(
res.status,
@@ -77,6 +87,8 @@ export interface SiteSettings {
site_title: string
site_desc: string
page_size: number
beian_no: string
site_logo: string
}
export interface NoteItem {
@@ -87,9 +99,12 @@ export interface NoteItem {
status?: string
tags: string[]
pinned: boolean
/** 公开列表项的封面图(正文第一张图;无图缺省) */
cover?: string
deleted_at?: number | null
created_at: number
updated_at: number
published_at: number
}
export interface NoteDetail extends NoteItem {
+20 -3
View File
@@ -1,6 +1,6 @@
import { createContext, useCallback, useContext, useEffect, useState } from 'react'
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { api, setCsrfToken } from './api'
import { api, setCsrfToken, setUnauthorizedHandler } from './api'
interface Me {
authenticated: boolean
@@ -22,10 +22,15 @@ const AuthContext = createContext<AuthState | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [loading, setLoading] = useState(true)
const [authenticated, setAuthenticated] = useState(false)
// 请求序号:login/logout 递增,使竞态中的迟到 /api/me 响应被丢弃
// (防登录成功后被先前的匿名响应覆盖,评审 round2 P2-15)
const seqRef = useRef(0)
const refresh = useCallback(async () => {
const seq = ++seqRef.current
try {
const me = await api<Me>('/api/me')
if (seqRef.current !== seq) return // 状态已被 login/logout 改变,丢弃迟到响应
setAuthenticated(me.authenticated)
if (me.authenticated && me.csrf_token) {
setCsrfToken(me.csrf_token)
@@ -33,10 +38,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setCsrfToken(null)
}
} catch {
if (seqRef.current !== seq) return
setAuthenticated(false)
setCsrfToken(null)
} finally {
setLoading(false)
if (seqRef.current === seq) setLoading(false)
}
}, [])
@@ -44,12 +50,22 @@ export function AuthProvider({ children }: { children: ReactNode }) {
void refresh()
}, [refresh])
// api 层 401 统一回调:置未登录态,RequireAuth 随即跳转 /admin/login
useEffect(() => {
setUnauthorizedHandler(() => {
setAuthenticated(false)
setCsrfToken(null)
})
return () => setUnauthorizedHandler(null)
}, [])
const login = useCallback(
async (password: string) => {
const res = await api<{ csrf_token: string }>('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ password }),
})
seqRef.current++ // 使在途的匿名 /api/me 响应失效
setCsrfToken(res.csrf_token)
setAuthenticated(true)
},
@@ -58,6 +74,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const logout = useCallback(async () => {
await api('/api/auth/logout', { method: 'POST' })
seqRef.current++
setCsrfToken(null)
setAuthenticated(false)
}, [])
+14
View File
@@ -0,0 +1,14 @@
import type { QueryClient } from '@tanstack/react-query'
/**
* 写操作成功后失效公共数据缓存(列表/标签页/详情/标签云)。
* 公共查询 staleTime=30s 且不随窗口聚焦刷新,管理端改动若不失效,
* 返回公共页最长 30s 展示旧内容(评审 round2 P2-15)。
* 前缀匹配:['notes'] → ['notes',page,tag],['note'] → ['note',slug]。
*/
export function invalidatePublic(qc: QueryClient) {
void qc.invalidateQueries({ queryKey: ['notes'] })
void qc.invalidateQueries({ queryKey: ['tag'] })
void qc.invalidateQueries({ queryKey: ['note'] })
void qc.invalidateQueries({ queryKey: ['tags'] })
}
+18 -4
View File
@@ -1,7 +1,3 @@
export function cn(...parts: Array<string | false | null | undefined>): string {
return parts.filter(Boolean).join(' ')
}
export function formatDate(unix: number): string {
return new Date(unix * 1000).toLocaleDateString('zh-CN', {
year: 'numeric',
@@ -20,6 +16,24 @@ export function formatDateTime(unix: number): string {
})
}
// 设计稿统一使用 YYYY-MM-DD 等宽日期
export function formatDateISO(unix: number): string {
const d = new Date(unix * 1000)
const p = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
}
// 阅读时长估算:中文约 400 字/分钟
export function readingMinutes(content: string): number {
return Math.max(1, Math.round(content.length / 400))
}
// 回收站剩余保留天数(30 天保留期)
export function daysRemaining(deletedAt: number): number {
const elapsedDays = Math.floor((Date.now() / 1000 - deletedAt) / 86400)
return Math.max(0, 30 - elapsedDays)
}
// 前端 slug 预览:与服务端 slugify 同规则(ASCII 部分),纯非 ASCII 时提示将自动生成
export function slugPreview(title: string, date: Date): string {
const t = title.trim().toLowerCase().replace(/_/g, '-').replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').replace(/^-+|-+$/g, '')
+239 -157
View File
@@ -1,13 +1,24 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Bold, Italic, Link2, Code, Table, Eye, EyeOff } from 'lucide-react'
import dayjs from 'dayjs'
import {
Bold, Code, Eye, EyeOff, Italic, Link2, List, Quote,
Save, Table, Trash2,
} from 'lucide-react'
import { Alert, Badge, Button, Card, DatePicker, Divider, Input, Select, Spin, Switch } from 'antd'
import { api, ApiError } from '../lib/api'
import type { NoteItem } from '../lib/api'
import Editor from '../components/Editor'
import { invalidatePublic } from '../lib/invalidate'
import { useDocumentTitle } from '../features/site'
import MarkdownViewer from '../components/MarkdownViewer'
import { AdminTopbar } from '../components/Layout'
import { useToast } from '@/hooks/use-toast'
import { slugPreview } from '../lib/utils'
// CodeMirror 体积大,单独拆 chunk,编辑器在首帧后异步加载
const Editor = lazy(() => import('../components/Editor'))
interface NoteFull extends NoteItem {
content: string
prev?: unknown
@@ -20,26 +31,30 @@ type Form = {
summary: string
content: string
status: 'public' | 'private'
tags: string
tags: string[]
pinned: boolean
publishedAt: number
}
const emptyForm: Form = {
const emptyForm = (): Form => ({
title: '',
slug: '',
summary: '',
content: '',
status: 'private',
tags: '',
tags: [],
pinned: false,
}
publishedAt: Math.floor(Date.now() / 1000),
})
export default function AdminEdit() {
const { id } = useParams()
const noteId = id ? Number(id) : null
const isNew = noteId === null
useDocumentTitle(isNew ? '新建笔记' : '编辑笔记')
const navigate = useNavigate()
const qc = useQueryClient()
const { toast } = useToast()
const { data: existing, isLoading } = useQuery({
queryKey: ['admin', 'note', noteId],
@@ -47,16 +62,28 @@ export default function AdminEdit() {
enabled: !isNew,
})
// 既有标签(含私有笔记)作为选择器候选项
const { data: allNotes } = useQuery({
queryKey: ['admin', 'notes'],
queryFn: () => api<{ items: NoteItem[] }>('/api/admin/notes'),
})
const [form, setForm] = useState<Form>(emptyForm)
const [savedAt, setSavedAt] = useState<number | null>(null)
const [errorMsg, setErrorMsg] = useState<string | null>(null)
const [slugFieldError, setSlugFieldError] = useState<string | null>(null)
const [showPreview, setShowPreview] = useState(true)
const [uploadMsg, setUploadMsg] = useState<string | null>(null)
const [showPreview, setShowPreview] = useState(false)
const [uploadMsg, setUploadMsg] = useState<{ kind: 'busy' | 'error'; text: string } | null>(null)
const dirtyRef = useRef(false)
const slugTouchedRef = useRef(false)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const tagOptions = useMemo(() => {
const seen = new Set<string>()
for (const n of allNotes?.items ?? []) for (const t of n.tags) seen.add(t)
for (const t of form.tags) seen.add(t)
return [...seen].map((t) => ({ value: t, label: t }))
}, [allNotes, form.tags])
useEffect(() => {
if (existing) {
setForm({
@@ -65,8 +92,9 @@ export default function AdminEdit() {
summary: existing.summary,
content: existing.content,
status: existing.status === 'public' ? 'public' : 'private',
tags: existing.tags.join(', '),
tags: existing.tags,
pinned: existing.pinned,
publishedAt: existing.published_at || existing.created_at,
})
slugTouchedRef.current = false
dirtyRef.current = false
@@ -81,15 +109,17 @@ export default function AdminEdit() {
}
const save = useMutation({
mutationFn: async (f: Form) => {
mutationFn: async (vars: { f: Form; auto: boolean }) => {
const { f } = vars
const payload = {
title: f.title,
slug: f.slug,
summary: f.summary,
content: f.content,
status: f.status,
tags: f.tags.split(/[,,]/).map((t) => t.trim()).filter(Boolean),
tags: f.tags,
pinned: f.pinned,
published_at: f.publishedAt,
}
if (isNew) {
// 新建:slug 由服务端从标题自动生成(首次保存即定稿,§8.2)
@@ -103,17 +133,21 @@ export default function AdminEdit() {
body: JSON.stringify(payload),
})
},
onSuccess: (saved) => {
setSavedAt(Date.now())
onSuccess: (saved, vars) => {
setSlugFieldError(null)
setErrorMsg(null)
dirtyRef.current = false
void qc.invalidateQueries({ queryKey: ['admin'] })
toast({ description: vars.auto ? '已自动保存' : '已保存' })
// 仅失效列表与公共缓存;不失效当前编辑详情(['admin','note',id]),
// 防 refetch 回滚保存期间继续输入的内容并抑制下一轮自动保存(评审 round2 P1-4)
void qc.invalidateQueries({ queryKey: ['admin', 'notes'] })
invalidatePublic(qc)
if (isNew) {
navigate(`/admin/notes/${saved.id}/edit`, { replace: true })
}
},
onError: (e) => {
toast({ description: '保存失败,请重试', variant: 'error' })
if (e instanceof ApiError && e.code === 'slug_conflict') {
setSlugFieldError(e.message)
} else {
@@ -122,6 +156,16 @@ export default function AdminEdit() {
},
})
const trashMutation = useMutation({
mutationFn: () => api(`/api/admin/notes/${noteId}`, { method: 'DELETE' }),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['admin'] })
invalidatePublic(qc)
navigate('/admin')
},
onError: () => toast({ description: '操作失败,请重试', variant: 'error' }),
})
const canSave = form.title.trim().length > 0
const autosaveReady = !isNew || form.content.trim().length > 0
@@ -130,7 +174,9 @@ export default function AdminEdit() {
if (!canSave || !autosaveReady || !dirtyRef.current) return
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => {
if (dirtyRef.current) save.mutate(form)
if (dirtyRef.current) {
save.mutate({ f: form, auto: true })
}
}, 2000)
return () => {
if (timerRef.current) clearTimeout(timerRef.current)
@@ -138,160 +184,196 @@ export default function AdminEdit() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [form, canSave, autosaveReady])
const wrapSelection = useCallback(
(before: string, after = before, placeholder = '文本') => {
const el = document.querySelector<HTMLElement>('.cm-content')
// 简化实现:在内容末尾/光标处理由 CodeMirror 自行接管,这里直接改源码
void el
set('content', form.content + `${before}${placeholder}${after}`)
},
[form.content],
)
const insert = (snippet: string) => set('content', form.content + snippet)
if (!isNew && isLoading) {
return <p className="py-12 text-center text-zinc-400">加载中…</p>
return (
<>
<AdminTopbar title="编辑笔记" />
<div className="pn-admin-content"><div className="pn-admin-loading"><Spin /></div></div>
</>
)
}
const slugDisplay = form.slug || (form.title ? slugPreview(form.title, new Date()) : '保存后自动生成')
return (
<div className="flex h-[calc(100vh-8rem)] flex-col">
<div className="mb-3 flex items-center gap-3">
<input
value={form.title}
onChange={(e) => set('title', e.target.value)}
placeholder="标题(必填)"
className="min-w-0 flex-1 rounded-lg border border-transparent bg-transparent px-2 py-1.5 text-xl font-semibold outline-none hover:border-zinc-200 focus:border-blue-500 dark:hover:border-zinc-800"
/>
<button
type="button"
<>
<AdminTopbar title={isNew ? '新建笔记' : '编辑笔记'}>
<Button
type="text"
icon={showPreview ? <EyeOff size={15} /> : <Eye size={15} />}
onClick={() => setShowPreview((v) => !v)}
className="inline-flex items-center gap-1 rounded-lg border border-zinc-300 px-2.5 py-1.5 text-xs text-zinc-600 dark:border-zinc-700 dark:text-zinc-400"
title="切换预览"
>
{showPreview ? <EyeOff size={13} /> : <Eye size={13} />}
{showPreview ? '隐藏预览' : '显示预览'}
</button>
<button
type="button"
onClick={() => save.mutate(form)}
{showPreview ? '隐藏预览' : '预览'}
</Button>
<Button
type="primary"
icon={<Save size={15} />}
disabled={!canSave || save.isPending}
className="rounded-lg bg-zinc-900 px-4 py-1.5 text-sm font-medium text-white disabled:opacity-50 dark:bg-zinc-100 dark:text-zinc-900"
onClick={() => save.mutate({ f: form, auto: false })}
>
{save.isPending ? '保存中…' : '保存'}
</button>
</div>
{save.isPending ? '保存中…' : isNew ? '保存草稿' : '保存'}
</Button>
</AdminTopbar>
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-zinc-400">
<button type="button" onClick={() => wrapSelection('**')} className="rounded p-1 hover:bg-zinc-100 dark:hover:bg-zinc-800"><Bold size={14} /></button>
<button type="button" onClick={() => wrapSelection('*')} className="rounded p-1 hover:bg-zinc-100 dark:hover:bg-zinc-800"><Italic size={14} /></button>
<button type="button" onClick={() => set('content', form.content + '[标题](https://)')} className="rounded p-1 hover:bg-zinc-100 dark:hover:bg-zinc-800"><Link2 size={14} /></button>
<button type="button" onClick={() => set('content', form.content + '\n```\n代码\n```\n')} className="rounded p-1 hover:bg-zinc-100 dark:hover:bg-zinc-800"><Code size={14} /></button>
<button type="button" onClick={() => set('content', form.content + '\n| 列1 | 列2 |\n| --- | --- |\n| a | b |\n')} className="rounded p-1 hover:bg-zinc-100 dark:hover:bg-zinc-800"><Table size={14} /></button>
<span className="mx-1 text-zinc-300 dark:text-zinc-700">|</span>
<span>粘贴 / 拖拽图片即可上传</span>
<span className="ml-auto">
{save.isPending
? '保存中…'
: savedAt
? `已保存 ${new Date(savedAt).toLocaleTimeString('zh-CN')}`
: isNew
? '未保存'
: '无改动'}
</span>
</div>
<div className="flex min-h-0 flex-1 gap-3">
<div className="flex min-w-0 flex-1 flex-col gap-3">
<div className="min-h-0 flex-1">
<Editor
value={form.content}
onChange={(v) => set('content', v)}
onUploadStart={(name) => setUploadMsg(`上传中:${name}…`)}
onUploadEnd={(err) => setUploadMsg(err ?? null)}
<div className="pn-admin-content">
<div className="pn-ae-grid">
<Card className="pn-ae-main min-w-0">
<Input
variant="filled"
className="pn-ae-title-input"
type="text"
value={form.title}
onChange={(e) => set('title', e.target.value)}
placeholder="输入标题…"
aria-label="笔记标题"
/>
</div>
{uploadMsg && <p className="text-xs text-zinc-400">{uploadMsg}</p>}
{showPreview && (
<div className="hidden min-h-0 flex-1 overflow-auto rounded-lg border border-zinc-200 p-4 lg:block dark:border-zinc-800">
<MarkdownViewer source={form.content} />
<div className="pn-ae-meta">
<span className="pn-ae-slug" title={`/notes/${slugDisplay}`}>
<Link2 size={12} />
<span>/notes/{slugDisplay}</span>
</span>
<div className="pn-ae-tags">
<Select
mode="tags"
className="pn-ae-tags-select"
value={form.tags}
options={tagOptions}
placeholder="添加或选择标签"
aria-label="标签"
tokenSeparators={[',']}
onChange={(v) => set('tags', v)}
/>
</div>
</div>
)}
</div>
<aside className="w-64 shrink-0 space-y-4 overflow-auto rounded-lg border border-zinc-200 p-4 dark:border-zinc-800">
<div>
<label className="text-xs font-medium text-zinc-500">slug</label>
<input
value={form.slug}
onChange={(e) => set('slug', e.target.value)}
placeholder={form.title ? slugPreview(form.title, new Date()) : '保存后自动生成'}
className={`mt-1 w-full rounded-md border px-2 py-1.5 font-mono text-xs outline-none ${
slugFieldError
? 'border-red-500 bg-red-50 dark:bg-red-950'
: 'border-zinc-300 dark:border-zinc-700'
}`}
/>
{slugFieldError && <p className="mt-1 text-xs text-red-600">{slugFieldError}</p>}
<p className="mt-1 text-[10px] leading-relaxed text-zinc-400">
留空保持不变;首次保存自动生成后定稿
</p>
</div>
<div>
<label className="text-xs font-medium text-zinc-500">标签(逗号分隔)</label>
<input
value={form.tags}
onChange={(e) => set('tags', e.target.value)}
placeholder="随笔, go"
className="mt-1 w-full rounded-md border border-zinc-300 px-2 py-1.5 text-xs outline-none focus:border-blue-500 dark:border-zinc-700"
/>
</div>
<div>
<label className="text-xs font-medium text-zinc-500">摘要(留空自动截取正文)</label>
<textarea
value={form.summary}
onChange={(e) => set('summary', e.target.value)}
rows={3}
className="mt-1 w-full resize-none rounded-md border border-zinc-300 px-2 py-1.5 text-xs outline-none focus:border-blue-500 dark:border-zinc-700"
/>
</div>
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-zinc-500">公开</span>
<button
type="button"
role="switch"
aria-checked={form.status === 'public'}
onClick={() => set('status', form.status === 'public' ? 'private' : 'public')}
className={`relative h-5 w-9 rounded-full transition ${
form.status === 'public' ? 'bg-green-500' : 'bg-zinc-300 dark:bg-zinc-700'
}`}
>
<span
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white transition-all ${
form.status === 'public' ? 'left-[1.15rem]' : 'left-0.5'
}`}
<div className="pn-ae-toolbar" role="toolbar" aria-label="格式工具">
<Button type="text" className="pn-ae-tool" aria-label="加粗" title="加粗" icon={<Bold size={15} />} onClick={() => insert('**文本**')} />
<Button type="text" className="pn-ae-tool" aria-label="斜体" title="斜体" icon={<Italic size={15} />} onClick={() => insert('*文本*')} />
<Button type="text" className="pn-ae-tool" aria-label="插入链接" title="插入链接" icon={<Link2 size={15} />} onClick={() => insert('[标题](https://)')} />
<Button type="text" className="pn-ae-tool" aria-label="插入代码" title="插入代码" icon={<Code size={15} />} onClick={() => insert('\n```\n代码\n```\n')} />
<Button type="text" className="pn-ae-tool" aria-label="无序列表" title="无序列表" icon={<List size={15} />} onClick={() => insert('\n- 列表项\n')} />
<Button type="text" className="pn-ae-tool" aria-label="引用" title="引用" icon={<Quote size={15} />} onClick={() => insert('\n> 引用\n')} />
<Button type="text" className="pn-ae-tool" aria-label="插入表格" title="插入表格" icon={<Table size={15} />} onClick={() => insert('\n| 列1 | 列2 |\n| --- | --- |\n| a | b |\n')} />
<span className="pn-ae-tool-hint">纯文本 · Markdown</span>
</div>
<div className="pn-ae-body-wrap">
<Suspense fallback={<div className="pn-admin-loading" style={{ minHeight: 420 }}><Spin /></div>}>
<Editor
value={form.content}
onChange={(v) => set('content', v)}
onUploadStart={(name) => setUploadMsg({ kind: 'busy', text: `上传中:${name}…` })}
onUploadEnd={(err) => setUploadMsg(err ? { kind: 'error', text: err } : null)}
/>
</Suspense>
</div>
{uploadMsg && (
<Alert
className="pn-ae-upload-alert"
type={uploadMsg.kind === 'busy' ? 'info' : 'error'}
showIcon
message={uploadMsg.text}
/>
</button>
</div>
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-zinc-500">置顶</span>
<button
type="button"
role="switch"
aria-checked={form.pinned}
onClick={() => set('pinned', !form.pinned)}
className={`relative h-5 w-9 rounded-full transition ${
form.pinned ? 'bg-amber-500' : 'bg-zinc-300 dark:bg-zinc-700'
}`}
>
<span
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white transition-all ${
form.pinned ? 'left-[1.15rem]' : 'left-0.5'
}`}
/>
</button>
</div>
{errorMsg && <p className="text-xs text-red-600">{errorMsg}</p>}
</aside>
)}
{showPreview && (
<div className="pn-ae-preview">
<MarkdownViewer source={form.content} />
</div>
)}
</Card>
<aside className="pn-ae-side">
<Card styles={{ body: { padding: 20 } }}>
<h3 className="pn-ae-side-title">发布设置</h3>
<div className="pn-ae-prow">
<span className="pn-ae-plabel">状态</span>
<Badge
status={form.status === 'public' ? 'success' : 'default'}
text={form.status === 'public' ? '公开' : '私有'}
/>
<Switch
checked={form.status === 'public'}
onChange={(checked) => set('status', checked ? 'public' : 'private')}
aria-label="公开笔记"
/>
</div>
<div className="pn-ae-prow">
<span className="pn-ae-plabel">置顶</span>
<Switch
checked={form.pinned}
onChange={(checked) => set('pinned', checked)}
aria-label="置顶笔记"
/>
</div>
<div className="pn-ae-prow">
<span className="pn-ae-plabel">发布日期</span>
<DatePicker
className="pn-ae-datepicker"
size="small"
value={dayjs.unix(form.publishedAt)}
onChange={(d) => d && set('publishedAt', d.unix())}
allowClear={false}
showTime={{ format: 'HH:mm' }}
format="YYYY-MM-DD HH:mm"
aria-label="发布日期"
/>
</div>
<div className="pn-ae-pfield">
<label className="pn-field-label text-xs" htmlFor="pn-ae-slug-input">固定链接</label>
<Input
id="pn-ae-slug-input"
type="text"
value={form.slug}
spellCheck={false}
status={slugFieldError ? 'error' : undefined}
placeholder={form.title ? slugPreview(form.title, new Date()) : '保存后自动生成'}
styles={{ input: { fontFamily: 'var(--font-mono)', fontSize: 12.5, lineHeight: 1.6 } }}
onChange={(e) => set('slug', e.target.value)}
/>
<span className="pn-ae-phint">留空保持不变;首次保存自动生成后定稿</span>
{slugFieldError && <span className="pn-ae-error">{slugFieldError}</span>}
</div>
<div className="pn-ae-pfield">
<label className="pn-field-label text-xs" htmlFor="pn-ae-summary">摘要</label>
<Input.TextArea
id="pn-ae-summary"
rows={3}
styles={{ textarea: { fontSize: 12.5, lineHeight: 1.6 } }}
value={form.summary}
placeholder="留空自动截取正文"
onChange={(e) => set('summary', e.target.value)}
/>
</div>
{errorMsg && <p className="pn-ae-error">{errorMsg}</p>}
{!isNew && (
<>
<Divider className="pn-ae-divider" />
<Button
danger
block
icon={<Trash2 size={15} />}
disabled={trashMutation.isPending}
onClick={() => trashMutation.mutate()}
>
{trashMutation.isPending ? '删除中…' : '移至回收站'}
</Button>
</>
)}
</Card>
</aside>
</div>
</div>
</div>
</>
)
}
+231 -111
View File
@@ -1,11 +1,18 @@
import { useMemo, useState } from 'react'
import { Link, useNavigate } from 'react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Pencil, Plus, Trash2 } from 'lucide-react'
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { CirclePlus, Eye, EyeOff, Pencil, Pin, Search, Trash2 } from 'lucide-react'
import { Badge, Button, Card, Empty, Input, Modal, Segmented, Table, Tag } from 'antd'
import type { TableProps } from 'antd'
import { api } from '../lib/api'
import type { NoteItem } from '../lib/api'
import { useAuth } from '../lib/auth'
import { formatDateTime } from '../lib/utils'
import { invalidatePublic } from '../lib/invalidate'
import { useDocumentTitle } from '../features/site'
import { formatDateISO } from '../lib/utils'
import { AdminTopbar } from '../components/Layout'
import { useToast } from '@/hooks/use-toast'
type StatusFilter = 'all' | 'public' | 'private'
function useAdminNotes() {
return useQuery({
@@ -15,18 +22,24 @@ function useAdminNotes() {
}
export default function AdminList() {
useDocumentTitle('笔记管理')
const { data, isLoading } = useAdminNotes()
const { logout } = useAuth()
const navigate = useNavigate()
const qc = useQueryClient()
const [confirmId, setConfirmId] = useState<number | null>(null)
const navigate = useNavigate()
const { toast } = useToast()
const [confirmNote, setConfirmNote] = useState<NoteItem | null>(null)
const [filter, setFilter] = useState<StatusFilter>('all')
const [query, setQuery] = useState('')
const trashMutation = useMutation({
mutationFn: (id: number) => api(`/api/admin/notes/${id}`, { method: 'DELETE' }),
onSuccess: () => {
setConfirmId(null)
setConfirmNote(null)
toast({ description: '笔记已移至回收站' })
void qc.invalidateQueries({ queryKey: ['admin'] })
invalidatePublic(qc)
},
onError: () => toast({ description: '操作失败,请重试', variant: 'error' }),
})
const toggleStatus = useMutation({
@@ -46,114 +59,221 @@ export default function AdminList() {
}),
})
},
onSuccess: () => void qc.invalidateQueries({ queryKey: ['admin'] }),
onSuccess: (_d, note) => {
toast({ description: note.status === 'public' ? '已转为私有' : '已转为公开' })
void qc.invalidateQueries({ queryKey: ['admin'] })
invalidatePublic(qc)
},
onError: () => toast({ description: '操作失败,请重试', variant: 'error' }),
})
return (
<div>
<div className="mb-4 flex items-center justify-between">
<h1 className="text-lg font-semibold">笔记管理({data?.total ?? 0})</h1>
<div className="flex gap-2">
const items = data?.items ?? []
const publicCount = items.filter((n) => n.status === 'public').length
const privateCount = items.length - publicCount
const visible = useMemo(() => {
const q = query.trim().toLowerCase()
return items.filter((n) => {
if (filter === 'public' && n.status !== 'public') return false
if (filter === 'private' && n.status === 'public') return false
if (q) {
const hay = `${n.title} ${n.slug} ${n.tags.join(' ')}`.toLowerCase()
if (!hay.includes(q)) return false
}
return true
})
}, [items, filter, query])
const columns: TableProps<NoteItem>['columns'] = [
{
title: '标题',
key: 'title',
render: (_, n) => (
<>
<Link
to="/admin/notes/new"
className="inline-flex items-center gap-1 rounded-lg bg-zinc-900 px-3 py-1.5 text-sm font-medium text-white hover:bg-zinc-800 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
to={`/admin/notes/${n.id}/edit`}
className="pn-al-note-title"
style={{ textDecoration: 'none' }}
>
<Plus size={15} /> 新建
{n.pinned && <Pin size={12} aria-label="置顶" />}
<span>{n.title}</span>
</Link>
<button
type="button"
onClick={() => void logout().then(() => navigate('/'))}
className="rounded-lg border border-zinc-300 px-3 py-1.5 text-sm text-zinc-600 hover:bg-zinc-50 dark:border-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800"
>
登出
</button>
</div>
<span className="pn-al-note-slug">/{n.slug}</span>
</>
),
},
{
title: '标签',
key: 'tags',
render: (_, n) =>
n.tags.length > 0 ? (
<div className="pn-al-tags">
{n.tags.map((t) => <Tag key={t}>{t}</Tag>)}
</div>
) : (
<span className="pn-al-note-slug">—</span>
),
},
{
title: '日期',
key: 'date',
render: (_, n) => <span className="pn-al-date">{formatDateISO(n.updated_at)}</span>,
},
{
title: '状态',
key: 'status',
render: (_, n) => (
<Badge status={n.status === 'public' ? 'success' : 'default'} text={n.status === 'public' ? '已发布' : '私有'} />
),
},
{
title: '操作',
key: 'actions',
className: 'pn-al-col-actions',
render: (_, n) => {
const isPublic = n.status === 'public'
return (
<div className="pn-al-actions-cell">
<Button
type="text"
icon={isPublic ? <EyeOff size={15} /> : <Eye size={15} />}
title={isPublic ? '转为私有' : '转为公开'}
aria-label={isPublic ? '转为私有' : '转为公开'}
disabled={toggleStatus.isPending}
onClick={() => void toggleStatus.mutate(n)}
/>
<Button
type="text"
icon={<Pencil size={15} />}
title="编辑"
aria-label={`编辑笔记:${n.title}`}
onClick={() => void navigate(`/admin/notes/${n.id}/edit`)}
/>
<Button
type="text"
danger
icon={<Trash2 size={15} />}
title="移至回收站"
aria-label={`移至回收站:${n.title}`}
onClick={() => setConfirmNote(n)}
/>
</div>
)
},
},
]
return (
<>
<AdminTopbar title="笔记管理">
<Input
className="pn-al-search"
allowClear
placeholder="搜索笔记…"
aria-label="搜索笔记"
value={query}
onChange={(e) => setQuery(e.target.value)}
prefix={<Search size={14} aria-hidden />}
/>
<Button
type="primary"
icon={<CirclePlus size={15} />}
onClick={() => void navigate('/admin/notes/new')}
>
新建笔记
</Button>
</AdminTopbar>
<div className="pn-admin-content">
<section className="pn-al-metrics">
<Card className="pn-al-metric" styles={{ body: { padding: '16px 18px' } }}>
<span className="text-[11.5px] tracking-[0.04em] text-muted-foreground">笔记</span>
<strong className="my-0.5 text-[28px] font-bold leading-[1.2] tracking-[-0.03em] text-card-foreground max-md:text-[22px]">
{data?.total ?? items.length}
</strong>
<span className="text-xs leading-normal text-muted-foreground max-md:hidden">
全部笔记,含 {privateCount} 篇私有
</span>
</Card>
<Card className="pn-al-metric" styles={{ body: { padding: '16px 18px' } }}>
<span className="text-[11.5px] tracking-[0.04em] text-muted-foreground">已发布</span>
<strong className="my-0.5 text-[28px] font-bold leading-[1.2] tracking-[-0.03em] text-card-foreground max-md:text-[22px]">
{publicCount}
</strong>
<span className="text-xs leading-normal text-muted-foreground max-md:hidden">
对外公开的笔记
</span>
</Card>
<Card className="pn-al-metric" styles={{ body: { padding: '16px 18px' } }}>
<span className="text-[11.5px] tracking-[0.04em] text-muted-foreground">私有</span>
<strong className="my-0.5 text-[28px] font-bold leading-[1.2] tracking-[-0.03em] text-card-foreground max-md:text-[22px]">
{privateCount}
</strong>
<span className="text-xs leading-normal text-muted-foreground max-md:hidden">
尚未公开,仅自己可见
</span>
</Card>
</section>
<Card className="pn-al-card min-w-0 flex-1 overflow-hidden" styles={{ body: { padding: 0 } }}>
<div className="pn-al-table-head">
<Segmented
value={filter}
onChange={(v) => setFilter(v as StatusFilter)}
className="min-w-0"
options={[
{ value: 'all', label: <>全部<Badge count={items.length} showZero size="small" className="pn-al-tab-badge" /></> },
{ value: 'public', label: <>已发布<Badge count={publicCount} showZero size="small" className="pn-al-tab-badge" /></> },
{ value: 'private', label: <>私有<Badge count={privateCount} showZero size="small" className="pn-al-tab-badge" /></> },
]}
/>
<span className="pn-al-table-note">共 {visible.length} 篇笔记</span>
</div>
<Table
className="pn-al-table"
columns={columns}
dataSource={visible}
rowKey="id"
loading={isLoading}
pagination={false}
scroll={{ x: 600 }}
rowClassName={(n) => (n.status === 'public' ? '' : 'pn-al-is-draft')}
locale={{
emptyText: (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={items.length === 0 ? '还没有笔记,点击「新建笔记」开始' : '没有符合筛选条件的笔记'}
/>
),
}}
/>
<div className="pn-al-table-foot">
<span className="pn-al-foot-note">共 {data?.total ?? 0} 篇 · 显示 {visible.length} 篇</span>
</div>
</Card>
</div>
{isLoading ? (
<p className="py-12 text-center text-zinc-400">加载中…</p>
) : !data || data.items.length === 0 ? (
<p className="py-12 text-center text-zinc-400">还没有笔记,点击「新建」开始</p>
) : (
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-900 dark:border-zinc-800">
{data.items.map((n) => (
<li key={n.id} className="flex items-center gap-3 px-4 py-3">
<span
className={`rounded px-1.5 py-0.5 text-xs ${
n.status === 'public'
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400'
: 'bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400'
}`}
>
{n.status === 'public' ? '公开' : '私有'}
</span>
{n.pinned && <span className="text-xs text-amber-600">置顶</span>}
<div className="min-w-0 flex-1">
<Link to={`/admin/notes/${n.id}/edit`} className="block truncate font-medium hover:text-blue-600">
{n.title}
</Link>
<p className="truncate text-xs text-zinc-400">
/{n.slug} · {formatDateTime(n.updated_at)}
{n.tags.length > 0 && ' · ' + n.tags.map((t) => '#' + t).join(' ')}
</p>
</div>
<button
type="button"
onClick={() => void toggleStatus.mutate(n)}
disabled={toggleStatus.isPending}
className="rounded px-2 py-1 text-xs text-zinc-500 hover:bg-zinc-100 disabled:opacity-50 dark:hover:bg-zinc-800"
>
{n.status === 'public' ? '转私有' : '转公开'}
</button>
<Link
to={`/admin/notes/${n.id}/edit`}
className="rounded p-1.5 text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800"
title="编辑"
>
<Pencil size={15} />
</Link>
<button
type="button"
onClick={() => setConfirmId(n.id)}
className="rounded p-1.5 text-red-500 hover:bg-red-50 dark:hover:bg-red-950"
title="删除(进回收站)"
>
<Trash2 size={15} />
</button>
{confirmId === n.id && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4">
<div className="w-full max-w-sm rounded-xl bg-white p-6 dark:bg-zinc-900">
<h3 className="font-semibold">移入回收站?</h3>
<p className="mt-1 text-sm text-zinc-500">
「{n.title}」将被软删除,30 天内可在回收站恢复。
</p>
{trashMutation.isError && (
<p className="mt-2 text-sm text-red-600">操作失败,请重试</p>
)}
<div className="mt-4 flex justify-end gap-2">
<button
type="button"
onClick={() => setConfirmId(null)}
className="rounded-lg border border-zinc-300 px-3 py-1.5 text-sm dark:border-zinc-700"
>
取消
</button>
<button
type="button"
onClick={() => trashMutation.mutate(n.id)}
disabled={trashMutation.isPending}
className="rounded-lg bg-red-600 px-3 py-1.5 text-sm text-white hover:bg-red-700 disabled:opacity-50"
>
{trashMutation.isPending ? '删除中…' : '确认删除'}
</button>
</div>
</div>
</div>
)}
</li>
))}
</ul>
)}
</div>
<Modal
open={confirmNote !== null}
onCancel={() => setConfirmNote(null)}
title="移入回收站?"
width={380}
centered
maskClosable={false}
cancelText="取消"
okText={trashMutation.isPending ? '删除中…' : '确认删除'}
confirmLoading={trashMutation.isPending}
onOk={() => {
if (confirmNote) trashMutation.mutate(confirmNote.id)
}}
>
<p className="pn-modal-desc">
「{confirmNote?.title}」将被软删除,30 天内可在回收站恢复。
</p>
{trashMutation.isError && <p className="pn-modal-error">操作失败,请重试</p>}
</Modal>
</>
)
}
+185 -91
View File
@@ -1,10 +1,20 @@
import { useEffect, useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Eye, EyeOff, ImageUp, KeyRound, Lock, RotateCcw, Save } from 'lucide-react'
import { Button, Card, Divider, Input, Select, Upload } from 'antd'
import { api, ApiError } from '../lib/api'
import type { SiteSettings } from '../lib/api'
import { invalidatePublic } from '../lib/invalidate'
import { useDocumentTitle } from '../features/site'
import { AdminTopbar } from '../components/Layout'
import { useToast } from '@/hooks/use-toast'
const PAGE_SIZE_OPTIONS = [10, 20, 30, 50].map((n) => ({ value: n, label: `${n} 篇` }))
export default function AdminSettings() {
useDocumentTitle('设置')
const qc = useQueryClient()
const { toast } = useToast()
const { data } = useQuery({
queryKey: ['admin', 'settings'],
queryFn: () => api<SiteSettings>('/api/admin/settings'),
@@ -12,14 +22,17 @@ export default function AdminSettings() {
const [title, setTitle] = useState('')
const [desc, setDesc] = useState('')
const [beian, setBeian] = useState('')
const [pageSize, setPageSize] = useState(10)
const [msg, setMsg] = useState<string | null>(null)
const [logo, setLogo] = useState('')
useEffect(() => {
if (data) {
setTitle(data.site_title)
setDesc(data.site_desc)
setBeian(data.beian_no)
setPageSize(data.page_size)
setLogo(data.site_logo ?? '')
}
}, [data])
@@ -27,75 +40,137 @@ export default function AdminSettings() {
mutationFn: () =>
api('/api/admin/settings', {
method: 'PUT',
body: JSON.stringify({ site_title: title, site_desc: desc, page_size: pageSize }),
body: JSON.stringify({ site_title: title, site_desc: desc, beian_no: beian, page_size: pageSize, site_logo: logo }),
}),
onSuccess: () => {
setMsg('已保存')
toast({ description: '设置已保存' })
void qc.invalidateQueries({ queryKey: ['site'] })
invalidatePublic(qc) // page_size 等设置影响公共列表
},
onError: (e) => setMsg(e instanceof ApiError ? e.message : '保存失败'),
onError: (e) => toast({ description: e instanceof ApiError ? e.message : '保存失败', variant: 'error' }),
})
return (
<div className="max-w-lg space-y-10">
<section>
<h1 className="mb-4 text-lg font-semibold">站点设置</h1>
<div className="space-y-4">
<div>
<label className="text-xs font-medium text-zinc-500" htmlFor="site-title">站点标题</label>
<input
id="site-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="mt-1 w-full rounded-lg border border-zinc-300 bg-transparent px-3 py-2 text-sm outline-none focus:border-blue-500 dark:border-zinc-700"
/>
</div>
<div>
<label className="text-xs font-medium text-zinc-500" htmlFor="site-desc">副标题 / 描述</label>
<textarea
id="site-desc"
value={desc}
onChange={(e) => setDesc(e.target.value)}
rows={2}
className="mt-1 w-full resize-none rounded-lg border border-zinc-300 bg-transparent px-3 py-2 text-sm outline-none focus:border-blue-500 dark:border-zinc-700"
/>
</div>
<div>
<label className="text-xs font-medium text-zinc-500" htmlFor="page-size">首页每页条数(1–100)</label>
<input
id="page-size"
type="number"
min={1}
max={100}
value={pageSize}
onChange={(e) => setPageSize(Number(e.target.value))}
className="mt-1 w-28 rounded-lg border border-zinc-300 bg-transparent px-3 py-2 text-sm outline-none focus:border-blue-500 dark:border-zinc-700"
/>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => saveSettings.mutate()}
disabled={saveSettings.isPending}
className="rounded-lg bg-zinc-900 px-4 py-1.5 text-sm font-medium text-white disabled:opacity-50 dark:bg-zinc-100 dark:text-zinc-900"
>
{saveSettings.isPending ? '保存中…' : '保存设置'}
</button>
{msg && <span className="text-sm text-zinc-500">{msg}</span>}
</div>
</div>
</section>
// antd Upload customRequest 的最小结构化类型(避免引入 rc-upload 内部类型路径)
const uploadLogo = async ({ file, onSuccess, onError }: {
file: unknown
onSuccess?: (ret?: unknown, xhr?: XMLHttpRequest) => void
onError?: (event: Error, body?: unknown) => void
}) => {
const fd = new FormData()
fd.append('file', file as File)
try {
const res = await api<{ url: string }>('/api/admin/images', { method: 'POST', body: fd })
setLogo(res.url)
onSuccess?.(res)
toast({ description: 'Logo 已上传,记得保存更改' })
} catch (e) {
onError?.(e instanceof Error ? e : new Error('上传失败'))
toast({ description: e instanceof ApiError ? e.message : '上传失败', variant: 'error' })
}
}
<ChangePassword />
</div>
return (
<>
<AdminTopbar title="站点设置">
<Button
type="primary"
icon={<Save size={15} />}
disabled={saveSettings.isPending}
onClick={() => saveSettings.mutate()}
>
{saveSettings.isPending ? '保存中…' : '保存更改'}
</Button>
</AdminTopbar>
<div className="pn-admin-content pn-as-content">
<Card className="pn-as-card">
<div className="pn-as-card-head">
<h3 className="pn-as-card-title">站点信息</h3>
<p className="pn-as-card-desc">这些信息会显示在博客的公开页面</p>
</div>
<Divider style={{ margin: '16px 0 20px' }} />
<div className="pn-as-fields">
<div className="pn-as-field">
<label htmlFor="site-title" className="pn-field-label">站点名称</label>
<Input
id="site-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
<div className="pn-as-field">
<label htmlFor="site-desc" className="pn-field-label">站点描述</label>
<Input.TextArea
id="site-desc"
rows={3}
styles={{ textarea: { minHeight: 76, padding: '8px 12px', lineHeight: 1.7 } }}
value={desc}
onChange={(e) => setDesc(e.target.value)}
/>
</div>
<div className="pn-as-field">
<label htmlFor="beian-no" className="pn-field-label">公安备案号</label>
<Input
id="beian-no"
placeholder="如:京公网安备11000000000000号"
value={beian}
onChange={(e) => setBeian(e.target.value)}
/>
<p className="pn-as-hint">显示在博客页脚并链接至公安部备案官网;留空则不显示。</p>
</div>
<div className="pn-as-field">
<label htmlFor="page-size" className="pn-field-label">首页每页条数</label>
<Select
id="page-size"
style={{ width: 140 }}
options={PAGE_SIZE_OPTIONS}
value={pageSize}
onChange={(v) => setPageSize(v)}
/>
</div>
<div className="pn-as-field">
<span className="pn-field-label">站点 Logo</span>
<div className="pn-as-logo-row">
{logo ? (
<img className="pn-as-logo-preview" src={logo} alt="站点 Logo 预览" />
) : (
<span className="pn-as-logo-empty">未设置</span>
)}
<Upload
accept="image/png,image/jpeg,image/webp,image/gif"
showUploadList={false}
customRequest={uploadLogo}
>
<Button icon={<ImageUp size={15} />}>{logo ? '更换图片' : '上传图片'}</Button>
</Upload>
{logo && (
<Button
icon={<RotateCcw size={15} />}
onClick={() => setLogo('')}
>
重置默认
</Button>
)}
</div>
<p className="pn-as-hint">显示在博客顶部站点名称前,并用作站点 favicon,建议正方形图片;「重置默认」恢复内置图标。修改后需点击「保存更改」。</p>
</div>
</div>
</Card>
<ChangePassword onToast={toast} />
</div>
</>
)
}
function ChangePassword() {
function ChangePassword({
onToast,
}: {
onToast: ReturnType<typeof useToast>['toast']
}) {
const [oldPw, setOldPw] = useState('')
const [newPw, setNewPw] = useState('')
const [msg, setMsg] = useState<string | null>(null)
const [ok, setOk] = useState(false)
const [showPw, setShowPw] = useState(false)
const change = useMutation({
mutationFn: () =>
@@ -104,57 +179,76 @@ function ChangePassword() {
body: JSON.stringify({ old_password: oldPw, new_password: newPw }),
}),
onSuccess: () => {
setOk(true)
setMsg('密码已修改')
onToast({ description: '密码已修改' })
setOldPw('')
setNewPw('')
},
onError: (e) => {
setOk(false)
if (e instanceof ApiError) {
setMsg(e.code === 'weak_password' ? '新密码长度至少 12 个字符' : e.status === 429 ? '尝试过于频繁,请稍后再试' : '旧密码不正确')
onToast({
description:
e.code === 'weak_password'
? '新密码长度至少 12 个字符'
: e.status === 429
? '尝试过于频繁,请稍后再试'
: '旧密码不正确',
variant: 'error',
})
} else {
setMsg('修改失败')
onToast({ description: '修改失败', variant: 'error' })
}
},
})
return (
<section>
<h2 className="mb-4 text-lg font-semibold">修改密码</h2>
<div className="space-y-4">
<div>
<label className="text-xs font-medium text-zinc-500" htmlFor="old-pw">旧密码</label>
<input
<Card className="pn-as-card">
<div className="pn-as-card-head">
<h3 className="pn-as-card-title">修改密码</h3>
<p className="pn-as-card-desc">更新管理后台的登录密码</p>
</div>
<Divider style={{ margin: '16px 0 20px' }} />
<div className="pn-as-fields">
<div className="pn-as-field">
<label htmlFor="old-pw" className="pn-field-label">旧密码</label>
<Input
id="old-pw"
type="password"
type={showPw ? 'text' : 'password'}
autoComplete="current-password"
prefix={<Lock size={15} className="pn-input-icon" aria-hidden />}
value={oldPw}
onChange={(e) => setOldPw(e.target.value)}
className="mt-1 w-full rounded-lg border border-zinc-300 bg-transparent px-3 py-2 text-sm outline-none focus:border-blue-500 dark:border-zinc-700"
/>
</div>
<div>
<label className="text-xs font-medium text-zinc-500" htmlFor="new-pw">新密码(≥12 字符)</label>
<input
<div className="pn-as-field">
<label htmlFor="new-pw" className="pn-field-label">新密码(≥12 字符)</label>
<Input
id="new-pw"
type="password"
value={newPw}
onChange={(e) => setNewPw(e.target.value)}
className="mt-1 w-full rounded-lg border border-zinc-300 bg-transparent px-3 py-2 text-sm outline-none focus:border-blue-500 dark:border-zinc-700"
type={showPw ? 'text' : 'password'}
autoComplete="new-password"
placeholder="请输入新密码,至少 12 个字符"
prefix={<Lock size={15} className="pn-input-icon" aria-hidden />}
suffix={
<Button
type="text"
size="small"
aria-label={showPw ? '隐藏密码' : '显示密码'}
onClick={() => setShowPw((v) => !v)}
icon={showPw ? <EyeOff size={15} /> : <Eye size={15} />}
/>
}
/>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => change.mutate()}
disabled={change.isPending || oldPw.length === 0 || newPw.length === 0}
className="rounded-lg bg-zinc-900 px-4 py-1.5 text-sm font-medium text-white disabled:opacity-50 dark:bg-zinc-100 dark:text-zinc-900"
>
{change.isPending ? '提交中…' : '修改密码'}
</button>
{msg && <span className={`text-sm ${ok ? 'text-green-600' : 'text-red-600'}`}>{msg}</span>}
<p className="pn-as-hint">新密码长度至少 12 个字符,建议混合字母、数字与符号。</p>
</div>
</div>
</section>
<div className="pn-as-card-foot">
<Button
disabled={change.isPending || oldPw.length === 0 || newPw.length === 0}
onClick={() => change.mutate()}
>
<KeyRound size={15} />
{change.isPending ? '提交中…' : '修改密码'}
</Button>
</div>
</Card>
)
}
+128 -38
View File
@@ -1,11 +1,20 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { RotateCcw } from 'lucide-react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { RotateCcw, Trash2 } from 'lucide-react'
import { Alert, App, Button, Card, Empty, Table, Tag } from 'antd'
import type { TableProps } from 'antd'
import { api } from '../lib/api'
import type { NoteItem } from '../lib/api'
import { formatDateTime } from '../lib/utils'
import { invalidatePublic } from '../lib/invalidate'
import { useDocumentTitle } from '../features/site'
import { daysRemaining, formatDateISO } from '../lib/utils'
import { AdminTopbar } from '../components/Layout'
import { useToast } from '@/hooks/use-toast'
export default function AdminTrash() {
useDocumentTitle('回收站')
const qc = useQueryClient()
const { toast } = useToast()
const { modal } = App.useApp()
const { data, isLoading } = useQuery({
queryKey: ['admin', 'trash'],
queryFn: () => api<{ items: NoteItem[]; total: number }>('/api/admin/trash'),
@@ -13,43 +22,124 @@ export default function AdminTrash() {
const restore = useMutation({
mutationFn: (id: number) => api(`/api/admin/trash/${id}/restore`, { method: 'POST' }),
onSuccess: () => void qc.invalidateQueries({ queryKey: ['admin'] }),
onSuccess: () => {
toast({ description: '笔记已恢复' })
void qc.invalidateQueries({ queryKey: ['admin'] })
invalidatePublic(qc)
},
onError: () => toast({ description: '恢复失败,请重试', variant: 'error' }),
})
const clear = useMutation({
mutationFn: () => api<{ deleted: number }>('/api/admin/trash', { method: 'DELETE' }),
onSuccess: (res) => {
toast({ description: `已清空回收站(${res.deleted} 篇)` })
void qc.invalidateQueries({ queryKey: ['admin'] })
invalidatePublic(qc)
},
onError: () => toast({ description: '操作失败,请重试', variant: 'error' }),
})
const confirmEmpty = () => {
modal.confirm({
title: '清空回收站?',
content: `将永久删除回收站中的 ${items.length} 篇笔记,此操作不可恢复。`,
okText: '清空',
okButtonProps: { danger: true },
cancelText: '取消',
onOk: () => clear.mutateAsync(),
})
}
const items = data?.items ?? []
const columns: TableProps<NoteItem>['columns'] = [
{
title: '标题',
key: 'title',
className: 'pn-at-title-cell',
render: (_, n) => (
<>
<span className="pn-at-note-title">{n.title}</span>
<span className="pn-at-note-slug">/{n.slug}</span>
</>
),
},
{
title: '删除时间',
key: 'deleted',
render: (_, n) => (
<span className="pn-at-mono">{n.deleted_at ? formatDateISO(n.deleted_at) : '—'}</span>
),
},
{
title: '剩余天数',
key: 'days',
render: (_, n) => {
const left = n.deleted_at ? daysRemaining(n.deleted_at) : 0
return left <= 3 ? <Tag color="red">{left} 天</Tag> : <span className="pn-at-days">{left} 天</span>
},
},
{
title: '操作',
key: 'actions',
className: 'pn-al-col-actions',
render: (_, n) => (
<div className="pn-at-actions">
<Button
type="text"
size="small"
icon={<RotateCcw size={14} />}
disabled={restore.isPending}
onClick={() => restore.mutate(n.id)}
>
恢复
</Button>
</div>
),
},
]
return (
<div>
<h1 className="mb-4 text-lg font-semibold">回收站({data?.total ?? 0})</h1>
<p className="mb-4 text-sm text-zinc-400">软删除的笔记保留 30 天,到期由每日 gc 物理清除;恢复后图片引用天然保全。</p>
{isLoading ? (
<p className="py-12 text-center text-zinc-400">加载中…</p>
) : !data || data.items.length === 0 ? (
<p className="py-12 text-center text-zinc-400">回收站是空的</p>
) : (
<ul className="divide-y divide-zinc-100 rounded-lg border border-zinc-200 dark:divide-zinc-900 dark:border-zinc-800">
{data.items.map((n) => (
<li key={n.id} className="flex items-center gap-3 px-4 py-3">
<span className="rounded bg-zinc-100 px-1.5 py-0.5 text-xs text-zinc-500 dark:bg-zinc-800">
{n.status === 'public' ? '公开' : '私有'}
</span>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{n.title}</p>
<p className="truncate text-xs text-zinc-400">
/{n.slug} · 删除于 {formatDateTime(n.deleted_at ?? 0)}
</p>
</div>
<button
type="button"
onClick={() => restore.mutate(n.id)}
disabled={restore.isPending}
className="inline-flex items-center gap-1 rounded px-2 py-1 text-xs text-zinc-600 hover:bg-zinc-100 disabled:opacity-50 dark:text-zinc-400 dark:hover:bg-zinc-800"
>
<RotateCcw size={13} /> 恢复
</button>
</li>
))}
</ul>
)}
{restore.isError && <p className="mt-3 text-sm text-red-600">恢复失败,请重试</p>}
</div>
<>
<AdminTopbar title="回收站">
<Button
danger
icon={<Trash2 size={15} />}
disabled={isLoading || items.length === 0 || clear.isPending}
onClick={confirmEmpty}
>
清空回收站
</Button>
</AdminTopbar>
<div className="pn-admin-content">
<Alert
role="note"
type="info"
showIcon
message="回收站中的笔记会在删除 30 天后永久删除,请及时恢复需要的内容。"
/>
<Card className="pn-al-card min-w-0 flex-1 overflow-hidden" styles={{ body: { padding: 0 } }}>
<Table
className="pn-al-table"
columns={columns}
dataSource={items}
rowKey="id"
loading={isLoading}
pagination={false}
scroll={{ x: 560 }}
locale={{
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="回收站是空的" />,
}}
/>
<div className="pn-al-table-foot">
<span className="pn-al-foot-note">共 {data?.total ?? 0} 篇已删除笔记</span>
</div>
</Card>
</div>
</>
)
}
+66 -93
View File
@@ -1,126 +1,99 @@
import { useState } from 'react'
import { Link } from 'react-router'
import { useQuery } from '@tanstack/react-query'
import { Pin } from 'lucide-react'
import { ArrowRight, Pin } from 'lucide-react'
import { Empty, Flex, Pagination, Skeleton, Tag } from 'antd'
import { api } from '../lib/api'
import type { NoteItem, NoteList, TagCount } from '../lib/api'
import { formatDate } from '../lib/utils'
import type { NoteItem, NoteList } from '../lib/api'
import { useDocumentTitle } from '../features/site'
import { formatDateISO } from '../lib/utils'
function useNotes(page: number, tag?: string) {
const qs = new URLSearchParams({ page: String(page), page_size: '10' })
if (tag) qs.set('tag', tag)
function useNotes(page: number) {
// 不传 page_size:服务端默认值即站点设置(管理员可改),首页随设置生效
return useQuery({
queryKey: ['notes', page, tag ?? ''],
queryFn: () => api<NoteList>(`/api/notes?${qs}`),
queryKey: ['notes', page],
queryFn: () => api<NoteList>(`/api/notes?page=${page}`),
})
}
function useTags() {
return useQuery({
queryKey: ['tags'],
queryFn: () => api<{ tags: TagCount[] }>('/api/tags'),
})
}
function NoteCard({ note }: { note: NoteItem }) {
function NotePost({ note }: { note: NoteItem }) {
return (
<article className="group border-b border-zinc-100 py-6 first:pt-0 dark:border-zinc-900">
<div className="flex items-center gap-2">
{note.pinned && (
<span className="inline-flex items-center gap-1 rounded bg-amber-100 px-1.5 py-0.5 text-xs text-amber-700 dark:bg-amber-900/40 dark:text-amber-400">
<Pin size={11} /> 置顶
</span>
)}
<time className="text-xs text-zinc-400">{formatDate(note.updated_at)}</time>
</div>
<h2 className="mt-1 text-xl font-semibold leading-snug">
<article className="pn-home-post">
{note.cover && (
<Link
to={`/notes/${encodeURIComponent(note.slug)}`}
className="hover:text-blue-600 dark:hover:text-blue-400"
className="pn-home-cover"
tabIndex={-1}
aria-hidden
>
{note.title}
<img src={note.cover} alt="" loading="lazy" />
</Link>
</h2>
{note.summary && (
<p className="mt-2 line-clamp-3 text-sm leading-relaxed text-zinc-600 dark:text-zinc-400">
{note.summary}
</p>
)}
{note.tags.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{note.tags.map((t) => (
<Link
key={t}
to={`/tags/${encodeURIComponent(t)}`}
className="rounded-full bg-zinc-100 px-2.5 py-0.5 text-xs text-zinc-600 hover:bg-zinc-200 dark:bg-zinc-800 dark:text-zinc-400 dark:hover:bg-zinc-700"
>
#{t}
</Link>
))}
</div>
)}
<div className="pn-home-post-main">
<time className="pn-home-date" dateTime={formatDateISO(note.published_at)}>
{note.pinned && (
<span className="pn-home-pin">
<Pin size={11} aria-hidden />置顶
</span>
)}
{formatDateISO(note.published_at)}
</time>
<h2 className="pn-home-post-title">
<Link to={`/notes/${encodeURIComponent(note.slug)}`}>
{note.title}
<ArrowRight className="pn-home-arrow" size={17} aria-hidden />
</Link>
</h2>
{note.summary && <p className="pn-home-summary">{note.summary}</p>}
{note.tags.length > 0 && (
<div className="pn-home-tags">
{note.tags.map((t) => (
<Tag key={t}>
<Link className="pn-tag-link" to={`/tags/${encodeURIComponent(t)}`}>{t}</Link>
</Tag>
))}
</div>
)}
</div>
</article>
)
}
export default function Home() {
const [page, setPage] = useState(1)
useDocumentTitle()
const { data, isLoading } = useNotes(page)
const { data: tagsData } = useTags()
const totalPages = data ? Math.max(1, Math.ceil(data.total / data.page_size)) : 1
return (
<div className="flex flex-col gap-8 lg:flex-row-reverse">
<aside className="lg:w-48 lg:shrink-0">
{tagsData && tagsData.tags.length > 0 && (
<div className="rounded-lg border border-zinc-200 p-4 dark:border-zinc-800">
<h3 className="mb-2 text-xs font-medium uppercase tracking-wider text-zinc-400">标签</h3>
<div className="flex flex-wrap gap-2 lg:flex-col lg:gap-1">
{tagsData.tags.map((t) => (
<Link
key={t.name}
to={`/tags/${encodeURIComponent(t.name)}`}
className="text-sm text-zinc-600 hover:text-blue-600 dark:text-zinc-400 dark:hover:text-blue-400"
>
#{t.name} <span className="text-zinc-400">({t.count})</span>
</Link>
))}
</div>
</div>
)}
</aside>
<section className="min-w-0 flex-1">
<div className="pn-home-content">
<section aria-label="笔记列表">
{isLoading ? (
<p className="py-12 text-center text-zinc-400">加载中…</p>
<Flex vertical gap={44} className="pn-home-skeleton">
{[0, 1, 2].map((i) => (
<Skeleton key={i} active title paragraph={{ rows: 2 }} />
))}
</Flex>
) : !data || data.items.length === 0 ? (
<p className="py-12 text-center text-zinc-400">还没有公开的笔记</p>
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="还没有公开的笔记" />
) : (
data.items.map((n) => <NoteCard key={n.slug} note={n} />)
)}
{totalPages > 1 && (
<nav className="mt-8 flex items-center justify-between text-sm">
<button
type="button"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
className="rounded px-3 py-1.5 disabled:opacity-40 hover:bg-zinc-100 dark:hover:bg-zinc-800"
>
← 上一页
</button>
<span className="text-zinc-400">
{page} / {totalPages}
</span>
<button
type="button"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
className="rounded px-3 py-1.5 disabled:opacity-40 hover:bg-zinc-100 dark:hover:bg-zinc-800"
>
下一页 →
</button>
</nav>
data.items.map((n) => <NotePost key={n.slug} note={n} />)
)}
</section>
{totalPages > 1 && (
<nav className="pn-home-pager" aria-label="分页">
<Pagination
size="small"
current={page}
pageSize={data?.page_size ?? 10}
total={data?.total ?? 0}
showSizeChanger={false}
showTotal={() => `共 ${data?.total ?? 0} 篇 · 第 ${page} 页 / 共 ${totalPages} 页`}
onChange={(p) => setPage(p)}
/>
</nav>
)}
</div>
)
}
+81 -33
View File
@@ -1,13 +1,17 @@
import { useState } from 'react'
import { useNavigate } from 'react-router'
import { Lock } from 'lucide-react'
import { ArrowLeft, ArrowRight, Eye, EyeOff, Lock } from 'lucide-react'
import { Alert, Button, Card, Input } from 'antd'
import { useAuth } from '../lib/auth'
import { ApiError } from '../lib/api'
import { useDocumentTitle } from '../features/site'
export default function Login() {
useDocumentTitle('管理员登录')
const { login, authenticated } = useAuth()
const navigate = useNavigate()
const [password, setPassword] = useState('')
const [showPw, setShowPw] = useState(false)
const [error, setError] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
@@ -24,7 +28,9 @@ export default function Login() {
navigate('/admin', { replace: true })
} catch (err) {
if (err instanceof ApiError) {
setError(err.status === 429 ? '尝试过于频繁,请稍后再试' : '密码错误')
if (err.status === 429) setError('尝试过于频繁,请稍后再试')
else if (err.status === 403) setError('请求被安全策略拦截,请刷新页面后重试')
else setError('密码错误,请重试')
} else {
setError('登录失败,请重试')
}
@@ -34,38 +40,80 @@ export default function Login() {
}
return (
<div className="flex min-h-screen items-center justify-center px-4">
<form
onSubmit={onSubmit}
className="w-full max-w-sm rounded-xl border border-zinc-200 p-8 dark:border-zinc-800"
<div className="pn-login-wrap">
<div className="pn-login-brand">
<span className="pn-login-eyebrow">ADMIN</span>
<span className="pn-login-wordmark">Pure Note</span>
</div>
<Card className="pn-login-card" styles={{ body: { padding: 0 } }}>
<form onSubmit={onSubmit}>
<h1 className="pn-login-title">管理员登录</h1>
<label htmlFor="pn-login-username" className="sr-only">用户名</label>
<input
id="pn-login-username"
className="sr-only"
type="text"
name="username"
autoComplete="username"
value="admin"
readOnly
tabIndex={-1}
/>
<label htmlFor="pn-login-password" className="sr-only">管理密码</label>
<Input
id="pn-login-password"
size="large"
variant="outlined"
type={showPw ? 'text' : 'password'}
name="password"
placeholder="请输入管理密码"
autoComplete="current-password"
autoFocus
required
value={password}
onChange={(e) => setPassword(e.target.value)}
prefix={<Lock size={16} className="pn-login-lock" aria-hidden />}
suffix={
<Button
type="text"
size="small"
aria-label={showPw ? '隐藏密码' : '显示密码'}
onClick={() => setShowPw((v) => !v)}
icon={showPw ? <EyeOff size={16} /> : <Eye size={16} />}
/>
}
/>
{error && (
<Alert role="alert" type="error" showIcon message={error} className="pn-login-alert" />
)}
<Button
htmlType="submit"
type="primary"
size="large"
block
className="mt-5"
disabled={submitting || password.length === 0}
>
<span>{submitting ? '登录中…' : '登录'}</span>
<ArrowRight size={16} />
</Button>
</form>
</Card>
<Button
type="text"
size="small"
className="pn-login-back"
icon={<ArrowLeft size={14} />}
onClick={() => navigate('/')}
>
<div className="mb-6 flex items-center justify-center gap-2 text-zinc-500">
<Lock size={18} />
<h1 className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">管理员登录</h1>
</div>
<label className="block text-sm text-zinc-600 dark:text-zinc-400" htmlFor="password">
口令
</label>
<input
id="password"
type="password"
autoFocus
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="mt-1.5 w-full rounded-lg border border-zinc-300 bg-transparent px-3 py-2 text-sm outline-none focus:border-blue-500 dark:border-zinc-700"
placeholder="输入管理员口令"
/>
{error && <p className="mt-2 text-sm text-red-600 dark:text-red-400">{error}</p>}
<button
type="submit"
disabled={submitting || password.length === 0}
className="mt-4 w-full rounded-lg bg-zinc-900 py-2 text-sm font-medium text-white hover:bg-zinc-800 disabled:opacity-50 dark:bg-zinc-100 dark:text-zinc-900 dark:hover:bg-zinc-200"
>
{submitting ? '登录中…' : '登录'}
</button>
<p className="mt-4 text-center text-xs text-zinc-400">连续失败将被暂时锁定</p>
</form>
返回博客
</Button>
</div>
)
}
+15 -7
View File
@@ -1,13 +1,21 @@
import { Link } from 'react-router'
import { useNavigate } from 'react-router'
import { ArrowLeft } from 'lucide-react'
import { Button } from 'antd'
import { useDocumentTitle } from '../features/site'
export default function NotFound() {
useDocumentTitle('页面不存在')
const navigate = useNavigate()
return (
<div className="flex min-h-[60vh] flex-col items-center justify-center">
<p className="text-6xl font-bold text-zinc-200 dark:text-zinc-800">404</p>
<p className="mt-4 text-zinc-500">页面不存在</p>
<Link to="/" className="mt-6 text-sm text-blue-600 hover:underline">
← 返回首页
</Link>
<div className="pn-404-page">
<p className="pn-404-code">404</p>
<h1 className="pn-404-title">页面不存在</h1>
<p className="pn-404-desc">这篇笔记可能已被删除,或链接有误。</p>
<div className="pn-404-actions">
<Button type="primary" size="small" icon={<ArrowLeft size={16} />} onClick={() => navigate('/')}>
返回首页
</Button>
</div>
</div>
)
}
+73 -47
View File
@@ -1,86 +1,112 @@
import { Link, useParams } from 'react-router'
import { Link, useNavigate, useParams } from 'react-router'
import { useQuery } from '@tanstack/react-query'
import { AlertTriangle } from 'lucide-react'
import { ArrowLeft, ArrowRight, Clock, Eye } from 'lucide-react'
import { Alert, Button, Card, Empty, Skeleton, Tag } from 'antd'
import { api } from '../lib/api'
import type { NoteDetail } from '../lib/api'
import { useAuth } from '../lib/auth'
import { useDocumentTitle } from '../features/site'
import MarkdownViewer from '../components/MarkdownViewer'
import { formatDate } from '../lib/utils'
import { formatDateISO, readingMinutes } from '../lib/utils'
export default function Note() {
const { slug = '' } = useParams()
const navigate = useNavigate()
const { authenticated } = useAuth()
const { data: note, isLoading, error } = useQuery({
queryKey: ['note', slug],
queryFn: () => api<NoteDetail>(`/api/notes/${encodeURIComponent(slug)}`),
retry: false,
})
useDocumentTitle(note?.title)
if (isLoading) return <p className="py-12 text-center text-zinc-400">加载中…</p>
if (isLoading) {
return (
<article className="pn-note-article">
<Skeleton active title paragraph={{ rows: 6 }} />
</article>
)
}
if (error || !note) {
return (
<div className="py-12 text-center">
<p className="text-4xl font-bold text-zinc-300 dark:text-zinc-700">404</p>
<p className="mt-2 text-zinc-500">笔记不存在或未公开</p>
<Link to="/" className="mt-4 inline-block text-sm text-blue-600 hover:underline">
← 返回首页
</Link>
</div>
<article className="pn-note-article">
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="笔记不存在或未公开">
<Button
type="text"
size="small"
className="pn-note-back"
icon={<ArrowLeft size={14} />}
onClick={() => navigate('/')}
>
返回首页
</Button>
</Empty>
</article>
)
}
const privatePreview = note.status === 'private'
return (
<article>
<nav className="mb-4 text-xs text-zinc-400">
<Link to="/" className="hover:text-zinc-600">首页</Link>
<span className="mx-1.5">/</span>
<span>{note.title}</span>
</nav>
<article className="pn-note-article">
<Button type="text" size="small" className="pn-note-back" icon={<ArrowLeft size={14} />} onClick={() => navigate('/')}>
返回首页
</Button>
{privatePreview && authenticated && (
<div className="mb-6 flex items-center gap-2 rounded-lg border border-amber-300 bg-amber-50 px-4 py-2.5 text-sm text-amber-800 dark:border-amber-800 dark:bg-amber-950 dark:text-amber-300">
<AlertTriangle size={16} />
私有预览:此笔记未公开,仅管理员可见
</div>
<Alert
role="note"
type="info"
showIcon={false}
className="pn-note-preview-alert"
message={
<>
<Eye size={15} />
私有预览:此笔记未公开,仅管理员可见
</>
}
/>
)}
<header className="mb-8">
<h1 className="text-3xl font-bold leading-tight">{note.title}</h1>
<div className="mt-2 flex flex-wrap items-center gap-3 text-xs text-zinc-400">
<time>{formatDate(note.created_at)}</time>
{note.tags.map((t) => (
<Link
key={t}
to={`/tags/${encodeURIComponent(t)}`}
className="rounded-full bg-zinc-100 px-2 py-0.5 hover:bg-zinc-200 dark:bg-zinc-800 dark:hover:bg-zinc-700"
>
#{t}
</Link>
))}
</div>
</header>
<h1 className="pn-note-title">{note.title}</h1>
<div className="pn-note-meta">
<time className="pn-note-meta-date" dateTime={formatDateISO(note.published_at)}>
{formatDateISO(note.published_at)}
</time>
<span className="pn-note-meta-sep">·</span>
<span className="pn-note-meta-read"><Clock size={14} />约 {readingMinutes(note.content)} 分钟阅读</span>
{note.tags.length > 0 && (
<>
<span className="pn-note-meta-sep">·</span>
{note.tags.map((t) => (
<Tag key={t}>
<Link className="pn-tag-link" to={`/tags/${encodeURIComponent(t)}`}>{t}</Link>
</Tag>
))}
</>
)}
</div>
<MarkdownViewer source={note.content} />
<nav className="mt-12 flex justify-between gap-4 border-t border-zinc-100 pt-6 text-sm dark:border-zinc-900">
<nav className="pn-note-postnav" aria-label="笔记导航">
{note.prev ? (
<Link
to={`/notes/${encodeURIComponent(note.prev.slug)}`}
className="text-zinc-600 hover:text-blue-600 dark:text-zinc-400"
>
← {note.prev.title}
<Link to={`/notes/${encodeURIComponent(note.prev.slug)}`} className="pn-note-nav-link">
<Card className="pn-note-nav-card" styles={{ body: { padding: 0 } }}>
<span className="pn-note-nav-label"><ArrowLeft size={14} />上一篇</span>
<span className="pn-note-nav-title">{note.prev.title}</span>
</Card>
</Link>
) : (
<span />
)}
{note.next ? (
<Link
to={`/notes/${encodeURIComponent(note.next.slug)}`}
className="text-right text-zinc-600 hover:text-blue-600 dark:text-zinc-400"
>
{note.next.title} →
<Link to={`/notes/${encodeURIComponent(note.next.slug)}`} className="pn-note-nav-link">
<Card className="pn-note-nav-card pn-note-nav-next" styles={{ body: { padding: 0 } }}>
<span className="pn-note-nav-label">下一篇<ArrowRight size={14} /></span>
<span className="pn-note-nav-title">{note.next.title}</span>
</Card>
</Link>
) : (
<span />
+83 -35
View File
@@ -1,45 +1,93 @@
import { Link, useParams } from 'react-router'
import { Link, useNavigate, useParams } from 'react-router'
import { useQuery } from '@tanstack/react-query'
import { ArrowRight, Tag } from 'lucide-react'
import { Empty, Skeleton, Spin, Tag as AntTag } from 'antd'
import { api } from '../lib/api'
import type { NoteList } from '../lib/api'
import { formatDate } from '../lib/utils'
import type { NoteList, TagCount } from '../lib/api'
import { useDocumentTitle } from '../features/site'
import { formatDateISO } from '../lib/utils'
function useTags() {
return useQuery({
queryKey: ['tags'],
queryFn: () => api<{ tags: TagCount[] }>('/api/tags'),
})
}
export default function TagPage() {
const { tag = '' } = useParams()
const { data, isLoading } = useQuery({
queryKey: ['tag', tag],
queryFn: () => api<NoteList>(`/api/notes?tag=${encodeURIComponent(tag)}&page=1&page_size=100`),
const { tag } = useParams()
const navigate = useNavigate()
useDocumentTitle(tag ? `标签:${tag}` : '标签')
const { data: tagsData } = useTags()
const { data, isLoading, error } = useQuery({
queryKey: ['tag', tag ?? ''],
queryFn: () =>
api<NoteList>(`/api/notes?tag=${encodeURIComponent(tag ?? '')}&page=1&page_size=100`),
enabled: tag !== undefined,
})
return (
<div>
<header className="mb-6">
<nav className="mb-2 text-xs text-zinc-400">
<Link to="/" className="hover:text-zinc-600">首页</Link>
<span className="mx-1.5">/</span>
<span>标签</span>
</nav>
<h1 className="text-2xl font-bold">#{tag}</h1>
<p className="mt-1 text-sm text-zinc-400">{data?.total ?? 0} 篇</p>
</header>
{isLoading ? (
<p className="py-12 text-center text-zinc-400">加载中…</p>
) : !data || data.items.length === 0 ? (
<p className="py-12 text-center text-zinc-400">该标签下暂无公开笔记</p>
) : (
<ul className="divide-y divide-zinc-100 dark:divide-zinc-900">
{data.items.map((n) => (
<li key={n.slug} className="py-4">
<Link
to={`/notes/${encodeURIComponent(n.slug)}`}
className="font-medium hover:text-blue-600 dark:hover:text-blue-400"
>
{n.title}
</Link>
<time className="ml-3 text-xs text-zinc-400">{formatDate(n.updated_at)}</time>
</li>
))}
</ul>
<div className="pn-tag-content">
<section className="pn-tag-pagehead">
<h1>标签</h1>
<p>按主题浏览所有公开笔记</p>
</section>
{/* 单选 CheckableTagGroup:选中态由组件承接;再次点击已选标签 = 取消(回全部) */}
<AntTag.CheckableTagGroup
multiple={false}
className="pn-tag-cloud"
value={tag ?? null}
onChange={(v) => navigate(v === null ? '/tags' : `/tags/${encodeURIComponent(v)}`)}
options={(tagsData?.tags ?? []).map((t) => ({
value: t.name,
className: 'pn-cloud-tag',
label: (
<>
<Tag size={14} />
<span>{t.name}</span>
<span className="pn-cloud-count">{t.count}</span>
</>
),
}))}
aria-label="全部标签"
/>
{!tagsData && (
<div className="pn-tag-cloud"><Spin size="small" /></div>
)}
{tagsData && tagsData.tags.length === 0 && (
<div className="pn-tag-empty">
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="还没有标签" />
</div>
)}
{tag === undefined && tagsData && (
<p className="pn-home-list-empty" style={{ marginTop: 48 }}>选择一个标签,查看对应笔记</p>
)}
{tag && (
<section className="pn-tag-groups">
<section>
<h3 className="pn-tag-group-title"><Tag size={16} />{tag}</h3>
<div className="pn-tag-rows">
{isLoading ? (
<Skeleton active title paragraph={{ rows: 3 }} />
) : error ? (
<p className="pn-home-list-empty">加载失败,请稍后重试</p>
) : !data || data.items.length === 0 ? (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="该标签下暂无公开笔记" />
) : (
data.items.map((n) => (
<Link key={n.slug} to={`/notes/${encodeURIComponent(n.slug)}`} className="pn-tag-row">
<time className="pn-tag-date">{formatDateISO(n.updated_at)}</time>
<span className="pn-tag-title">{n.title}</span>
<span className="pn-tag-arrow" aria-hidden><ArrowRight size={16} /></span>
</Link>
))
)}
</div>
</section>
</section>
)}
</div>
)
+415
View File
@@ -0,0 +1,415 @@
import { useMemo } from 'react'
import { theme } from 'antd'
import type { ConfigProviderProps } from 'antd'
import { createStyles } from 'antd-style'
import { clsx } from 'clsx'
/**
* ConfigProvider 主题:把应用的黑白灰 zinc 体系映射到 antd token。
* 浅色 token 与设计稿 theme-vars 一致;深色走 darkAlgorithm 并覆盖
* index.css `.dark` 中定义的语义色。组件样式跟随 antd,本文件只做
* 品牌色/圆角/阴影的对齐,不再逐像素复刻旧手搓样式。
*/
const useStyles = createStyles(({ css }) => {
return {
buttonPrimaryLight: css({
backgroundColor: '#18181b',
color: '#ffffff',
border: '1px solid #18181b',
fontWeight: 500,
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
}),
buttonPrimaryDark: css({
backgroundColor: '#fafafa',
color: '#18181b',
border: '1px solid #fafafa',
fontWeight: 500,
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
}),
buttonDefaultLight: css({
backgroundColor: '#ffffff',
color: '#18181b',
border: '1px solid #e4e4e7',
fontWeight: 500,
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
}),
buttonDefaultDark: css({
backgroundColor: '#27272a',
color: '#fafafa',
border: '1px solid #3f3f46',
fontWeight: 500,
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
}),
buttonDangerLight: css({
backgroundColor: '#dc2626',
color: '#ffffff',
border: '1px solid #dc2626',
fontWeight: 500,
}),
buttonDangerDark: css({
backgroundColor: '#ef4444',
color: '#fafafa',
border: '1px solid #ef4444',
fontWeight: 500,
}),
inputRootLight: css({
borderColor: '#e4e4e7',
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
}),
inputRootDark: css({
borderColor: '#3f3f46',
transition: 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)',
}),
inputElementLight: css({
color: '#18181b',
}),
inputElementDark: css({
color: '#fafafa',
}),
inputErrorLight: css({
borderColor: '#dc2626',
}),
inputErrorDark: css({
borderColor: '#ef4444',
}),
selectRoot: css({
borderColor: '#e4e4e7',
}),
notificationRootLight: css({
'&.ant-notification-notice, & .ant-notification-notice': {
border: '1px solid #e4e4e7',
borderRadius: 8,
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)',
},
}),
notificationRootDark: css({
'&.ant-notification-notice, & .ant-notification-notice': {
border: '1px solid #3f3f46',
borderRadius: 8,
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -2px rgba(0, 0, 0, 0.3)',
},
}),
notificationTitle: css({
fontWeight: 600,
}),
notificationDescriptionLight: css({
color: '#525252',
}),
notificationDescriptionDark: css({
color: '#d4d4d8',
}),
}
})
/* eslint-disable react-hooks/exhaustive-deps */
const useShadcnTheme = (dark: boolean) => {
const { styles } = useStyles()
return useMemo<ConfigProviderProps>(
() => ({
theme: {
algorithm: dark ? theme.darkAlgorithm : theme.defaultAlgorithm,
token: dark
? {
// 深色:与 index.css `.dark` 语义变量对齐(zinc 深色 ramp)
fontFamily: 'var(--font-sans)',
// 控件高度对齐设计稿:普通按钮 34 / 紧凑 24 / 登录大按钮 44
controlHeight: 34,
controlHeightSM: 24,
controlHeightLG: 44,
colorPrimary: '#fafafa',
colorSuccess: '#22c55e',
colorWarning: '#f97316',
colorError: '#ef4444',
colorInfo: '#e4e4e7',
colorTextBase: '#fafafa',
colorBgBase: '#18181b',
colorPrimaryBg: '#27272a',
colorPrimaryBgHover: '#3f3f46',
colorPrimaryBorder: '#3f3f46',
colorPrimaryBorderHover: '#52525b',
colorPrimaryHover: '#ffffff',
colorPrimaryActive: '#d4d4d8',
colorPrimaryText: '#fafafa',
colorPrimaryTextHover: '#ffffff',
colorPrimaryTextActive: '#e4e4e7',
colorSuccessBg: '#0f2417',
colorSuccessBgHover: '#163321',
colorSuccessBorder: '#166534',
colorSuccessBorderHover: '#15803d',
colorSuccessHover: '#16a34a',
colorSuccessActive: '#15803d',
colorSuccessText: '#4ade80',
colorSuccessTextHover: '#4ade80',
colorSuccessTextActive: '#22c55e',
colorWarningBg: '#271a0c',
colorWarningBgHover: '#33220f',
colorWarningBorder: '#9a3412',
colorWarningBorderHover: '#c2410c',
colorWarningHover: '#ea580c',
colorWarningActive: '#c2410c',
colorWarningText: '#fb923c',
colorWarningTextHover: '#fb923c',
colorWarningTextActive: '#f97316',
colorErrorBg: '#2a1416',
colorErrorBgHover: '#38191c',
colorErrorBorder: '#7f1d1d',
colorErrorBorderHover: '#991b1b',
colorErrorHover: '#dc2626',
colorErrorActive: '#b91c1c',
colorErrorText: '#fca5a5',
colorErrorTextHover: '#fca5a5',
colorErrorTextActive: '#f87171',
colorInfoBg: '#27272a',
colorInfoBgHover: '#3f3f46',
colorInfoBorder: '#3f3f46',
colorInfoBorderHover: '#52525b',
colorInfoHover: '#f4f4f5',
colorInfoActive: '#e4e4e7',
colorInfoText: '#fafafa',
colorInfoTextHover: '#f4f4f5',
colorInfoTextActive: '#e4e4e7',
colorText: '#fafafa',
colorTextSecondary: '#d4d4d8',
colorTextTertiary: '#a1a1aa',
colorTextQuaternary: '#71717a',
colorTextDisabled: '#71717a',
colorBgContainer: '#27272a',
colorBgElevated: '#27272a',
colorBgLayout: '#18181b',
colorBgSpotlight: 'rgba(250, 250, 250, 0.85)',
colorBgMask: 'rgba(0, 0, 0, 0.6)',
colorBorder: '#3f3f46',
colorBorderSecondary: '#27272a',
borderRadius: 10,
borderRadiusXS: 2,
borderRadiusSM: 6,
borderRadiusLG: 14,
padding: 16,
paddingSM: 12,
paddingLG: 24,
margin: 16,
marginSM: 12,
marginLG: 24,
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.3), 0 1px 2px -1px rgba(0, 0, 0, 0.25)',
boxShadowSecondary:
'0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -2px rgba(0, 0, 0, 0.3)',
}
: {
fontFamily: 'var(--font-sans)',
// 控件高度对齐设计稿:普通按钮 34 / 紧凑 24 / 登录大按钮 44
controlHeight: 34,
controlHeightSM: 24,
controlHeightLG: 44,
colorPrimary: '#262626',
colorSuccess: '#22c55e',
colorWarning: '#f97316',
colorError: '#ef4444',
colorInfo: '#262626',
colorTextBase: '#262626',
colorBgBase: '#ffffff',
colorPrimaryBg: '#f5f5f5',
colorPrimaryBgHover: '#e5e5e5',
colorPrimaryBorder: '#d4d4d4',
colorPrimaryBorderHover: '#a3a3a3',
colorPrimaryHover: '#404040',
colorPrimaryActive: '#171717',
colorPrimaryText: '#262626',
colorPrimaryTextHover: '#404040',
colorPrimaryTextActive: '#171717',
colorSuccessBg: '#f0fdf4',
colorSuccessBgHover: '#dcfce7',
colorSuccessBorder: '#bbf7d0',
colorSuccessBorderHover: '#86efac',
colorSuccessHover: '#16a34a',
colorSuccessActive: '#15803d',
colorSuccessText: '#16a34a',
colorSuccessTextHover: '#16a34a',
colorSuccessTextActive: '#15803d',
colorWarningBg: '#fff7ed',
colorWarningBgHover: '#fed7aa',
colorWarningBorder: '#fdba74',
colorWarningBorderHover: '#fb923c',
colorWarningHover: '#ea580c',
colorWarningActive: '#c2410c',
colorWarningText: '#ea580c',
colorWarningTextHover: '#ea580c',
colorWarningTextActive: '#c2410c',
colorErrorBg: '#fef2f2',
colorErrorBgHover: '#fecaca',
colorErrorBorder: '#fca5a5',
colorErrorBorderHover: '#f87171',
colorErrorHover: '#dc2626',
colorErrorActive: '#b91c1c',
colorErrorText: '#dc2626',
colorErrorTextHover: '#dc2626',
colorErrorTextActive: '#b91c1c',
colorInfoBg: '#f5f5f5',
colorInfoBgHover: '#e5e5e5',
colorInfoBorder: '#d4d4d4',
colorInfoBorderHover: '#a3a3a3',
colorInfoHover: '#404040',
colorInfoActive: '#171717',
colorInfoText: '#262626',
colorInfoTextHover: '#404040',
colorInfoTextActive: '#171717',
colorText: '#262626',
colorTextSecondary: '#525252',
colorTextTertiary: '#737373',
colorTextQuaternary: '#a3a3a3',
colorTextDisabled: '#a3a3a3',
colorBgContainer: '#ffffff',
colorBgElevated: '#ffffff',
colorBgLayout: '#fafafa',
colorBgSpotlight: 'rgba(38, 38, 38, 0.85)',
colorBgMask: 'rgba(38, 38, 38, 0.45)',
colorBorder: '#e5e5e5',
colorBorderSecondary: '#f5f5f5',
borderRadius: 10,
borderRadiusXS: 2,
borderRadiusSM: 6,
borderRadiusLG: 14,
padding: 16,
paddingSM: 12,
paddingLG: 24,
margin: 16,
marginSM: 12,
marginLG: 24,
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1)',
boxShadowSecondary:
'0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1)',
},
components: {
Button: {
primaryShadow: 'none',
defaultShadow: 'none',
dangerShadow: 'none',
defaultBorderColor: dark ? '#3f3f46' : '#e4e4e7',
defaultColor: dark ? '#fafafa' : '#18181b',
defaultBg: dark ? '#27272a' : '#ffffff',
defaultHoverBg: dark ? '#3f3f46' : '#f4f4f5',
defaultHoverBorderColor: dark ? '#52525b' : '#d4d4d8',
defaultHoverColor: dark ? '#fafafa' : '#18181b',
defaultActiveBg: dark ? '#52525b' : '#e4e4e7',
defaultActiveBorderColor: dark ? '#52525b' : '#d4d4d8',
borderRadius: 6,
},
Input: {
activeShadow: 'none',
hoverBorderColor: dark ? '#a1a1aa' : '#a1a1aa',
activeBorderColor: dark ? '#d4d4d8' : '#18181b',
borderRadius: 6,
},
Select: {
optionSelectedBg: dark ? '#3f3f46' : '#f4f4f5',
optionActiveBg: dark ? '#27272a' : '#fafafa',
optionSelectedFontWeight: 500,
borderRadius: 6,
},
Alert: {
borderRadiusLG: 8,
},
Modal: {
borderRadiusLG: 12,
},
Switch: {
trackHeight: 22,
trackMinWidth: 44,
innerMinMargin: 4,
innerMaxMargin: 24,
},
Checkbox: {
borderRadiusSM: 4,
},
Notification: {
colorSuccessBg: dark ? '#0f2417' : '#f0fdf4',
colorErrorBg: dark ? '#2a1416' : '#fef2f2',
colorInfoBg: dark ? '#27272a' : '#f5f5f5',
colorWarningBg: dark ? '#271a0c' : '#fff7ed',
},
Card: {},
Tooltip: {},
Radio: {},
},
},
button: {
classNames: ({ props }) => ({
root: clsx(
(props as { type?: string }).type === 'primary' &&
(dark ? styles.buttonPrimaryDark : styles.buttonPrimaryLight),
(props as { type?: string }).type === 'default' &&
(dark ? styles.buttonDefaultDark : styles.buttonDefaultLight),
(props as { danger?: boolean; type?: string }).danger &&
(props as { type?: string }).type !== 'text' &&
(dark ? styles.buttonDangerDark : styles.buttonDangerLight),
),
}),
},
input: {
classNames: ({ props }) => ({
root: clsx(
dark ? styles.inputRootDark : styles.inputRootLight,
(props as { status?: string }).status === 'error' &&
(dark ? styles.inputErrorDark : styles.inputErrorLight),
),
input: dark ? styles.inputElementDark : styles.inputElementLight,
}),
},
select: {
classNames: {
root: styles.selectRoot,
},
},
notification: {
classNames: {
root: dark ? styles.notificationRootDark : styles.notificationRootLight,
title: styles.notificationTitle,
description: dark
? styles.notificationDescriptionDark
: styles.notificationDescriptionLight,
},
},
wave: {},
app: {},
card: {},
modal: {},
alert: {},
colorPicker: {},
checkbox: {},
dropdown: {},
datePicker: {},
inputNumber: {},
popover: {},
tooltip: {},
switch: {},
radio: {},
segmented: {},
progress: {},
}),
[
dark,
styles.buttonDangerDark,
styles.buttonDangerLight,
styles.buttonDefaultDark,
styles.buttonDefaultLight,
styles.buttonPrimaryDark,
styles.buttonPrimaryLight,
styles.inputElementDark,
styles.inputElementLight,
styles.inputErrorDark,
styles.inputErrorLight,
styles.inputRootDark,
styles.inputRootLight,
styles.notificationDescriptionDark,
styles.notificationDescriptionLight,
styles.notificationRootDark,
styles.notificationRootLight,
styles.notificationTitle,
styles.selectRoot,
],
)
}
/* eslint-enable react-hooks/exhaustive-deps */
export default useShadcnTheme
+67
View File
@@ -0,0 +1,67 @@
// api 客户端单测(评审 round2 P1-2 / P2-15 的回归守护):
// 1. FormData 请求不得强设 Content-Type(multipart boundary 由浏览器生成);
// 2. 401 触发全局未授权回调(AuthProvider 据此跳登录页)。
import { describe, it, expect, vi, afterEach } from 'vitest'
import { api, ApiError, setUnauthorizedHandler } from '../src/lib/api'
function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
describe('api 客户端', () => {
afterEach(() => {
vi.unstubAllGlobals()
setUnauthorizedHandler(null)
})
it('FormData 请求不强设 Content-Type(保留浏览器 multipart boundary)', async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(201, { data: { id: 1, url: '/api/images/1' } }))
vi.stubGlobal('fetch', fetchMock)
const form = new FormData()
form.append('file', new Blob(['x'], { type: 'image/png' }), 'a.png')
await api<{ id: number }>('/api/admin/images', { method: 'POST', body: form })
expect(fetchMock).toHaveBeenCalledOnce()
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]
const headers = init.headers as Headers
expect(headers.has('Content-Type')).toBe(false)
})
it('JSON 请求默认补 application/json 并携带 CSRF 头', async () => {
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(200, { data: { ok: true } }))
vi.stubGlobal('fetch', fetchMock)
const { setCsrfToken } = await import('../src/lib/api')
setCsrfToken('token-abc')
await api('/api/admin/notes/1', { method: 'PUT', body: JSON.stringify({ title: 'x' }) })
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]
const headers = init.headers as Headers
expect(headers.get('Content-Type')).toBe('application/json')
expect(headers.get('X-CSRF-Token')).toBe('token-abc')
setCsrfToken(null)
})
it('401 触发全局未授权回调并抛出 ApiError', async () => {
const fetchMock = vi.fn().mockResolvedValue(
jsonResponse(401, { error: { code: 'unauthorized', message: '未登录或会话已过期' } }),
)
vi.stubGlobal('fetch', fetchMock)
const handler = vi.fn()
setUnauthorizedHandler(handler)
try {
await api('/api/admin/notes', { method: 'DELETE' })
expect.unreachable('应抛出 ApiError')
} catch (e) {
expect(e).toBeInstanceOf(ApiError)
expect((e as ApiError).status).toBe(401)
expect((e as ApiError).code).toBe('unauthorized')
}
expect(handler).toHaveBeenCalledOnce()
})
})
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import Login from '../src/pages/Login'
vi.mock('../src/lib/auth', () => ({
useAuth: () => ({ login: vi.fn(), authenticated: false }),
}))
vi.mock('../src/features/site', () => ({
useDocumentTitle: () => {},
}))
describe('Login 表单密码管理器适配', () => {
it('暴露隐藏用户名字段与 current-password 自动填充标记', () => {
render(
<MemoryRouter>
<Login />
</MemoryRouter>,
)
const username = screen.getByLabelText('用户名') as HTMLInputElement
expect(username.type).toBe('text')
expect(username.value).toBe('admin')
expect(username.readOnly).toBe(true)
expect(username.getAttribute('autocomplete')).toBe('username')
const password = screen.getByLabelText('管理密码') as HTMLInputElement
expect(password.getAttribute('autocomplete')).toBe('current-password')
})
})
+5 -1
View File
@@ -15,7 +15,11 @@
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"types": ["vite/client"]
"types": ["vite/client"],
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src", "tests"]
}
+16 -1
View File
@@ -1,3 +1,4 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
@@ -8,14 +9,28 @@ export default defineConfig({
// /notes/assets/… 导致白屏(设计 §8.3-2,禁止修改)
base: '/',
plugins: [react(), tailwindcss()],
resolve: {
// shadcn 组件别名的运行时解析(components.json aliases)
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
server: {
proxy: {
// 同源是 Cookie/CSRF 成立的前提(§10.5)
'/api': 'http://127.0.0.1:8080',
// changeOrigin:false —— 保留浏览器原始 Host(localhost:5173),
// 否则后端 Origin 校验会因 Host 被改写为 127.0.0.1:8080 而判定跨源 403。
'/api': {
target: 'http://127.0.0.1:8080',
changeOrigin: false,
},
},
},
build: {
// 产物内容 hash 文件名(§8.3-5)
assetsDir: 'assets',
// 不做手动 codeSplitting 分组:分组 chunk 会被提升进入口静态依赖,
// HTML 对其发出 modulepreload,导致 lazy() 路由的 CodeMirror 在
// 所有页面被预加载(Lighthouse unused-javascript 549KiB 的根因)。
},
})
+8 -1
View File
@@ -1,10 +1,17 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
resolve: {
// 与 vite.config 一致:shadcn 组件的 @/ 别名
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
test: {
environment: 'jsdom',
environment: 'happy-dom',
include: ['tests/**/*.test.?(c|m)[jt]s?(x)'],
},
})