Compare commits

..
45 Commits
Author SHA1 Message Date
Sakurasan bf3ce934dd 后台管理手机端适配:侧栏收成 ☰ 抽屉,列表/总览响应式单列,编辑器贴边与工具栏收紧,修 iOS 聚焦缩放 2026-10-01 00:15:33 +08:00
Sakurasan 92654dec9f 带图短文桌面端左右分栏:文字 2/5、图 3/5,图不再全宽铺开吃高度;窄屏维持堆叠 2026-09-30 22:55:42 +08:00
Sakurasan a4e182aeb4 短文配图网格改 2:1 定高:宽列下 1:1 格子顶掉一整屏,行 minmax(0,1fr) 防图片自然高撑爆 2026-09-30 22:37:39 +08:00
Sakurasan cc01650d0d vivid 品牌位做减法:头像外圈光环与绿色在线点都去掉 2026-09-30 22:37:39 +08:00
Sakurasan 942bad6158 yohaku 手机端打磨:dock 去圆标只留站名,压掉 iOS 顶部弹性露底(100dvh) 2026-09-30 21:06:43 +08:00
Sakurasan 1b3eb870da 文件删除前查引用:被文章/项目/站主头像用着就先挡住
- store.FileReferences(key) 反查谁在用这个文件:文章(封面 + content_md +
  content_html)、项目封面、settings.owner_avatar_key。匹配的键是 files.key 本身
  而不是完整 URL——本地 /uploads/{key}、R2 {publicBase}/{key}、缩略图
  /uploads/thumb/{key} 三种形态都以 key 结尾,换存储端后正文里的老链接照样查得到。
  草稿也算:现在没发布,删了将来发出来就是裂的。
- 按需 LIKE 现扫,不维护计数表:写入口有编辑器、外链转存、短文、项目、头像好几处,
  计数一旦漂移就再也信不过;删文件是低频操作,扫全表几十毫秒换一个永远正确的答案。
- 新端点 GET /api/admin/files/{id}/refs;DELETE 默认对在用文件返回 409(消息带引用数
  和前三个位置名),明确带 force=1 才真删。守卫放在动 blob 之前——存储端一删就没法回头。
- 后台删除弹层先把引用清单摊出来(文章《标题》(草稿)、站主头像),仍然要删才带 force。
  引用查询失败就按无引用走:真被引用时后端会 409,不会静默删掉在用文件。

顺手把另一个 agent 工具的本地草稿目录(.zcode/、.zcodeignore)加进 .gitignore。
2026-09-30 02:03:14 +08:00
Sakurasan f1e639e0ba 账户中心:站主资料可编辑 + 身份绑定 + passkey 登录
后台新增 /admin/account 一页,四块:公开资料、密码、第三方账号、Passkey。

-  schema:users 加 role(默认 reader),新表 user_identities、passkeys。
  user_identities 上双 UNIQUE —— (provider, extern_uid) 防一个外部账号顶两个身份,
  (user_id, provider) 防一站主绑两个同平台号,绑错也劫持不了。
  extern_uid 存平台稳定 ID,不存用户名(用户名可改)。
- 头像存 files 里的 key 而非 URL,换存储/CDN 不失效;单 key SetSetting 写入,
  避开 UpdateSettings 的整表替换会把它抹掉。站主名/简介从设置页挪到账户页,
  一个字段只留一个编辑入口。
- OAuth 绑定要求先有后台会话(绑定动作本身是提权路径);已绑的站主身份登录后
  直接发 one_session,读者身份仍发 one_reader。
- passkey 走 go-webauthn v0.15.0(最后一条吃 go 1.24 的版本线),可发现凭据登录。
  必须显式设 ONE_WEBAUTHN_ORIGINS 才启用,不配就安静关掉。
  签名计数只记克隆警告、不硬拦 —— 云同步 passkey 的计数本就不单调。
- 密码故意留在 ONE_ADMIN_PASSWORD,不做哈希入库:这是「解绑一切、删光 passkey
  也还能进门」的保底,比 env 明文更值得守。memos 那个 SSO 建号随机密码无重置
  入口的坑,从设计上绕开。

已知限制:会话仍是有状态无关的 HMAC cookie,删 passkey / 解绑不会让已发出的
7 天后台会话失效 —— 要修得加一张吊销表。
2026-09-30 01:08:55 +08:00
Sakurasan 7e302c51a6 新站标「一横」:墨圆 + 一道白横,接进两套 UI 与图标
立意:既是字母 O 也是汉字「一」,对应站名 ONE / 一个博客。

- favicon.svg 重画:横杠用 fill-rule=evenodd 挖成真洞而不是叠一块纸色矩形,
  所以纸感 / 墨色暗端 / 后台卡片任何底色都直接透过去,不必为皮肤各出一版;
  SVG 内带 prefers-color-scheme,标签页深浅自动翻色
- 补 favicon-16/32.png(不认 SVG 的老浏览器)与 apple-touch-icon.png 180
  (iOS 不支持 SVG 且会把透明压成黑底,故铺纸色实底)。PNG 由临时 Go 程序
  按 8x8 子采样光栅化生成,生成器未入库,产物是资产
- LogoMark.vue 内联 SVG 组件:颜色跟 currentColor,尺寸由外层给
- 接入 6 处:classic 桌面左栏品牌位(原来是个排版字符 ○)、移动顶栏 brand、
  时间线头像位、详情页头像位、快发盒头像位;vivid 顶栏与底部 dock 品牌标记
  (右下角绿色在线点保留,它是「有人在写」的信号,不属于被替换的部分)
- 顺带清理:三处只为算首字母的死代码(initial 与只服务于它的 userName)、
  vivid 里一条被同特指度靠后规则完全覆盖的 .av 死规则;标记 wrapper 统一加
  flex:none,否则站名一长会被压成椭圆
2026-09-29 21:04:55 +08:00
Sakurasan e6e4bdc71c 修两套 UI 的横向溢出 + vivid 页脚把源码入口从社交排里分出来
溢出(两处,同一类成因):
- vivid 时间线:短文正文用的是 .note .body 而不是 .prose,所以 c6eb0bb 给
  .prose 加的 overflow-wrap:anywhere 没覆盖到它,正文里的裸链接把列撑到 552px
  (视口 516)。补上 anywhere 而非 break-word——要参与 min-content 计算才压得住
- classic 时间线:.shell 的页边距在 640px 断点从 20px 收到 12px,而 .row-feed 的
  负出血要到 520px 才跟着收到 -12px,521~640 这段出血比父容器内边距多 4px,
  整页横向溢出。把出血断点对齐到 640px(桌面视觉不动)
- LinkCard 的 .lc-site 同样补 anywhere:主机名是最典型的不可断行长 token

vivid 页脚:源码仓库入口原先被 concat 进社交数组,和站主的 GitHub/X 排成同一行,
看起来像「又一个社交账号」。改成社交排只放账号(同 classic RightRail 的过滤),
源码图标与版权单独成 .foot-meta 压在页脚最底;样式写在 .foot a 之后,靠同特指度
的后者压掉页脚链接默认的强调色
2026-09-29 12:01:11 +08:00
Sakurasan baf14897aa 注释同步:皮肤精简后 data-theme 说明改为 paper/ink 2026-09-29 02:55:47 +08:00
Sakurasan 8ff9878e91 精简 classic 皮肤:去掉鼠尾草/玫瑰,亮端固定纸感;重新设计墨色暗板——底色更深更中性(褪黄褐)、次级文字降金相改暖灰、鎏金强调提亮提纯,层次靠三档灰阶;后台暗色 token 同步;设置页皮肤选择器改为固定展示 2026-09-29 02:45:44 +08:00
Sakurasan 1a43199ca0 重写手机图片预览(不参考旧实现):
- 真凶补刀:lightbox.js 状态模块里一直有 body.style.overflow='hidden'——上次撤销只撤了组件没撤它,撑屏从未消失。现在彻底移除,注释写明绝不可再碰 body inline overflow(会覆盖 overflow-x: clip)
- 滚动封锁改由遮罩自身 @wheel/@touchmove.prevent 拦截:事件不落页面,背景滚不动、滚动位置保留
- 图片尺寸全走百分比(遮罩即视口),不再引入 vw/vh/dvh
- touch-action:none 手势归遮罩:滑动翻页(位移阈值判定)、屏蔽双击缩放/长按菜单/下拉刷新
- 手机断点:箭头贴边缩小、关闭钮/页码走 safe-area、图片全宽无圆角
- Transition 加 :duration 兜底,防 transitionend 不触发时透明遮罩残留吃点击
2026-09-29 02:27:42 +08:00
Sakurasan 84f88cb473 撤销 ce07f4a(lightbox 手机适配 + 正文图预览委托):真机上反复出问题(撑屏/缩放错乱),全部回退到原有简单预览;仅保留 lb-open 隐藏吸顶栏的亮带修复 2026-09-29 02:07:06 +08:00
Sakurasan c6eb0bbe5b 修真机首页缩成一团:.prose 补 overflow-wrap:anywhere——时间线正文里的裸 URL 不可断行把布局撑到 557px,手机浏览器缩放适配溢出宽度后整页缩小、右侧留出被裁剪的空白区 2026-09-29 01:53:24 +08:00
Sakurasan 165f87d086 修复真机撑屏:锁滚动只改 overflowY——整个 overflow 改 hidden 会覆盖样式表的 overflow-x: clip,横向从裁剪变可滚动容器,整页能被拖宽 2026-09-29 01:48:42 +08:00
Sakurasan 2674bea223 真机修复:lightbox 打开时隐藏吸顶顶栏——backdrop-filter 元素的合成层在真机上会盖住 z-index 更高的遮罩(顶部出现未遮罩亮带);顺带全屏观感更干净 2026-09-29 01:38:24 +08:00
Sakurasan ce07f4a347 图片预览手机适配 + 正文图统一接入 lightbox
- lightbox:打开锁背景滚动(手机上背景不再跟着滑)、手机滑动左右翻页、
  dvh 跟随全面屏可视高度、关闭钮/角标走 safe-area、图片全宽无圆角
- 全局 capture 委托:时间线/详情页 v-html 正文里的图统一可点开预览,
  capture+stop 压住整卡跳转;thumbifyHtml 把原图地址留在 data-fullsrc,
  预览用原图而非 480w 缩略图;链接卡缩略图不劫持(点击仍是打开链接)
2026-09-29 01:29:19 +08:00
Sakurasan c2d5e9db89 主题功能同步:vivid 页脚补社交按钮排(含源码入口,居中);classic 文章页补右栏目录(滚动高亮)与顶部阅读进度条——快发盒经核实两套 UI 均已存在,无需补 2026-09-29 01:23:29 +08:00
Sakurasan 3eb0aab788 页脚:社交按钮提到第一排居中显示(链接行/版权行依次在下) 2026-09-29 01:07:19 +08:00
Sakurasan f7669badf2 长文自动摘要与自动封面:未填时从正文派生——摘要剥语法取开头 110 字(标题行/代码块/图链接剔除),封面取正文第一张图;更新只在「新旧都空」时补,站主主动清空的保得住;保存响应回写编辑器可见 2026-09-29 01:05:46 +08:00
Sakurasan 6e6196d68b 工具栏补表格插入:插入组新增表格按钮,光标处铺 2×2 模板(表头/分隔/两行内容) 2026-09-29 00:56:34 +08:00
Sakurasan 23771f0661 文章排版四件套(参考 xLog):盘古之白 + 表格圆角斑马纹 + 代码块封顶与复制按钮 + 标题悬停锚点
- PanguHTML:CJK 与拉丁/数字间补空格,标签原样保留、code/pre 内文本不动;详情/时间线短文/RSS 描述三个出口生效(带单测)
- 表格改圆角容器 + tbody 斑马纹 + 纵向大留白(classic/vivid 双主题)
- pre max-height 30.5em 封顶内部滚动;enhanceProse 注入悬停复制按钮(App 全局委托),按下有 2px 物理感
- 带 id 的 h2-h4 悬停浮现 # 锚点,点击跳转
- 中文字体栈此前已配(PingFang SC 等),无需改动
2026-09-29 00:53:50 +08:00
Sakurasan 44a24736aa md 工具栏命令配快捷键:⌘B 加粗 / ⌘I 斜体 / ⌘E 行内代码 / ⌘K 链接(CodeMirror keymap),tooltip 自动带出(Mod 在 Mac 显示 ⌘) 2026-09-29 00:36:22 +08:00
Sakurasan 55d898cd42 编辑器工具栏改 xLog 风格:SVG 线性图标按钮(加粗/斜体/删除线/代码/标题/引用/列表/任务/分隔线/链接/图片/日期),32px 圆角、悬停出底框,替代文本 chip 2026-09-29 00:25:18 +08:00
Sakurasan ab96426489 编辑器手动保存(按钮/⌘S/发布/转草稿)弹 toast 回音——「已保存/已发布 · 时间」;创建成功与空内容校验同样有提示;自动保存保持静默 2026-09-28 23:57:38 +08:00
Sakurasan c84a383e5c 后台统一状态提示:共享 toast 模块(ToastStack 挂后台布局与登录页),登录成功/失败、文章/标签/作品/文件/评论/设置操作全部有反馈,替换掉原生 alert;编辑器局部 toast 并入共享实现 2026-09-28 23:52:16 +08:00
Sakurasan f7a2e83727 编辑器三件套(参考 xLog):CodeMirror 源码模式 + 外链图片一键转存 + 上传进度/前置校验
- md 页签从裸 textarea 升级 CodeMirror 6:markdown 语法高亮、行内历史,粘贴/拖图直传;
  markdown 仍是唯一真相源,转存/切换用 syncCM 全量灌回,工具栏选区辅助改为面向 CM
- POST /api/admin/files/import:外链抓取复用 linkmeta SSRF 防护拨号与跳转限制,
  二进制传输放宽 30s;类型按内容嗅探反查白名单,与手动上传共用去重/落盘(persistFile 抽取共享)
- 上传走 XHR 进度回调 + 编辑器 toast 栈:类型/大小前置校验(与后端白名单一致)、逐文件进度
- persistFile 去重路径补 URL 解析——此前命中去重返回的行没有 URL,转存替换会把正文图链清空(已修复受损数据)
- hub nil 安全(测试环境未装配时不 panic)
2026-09-28 23:42:24 +08:00
Sakurasan b93fe9847c 长文图片缩放:编辑器拖拽把手的比例在文章页生效(alt 数字还原为宽度并居中)+ 把手默认半透明提高可发现性 2026-09-28 23:05:08 +08:00
Sakurasan a74299d716 侧栏「最近更新」:短文显示正文摘录(贴链接的回退到卡片标题、纯图显示占位)+ 配图短文带 34px 缩略图 2026-09-28 22:39:06 +08:00
Sakurasan da0333e09c 标签只属于长文:短文落库强制清空标签,编辑器切到短文隐藏并清空标签栏,存量短文标签关联已清理 2026-09-28 22:30:02 +08:00
Sakurasan 1db1698174 链接卡片图片修复:img 加 referrerpolicy=no-referrer 绕过哔哩哔哩等 CDN 防盗链;抓取 UA 换浏览器形态;识别并丢弃 X 的全站占位图 2026-09-28 21:50:41 +08:00
Sakurasan a234afe448 评论实时更新:SSE 订阅文章频道,发评/编辑/删除/审核通过即时刷新打开中的页面
- internal/hub:按文章 ID 的进程内发布/订阅,信号不携带内容(未审核评论不外泄)
- GET /api/comments/stream?post_id=N:SSE 长连 + 25s 心跳 + X-Accel-Buffering off
- 广播点:发评论、作者编辑/删除、后台审核通过、后台删除(与公开端共享同一 hub)
- 前端 EventSource 静默刷新(不亮加载态、不干扰正在输入/编辑的状态)
- requestLog 的 statusRecorder 补 Flush 透传——包装器没实现 Flusher 时流式端点整体不可用
2026-09-28 21:42:11 +08:00
Sakurasan 661d157b6c 短文支持链接预览卡片:新增 linkmeta 抓取 + 字符集解码
- internal/linkmeta:取正文第一个 http(s) 链接,解析 og: → twitter: → <title>/
  description 逐级兜底,解 HTML 实体、相对图片补成绝对地址。按对外抓取设防:
  只放行 http(s)、读满 512KB 即停、最多 3 次跳转、整体限时;SSRF 防护做在拨号
  那一刻——解析出 IP 后筛掉回环/私网/链路本地/组播/CGNAT 再直连该 IP,堵住
  DNS 重绑定窗口
- 字符集转换:按「HTTP 头 charset → <meta charset> → UTF-8」解码,gb2312 归一
  成 gbk(超集,按声明解会漏字);认不出的字符名退回原始字节不报错。为此引入
  golang.org/x/text(官方包,约 +1MB 二进制)。tidy 顺带把 aws-sdk 三个包从
  indirect 修正为直接依赖——storage/r2.go 本来就直接用它们
- posts 加 link_card 列(JSON,沿用 images 的编解码);admin 保存短文时重算:
  同链接沿用旧卡片不重复打远端,删链接或抓取失败则清空,长文不参与
- 前端 LinkCard 组件接两套 UI 的时间线与详情页,配色写成 vivid 变量优先、
  classic 变量兜底,一份样式两边通用;外链图挂了自动隐藏不留空框
- 卡片用 div[role=link] 而非 <a>(vivid 整条短文包在 RouterLink 里,嵌套 <a>
  非法),点击必须 preventDefault——只 stop 挡不住祖先 <a> 的默认激活行为
2026-09-28 17:13:20 +08:00
Sakurasan 232abf2019 缩略图缓存加容量闸与删除联动清理
- Store 增 MaxBytes(默认 256MB):写入后节流扫描(10 分钟一次、同时只跑一个),
  超限按 mtime 从旧到新删到 3/4 水位(滞回,避免卡在边界反复删),顺带清掉
  进程被杀留下的 .tmp
- 命中缓存时 Touch 更新 mtime,淘汰因此是 LRU 而非「最早生成的先死」,
  热图不会被误删后反复回源拉存储
- 新增 Purge(sha):后台删除上传文件时连带清掉该图所有宽度的产物与 .failed
  标记,否则已删图片会永久占着缓存
2026-09-28 15:09:54 +08:00
Sakurasan 6108aca34c 缩略图懒生成 + 评论登录限流:时间线不再拉原图
- 新增 /uploads/thumb/{key}?w=:首访从存储端读一次原图,标准库盒均值缩放后
  落盘 data/.thumbnail_cache(按内容哈希命名,天然失效);失败写 .failed 冷却
  一小时,非图片/超大一律 302 回原图,前端无感。原图路由改用 ServeContent,
  补上视频拖动进度条所需的 Range 支持
- 前端 thumbURL/thumbifyHtml 接入两套 UI 的时间线配图、长文封面与短文正文,
  并补 loading=lazy;详情页与灯箱仍用原图。/api/site 改为
  {settings, uploads_public_base},让前端识别哪些直链属于自家存储
- 后台登录的滑动窗口限速器抽成 internal/ratelimit 共享包(调用面不变),
  新增:读者登录失败按 IP 20 次/10 分钟、评论写入按读者 5 条/分钟
  (站主豁免,且只计成功写入)
2026-09-28 13:34:19 +08:00
Sakurasan b6342a622d 时间线:长文显示封面——摘要下方圆角媒体位,样式与短文配图一致(vivid 本就有,补齐 classic) 2026-09-28 01:57:46 +08:00
Sakurasan baf14ec6ca 评论登录新增 Google OAuth 与 Telegram Login Widget
- Google:授权码流程(openid email profile),handle 优先用已验证邮箱,头像取 picture
- Telegram:官方 widget 直接回传签名资料,后端按官方算法验签(secret=SHA256(bot_token),除 hash 外全字段排序比对)+ auth_date 24h 时效
- providers 列表自动按配置下发(未配置的不显示);widget 型带 bot 用户名
- 三个登录方式共用读者会话与 upsert;Telegram 走 POST 无跳转
- .env.example 补 ONE_GOOGLE_* / ONE_TELEGRAM_* 模板
2026-09-28 01:32:39 +08:00
Sakurasan 9149f672e7 .env.example:补 GitHub OAuth 凭据模板(评论登录) 2026-09-28 00:52:40 +08:00
Sakurasan 1a2db5ddac 管理员免登录直接评论:后台会话映射站主身份(免审核、不可被禁言)+ 评论作者字段对齐后端 user
- 公开 API 解析顺序:读者 cookie → 管理员 cookie(provider=admin 站主读者,名字取站点作者名)
- 站主发评论跳过禁言与审核;后台禁言接口拒绝站主身份
- CommentItem 读 c.author 改为 c.user(后端实际字段),站主徽章按 provider 判断
2026-09-28 00:43:25 +08:00
Sakurasan 0929f880e3 评论区:禁言卡判断挪到匿名判断之后——匿名访客读 user.banned 抛错导致整个评论区消失 2026-09-28 00:27:41 +08:00
Sakurasan 8e9d98e261 OAuth 登录记住发起页:回调后回到登录前所在的前台 origin(开发时 3000/8080 分离不再落错站) 2026-09-28 00:15:17 +08:00
Sakurasan cfd6948987 评论阶段二:GitHub OAuth 登录 + 评论表/用户表 + 先审后显开关 + 禁言 + 后台评论管理页
- auth 包:读者会话(one_reader,与后台令牌互斥)+ GitHub OAuth 客户端;Verify 校验 HMAC 与 reader: 前缀
- 公开 API:auth 五端点、评论列表(顶层可见+自己待审、回复内嵌)、发表(登录/禁言/开关/500 字校验)、10 分钟编辑窗、软删墓碑
- 管理端:评论列表(待审/已通过/全部)、通过、软删、读者列表、禁言切换
- 前台:登录卡/禁言卡;后台:评论管理页 + 设置页审核开关
2026-09-27 23:41:40 +08:00
Sakurasan 740d47bbf6 短文配图:快发盒上传 + Twitter 式网格 + 全站 lightbox 预览;整卡点击进详情、时间线短文豁免首字 2026-09-27 23:41:34 +08:00
Sakurasan 08250b58ef 导航:修复顶栏隐藏/唤回在滚动徘徊时的抽搐
根因:隐藏/唤回是单事件方向判定——滚动事件里下滑 4px 就藏、往上一有
回弹就现身。拖动页面在阈值附近徘徊时(触控板噪声 / 惯性回弹 / 滚动条
微调都会产生正负几十像素的事件),顶栏每次都被翻转,吸顶头(标题 +
切换 tabs + 发布盒顶部)跟着一起上下滑动——即站主截图里的抽搐。

修复:改成「同向累计迟滞」——滚动事件按方向累计净滚动量(acc),
同向持续累计、反向从新方向重新累计,小幅往复正负抵消:

- 累计下滑 40px(且 y > 120,滚出页首区)→ 隐藏
- 隐藏状态下累计上滑 60px → 唤回
- 滚回页首(y ≤ 80)或菜单打开时强制显示(不变)

实测:在阈值附近以 ±70px 快速往复 1.1 秒,翻转 0 次、顶栏稳定;
持续滚动(下滑 → 隐藏、上滑 81px → 唤回、再下滑 → 再隐藏)状态
切换全部正确。prefers-reduced-motion 下过渡照旧禁用。
2026-09-27 17:06:15 +08:00
Sakurasan 34f0faedcc 文件上传:R2 对象存储 + 后台文件管理 + 编辑器/封面联动
存储抽象(internal/storage,新包)
- BlobStore 接口(Put / Open / Delete)+ 两个实现:R2(aws-sdk-go-v2 走
  R2 的 S3 兼容 API,path-style、region auto)与本地磁盘(DataDir/uploads,
  零配置兜底)。选择:Endpoint / S3Api / Bucket / AccessKey / SecretAccessKey
  齐全 → R2,缺任一项回落本地并在启动日志提示缺失的字段名(只报名字不报值)
- 端点 URL 里的路径段不交给 SDK:path-style 下它会被折进对象 key,
  导致「数据库 key」和「实际对象 key」对不上(直链 404,实测复现)。
  EndpointKeyPrefix 提取路径段给上传 handler 拼进 key,StripEndpointPath
  只取 scheme://host 给 SDK——数据库 / 存储端 / 直链三方一致

数据与 API
- files 表:id / key(唯一) / name / mime / size / sha256 / store(r2|local) /
  created_at;URL 不入库,按「store 来源 + PublicBase」响应时解析,
  切存储端不破坏存量链接
- POST /api/admin/files:multipart 多文件,单文件 ≤50MB(MaxBytesReader 64MB);
  类型白名单 = 图片(jpg/png/webp/gif/avif)+ 附件(pdf/zip/txt),
  扩展名 + http.DetectContentType 双重校验(实测拦截随机字节改名 .png),
  SVG 拒绝(同源脚本);内容 sha256 做 key(2026/09/{哈希前12位}{扩展名}),
  同内容重复上传自动去重复用
- GET /api/admin/files(分页 + 文件名搜索)、DELETE /{id}(先删对象再删行,
  存储端失败保留行可重试)
- 公开路由 GET /uploads/{key}(main.go 挂载):按 key 查行、存储层流式返回,
  Cache-Control immutable + ETag 304;R2 + PublicBase 时 302 直链(后端不出流量)
- URL 解析:FileURL(store, key, PublicBase)——R2 且配了公开域名走直链,
  否则 /uploads/ 流式

后台文件管理页(FilesView,「工作台 → 文件」)
- 点击 / 拖拽多选上传(uploadFiles 走 FormData 裸 fetch,401 广播与
  request() 一致);缩略图卡片网格(图片出图、其他出类型占位);
  复制链接(clipboard,非 https 回落 prompt)/ 打开 / 删除(确认提示);
  分页、loading/empty 沿用既有模式

编辑器联动(EditorView)
- 封面:URL 输入框旁「上传」按钮,选图自动填 cover_url
- wysiwyg:Crepe ImageBlock 官方 onUpload 钩子——粘贴 / 拖拽 / 插图
  全部走上传,返回 URL 后由 Crepe 插节点
- Markdown 模式:插图弹层加「上传」按钮 + 编辑器粘贴 / 拖拽图片,
  上传后在原光标处插入 ![](url)(异步上传先记光标位,逐张追踪偏移)

.env.example
- 模板入库(无敏感值):Endpoint = 公开访问域名(直链)、S3Api = 上传端点、
  Bucket / AccessKey / SecretAccessKey

验证
- 本地兜底全流程:上传 201、同内容去重复用、随机字节改名 .png 被
  内容嗅探拒绝、.svg 拒绝、公开路由 immutable 缓存头 + 内容一致、
  删除后存储与公开路由双清 404
- R2 真实链路(站主 .env):上传 store=r2、直链 200(过程中定位并修复
  S3Api 路径段折进 key 导致的直链 404,见 EndpointKeyPrefix)
- 后端 go build/test/vet 全绿;前端构建通过
2026-09-27 16:26:11 +08:00
97 changed files with 10439 additions and 488 deletions

No files matched your search

+22
View File
@@ -0,0 +1,22 @@
AccessKey=
SecretAccessKey=
S3Api="https://<account_id>.r2.cloudflarestorage.com"
PublicURL=
Bucket=
# GitHub OAuth(评论登录):https://github.com/settings/developers
# 回调地址填 http://localhost:8080/api/auth/callback/github(线上换成正式域名)
ONE_GITHUB_CLIENT_ID=
ONE_GITHUB_CLIENT_SECRET=
# Telegram 登录(Login Widget):@BotFather 建 bot 拿 token,
# 再用 /setdomain 把域名登记给 bot(本地开发填 localhost)
ONE_TELEGRAM_BOT=
ONE_TELEGRAM_BOT_TOKEN=
# Google 登录:https://console.cloud.google.com/apis/credentials 建 OAuth 客户端,
# 回调地址填 http://localhost:8080/api/auth/callback/google(线上换成正式域名)
ONE_GOOGLE_CLIENT_ID=
ONE_GOOGLE_CLIENT_SECRET=
ONE_SECRET=# 任意字符串,用于加密
+4
View File
@@ -12,6 +12,10 @@ data/
# 开发进程日志 / pid
.run/
# 其他 agent 工具的本地草稿(计划、忽略清单),不进仓库
.zcode/
.zcodeignore
# 二进制
one-server
backend/one-server
+29 -2
View File
@@ -1,21 +1,48 @@
module oneblog
go 1.24
go 1.24.0
require (
github.com/aws/aws-sdk-go-v2 v1.47.1
github.com/aws/aws-sdk-go-v2/config v1.33.6
github.com/aws/aws-sdk-go-v2/credentials v1.20.6
github.com/aws/aws-sdk-go-v2/service/s3 v1.113.4
github.com/go-webauthn/webauthn v0.15.0
github.com/lib/pq v1.10.9
github.com/yuin/goldmark v1.7.13
golang.org/x/text v0.30.0
modernc.org/sqlite v1.39.0
)
require (
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.5 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.4 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.10.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.38.1 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.51.1 // indirect
github.com/aws/smithy-go v1.28.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/go-webauthn/x v0.1.26 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/google/go-tpm v0.9.6 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/crypto v0.43.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/sys v0.34.0 // indirect
golang.org/x/sys v0.37.0 // indirect
modernc.org/libc v1.66.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
+72 -8
View File
@@ -1,5 +1,55 @@
github.com/aws/aws-sdk-go-v2 v1.47.1 h1:uOIZnp4PK3ZhKI0dNrJrhTEsLxbpXHTAJlwoS1pvAtw=
github.com/aws/aws-sdk-go-v2 v1.47.1/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 h1:GPRlPwz40I2B2VrBEASOA3Bi77NyeqejNLkifosX0rs=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20/go.mod h1:g7PNzKcsOKWb4fkSRBA7BZVAS6Y8IcxzN+nRohhQ1Q8=
github.com/aws/aws-sdk-go-v2/config v1.33.6 h1:MBjkSTLczek/UgiK+EYPIoRTqE7gP8vtW3OFbFo7Nug=
github.com/aws/aws-sdk-go-v2/config v1.33.6/go.mod h1:grRAFzdAZJrwcbasJRg2MPvIrVjtlfXllHssN6+E1JE=
github.com/aws/aws-sdk-go-v2/credentials v1.20.6 h1:NpAFXCU7NzXNkdGK3zQTtsRJ+3v9tZQV0xcdRw8uBdw=
github.com/aws/aws-sdk-go-v2/credentials v1.20.6/go.mod h1:mcZCoiPnyMvP8VMNbygNX5lLqSlkYJIMPODylQMurOk=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.1 h1:8gALAAmacnIXh+z6VkdDanv4/IkG5APdg4DZLDTmLog=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.1/go.mod h1:Z7IJhJU+poOdJjUR2wpyY21ossQ1XS/R3Lk9Msq5kM4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4 h1:CLq4+8UHCI+ZZYl/EuJxXovaIVN2xeeT8JV+dsApQ5E=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4/go.mod h1:Wv4q5sAM04xAMkoOedxLx2inVf6K5FdxYp+A61L+q/0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.4 h1:dD4MR81I7YkpEBRk6UP9rocC2QnT3qVuXwzlYTtfGEs=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.4/go.mod h1:EcXV1kAFd5XwSkDHlj94gnF3q5CkJyYiIJfH8N0VmrE=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.4 h1:7Wo47d/xn/7KttCSBd8EGYeZ7ULRFRkUHr6vkZPBzVQ=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.4/go.mod h1:tDB2IVC1xC3vX8o+6uRlzhTxP3g1b77CZXFX/oD2FnQ=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 h1:bAdDl/HkGCcGPoe25ToSHEw23VIxt6CT5fLcg111BKg=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19/go.mod h1:KaUzbLxv4CeSxh6ZCl9B4m7CuFenS8kUEaDs+f/DQr4=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.5 h1:/TYsZXdA8UTa+WCtCYSAJIr1vwl0+eho6TUgJGwFFO8=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.5/go.mod h1:qPqp1Uwd/BqdhPufv6oem9j5J7HNsgc2V22dUiDPn+s=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.4 h1:29SvnfGhXjTl8ONxFwbj2rs6lbhiFXD2CgFQmbT/bXY=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.4/go.mod h1:wm04I5DMuNVvZHFe/dHnUxincvNbbK7AiNBbYsQivek=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.4 h1:pPiWfgeNxqluKEph7hvU88kuGKBPOWzO+Dk9t2zqqNs=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.4/go.mod h1:YlwGoIUDG/3kBQbdNOVs/xKZ9J01G8e/6D1mRBj9uTk=
github.com/aws/aws-sdk-go-v2/service/s3 v1.113.4 h1:n6kO3OlBvnDEksQpvBLbAldjHwGlu8kErvhHJkhlaRY=
github.com/aws/aws-sdk-go-v2/service/s3 v1.113.4/go.mod h1:9APRWGLFITKD+xzWSIyT9V7QV4bNlEuIieWlzXgGFlI=
github.com/aws/aws-sdk-go-v2/service/signin v1.10.1 h1:DzCCWLzcIRQ77F3DEUljud7bEjTgFOIKXP52NmVRyhU=
github.com/aws/aws-sdk-go-v2/service/signin v1.10.1/go.mod h1:xpo/geVldu8payT375WekctUzopG/hBU7miiqItMUlw=
github.com/aws/aws-sdk-go-v2/service/sso v1.38.1 h1:Umtl/0YZhng4xndfW3lKJrYYP7NLEjI6bGXVomwLcs0=
github.com/aws/aws-sdk-go-v2/service/sso v1.38.1/go.mod h1:rRD/dnm7q0HYE/I5TMaPgkWyyUGLcwuxHLABsLnQ3e0=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1 h1:orIWdNiLgzrhu/11RcPPKO/SBzUUymbUQuZbSPImghg=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1/go.mod h1:skwM/xsbR/1ReUTesv9BhpJp1VjajR7DWQnuVLwiXsQ=
github.com/aws/aws-sdk-go-v2/service/sts v1.51.1 h1:0HOqZXRvMytH6bFHVIc0oJX07sZjfhz0zXtjs6gdE8s=
github.com/aws/aws-sdk-go-v2/service/sts v1.51.1/go.mod h1:26zA0GhDrLo+yiLI2yXWxqB1PdsShfLikoI7GOEgugM=
github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ=
github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.15.0 h1:LR1vPv62E0/6+sTenX35QrCmpMCzLeVAcnXeH4MrbJY=
github.com/go-webauthn/webauthn v0.15.0/go.mod h1:hcAOhVChPRG7oqG7Xj6XKN1mb+8eXTGP/B7zBLzkX5A=
github.com/go-webauthn/x v0.1.26 h1:eNzreFKnwNLDFoywGh9FA8YOMebBWTUNlNSdolQRebs=
github.com/go-webauthn/x v0.1.26/go.mod h1:jmf/phPV6oIsF6hmdVre+ovHkxjDOmNH0t6fekWUxvg=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-tpm v0.9.6 h1:Ku42PT4LmjDu1H5C5ISWLlpI1mj+Zq7sPGKoRw2XROA=
github.com/google/go-tpm v0.9.6/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -10,21 +60,35 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
+254
View File
@@ -0,0 +1,254 @@
// 账户页的后端:站主资料、身份绑定列表、passkey 管理。
//
// 和「站点设置」的分工:站点设置管站点(标题、皮肤、评论开关),账户页管
// 「你是谁 + 你能用什么方式登录」。昵称/简介仍是 author_name/author_bio
// 两个 settings 键(单一来源,前端各处照旧读),头像另用 owner_avatar_key
// 单键写入,避开 UpdateSettings 的全量替换。
package admin
import (
"errors"
"net/http"
"strings"
"oneblog/internal/httpx"
"oneblog/internal/model"
"oneblog/internal/storage"
"oneblog/internal/store"
)
const maxAvatarKeyLen = 160
// accountView 是账户页一次拉取的全部数据。
type accountView struct {
Name string `json:"name"`
Bio string `json:"bio"`
AvatarKey string `json:"avatar_key"`
AvatarURL string `json:"avatar_url"`
Handle string `json:"handle"`
Password passwordInfo `json:"password"`
Identities []model.UserIdentity `json:"identities"`
Passkeys []model.Passkey `json:"passkeys"`
// Providers 告诉前端哪些平台可以绑(未配凭据的平台不出现)。
Providers []string `json:"providers"`
}
type passwordInfo struct {
// 站主密码由环境变量管理,不进库也不做哈希 —— 这条退路保证
// 「解绑所有身份 + 删光 passkey」也不会把自已锁在门外。
ManagedBy string `json:"managed_by"`
Username string `json:"username"`
}
func (a *API) account(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
httpx.Error(w, http.StatusMethodNotAllowed, "GET required")
return
}
v, err := a.buildAccount()
if err != nil {
httpx.ServerError(w, err)
return
}
httpx.OK(w, v)
}
func (a *API) buildAccount() (accountView, error) {
owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser)
if err != nil {
return accountView{}, err
}
st, err := a.Store.GetSettings()
if err != nil {
return accountView{}, err
}
ids, err := a.Store.ListIdentities(owner.ID)
if err != nil {
return accountView{}, err
}
pks, err := a.Store.ListPasskeys(owner.ID)
if err != nil {
return accountView{}, err
}
v := accountView{
// 昵称以站主行的 name 为准;老数据里它是空的,回落到站点设置的作者名。
Name: firstNonEmptyStr(owner.Name, st.AuthorName),
Bio: st.AuthorBio,
AvatarKey: st.AuthorAvatarKey,
AvatarURL: a.avatarURL(st.AuthorAvatarKey),
Handle: owner.Handle,
Password: passwordInfo{ManagedBy: "env:ONE_ADMIN_PASSWORD", Username: a.Cfg.AdminUser},
Identities: ids,
Passkeys: pks,
}
// 可绑定的平台:只有跳转式 OAuth 能在后台发起。Telegram 是评论区里的
// 登录 widget,后台没有它的入口,所以不进这个列表(已绑的记录仍会显示)。
for _, p := range []string{"github", "google"} {
if a.providerEnabled(p) {
v.Providers = append(v.Providers, p)
}
}
return v, nil
}
// providerEnabled 判断某个第三方平台是否配了凭据。绑定入口只列已配置的,
// 否则点了必然报错。
func (a *API) providerEnabled(name string) bool {
switch name {
case "github":
return a.Cfg.GitHubClientID != "" && a.Cfg.GitHubClientSecret != ""
case "google":
return a.Cfg.GoogleClientID != "" && a.Cfg.GoogleClientSecret != ""
case "telegram":
return a.Cfg.TelegramBot != "" && a.Cfg.TelegramToken != ""
}
return false
}
// avatarURL 把 files key 解析成可访问 URL。key 指向的文件已删除时返回空串
// (前端会自动回落到站标),不留一个打不开的链接。
func (a *API) avatarURL(key string) string {
if key == "" {
return ""
}
f, err := a.Store.GetFileByKey(key)
if err != nil {
return ""
}
return storage.FileURL(f.Store, f.Key, a.Cfg.UploadsPublicBase)
}
type patchAccountRequest struct {
Name *string `json:"name"`
Bio *string `json:"bio"`
AvatarKey *string `json:"avatar_key"`
}
// patchAccount 改资料。只动传了的字段;头像 key 必须是 files 表里真实存在的
// 图片,免得存一个指向任意字符串的死链。
func (a *API) patchAccount(w http.ResponseWriter, r *http.Request) {
var in patchAccountRequest
if err := httpx.Decode(r, &in); err != nil {
httpx.BadRequest(w, "invalid body")
return
}
owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser)
if err != nil {
httpx.ServerError(w, err)
return
}
st, err := a.Store.GetSettings()
if err != nil {
httpx.ServerError(w, err)
return
}
changed := false
if in.Name != nil {
name := strings.TrimSpace(*in.Name)
if len([]rune(name)) > 40 {
httpx.BadRequest(w, "昵称最多 40 字")
return
}
if name == "" {
httpx.BadRequest(w, "昵称不能为空")
return
}
// 站主行与站点设置两处都要写:前者是身份来源,后者是既有前端读的地方。
if _, err := a.Store.UpdateProfile(owner.ID, name); err != nil {
httpx.ServerError(w, err)
return
}
st.AuthorName = name
changed = true
}
if in.Bio != nil {
bio := strings.TrimSpace(*in.Bio)
if len([]rune(bio)) > 200 {
httpx.BadRequest(w, "简介最多 200 字")
return
}
st.AuthorBio = bio
changed = true
}
if in.AvatarKey != nil {
key := strings.TrimSpace(*in.AvatarKey)
if len(key) > maxAvatarKeyLen {
httpx.BadRequest(w, "头像 key 过长")
return
}
if key != "" {
f, err := a.Store.GetFileByKey(key)
if errors.Is(err, store.ErrNotFound) {
httpx.BadRequest(w, "头像文件不存在,请重新上传")
return
}
if err != nil {
httpx.ServerError(w, err)
return
}
if !strings.HasPrefix(f.Mime, "image/") {
httpx.BadRequest(w, "头像必须是图片")
return
}
}
st.AuthorAvatarKey = key
changed = true
}
if !changed {
httpx.BadRequest(w, "没有要更新的字段")
return
}
// AuthorAvatarURL 是算出来的,不入库;写库前清掉免得误读。
st.AuthorAvatarURL = ""
if err := a.Store.UpdateSettings(st); err != nil {
httpx.ServerError(w, err)
return
}
// 头像键单独写:UpdateSettings 是全量替换,不含这个键。
if in.AvatarKey != nil {
if err := a.Store.SetSetting("owner_avatar_key", st.AuthorAvatarKey); err != nil {
httpx.ServerError(w, err)
return
}
}
v, err := a.buildAccount()
if err != nil {
httpx.ServerError(w, err)
return
}
httpx.OK(w, v)
}
// unbindIdentity 解绑一个第三方登录方式。
// 站主始终有环境变量密码兜底,所以这里不需要「不能解绑唯一登录方式」的护栏。
func (a *API) unbindIdentity(w http.ResponseWriter, r *http.Request) {
provider := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/admin/account/identities/"), "/")
if provider == "" || strings.Contains(provider, "/") {
httpx.BadRequest(w, "bad provider")
return
}
owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser)
if err != nil {
httpx.ServerError(w, err)
return
}
if err := a.Store.UnbindIdentity(owner.ID, provider); err != nil {
if errors.Is(err, store.ErrNotFound) {
httpx.NotFound(w)
return
}
httpx.ServerError(w, err)
return
}
httpx.OK(w, map[string]any{"ok": true})
}
func firstNonEmptyStr(vals ...string) string {
for _, v := range vals {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
+166
View File
@@ -0,0 +1,166 @@
package admin
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"oneblog/internal/model"
)
// doAs 带着有效后台会话发一个请求。
func doAs(t *testing.T, h http.Handler, method, path string, body string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, strings.NewReader(body))
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
// 用与 newTestAPI 里 NewSessions 相同的 secret 签一个会话
sess := NewSessions("test-secret", time.Hour)
tok, _ := sess.Issue("admin")
req.AddCookie(&http.Cookie{Name: cookieName, Value: tok})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
func TestAccountGET(t *testing.T) {
a, h := newTestAPI(t)
if _, err := a.Store.EnsureOwner("admin"); err != nil {
t.Fatal(err)
}
rec := doAs(t, h, http.MethodGet, "/api/admin/account", "")
if rec.Code != http.StatusOK {
t.Fatalf("got %d %s", rec.Code, rec.Body.String())
}
var v struct {
Name string `json:"name"`
Handle string `json:"handle"`
Password struct{} `json:"password"`
Providers []string `json:"providers"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &v); err != nil {
t.Fatal(err)
}
if v.Handle != "admin" {
t.Fatalf("handle=%q", v.Handle)
}
// 测试配置里没有 OAuth 凭据,可绑平台应为空
if len(v.Providers) != 0 {
t.Fatalf("providers=%v, want empty", v.Providers)
}
}
func TestAccountPATCHProfile(t *testing.T) {
a, h := newTestAPI(t)
if _, err := a.Store.EnsureOwner("admin"); err != nil {
t.Fatal(err)
}
rec := doAs(t, h, http.MethodPatch, "/api/admin/account", `{"name":"麻衣","bio":"活着就是为了樱岛麻衣"}`)
if rec.Code != http.StatusOK {
t.Fatalf("got %d %s", rec.Code, rec.Body.String())
}
// 昵称要同时落在站主行与 settings(前端各处仍读 settings.author_name)
owner, err := a.Store.GetOwner()
if err != nil {
t.Fatal(err)
}
if owner.Name != "麻衣" {
t.Fatalf("owner.name=%q", owner.Name)
}
st, err := a.Store.GetSettings()
if err != nil {
t.Fatal(err)
}
if st.AuthorName != "麻衣" || st.AuthorBio != "活着就是为了樱岛麻衣" {
t.Fatalf("settings 未同步: %+v", st)
}
}
func TestAccountPATCHAvatarKey(t *testing.T) {
a, h := newTestAPI(t)
if _, err := a.Store.EnsureOwner("admin"); err != nil {
t.Fatal(err)
}
// 不存在的 key 必须拒:否则会存下一个永远解析不出的头像
rec := doAs(t, h, http.MethodPatch, "/api/admin/account", `{"avatar_key":"2026/09/nope.png"}`)
if rec.Code != http.StatusBadRequest {
t.Fatalf("不存在的 key: got %d, want 400", rec.Code)
}
// 真实存在但不是图片的也要拒
f, err := a.Store.CreateFile(model.File{
Key: "2026/09/notes.txt", Name: "notes.txt", Mime: "text/plain",
Size: 4, SHA256: strings.Repeat("a", 64), Store: "local",
})
if err != nil {
t.Fatal(err)
}
rec = doAs(t, h, http.MethodPatch, "/api/admin/account", `{"avatar_key":"`+f.Key+`"}`)
if rec.Code != http.StatusBadRequest {
t.Fatalf("非图片: got %d, want 400", rec.Code)
}
// 图片就放行,并且单独写 owner_avatar_key(绕开 UpdateSettings 全量替换)
img, err := a.Store.CreateFile(model.File{
Key: "2026/09/me.png", Name: "me.png", Mime: "image/png",
Size: 4, SHA256: strings.Repeat("b", 64), Store: "local",
})
if err != nil {
t.Fatal(err)
}
rec = doAs(t, h, http.MethodPatch, "/api/admin/account", `{"avatar_key":"`+img.Key+`"}`)
if rec.Code != http.StatusOK {
t.Fatalf("图片头像: got %d %s", rec.Code, rec.Body.String())
}
st, _ := a.Store.GetSettings()
if st.AuthorAvatarKey != img.Key {
t.Fatalf("avatar key=%q", st.AuthorAvatarKey)
}
if !strings.Contains(rec.Body.String(), "/uploads/"+img.Key) {
t.Fatalf("响应里没解析出头像 URL: %s", rec.Body.String())
}
// 站点设置的整体 PUT 不该把头像键冲掉(两者写入路径分开)
if err := a.Store.UpdateSettings(st); err != nil {
t.Fatal(err)
}
after, _ := a.Store.GetSettings()
if after.AuthorAvatarKey != img.Key {
t.Fatalf("UpdateSettings 把头像键清了: %q", after.AuthorAvatarKey)
}
}
func TestAccountRejectsAnonymous(t *testing.T) {
_, h := newTestAPI(t)
for _, path := range []string{"/api/admin/account", "/api/admin/account/passkeys"} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
if rec.Code != http.StatusUnauthorized {
t.Errorf("%s: got %d, want 401", path, rec.Code)
}
}
}
func TestAccountUnbindUnknown(t *testing.T) {
a, h := newTestAPI(t)
if _, err := a.Store.EnsureOwner("admin"); err != nil {
t.Fatal(err)
}
rec := doAs(t, h, http.MethodDelete, "/api/admin/account/identities/github", "")
if rec.Code != http.StatusNotFound {
t.Fatalf("没绑过还解绑: got %d, want 404", rec.Code)
}
}
// passkey 未配置时必须明确不可用,而不是假装成功
func TestPasskeysUnavailableWhenNil(t *testing.T) {
a, h := newTestAPI(t)
if a.Passkeys != nil {
t.Skip("测试构造里不该有 Passkeys")
}
rec := doAs(t, h, http.MethodPost, "/api/admin/account/passkeys/begin", "")
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("got %d, want 503", rec.Code)
}
}
+573 -3
View File
@@ -4,22 +4,50 @@
package admin
import (
"context"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"oneblog/internal/auth"
"oneblog/internal/config"
"oneblog/internal/httpx"
"oneblog/internal/hub"
"oneblog/internal/linkmeta"
"oneblog/internal/model"
"oneblog/internal/storage"
"oneblog/internal/store"
"oneblog/internal/thumbs"
)
type API struct {
Store *store.Store
Cfg *config.Config
Sessions *Sessions
// Hub 是评论变更广播(与公开 API 共享同一实例,main.go 装配);
// 审核通过 / 后台删除评论时让前台打开着的页面即时刷新。
Hub *hub.Hub
// 文件上传的存储后端与公开域名(main.go 装配,两个 API 共享同一实例)
Blobs storage.BlobStore
PublicBase string
// Thumbs 是缩略图磁盘缓存(main.go 装配)。删上传文件时顺手清掉它的
// 缩略图产物,否则已删图片会一直占着缓存。
Thumbs *thumbs.Store
// Passkeys 是 WebAuthn 服务(main.go 装配;未配置时为 nil,相关端点直接 503)
Passkeys *auth.Passkeys
loginOnce sync.Once
logins *loginLimiter
@@ -56,7 +84,44 @@ func (a *API) Routes() http.Handler {
mux.HandleFunc("/api/admin/tags/", a.guard(a.tagByID))
mux.HandleFunc("/api/admin/projects", a.guard(a.listProjects))
mux.HandleFunc("/api/admin/projects/", a.guard(a.projectByID))
mux.HandleFunc("/api/admin/files", a.guard(a.files))
mux.HandleFunc("/api/admin/files/import", a.guard(a.importFiles))
mux.HandleFunc("/api/admin/files/", a.guard(a.fileByID))
mux.HandleFunc("/api/admin/settings", a.guard(a.settings))
// 账户页:资料 + 身份绑定 + passkey
mux.HandleFunc("/api/admin/account", a.guard(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
a.account(w, r)
case http.MethodPatch, http.MethodPut:
a.patchAccount(w, r)
default:
httpx.Error(w, http.StatusMethodNotAllowed, "GET/PATCH required")
}
}))
mux.HandleFunc("/api/admin/account/identities/", a.guard(a.unbindIdentity))
mux.HandleFunc("/api/admin/account/passkeys", a.guard(a.listPasskeys))
mux.HandleFunc("/api/admin/account/passkeys/begin", a.guard(a.beginPasskey))
mux.HandleFunc("/api/admin/account/passkeys/finish", a.guard(a.finishPasskey))
mux.HandleFunc("/api/admin/account/passkeys/", a.guard(a.deletePasskey))
mux.HandleFunc("/api/admin/comments", a.guard(a.adminComments))
mux.HandleFunc("/api/admin/comments/", a.guard(a.adminCommentByID))
mux.HandleFunc("/api/admin/readers", a.guard(a.adminReaders))
mux.HandleFunc("/api/admin/readers/", a.guard(func(w http.ResponseWriter, r *http.Request) {
// 路径形如 /api/admin/readers/{id}/ban
rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/admin/readers/"), "/")
parts := strings.Split(rest, "/")
if len(parts) != 2 || parts[1] != "ban" {
httpx.NotFound(w)
return
}
id, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil || id <= 0 {
httpx.BadRequest(w, "bad reader id")
return
}
a.adminReaderBan(w, r, id)
}))
return mux
}
@@ -104,7 +169,7 @@ func (a *API) login(w http.ResponseWriter, r *http.Request) {
return
}
key := sourceKey(r)
if a.limiter().blocked(key) {
if a.limiter().Blocked(key) {
httpx.Error(w, http.StatusTooManyRequests, "失败次数过多,请 10 分钟后再试")
return
}
@@ -116,11 +181,11 @@ func (a *API) login(w http.ResponseWriter, r *http.Request) {
userOK := subtle.ConstantTimeCompare([]byte(in.Username), []byte(a.Cfg.AdminUser)) == 1
passOK := subtle.ConstantTimeCompare([]byte(in.Password), []byte(a.Cfg.AdminPass)) == 1
if !userOK || !passOK {
a.limiter().fail(key)
a.limiter().Add(key)
httpx.Unauthorized(w)
return
}
a.limiter().reset(key)
a.limiter().Reset(key)
token, exp := a.Sessions.Issue(a.Cfg.AdminUser)
http.SetCookie(w, &http.Cookie{
Name: cookieName,
@@ -184,6 +249,8 @@ func (a *API) listPosts(w http.ResponseWriter, r *http.Request) {
return
}
in.Status = store.NormalizeStatus(in.Status)
attachLinkCard(r.Context(), &in, nil)
autoMeta(&in, nil)
p, err := a.Store.Create(in)
if err != nil {
httpx.ServerError(w, err)
@@ -223,6 +290,16 @@ func (a *API) postByID(w http.ResponseWriter, r *http.Request) {
if in.Status != "" {
in.Status = store.NormalizeStatus(in.Status)
}
// 多读一次当前行:链接卡片没变就不必再打远端;摘要/封面自动
// 生成也以它为准——「新值与旧值都为空」才补,主动清空的保得住
var curCard *model.LinkCard
var curPost *model.Post
if cur, err := a.Store.Get(id); err == nil {
curCard = cur.LinkCard
curPost = &cur
}
attachLinkCard(r.Context(), &in, curCard)
autoMeta(&in, curPost)
p, err := a.Store.Update(id, in)
writeOne(w, p, err)
case http.MethodDelete:
@@ -545,3 +622,496 @@ func (a *API) bulkPosts(w http.ResponseWriter, r *http.Request) {
httpx.BadRequest(w, "action must be one of publish|draft|delete")
}
}
// ---------- files(上传与文件管理) ----------
// 单文件上限与类型白名单。SVG 拒绝:同源内联可执行脚本。
const (
maxFileUpload = 50 << 20
)
var allowFileExt = map[string]string{
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
".gif": "image/gif",
".avif": "image/avif",
".pdf": "application/pdf",
".zip": "application/zip",
".txt": "text/plain",
}
func (a *API) files(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
fp, err := a.Store.ListFiles(httpx.QueryInt(r, "page", 1), httpx.QueryInt(r, "size", 20), httpx.QueryString(r, "q"))
if err != nil {
httpx.ServerError(w, err)
return
}
for i := range fp.Items {
fp.Items[i].URL = storage.FileURL(fp.Items[i].Store, fp.Items[i].Key, a.Cfg.UploadsPublicBase)
}
httpx.OK(w, fp)
case http.MethodPost:
a.uploadFiles(w, r)
default:
httpx.Error(w, http.StatusMethodNotAllowed, "GET/POST required")
}
}
func (a *API) uploadFiles(w http.ResponseWriter, r *http.Request) {
// 64MB = 50MB 文件 + multipart 编码开销
r.Body = http.MaxBytesReader(w, r.Body, maxFileUpload+(8<<20))
if err := r.ParseMultipartForm(8 << 20); err != nil {
httpx.BadRequest(w, "上传失败:请求体超过上限(单文件 50MB)")
return
}
if r.MultipartForm != nil {
defer r.MultipartForm.RemoveAll()
}
fhs := r.MultipartForm.File["file"]
if len(fhs) == 0 {
httpx.BadRequest(w, "没有收到文件")
return
}
out := make([]model.File, 0, len(fhs))
for _, fh := range fhs {
f, err := a.storeOne(r.Context(), fh)
if err != nil {
if bu, ok := err.(badUpload); ok {
httpx.BadRequest(w, string(bu))
return
}
httpx.ServerError(w, err)
return
}
out = append(out, f)
}
// URL 统一在这里解析:storeOne 的「去重复用已有行」路径也不例外
for i := range out {
out[i].URL = storage.FileURL(out[i].Store, out[i].Key, a.Cfg.UploadsPublicBase)
}
httpx.Created(w, out)
}
// badUpload 区分「文件本身的问题」(类型 / 大小 → 400 直接告诉站主)与
// 服务器故障(500,已入库的部分保留)。
type badUpload string
func (e badUpload) Error() string { return string(e) }
// storeOne 校验、哈希、落盘单个文件。内容哈希做 key(同年月分目录),
// 同内容重复上传直接复用已有行,不产生孤儿对象。
func (a *API) storeOne(ctx context.Context, fh *multipart.FileHeader) (model.File, error) {
if fh.Size > maxFileUpload {
return model.File{}, badUpload(fmt.Sprintf("%s:超过单文件 50MB 上限", fh.Filename))
}
ext := strings.ToLower(filepath.Ext(fh.Filename))
mime, ok := allowFileExt[ext]
if !ok {
return model.File{}, badUpload(fmt.Sprintf("%s:不支持的类型 %q", fh.Filename, ext))
}
src, err := fh.Open()
if err != nil {
return model.File{}, err
}
defer src.Close()
// 头 512 字节给 http.DetectContentType 嗅探真实类型,全文流过 sha256,
// 同时落到临时文件(S3 PutObject 需要确定的 ContentLength)。
tmp, err := os.CreateTemp("", "one-upload-*")
if err != nil {
return model.File{}, err
}
defer os.Remove(tmp.Name())
hasher := sha256.New()
head := make([]byte, 512)
hn, _ := io.ReadFull(src, head)
head = head[:hn]
for _, w := range []io.Writer{tmp, hasher} {
if _, err := w.Write(head); err != nil {
tmp.Close()
return model.File{}, err
}
}
copied, err := io.Copy(io.MultiWriter(tmp, hasher), src)
if err != nil {
tmp.Close()
return model.File{}, err
}
if err := tmp.Close(); err != nil {
return model.File{}, err
}
size := int64(hn) + copied
// 嗅探结果必须与扩展名声明的类型完全一致(防改后缀绕过白名单)。
// 不给 octet-stream 留口子:一张「image/png」嗅探出 octet-stream
// 就绝不是 PNG——随机字节改名上传就是这么溜进来的。
detected := strings.SplitN(http.DetectContentType(head), ";", 2)[0]
if detected != mime {
return model.File{}, badUpload(fmt.Sprintf("%s:文件内容与扩展名不符", fh.Filename))
}
sum := hex.EncodeToString(hasher.Sum(nil))
return a.persistFile(ctx, fh.Filename, ext, mime, tmp.Name(), size, sum)
}
// persistFile 落库共享段:内容哈希做 key(同年月分目录)、去重复用、
// Put 对象存储、建行。storeOne(本地上传)与 importOne(外链转存)共用。
func (a *API) persistFile(ctx context.Context, name, ext, mime, tmpPath string, size int64, sum string) (model.File, error) {
key := fmt.Sprintf("%s/%s%s", time.Now().UTC().Format("2006/01"), sum[:12], ext)
// S3Api 端点带路径段时(如 .../oss),该段会折进对象 key——
// 数据库必须记录同样的完整 key,直链才不会 404
if p := storage.EndpointKeyPrefix(a.Cfg.S3Endpoint); p != "" {
key = p + "/" + key
}
// 内容去重:同一份内容只存一份,复用已有行。
// URL 必须按当前存储配置重新解析——去重路径不走下面的 created 赋值,
// 漏了它转存替换会拿到空 URL(真实事故:正文图片链接被清空)。
if exist, err := a.Store.GetFileByKey(key); err == nil {
exist.URL = storage.FileURL(exist.Store, exist.Key, a.Cfg.UploadsPublicBase)
return exist, nil
}
f, err := os.Open(tmpPath)
if err != nil {
return model.File{}, err
}
defer f.Close()
if err := a.Blobs.Put(ctx, key, f, size, mime); err != nil {
return model.File{}, err
}
created, err := a.Store.CreateFile(model.File{
Key: key,
Name: name,
Mime: mime,
Size: size,
SHA256: sum,
Store: a.Cfg.StorageDriver,
})
if err != nil {
return model.File{}, err
}
created.URL = storage.FileURL(created.Store, created.Key, a.Cfg.UploadsPublicBase)
return created, nil
}
// mimeToExt 是 allowFileExt 的反向映射:外链转存时内容嗅探出 mime,
// 反推扩展名(URL 本身可能不带后缀或后缀不可信)。
var mimeToExt = func() map[string]string {
m := make(map[string]string, len(allowFileExt))
for ext, mime := range allowFileExt {
m[mime] = ext
}
return m
}()
// importFiles 外链转存:POST /api/admin/files/import {"urls": [...]}。
// 站主把别处的图片贴进正文后一键搬进自己的存储——与手动上传同一套
// 白名单、内容嗅探与内容去重;抓取走 linkmeta 的 SSRF 防护拨号。
func (a *API) importFiles(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
httpx.Error(w, http.StatusMethodNotAllowed, "POST required")
return
}
var in struct {
URLs []string `json:"urls"`
}
if err := httpx.Decode(r, &in); err != nil {
httpx.BadRequest(w, "invalid body")
return
}
if len(in.URLs) == 0 || len(in.URLs) > 20 {
httpx.BadRequest(w, "urls 需要1-20个")
return
}
// files 带上 source(原址),前端按它做正文替换
type imported struct {
Source string `json:"source"`
File model.File `json:"file"`
}
files := make([]imported, 0, len(in.URLs))
errs := map[string]string{}
for _, u := range in.URLs {
f, err := a.importOne(r.Context(), u)
if err != nil {
if bu, ok := err.(badUpload); ok {
errs[u] = string(bu)
} else {
errs[u] = err.Error()
}
continue
}
files = append(files, imported{Source: u, File: f})
}
httpx.OK(w, map[string]any{"files": files, "errors": errs})
}
func (a *API) importOne(ctx context.Context, rawURL string) (model.File, error) {
cctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
data, ct, err := linkmeta.FetchBytes(cctx, rawURL, maxFileUpload)
if err != nil {
return model.File{}, fmt.Errorf("抓取失败:%w", err)
}
if len(data) == 0 {
return model.File{}, badUpload("空内容")
}
// 类型必须落在本站白名单里:嗅探优先(不信响应头,更不信 URL 后缀)
detected := strings.SplitN(http.DetectContentType(data[:512]), ";", 2)[0]
ext, ok := mimeToExt[detected]
if !ok {
return model.File{}, badUpload("不支持的类型 " + detected)
}
_ = ct
// 落临时文件:persistFile / S3 PutObject 都要确定的文件与长度
tmp, err := os.CreateTemp("", "one-import-*")
if err != nil {
return model.File{}, err
}
defer os.Remove(tmp.Name())
size, err := tmp.Write(data)
if err != nil {
tmp.Close()
return model.File{}, err
}
if err := tmp.Close(); err != nil {
return model.File{}, err
}
sum := sha256.Sum256(data)
// 名字取 URL 路径末段(仅用于文件管理页展示,不参与存储路径)
name := rawURL
if u, err := url.Parse(rawURL); err == nil && u.Path != "" {
if base := path.Base(u.Path); base != "" && base != "/" && base != "." {
name = base
}
}
return a.persistFile(ctx, name, ext, detected, tmp.Name(), int64(size), hex.EncodeToString(sum[:]))
}
func (a *API) fileByID(w http.ResponseWriter, r *http.Request) {
rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/admin/files/"), "/")
idPart, sub := rest, ""
if i := strings.Index(rest, "/"); i >= 0 {
idPart, sub = rest[:i], rest[i+1:]
}
// /files/{id} 与 /files/{id}/refs 两种形态,别的一律不收
if sub != "" && (sub != "refs" || r.Method != http.MethodGet) {
httpx.BadRequest(w, "bad file path")
return
}
id, err := parseInt(idPart)
if err != nil {
httpx.BadRequest(w, "bad file id")
return
}
switch r.Method {
case http.MethodGet:
if sub == "refs" {
a.fileRefs(w, r, id)
return
}
f, err := a.Store.GetFile(id)
if errors.Is(err, store.ErrNotFound) {
httpx.NotFound(w)
return
}
if err != nil {
httpx.ServerError(w, err)
return
}
f.URL = storage.FileURL(f.Store, f.Key, a.Cfg.UploadsPublicBase)
httpx.OK(w, f)
case http.MethodDelete:
f, err := a.Store.GetFile(id)
if errors.Is(err, store.ErrNotFound) {
httpx.NotFound(w)
return
}
if err != nil {
httpx.ServerError(w, err)
return
}
// 正文/封面/项目/站主头像里还在用就先挡住,除非明确带 force=1。
// 检查放在删对象之前:一旦 blob 删了就没法回头。
if refs, err := a.Store.FileReferences(f.Key); err != nil {
httpx.ServerError(w, err)
return
} else if len(refs) > 0 && httpx.QueryString(r, "force") != "1" {
httpx.Error(w, http.StatusConflict, fileInUseMessage(refs))
return
}
// 先删对象存储再删行:存储端失败时行保留,可以重试
if err := a.Blobs.Delete(r.Context(), f.Key); err != nil {
httpx.ServerError(w, err)
return
}
if _, err := a.Store.DeleteFile(id); err != nil {
httpx.ServerError(w, err)
return
}
if a.Thumbs != nil {
a.Thumbs.Purge(f.SHA256)
}
httpx.OK(w, map[string]any{"ok": true})
default:
httpx.Error(w, http.StatusMethodNotAllowed, "GET/DELETE required")
}
}
// fileRefs 报告这个文件被谁引用,供前端在删除弹层里列出来。
func (a *API) fileRefs(w http.ResponseWriter, r *http.Request, id int64) {
f, err := a.Store.GetFile(id)
if errors.Is(err, store.ErrNotFound) {
httpx.NotFound(w)
return
}
if err != nil {
httpx.ServerError(w, err)
return
}
refs, err := a.Store.FileReferences(f.Key)
if err != nil {
httpx.ServerError(w, err)
return
}
httpx.OK(w, map[string]any{"key": f.Key, "count": len(refs), "items": refs})
}
// fileInUseMessage 是挡住删除时回给调用方的一句话,只列前三个引用。
func fileInUseMessage(refs []model.FileRef) string {
names := make([]string, 0, 3)
for _, x := range refs {
if x.Kind == "avatar" {
names = append(names, "站主头像")
continue
}
t := x.Title
if t == "" {
t = x.Slug
}
names = append(names, "《"+t+"》")
if len(names) == 3 {
break
}
}
label := strings.Join(names, "、")
if len(refs) > len(names) {
label += " 等"
}
return fmt.Sprintf("该文件正被 %d 处引用:%s,删除后这些位置会失效(确认要删带 force=1)", len(refs), label)
}
// ---------- comments(评论审核与管理) ----------
func (a *API) adminComments(w http.ResponseWriter, r *http.Request) {
status := httpx.QueryString(r, "status")
page := httpx.QueryInt(r, "page", 1)
size := httpx.QueryInt(r, "size", 20)
fp, err := a.Store.ListCommentsAdmin(status, page, size)
if err != nil {
httpx.ServerError(w, err)
return
}
httpx.OK(w, fp)
}
func (a *API) adminCommentByID(w http.ResponseWriter, r *http.Request) {
rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/admin/comments/"), "/")
id, err := parseInt(rest)
if err != nil {
httpx.BadRequest(w, "bad comment id")
return
}
switch r.Method {
case http.MethodPut:
// 审核动作:{"status": "visible" | "pending"}
var in struct {
Status string `json:"status"`
}
if err := httpx.Decode(r, &in); err != nil {
httpx.BadRequest(w, "invalid body")
return
}
if in.Status != "visible" && in.Status != "pending" {
httpx.BadRequest(w, "status 只支持 visible / pending")
return
}
if err := a.Store.SetCommentStatus(id, in.Status); err != nil {
if errors.Is(err, store.ErrNotFound) {
httpx.NotFound(w)
return
}
httpx.ServerError(w, err)
return
}
if in.Status == "visible" && a.Hub != nil {
if c, err := a.Store.GetComment(id); err == nil {
a.Hub.Broadcast(c.PostID)
}
}
httpx.OK(w, map[string]any{"ok": true})
case http.MethodDelete:
// 后台删除同样走软删(墓碑保楼层)
if err := a.Store.DeleteComment(id); err != nil {
if errors.Is(err, store.ErrNotFound) {
httpx.NotFound(w)
return
}
httpx.ServerError(w, err)
return
}
if a.Hub != nil {
if c, err := a.Store.GetComment(id); err == nil {
a.Hub.Broadcast(c.PostID)
}
}
httpx.OK(w, map[string]any{"ok": true})
default:
httpx.Error(w, http.StatusMethodNotAllowed, "PUT/DELETE required")
}
}
// ---------- readers(评论用户与禁言) ----------
func (a *API) adminReaders(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
readers, err := a.Store.ListReaders()
if err != nil {
httpx.ServerError(w, err)
return
}
httpx.OK(w, map[string]any{"readers": readers})
default:
httpx.Error(w, http.StatusMethodNotAllowed, "GET required")
}
}
func (a *API) adminReaderBan(w http.ResponseWriter, r *http.Request, id int64) {
var in struct {
Banned bool `json:"banned"`
}
if err := httpx.Decode(r, &in); err != nil {
httpx.BadRequest(w, "invalid body")
return
}
// 站主身份(管理员会话映射出来的读者)不允许禁言
if rd, err := a.Store.GetReader(id); err == nil && rd.Provider == "admin" {
httpx.BadRequest(w, "不能禁言站主身份")
return
}
if err := a.Store.SetReaderBanned(id, in.Banned); err != nil {
if errors.Is(err, store.ErrNotFound) {
httpx.NotFound(w)
return
}
httpx.ServerError(w, err)
return
}
httpx.OK(w, map[string]any{"ok": true, "banned": in.Banned})
}
+147
View File
@@ -0,0 +1,147 @@
package admin
import (
"encoding/json"
"errors"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"oneblog/internal/model"
"oneblog/internal/storage"
"oneblog/internal/store"
)
func itoa(n int64) string { return strconv.FormatInt(n, 10) }
// seed 放一个本地 blob + 一行 files,再按 body 建一篇引用它的短文(body 为空表示不引用)。
func seed(t *testing.T, a *API, key, body string) model.File {
t.Helper()
if err := a.Blobs.Put(t.Context(), key, strings.NewReader("pngbytes"), 8, "image/png"); err != nil {
t.Fatal(err)
}
f, err := a.Store.CreateFile(model.File{
Key: key, Name: filepath.Base(key), Mime: "image/png",
Size: 8, SHA256: strings.Repeat("f", 64), Store: "local",
})
if err != nil {
t.Fatal(err)
}
if body != "" {
if _, err := a.Store.Create(model.PostInput{
Kind: model.KindShort, Slug: "s-" + key, ContentMd: body, Status: model.StatusPublished,
}); err != nil {
t.Fatal(err)
}
}
return f
}
func TestFileRefsEndpoint(t *testing.T) {
a, h := newTestAPI(t)
a.Blobs = storage.NewLocal(t.TempDir())
f := seed(t, a, "2026/09/aaa.png", `看图 ![](/uploads/2026/09/aaa.png)`)
// 顺手把站主头像也指到同一张图,refs 要把这一路也报出来
if err := a.Store.SetSetting("owner_avatar_key", f.Key); err != nil {
t.Fatal(err)
}
rec := doAs(t, h, http.MethodGet, "/api/admin/files/"+itoa(f.ID)+"/refs", "")
if rec.Code != http.StatusOK {
t.Fatalf("got %d %s", rec.Code, rec.Body.String())
}
var v struct {
Key string `json:"key"`
Count int `json:"count"`
Items []model.FileRef `json:"items"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &v); err != nil {
t.Fatal(err)
}
if v.Key != f.Key || v.Count != 2 || len(v.Items) != 2 {
t.Fatalf("refs 不对: %+v", v)
}
var kinds = map[string]bool{}
for _, x := range v.Items {
kinds[x.Kind] = true
}
if !kinds["post"] || !kinds["avatar"] {
t.Fatalf("缺引用类型: %+v", v.Items)
}
// 不存在的文件:404 而不是空列表
rec = doAs(t, h, http.MethodGet, "/api/admin/files/9999/refs", "")
if rec.Code != http.StatusNotFound {
t.Fatalf("want 404, got %d", rec.Code)
}
// 乱七八糟的子路径不收
rec = doAs(t, h, http.MethodGet, "/api/admin/files/1/nope", "")
if rec.Code != http.StatusBadRequest {
t.Fatalf("want 400, got %d", rec.Code)
}
}
// 有引用时默认挡住,而且挡住之后 blob 和行都得还在(可重试)。
func TestFileDeleteBlockedByRefs(t *testing.T) {
a, h := newTestAPI(t)
dir := t.TempDir()
a.Blobs = storage.NewLocal(dir)
f := seed(t, a, "2026/09/bbb.png", `![](/uploads/2026/09/bbb.png)`)
rec := doAs(t, h, http.MethodDelete, "/api/admin/files/"+itoa(f.ID), "")
if rec.Code != http.StatusConflict {
t.Fatalf("want 409, got %d %s", rec.Code, rec.Body.String())
}
var e struct {
Error string `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &e); err != nil {
t.Fatal(err)
}
if !strings.Contains(e.Error, "1 处引用") {
t.Fatalf("错误消息没报引用数: %q", e.Error)
}
if _, err := os.Stat(filepath.Join(dir, "2026/09/bbb.png")); err != nil {
t.Fatalf("被挡住时不该动存储: %v", err)
}
if _, err := a.Store.GetFile(f.ID); err != nil {
t.Fatalf("被挡住时不该删行: %v", err)
}
}
// 明确带 force=1 才真删:行、blob 一起走。
func TestFileDeleteForceProceeds(t *testing.T) {
a, h := newTestAPI(t)
dir := t.TempDir()
a.Blobs = storage.NewLocal(dir)
f := seed(t, a, "2026/09/ccc.png", `![](/uploads/2026/09/ccc.png)`)
rec := doAs(t, h, http.MethodDelete, "/api/admin/files/"+itoa(f.ID)+"?force=1", "")
if rec.Code != http.StatusOK {
t.Fatalf("want 200, got %d %s", rec.Code, rec.Body.String())
}
if _, err := a.Store.GetFile(f.ID); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("行应已删除, got %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "2026/09/ccc.png")); !os.IsNotExist(err) {
t.Fatalf("blob 应已删除, got %v", err)
}
}
// 没被引用的文件不用 force 也能删(守卫不能把所有删除都挡死)。
func TestFileDeleteUnreferenced(t *testing.T) {
a, h := newTestAPI(t)
a.Blobs = storage.NewLocal(t.TempDir())
f := seed(t, a, "2026/09/ddd.png", "")
rec := doAs(t, h, http.MethodDelete, "/api/admin/files/"+itoa(f.ID), "")
if rec.Code != http.StatusOK {
t.Fatalf("want 200, got %d %s", rec.Code, rec.Body.String())
}
if _, err := a.Store.GetFile(f.ID); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("行应已删除, got %v", err)
}
}
+7 -53
View File
@@ -1,18 +1,17 @@
package admin
import (
"net"
"net/http"
"sync"
"time"
"oneblog/internal/ratelimit"
)
// loginLimiter 限制每个来源 IP 的登录失败次数(滑动窗口),
// 防止默认/弱口令被在线暴力破解。成功登录后计数清零。
type loginLimiter struct {
mu sync.Mutex
hits map[string][]time.Time
}
// 实现挪到了 internal/ratelimit(读者登录/评论写入共用),这里保留
// 原有调用面与常量。
type loginLimiter = ratelimit.Window
const (
maxLoginFails = 10
@@ -20,55 +19,10 @@ const (
)
func newLoginLimiter() *loginLimiter {
return &loginLimiter{hits: make(map[string][]time.Time)}
return ratelimit.New(maxLoginFails, loginWindow)
}
func (l *loginLimiter) blocked(key string) bool {
l.mu.Lock()
defer l.mu.Unlock()
return len(l.recent(key, time.Now())) >= maxLoginFails
}
func (l *loginLimiter) fail(key string) {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
l.hits[key] = append(l.recent(key, now), now)
}
func (l *loginLimiter) reset(key string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.hits, key)
}
// recent 返回窗口内的失败时间;调用方必须持有 l.mu。
func (l *loginLimiter) recent(key string, now time.Time) []time.Time {
hs := l.hits[key]
cut := now.Add(-loginWindow)
i := 0
for ; i < len(hs); i++ {
if hs[i].After(cut) {
break
}
}
if i > 0 {
hs = hs[i:]
l.hits[key] = hs
}
if len(hs) == 0 {
delete(l.hits, key)
}
return hs
}
func sourceKey(r *http.Request) string {
// 只信连接层地址;X-Forwarded-For 可被伪造,不作为限速键。
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}
func sourceKey(r *http.Request) string { return ratelimit.SourceKey(r) }
// isTLS 判断最终用户看到的是不是 HTTPS(含反代 X-Forwarded-Proto)。
func isTLS(r *http.Request) bool {
+45
View File
@@ -0,0 +1,45 @@
package admin
import (
"context"
"time"
"oneblog/internal/linkmeta"
"oneblog/internal/model"
)
// linkFetchBudget 是给外链抓取的时间预算:发布不该被一个慢站点拖住,
// 超时就当作「这篇没有卡片」。
const linkFetchBudget = 2500 * time.Millisecond
// linkFetch 是要不要抓远端的注入点:默认走带 SSRF 防护的真实抓取,
// 测试里换成假实现(真实抓取在测试环境会被自己的防护拦掉)。
var linkFetch = linkmeta.Fetch
// attachLinkCard 按正文重算短文链接卡片,结果写进 in.LinkCard。
//
// 只处理短文(长文在时间线已有封面媒体位,再加卡片会抢位置);
// 链接没变就沿用旧卡片,不重复抓远端;正文里删掉链接、或抓取失败,
// 一律传空卡片把旧的清掉——宁可没有卡片,也不要挂着别的地址抓来的旧数据。
func attachLinkCard(ctx context.Context, in *model.PostInput, cur *model.LinkCard) {
if in.Kind != model.KindShort || in.ContentMd == "" {
return
}
u := linkmeta.FirstURL(in.ContentMd)
if u == "" {
in.LinkCard = &model.LinkCard{}
return
}
if cur != nil && cur.URL == u {
in.LinkCard = cur
return
}
cctx, cancel := context.WithTimeout(ctx, linkFetchBudget)
defer cancel()
card, err := linkFetch(cctx, u)
if err != nil {
in.LinkCard = &model.LinkCard{}
return
}
in.LinkCard = card
}
+112
View File
@@ -0,0 +1,112 @@
package admin
import (
"context"
"errors"
"testing"
"oneblog/internal/model"
"oneblog/internal/store"
)
func TestAttachLinkCard(t *testing.T) {
old := linkFetch
t.Cleanup(func() { linkFetch = old })
t.Run("长文不动", func(t *testing.T) {
called := false
linkFetch = func(context.Context, string) (*model.LinkCard, error) {
called = true
return nil, nil
}
in := &model.PostInput{Kind: model.KindLong, ContentMd: "看 https://a.cn"}
attachLinkCard(context.Background(), in, nil)
if in.LinkCard != nil || called {
t.Fatalf("长文不该抓取,card=%+v called=%v", in.LinkCard, called)
}
})
t.Run("没链接则清空", func(t *testing.T) {
linkFetch = func(context.Context, string) (*model.LinkCard, error) {
t.Fatal("没链接不该抓")
return nil, nil
}
in := &model.PostInput{Kind: model.KindShort, ContentMd: "纯文本一句"}
attachLinkCard(context.Background(), in, &model.LinkCard{URL: "https://old.cn", Title: "旧"})
if in.LinkCard == nil || !in.LinkCard.Empty() {
t.Fatalf("应传空卡片清掉旧的,got %+v", in.LinkCard)
}
})
t.Run("链接未变则沿用不重抓", func(t *testing.T) {
linkFetch = func(context.Context, string) (*model.LinkCard, error) {
t.Fatal("同一个链接不该重复抓")
return nil, nil
}
cur := &model.LinkCard{URL: "https://a.cn", Title: "已抓过"}
in := &model.PostInput{Kind: model.KindShort, ContentMd: "看 https://a.cn"}
attachLinkCard(context.Background(), in, cur)
if in.LinkCard != cur {
t.Fatalf("应沿用旧卡片,got %+v", in.LinkCard)
}
})
t.Run("新链接则抓取", func(t *testing.T) {
var gotURL string
linkFetch = func(_ context.Context, u string) (*model.LinkCard, error) {
gotURL = u
return &model.LinkCard{URL: u, Title: "新卡片"}, nil
}
in := &model.PostInput{Kind: model.KindShort, ContentMd: "换成 https://b.cn/x。"}
attachLinkCard(context.Background(), in, &model.LinkCard{URL: "https://a.cn", Title: "旧"})
if gotURL != "https://b.cn/x" {
t.Fatalf("结尾标点没剪掉: %q", gotURL)
}
if in.LinkCard == nil || in.LinkCard.Title != "新卡片" {
t.Fatalf("card=%+v", in.LinkCard)
}
})
t.Run("抓取失败则清空", func(t *testing.T) {
linkFetch = func(context.Context, string) (*model.LinkCard, error) {
return nil, errors.New("timeout")
}
in := &model.PostInput{Kind: model.KindShort, ContentMd: "https://slow.cn"}
attachLinkCard(context.Background(), in, &model.LinkCard{URL: "https://a.cn", Title: "旧"})
if in.LinkCard == nil || !in.LinkCard.Empty() {
t.Fatalf("失败应清空,got %+v", in.LinkCard)
}
})
}
func TestLinkCardRoundTrip(t *testing.T) {
a, _ := newTestAPI(t)
card := &model.LinkCard{URL: "https://example.cn/x", Title: "示例标题", Desc: "描述", Site: "example", Image: "https://cdn/i.png"}
p, err := a.Store.Create(model.PostInput{Kind: model.KindShort, ContentMd: "看 https://example.cn/x",
Status: model.StatusPublished, LinkCard: card})
if err != nil {
t.Fatal(err)
}
if p.LinkCard == nil || p.LinkCard.Title != "示例标题" || p.LinkCard.Image != "https://cdn/i.png" {
t.Fatalf("创建后没读回卡片: %+v", p.LinkCard)
}
// 列表接口也要带上(时间线靠它渲染)
page, err := a.Store.List(store.ListOptions{Page: 1, Size: 10})
if err != nil {
t.Fatal(err)
}
if len(page.Items) != 1 || page.Items[0].LinkCard == nil {
t.Fatalf("列表未带卡片: %+v", page.Items)
}
// 空卡片 = 清空
if _, err := a.Store.Update(p.ID, model.PostInput{LinkCard: &model.LinkCard{}}); err != nil {
t.Fatal(err)
}
got, err := a.Store.Get(p.ID)
if err != nil {
t.Fatal(err)
}
if got.LinkCard != nil {
t.Fatalf("应已清空,got %+v", got.LinkCard)
}
}
+68
View File
@@ -0,0 +1,68 @@
// 长文的自动摘要与自动封面:站主没填这两项时从正文派生——
// 摘要取正文开头的一段纯文本,封面取正文第一张图。
// 更新路径只在「新值与旧值都为空」时才生成,站主主动清空的要保得住。
package admin
import (
"regexp"
"strings"
"oneblog/internal/model"
)
const autoSummaryLen = 110
var (
mdImageRe = regexp.MustCompile(`!\[[^\]]*\]\(([^)\s]+)[^)]*\)`)
mdCodeFence = regexp.MustCompile("(?s)```.*?```")
mdLinkRe = regexp.MustCompile(`\[([^\]]*)\]\([^)]*\)`)
mdHeadingRe = regexp.MustCompile(`(?m)^#{1,6}\s+.*$`) // 标题行整行不进摘要(与文章标题重复)
mdNoiseRe = regexp.MustCompile(`(?m)^([>\s*-]+|\d+\.\s)+`)
mdEmphasis = strings.NewReplacer("**", "", "__", "", "~~", "", "*", "", "_", "")
wsRe = regexp.MustCompile(`\s+`)
)
// autoMeta 按需填充 in.Summary / in.CoverURL。
// cur 为更新前的旧文章(新建时传 nil)。
func autoMeta(in *model.PostInput, cur *model.Post) {
if in.Kind != model.KindLong || strings.TrimSpace(in.ContentMd) == "" {
return
}
if strings.TrimSpace(in.Summary) == "" && (cur == nil || strings.TrimSpace(cur.Summary) == "") {
in.Summary = summarizeMarkdown(in.ContentMd, autoSummaryLen)
}
if strings.TrimSpace(in.CoverURL) == "" && (cur == nil || strings.TrimSpace(cur.CoverURL) == "") {
in.CoverURL = firstImage(in.ContentMd)
}
}
// summarizeMarkdown 剥掉 markdown 语法后取正文开头一段纯文本
func summarizeMarkdown(md string, maxRunes int) string {
s := mdCodeFence.ReplaceAllString(md, " ") // 代码块整段不进摘要
s = mdHeadingRe.ReplaceAllString(s, " ")
s = mdImageRe.ReplaceAllString(s, " ")
s = mdLinkRe.ReplaceAllString(s, "$1")
s = mdNoiseRe.ReplaceAllString(s, " ")
s = mdEmphasis.Replace(s)
s = strings.ReplaceAll(s, "`", "")
s = wsRe.ReplaceAllString(s, " ")
s = strings.TrimSpace(s)
runes := []rune(s)
if len(runes) > maxRunes {
return string(runes[:maxRunes]) + "…"
}
return s
}
// firstImage 取正文第一张图片的地址(markdown 图;http/站内路径均可)
func firstImage(md string) string {
m := mdImageRe.FindStringSubmatch(md)
if m == nil {
return ""
}
u := m[1]
if strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") || strings.HasPrefix(u, "/uploads/") {
return u
}
return ""
}
+62
View File
@@ -0,0 +1,62 @@
package admin
import (
"strings"
"testing"
"oneblog/internal/model"
)
func TestSummarizeMarkdown(t *testing.T) {
md := "## 标题\n\n这是**正文**的第一段,含[链接](https://x.com)与图片:\n\n![封面](https://a.com/1.png)\n\n```go\ncode ignored\n```\n\n后续内容"
got := summarizeMarkdown(md, 110)
for _, bad := range []string{"#", "**", "![]", "```", "code ignored"} {
if strings.Contains(got, bad) {
t.Errorf("summary 含语法残留 %q: %q", bad, got)
}
}
if !strings.Contains(got, "链接") || !strings.HasPrefix(got, "这是") {
t.Errorf("summary 应以正文开头并保留链接文字: %q", got)
}
long := strings.Repeat("长", 200)
if got := summarizeMarkdown(long, 110); len([]rune(got)) != 111 || !strings.HasSuffix(got, "…") {
t.Errorf("超长应截到 110+省略号, got %d runes", len([]rune(got)))
}
}
func TestFirstImage(t *testing.T) {
md := "前言\n\n![封面](https://a.com/1.png)\n\n![第二张](https://a.com/2.png)"
if got := firstImage(md); got != "https://a.com/1.png" {
t.Errorf("firstImage = %q", got)
}
if got := firstImage("没有图片"); got != "" {
t.Errorf("无图应返回空, got %q", got)
}
}
func TestAutoMeta(t *testing.T) {
in := model.PostInput{Kind: model.KindLong, ContentMd: "# T\n\n正文内容 ![图](https://a.com/x.png)"}
autoMeta(&in, nil)
if in.Summary == "" || in.CoverURL != "https://a.com/x.png" {
t.Errorf("空字段应自动填充: summary=%q cover=%q", in.Summary, in.CoverURL)
}
// 站主已填的不能覆盖
in2 := model.PostInput{Kind: model.KindLong, ContentMd: "正文", Summary: "手写的", CoverURL: ""}
autoMeta(&in2, nil)
if in2.Summary != "手写的" {
t.Errorf("手写摘要不应被覆盖: %q", in2.Summary)
}
// 更新路径:新值与旧值都空才补——主动清空的保得住
cleared := model.PostInput{Kind: model.KindLong, ContentMd: "正文", Summary: "", CoverURL: ""}
cur := model.Post{Summary: "旧摘要", CoverURL: "旧封面"}
autoMeta(&cleared, &cur)
if cleared.Summary != "" || cleared.CoverURL != "" {
t.Errorf("主动清空不应复活: summary=%q cover=%q", cleared.Summary, cleared.CoverURL)
}
// 新旧都空 → 生成
never := model.PostInput{Kind: model.KindLong, ContentMd: "正文 ![图](/uploads/a.png)"}
autoMeta(&never, &model.Post{})
if never.CoverURL != "/uploads/a.png" {
t.Errorf("新旧都空应取首图: %q", never.CoverURL)
}
}
+141
View File
@@ -0,0 +1,141 @@
// Passkey 的管理端点:列出、注册(两步)、删除。
//
// 全部在 guard 之后 —— 注册凭据等于发放永久登录方式,必须已是管理员。
// 删光 passkey 也不会把自已锁死:站主密码走环境变量,不在这张表里。
package admin
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"oneblog/internal/auth"
"oneblog/internal/httpx"
"oneblog/internal/store"
)
// passkeyName 是站主给这把凭据起的名字(「MacBook 指纹」「iPhone」)。
type passkeyNameRequest struct {
Name string `json:"name"`
}
func (a *API) listPasskeys(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
httpx.Error(w, http.StatusMethodNotAllowed, "GET required")
return
}
owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser)
if err != nil {
httpx.ServerError(w, err)
return
}
list, err := a.Store.ListPasskeys(owner.ID)
if err != nil {
httpx.ServerError(w, err)
return
}
httpx.OK(w, map[string]any{"passkeys": list})
}
func (a *API) beginPasskey(w http.ResponseWriter, r *http.Request) {
if a.Passkeys == nil {
httpx.Error(w, http.StatusServiceUnavailable, "passkey 未启用")
return
}
owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser)
if err != nil {
httpx.ServerError(w, err)
return
}
existing, err := a.Store.ListPasskeys(owner.ID)
if err != nil {
httpx.ServerError(w, err)
return
}
creation, token, err := a.Passkeys.BeginRegistration(owner.ID, owner.Handle, owner.Name, existing)
if err != nil {
httpx.ServerError(w, err)
return
}
httpx.OK(w, map[string]any{"options": creation, "token": token})
}
func (a *API) finishPasskey(w http.ResponseWriter, r *http.Request) {
if a.Passkeys == nil {
httpx.Error(w, http.StatusServiceUnavailable, "passkey 未启用")
return
}
var in struct {
Token string `json:"token"`
Name string `json:"name"`
Credential json.RawMessage `json:"credential"`
}
if err := httpx.Decode(r, &in); err != nil || in.Token == "" || len(in.Credential) == 0 {
httpx.BadRequest(w, "token 与 credential 都要传")
return
}
name := strings.TrimSpace(in.Name)
if len([]rune(name)) > 40 {
httpx.BadRequest(w, "名称最多 40 字")
return
}
if name == "" {
name = "未命名设备"
}
owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser)
if err != nil {
httpx.ServerError(w, err)
return
}
existing, err := a.Store.ListPasskeys(owner.ID)
if err != nil {
httpx.ServerError(w, err)
return
}
pk, err := a.Passkeys.FinishRegistration(in.Token, owner.ID, owner.Handle, owner.Name, existing, in.Credential)
if errors.Is(err, auth.ErrSessionExpired) {
httpx.Error(w, http.StatusGone, "注册已过期,请重新开始")
return
}
if err != nil {
httpx.Error(w, http.StatusBadRequest, "passkey 校验失败:"+err.Error())
return
}
pk.Name = name
created, err := a.Store.AddPasskey(pk)
if err != nil {
httpx.ServerError(w, err)
return
}
// 公钥不回传:前端不需要,少一处能误用的字段
created.PublicKey = ""
httpx.Created(w, created)
}
func (a *API) deletePasskey(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
httpx.Error(w, http.StatusMethodNotAllowed, "DELETE required")
return
}
id, err := strconv.ParseInt(strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/admin/account/passkeys/"), "/"), 10, 64)
if err != nil || id <= 0 {
httpx.BadRequest(w, "bad passkey id")
return
}
owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser)
if err != nil {
httpx.ServerError(w, err)
return
}
if err := a.Store.DeletePasskey(id, owner.ID); err != nil {
if errors.Is(err, store.ErrNotFound) {
httpx.NotFound(w)
return
}
httpx.ServerError(w, err)
return
}
httpx.OK(w, map[string]any{"ok": true})
}
+280
View File
@@ -0,0 +1,280 @@
// 账户相关的公开端点:第三方身份绑定、passkey 登录。
//
// 绑定复用登录的 OAuth 跳转,只在发起时多打一个一次性 cookie 表明意图;
// 回调拿到身份后统一走 afterIdentity 分流,免得三个 provider 各写一遍判断。
//
// 分流规则(顺序即优先级):
// 1. 带绑定意图 + 当前是有效管理员会话 → 把该外部身份绑到站主账号,回账户页
// 2. 该外部身份已绑到某账号 → 站主发后台会话,读者发读者会话
// 3. 谁都不认识 → 按老路建/更新读者档案,发读者会话
//
// 第 1 步必须校验管理员会话:否则任何人都能跑一遍自己的 OAuth 流程,
// 把身份塞进别人的账号上。
package api
import (
"encoding/json"
"errors"
"log"
"net/http"
"net/url"
"strings"
"oneblog/internal/auth"
"oneblog/internal/httpx"
"oneblog/internal/model"
"oneblog/internal/ratelimit"
"oneblog/internal/store"
)
const oauthBindCook = "one_oauth_bind"
// startOAuth 备好 state(防 CSRF)与回跳地址,然后跳到 provider 授权页。
// authorize 拿到 state 拼出最终授权 URL —— state 必须在这里生成,
// 又要出现在 URL 里,所以用回调而不是先算好传进来。
func (a *API) startOAuth(w http.ResponseWriter, r *http.Request, authorize func(state string) string) {
state := randHex(16)
http.SetCookie(w, &http.Cookie{Name: oauthStateCook, Value: state, Path: "/",
HttpOnly: true, MaxAge: 600})
// 记下发起登录的前台 origin:开发时前端 :3000、后端 :8080 分离,
// 回调只有靠它才知道该跳回哪儿。
if ref := r.Referer(); ref != "" {
if u, err := url.Parse(ref); err == nil && u.Scheme != "" && u.Host != "" {
http.SetCookie(w, &http.Cookie{Name: oauthBackCook,
Value: u.Scheme + "://" + u.Host, Path: "/", HttpOnly: true, MaxAge: 600})
}
}
http.Redirect(w, r, authorize(state), http.StatusFound)
}
// beginBind 发起绑定。只有已登录的后台管理员能发起,且平台必须已配置。
func (a *API) beginBind(w http.ResponseWriter, r *http.Request) {
provider := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/auth/"), "/")
provider = strings.TrimSuffix(provider, "/bind")
if !a.adminSessionValid(r) {
httpx.Unauthorized(w)
return
}
setBindCookie(w)
switch provider {
case "github":
if !a.GH.Enabled() {
clearBindCookie(w)
httpx.NotFound(w)
return
}
a.startOAuth(w, r, func(state string) string {
return a.GH.LoginURL(a.Cfg.SiteURL+"/api/auth/callback/github", state)
})
case "google":
if !a.GG.Enabled() {
clearBindCookie(w)
httpx.NotFound(w)
return
}
a.startOAuth(w, r, func(state string) string {
return a.GG.LoginURL(a.Cfg.SiteURL+"/api/auth/callback/google", state)
})
default:
clearBindCookie(w)
httpx.NotFound(w)
}
}
func setBindCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{Name: oauthBindCook, Value: "1", Path: "/",
HttpOnly: true, MaxAge: 600, SameSite: http.SameSiteLaxMode})
}
func clearBindCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{Name: oauthBindCook, Value: "", Path: "/", MaxAge: -1})
}
func bindRequested(r *http.Request) bool {
ck, err := r.Cookie(oauthBindCook)
return err == nil && ck.Value == "1"
}
// adminSessionValid 判断当前请求带的是不是有效后台会话。
// 公开 API 只依赖注入的接口(不 import admin 包,免得两个 API 互相引用成环)。
func (a *API) adminSessionValid(r *http.Request) bool {
if a.AdminSessions == nil {
return false
}
ck, err := r.Cookie(adminCookieName)
if err != nil || ck.Value == "" {
return false
}
_, verr := a.AdminSessions.Verify(ck.Value)
return verr == nil
}
const adminCookieName = "one_session"
// afterIdentity 见文件头的分流规则。返回空串表示响应已写好,调用方直接 return。
func (a *API) afterIdentity(w http.ResponseWriter, r *http.Request, provider, externUID, display string, persona model.Reader) string {
defer clearBindCookie(w) // 意图用完即清,免得下次普通登录误判成绑定
if externUID == "" {
// provider 没给稳定 id:宁可退回老流程按 handle 认人,也不建一条
// 空 extern_uid 的绑定 —— 空值会和别人的空值撞唯一键。
return a.issueReaderLogin(w, r, persona)
}
if bindRequested(r) {
owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser)
if err != nil {
httpx.ServerError(w, err)
return ""
}
if err := a.Store.BindIdentity(owner.ID, provider, externUID, display); err != nil {
if errors.Is(err, store.ErrConflict) {
httpx.Error(w, http.StatusConflict, "该账号已绑定到其他用户")
return ""
}
httpx.ServerError(w, err)
return ""
}
http.Redirect(w, r, strings.TrimRight(a.Cfg.SiteURL, "/")+"/admin/account?bound="+url.QueryEscape(provider), http.StatusFound)
return ""
}
// 已绑定的身份优先于新建档案:站主用绑定的 GitHub 登录要拿到后台会话
u, err := a.Store.GetUserByIdentity(provider, externUID)
switch {
case err == nil && u.Role == model.RoleOwner:
a.issueAdminSession(w, r)
return ""
case err == nil:
return a.issueReaderSession(w, r, u.ID)
case !errors.Is(err, store.ErrNotFound):
httpx.ServerError(w, err)
return ""
}
return a.issueReaderLogin(w, r, persona)
}
func (a *API) issueReaderLogin(w http.ResponseWriter, r *http.Request, persona model.Reader) string {
reader, err := a.Store.UpsertReader(persona)
if err != nil {
httpx.ServerError(w, err)
return ""
}
return a.issueReaderSession(w, r, reader.ID)
}
func (a *API) issueReaderSession(w http.ResponseWriter, r *http.Request, readerID int64) string {
token, _ := a.ReaderSessions.Issue(readerID)
http.SetCookie(w, &http.Cookie{Name: auth.ReaderCookie, Value: token, Path: "/",
HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: a.ReaderSessions.TTL()})
return a.loginBack(w, r)
}
// loginBack 决定登录完跳回哪儿:优先回发起登录的前台 origin
// (开发时前端 :3000、后端 :8080 分离,只有它才不会跳错站),
// 没有记录(直接敲 URL 进来的)就回站点根。
func (a *API) loginBack(w http.ResponseWriter, r *http.Request) string {
back := a.Cfg.SiteURL
if ck, err := r.Cookie(oauthBackCook); err == nil && ck.Value != "" {
if u, err := url.Parse(ck.Value); err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" && u.Path == "" {
back = u.Scheme + "://" + u.Host
}
}
http.SetCookie(w, &http.Cookie{Name: oauthBackCook, Value: "", Path: "/", MaxAge: -1})
return back
}
// issueAdminSession 让已绑定的第三方身份直接换发后台会话 —— 「绑定即提权」
// 的落点。Secure / SameSite 与密码登录发的 cookie 完全一致,否则 HTTPS 下
// 浏览器会把它当不安全 cookie 丢掉。
func (a *API) issueAdminSession(w http.ResponseWriter, r *http.Request) {
token, exp := a.AdminSessions.Issue(a.Cfg.AdminUser)
http.SetCookie(w, &http.Cookie{
Name: adminCookieName, Value: token, Path: "/", HttpOnly: true,
Secure: isTLS(r), SameSite: http.SameSiteLaxMode,
Expires: exp, MaxAge: a.AdminSessions.TTL(),
})
}
// isTLS 判断最终用户看到的是不是 HTTPS(含反代 X-Forwarded-Proto)。
// admin 包有一份同名私有函数:两边各自独立,不为三行代码造共享包。
func isTLS(r *http.Request) bool {
if r.TLS != nil {
return true
}
return r.Header.Get("X-Forwarded-Proto") == "https"
}
// ---------- passkey 登录(公开) ----------
// passkeyBegin 发起一次发现式登录:不预先要用户名,凭据自己带出身份。
func (a *API) passkeyBegin(w http.ResponseWriter, r *http.Request) {
if a.Passkeys == nil {
httpx.NotFound(w)
return
}
if a.passkeyFails.Blocked(ratelimit.SourceKey(r)) {
httpx.Error(w, http.StatusTooManyRequests, "尝试次数过多,请稍后再试")
return
}
options, token, err := a.Passkeys.BeginLogin()
if err != nil {
httpx.ServerError(w, err)
return
}
httpx.OK(w, map[string]any{"options": options, "token": token})
}
// passkeyFinish 校验断言。命中站主发后台会话,命中读者发读者会话。
func (a *API) passkeyFinish(w http.ResponseWriter, r *http.Request) {
if a.Passkeys == nil {
httpx.NotFound(w)
return
}
var in struct {
Token string `json:"token"`
Credential json.RawMessage `json:"credential"`
}
if err := httpx.Decode(r, &in); err != nil || in.Token == "" || len(in.Credential) == 0 {
httpx.BadRequest(w, "token 与 credential 都要传")
return
}
ip := ratelimit.SourceKey(r)
if a.passkeyFails.Blocked(ip) {
httpx.Error(w, http.StatusTooManyRequests, "尝试次数过多,请稍后再试")
return
}
res, err := a.Passkeys.FinishLogin(in.Token, in.Credential, func(credID string) (model.Passkey, error) {
return a.Store.GetPasskeyByCredentialID(credID)
})
if err != nil {
a.passkeyFails.Add(ip)
httpx.Error(w, http.StatusUnauthorized, "passkey 校验失败")
return
}
pk, err := a.Store.GetPasskeyByCredentialID(res.CredentialID)
if err != nil {
a.passkeyFails.Add(ip)
httpx.Unauthorized(w)
return
}
if err := a.Store.TouchPasskey(pk.ID, res.SignCount); err != nil {
// 计数回写失败不该挡住已验签成功的登录,但要留痕:
// 丢了计数就等于丢了克隆检测能力。
log.Printf("passkey: 回写签名计数失败 (id=%d): %v", pk.ID, err)
}
if res.CloneWarning {
log.Printf("passkey: 凭据 %q 签名计数回退,可能被克隆或多设备同步", pk.Name)
}
u, err := a.Store.GetReader(pk.UserID)
if err != nil {
httpx.Unauthorized(w)
return
}
if u.Role == model.RoleOwner {
a.issueAdminSession(w, r)
httpx.OK(w, map[string]any{"ok": true, "role": u.Role})
return
}
a.issueReaderSession(w, r, u.ID)
httpx.OK(w, map[string]any{"ok": true, "role": u.Role})
}
+187
View File
@@ -0,0 +1,187 @@
package api
import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"oneblog/internal/auth"
"oneblog/internal/config"
"oneblog/internal/model"
)
// fakeAdmin 满足 API.AdminSessions 接口。不 import admin 包 ——
// 两个 API 之间不该为了测试互相依赖。
type fakeAdmin struct{ valid map[string]bool }
func (f fakeAdmin) Verify(token string) (string, error) {
if f.valid[token] {
return "admin", nil
}
return "", errors.New("bad session")
}
func (f fakeAdmin) Issue(string) (string, time.Time) {
return "issued-admin-token", time.Now().Add(time.Hour)
}
func (f fakeAdmin) TTL() int { return 3600 }
func newAccountAPI(t *testing.T) (*API, http.Handler) {
t.Helper()
a, h := newTestAPI(t)
a.Cfg = &config.Config{SiteURL: "http://localhost:8080", AdminUser: "admin"}
a.AdminSessions = fakeAdmin{valid: map[string]bool{"good-session": true}}
a.GH = auth.GitHub{ClientID: "id", ClientSecret: "sec"}
return a, h
}
// 绑定动作必须已登录后台 —— 否则任何人都能往别人账号上塞身份
func TestBindRequiresAdminSession(t *testing.T) {
_, h := newAccountAPI(t)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/auth/github/bind", nil))
if rec.Code != http.StatusUnauthorized {
t.Fatalf("无会话绑定: got %d, want 401", rec.Code)
}
}
func TestBindWithAdminSessionLinksAndRedirects(t *testing.T) {
a, h := newAccountAPI(t)
owner, err := a.Store.EnsureOwner("admin")
if err != nil {
t.Fatal(err)
}
back := a.Cfg.SiteURL + "/admin/account"
req := httptest.NewRequest(http.MethodGet, "/api/auth/github/bind", nil)
req.AddCookie(&http.Cookie{Name: "one_session", Value: "good-session"})
req.AddCookie(&http.Cookie{Name: oauthBackCook, Value: back})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusFound {
t.Fatalf("got %d %s, want 302 跳 GitHub", rec.Code, rec.Body.String())
}
loc := rec.Header().Get("Location")
if !strings.HasPrefix(loc, "https://github.com/login/oauth/authorize") {
t.Fatalf("没跳授权页: %s", loc)
}
// 绑定意图必须落到一次性 cookie 上
var sawBind bool
for _, ck := range rec.Result().Cookies() {
if ck.Name == oauthBindCook && ck.Value == "1" {
sawBind = true
}
}
if !sawBind {
t.Fatal("绑定 cookie 没设上,回调时无从判断意图")
}
_ = owner
}
// 回调命中「已绑定给站主」的身份 → 必须发后台会话,而不是读者会话
func TestBoundOwnerIdentityGrantsAdminSession(t *testing.T) {
a, _ := newAccountAPI(t)
owner, err := a.Store.EnsureOwner("admin")
if err != nil {
t.Fatal(err)
}
if err := a.Store.BindIdentity(owner.ID, "github", "4242", "littleckin"); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/api/auth/callback/github", strings.NewReader("code=x&state=y"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
// 直接测分流函数:绕开真 GitHub
back := a.afterIdentity(rec, req, "github", "4242", "littleckin",
model.Reader{Provider: "github", Handle: "littleckin", Name: "n"})
if back != "" {
t.Fatalf("站主命中绑定应自己收尾(不发跳),got back=%q", back)
}
var admin, reader bool
for _, ck := range rec.Result().Cookies() {
switch ck.Name {
case "one_session":
admin = ck.Value == "issued-admin-token"
case auth.ReaderCookie:
reader = true
}
}
if !admin {
t.Fatal("没发后台会话")
}
if reader {
t.Fatal("站主登录不该只拿到读者会话")
}
}
// 陌生身份(没绑定)走老路:建读者档案 + 发读者会话 + 回跳
func TestUnknownIdentityFallsBackToReader(t *testing.T) {
a, _ := newAccountAPI(t)
if _, err := a.Store.EnsureOwner("admin"); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/x", nil)
req.AddCookie(&http.Cookie{Name: oauthBackCook, Value: "http://localhost:3000"})
rec := httptest.NewRecorder()
back := a.afterIdentity(rec, req, "github", "999", "stranger",
model.Reader{Provider: "github", Handle: "stranger", Name: "Stranger"})
if back != "http://localhost:3000" {
t.Fatalf("回跳地址不对: %q", back)
}
var reader bool
for _, ck := range rec.Result().Cookies() {
if ck.Name == auth.ReaderCookie && ck.Value != "" {
reader = true
}
if ck.Name == "one_session" {
t.Fatal("陌生身份拿到了后台会话")
}
}
if !reader {
t.Fatal("没发读者会话")
}
}
// 已被别人绑走的外部账号,不能再绑给站主
func TestBindConflictReturns409(t *testing.T) {
a, _ := newAccountAPI(t)
owner, _ := a.Store.EnsureOwner("admin")
other, err := a.Store.UpsertReader(model.Reader{Provider: "github", Handle: "real-owner", Name: "x"})
if err != nil {
t.Fatal(err)
}
if err := a.Store.BindIdentity(other.ID, "github", "4242", "real-owner"); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/x", nil)
req.AddCookie(&http.Cookie{Name: oauthBindCook, Value: "1"})
rec := httptest.NewRecorder()
a.afterIdentity(rec, req, "github", "4242", "hijack",
model.Reader{Provider: "github", Handle: "hijack", Name: "h"})
if rec.Code != http.StatusConflict {
t.Fatalf("got %d, want 409", rec.Code)
}
// 确认没被抢走
got, err := a.Store.GetUserByIdentity("github", "4242")
if err != nil || got.ID != other.ID {
t.Fatalf("身份归属被改动: %+v err=%v", got, err)
}
_ = owner
}
// passkey 未启用时公开端点要 404,不能 500
func TestPasskeyEndpointsAbsentWhenDisabled(t *testing.T) {
a, h := newAccountAPI(t)
if a.Passkeys != nil {
t.Skip("Passkeys 应未装配")
}
for _, p := range []string{"/api/auth/passkey/begin", "/api/auth/passkey/finish"} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, p, strings.NewReader("{}")))
if rec.Code != http.StatusNotFound {
t.Errorf("%s: got %d, want 404", p, rec.Code)
}
}
}
+153 -2
View File
@@ -6,22 +6,76 @@ import (
"encoding/xml"
"errors"
"html"
"io"
"net/http"
"strconv"
"strings"
"time"
"oneblog/internal/auth"
"oneblog/internal/config"
"oneblog/internal/httpx"
"oneblog/internal/hub"
"oneblog/internal/model"
"oneblog/internal/ratelimit"
"oneblog/internal/render"
"oneblog/internal/storage"
"oneblog/internal/store"
"oneblog/internal/thumbs"
)
type API struct {
Store *store.Store
Cfg *config.Config
Blobs storage.BlobStore // 文件上传的存储后端(main.go 装配,与 admin 共享)
// Thumbs 是缩略图磁盘缓存(main.go 装配,DataDir/.thumbnail_cache)。
// 为 nil 时 /uploads/thumb/ 路由直接回原图。
Thumbs *thumbs.Store
// 评论区读者会话与 GitHub OAuth(main.go 装配)
ReaderSessions *auth.ReaderSessions
GH auth.GitHub
// AdminSessions 是后台管理员会话(admin.Sessions 满足它)。
// 前台访客登录时命中「已绑定给站主」的身份就靠它发后台会话,
// 所以除了 Verify 还要 Issue/TTL。
AdminSessions interface {
Verify(token string) (string, error)
Issue(user string) (string, time.Time)
TTL() int
}
// 其余登录方式(main.go 装配,未配置的自动不开放)
GG auth.Google
TG auth.Telegram
// Passkeys 是 WebAuthn 服务(main.go 装配;nil 表示未启用,路由不开放)
Passkeys *auth.Passkeys
// passkeyFails 按 IP 限失败次数:passkey 登录是公开端点,
// 虽然伪造断言过不了验签,但不该让人无限次试。
passkeyFails *ratelimit.Window
// Hub 是评论变更的进程内广播(SSE 用;与后台 admin 共享同一实例)
Hub *hub.Hub
// 限流(Routes 里惰性初始化):读者登录失败按 IP 计、评论写入按读者计。
// 登录入口此前裸奔——OAuth 跳转本身难刷,但 state 校验失败、
// Telegram 伪造签名这类恶意请求需要一个兜底。
authFails *ratelimit.Window
commentNew *ratelimit.Window
}
const (
maxAuthFails = 20 // 窗口内允许的登录失败(含伪造回调)
authFailWindow = 10 * time.Minute
maxComments = 5 // 每个读者每窗口最多发几条
commentWindow = time.Minute
)
func (a *API) Routes() http.Handler {
if a.authFails == nil {
a.authFails = ratelimit.New(maxAuthFails, authFailWindow)
a.commentNew = ratelimit.New(maxComments, commentWindow)
}
if a.passkeyFails == nil {
a.passkeyFails = ratelimit.New(maxAuthFails, authFailWindow)
}
mux := http.NewServeMux()
mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
if err := a.Store.Ping(r.Context()); err != nil {
@@ -30,6 +84,23 @@ func (a *API) Routes() http.Handler {
}
httpx.OK(w, map[string]any{"ok": true, "driver": a.Cfg.Driver})
})
// 读者登录与评论
mux.HandleFunc("/api/auth/providers", a.authProviders)
mux.HandleFunc("/api/auth/me", a.authMe)
mux.HandleFunc("/api/auth/logout", a.authLogout)
mux.HandleFunc("/api/auth/github/login", a.githubLogin)
mux.HandleFunc("/api/auth/callback/github", a.githubCallback)
mux.HandleFunc("/api/auth/google/login", a.googleLogin)
mux.HandleFunc("/api/auth/callback/google", a.googleCallback)
mux.HandleFunc("/api/auth/telegram", a.telegramAuth)
// 第三方身份绑定(需已登录后台)与 passkey 登录
mux.HandleFunc("/api/auth/github/bind", a.beginBind)
mux.HandleFunc("/api/auth/google/bind", a.beginBind)
mux.HandleFunc("/api/auth/passkey/begin", a.passkeyBegin)
mux.HandleFunc("/api/auth/passkey/finish", a.passkeyFinish)
mux.HandleFunc("/api/comments", a.comments)
mux.HandleFunc("/api/comments/stream", a.commentsStream)
mux.HandleFunc("/api/comments/", a.commentSub)
mux.HandleFunc("/api/site", a.site)
mux.HandleFunc("/api/posts", a.listPosts)
mux.HandleFunc("/api/posts/", a.getPost)
@@ -54,7 +125,23 @@ func (a *API) site(w http.ResponseWriter, r *http.Request) {
httpx.ServerError(w, err)
return
}
httpx.OK(w, st)
st.AuthorAvatarURL = a.avatarURL(st.AuthorAvatarKey)
// uploads_public_base 告诉前端哪些图片直链是自己的存储(可转 /uploads/thumb/ 缩略图)
httpx.OK(w, map[string]any{"settings": st, "uploads_public_base": a.Cfg.UploadsPublicBase})
}
// avatarURL 把 settings 里的头像 key 解析成可访问 URL。
// key 指向的文件已被删除时返回空串 —— 前台据此回落到站标,
// 而不是留一个打不开的裂图。
func (a *API) avatarURL(key string) string {
if key == "" {
return ""
}
f, err := a.Store.GetFileByKey(key)
if err != nil {
return ""
}
return storage.FileURL(f.Store, f.Key, a.Cfg.UploadsPublicBase)
}
func listOptions(r *http.Request, defSize int) store.ListOptions {
@@ -74,6 +161,12 @@ func (a *API) listPosts(w http.ResponseWriter, r *http.Request) {
httpx.ServerError(w, err)
return
}
// 时间线上短文直接铺正文:出口处做盘古之白(库里存的是原始渲染结果)
for i := range page.Items {
if page.Items[i].Kind == model.KindShort {
page.Items[i].ContentHTML = render.PanguHTML(page.Items[i].ContentHTML)
}
}
httpx.OK(w, page)
}
@@ -102,6 +195,8 @@ func (a *API) getPost(w http.ResponseWriter, r *http.Request) {
if err != nil {
nb = nil
}
// 详情正文出口做盘古之白(存库不动,改的是渲染层)
p.ContentHTML = render.PanguHTML(p.ContentHTML)
httpx.OK(w, struct {
model.Post
Neighbors []model.Post `json:"neighbors"`
@@ -201,7 +296,7 @@ func (a *API) RSS(w http.ResponseWriter, r *http.Request) {
}
desc := p.Summary
if desc == "" {
desc = trimRunes(stripTags(p.ContentHTML), 160)
desc = trimRunes(stripTags(render.PanguHTML(p.ContentHTML)), 160)
}
item := rssItem{
Title: title,
@@ -275,3 +370,59 @@ func orDefault(s, def string) string {
}
return s
}
// ---------- uploads(上传文件的公开访问) ----------
// UploadsHandler 供 main.go 挂在根 mux 的 /uploads/ 前缀上。按 key 查索引行,
// 经存储层流式返回本体;R2 + 公开域名时 302 到直链(后端不出流量)。
// key 必须在 files 表里有行——防止拿任意对象名探测存储端。
func (a *API) UploadsHandler() http.Handler {
return http.HandlerFunc(a.uploads)
}
func (a *API) uploads(w http.ResponseWriter, r *http.Request) {
key := strings.TrimPrefix(r.URL.Path, "/uploads/")
if key == "" || strings.Contains(key, "..") {
httpx.NotFound(w)
return
}
f, err := a.Store.GetFileByKey(key)
if errors.Is(err, store.ErrNotFound) {
httpx.NotFound(w)
return
}
if err != nil {
httpx.ServerError(w, err)
return
}
// R2 且配了公开域名:302 到直链,后端不出流量
if f.Store == "r2" && a.Cfg.UploadsPublicBase != "" {
http.Redirect(w, r, storage.FileURL(f.Store, f.Key, a.Cfg.UploadsPublicBase), http.StatusFound)
return
}
rc, size, err := a.Blobs.Open(r.Context(), f.Key)
if err != nil {
httpx.NotFound(w)
return
}
defer rc.Close()
// ServeContent 自带 Range(视频/音频拖动进度条必需)、ETag 比对与 304。
// key 含内容哈希:内容不变则 URL 不变,可永久缓存。
w.Header().Set("Content-Type", f.Mime)
w.Header().Set("ETag", `"`+f.SHA256+`"`)
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
if rs, ok := rc.(io.ReadSeeker); ok {
http.ServeContent(w, r, f.Name, time.Time{}, rs)
return
}
// 非本地存储拿不到 Seeker 时退回流式拷贝
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
if match := r.Header.Get("If-None-Match"); match != "" && match == `"`+f.SHA256+`"` {
w.WriteHeader(http.StatusNotModified)
return
}
io.Copy(w, rc)
}
+96
View File
@@ -0,0 +1,96 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"oneblog/internal/auth"
"oneblog/internal/config"
"oneblog/internal/db"
"oneblog/internal/model"
"oneblog/internal/store"
)
func newTestAPI(t *testing.T) (*API, http.Handler) {
t.Helper()
d, err := db.Open("sqlite", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { d.Close() })
st, err := store.New(d)
if err != nil {
t.Fatalf("store: %v", err)
}
a := &API{
Store: st,
Cfg: &config.Config{SiteURL: "http://localhost:8080"},
ReaderSessions: auth.NewReaderSessions("test-secret", time.Hour),
TG: auth.Telegram{Bot: "testbot", Token: "123:abc"},
}
settings, err := st.GetSettings()
if err != nil {
t.Fatalf("settings: %v", err)
}
settings.CommentsEnabled = true
if err := st.UpdateSettings(settings); err != nil {
t.Fatalf("enable comments: %v", err)
}
return a, a.Routes()
}
// Telegram 伪造签名反复重试应触发 IP 限速
func TestTelegramAuthRateLimited(t *testing.T) {
_, h := newTestAPI(t)
body := `{"id":1,"first_name":"x","hash":"deadbeef"}`
var rec *httptest.ResponseRecorder
for i := 0; i < maxAuthFails; i++ {
req := httptest.NewRequest(http.MethodPost, "/api/auth/telegram", strings.NewReader(body))
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("attempt %d: got %d, want 403", i+1, rec.Code)
}
}
req := httptest.NewRequest(http.MethodPost, "/api/auth/telegram", strings.NewReader(body))
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("after %d failures: got %d, want 429", maxAuthFails, rec.Code)
}
}
// 评论写入按读者限速:第 maxComments+1 条被拒
func TestCommentRateLimited(t *testing.T) {
a, h := newTestAPI(t)
p, err := a.Store.Create(model.PostInput{Kind: model.KindLong, Title: "t", Slug: "t",
ContentMd: "x", Status: model.StatusPublished})
if err != nil {
t.Fatal(err)
}
rd, err := a.Store.UpsertReader(model.Reader{Provider: "github", Handle: "u1", Name: "u1"})
if err != nil {
t.Fatal(err)
}
tok, _ := a.ReaderSessions.Issue(rd.ID)
post := func(i int) *httptest.ResponseRecorder {
body, _ := json.Marshal(map[string]any{"post_id": p.ID, "body_md": "好"})
req := httptest.NewRequest(http.MethodPost, "/api/comments", strings.NewReader(string(body)))
req.AddCookie(&http.Cookie{Name: auth.ReaderCookie, Value: tok})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
for i := 0; i < maxComments; i++ {
if rec := post(i); rec.Code != http.StatusCreated {
t.Fatalf("comment %d: got %d %s", i+1, rec.Code, rec.Body.String())
}
}
if rec := post(maxComments); rec.Code != http.StatusTooManyRequests {
t.Fatalf("over limit: got %d, want 429", rec.Code)
}
}
+429
View File
@@ -0,0 +1,429 @@
// 读者登录与评论的公开接口。登录走 GitHub OAuth 整页跳转;
// 会话是 httpOnly cookie(one_reader),与后台会话(one_session)互不相通。
//
// 审核:设置里开了「先审后显」时,新评论 status=pending——
// 只有作者自己能在列表里看到(带「审核中」角标),站主通过后才公开。
package api
import (
"crypto/rand"
"encoding/hex"
"errors"
"net/http"
"strconv"
"strings"
"time"
"oneblog/internal/auth"
"oneblog/internal/httpx"
"oneblog/internal/model"
"oneblog/internal/ratelimit"
"oneblog/internal/render"
"oneblog/internal/store"
)
const (
readerCookie = "one_reader"
oauthStateCook = "one_oauth_state"
oauthBackCook = "one_oauth_back"
maxCommentLen = 500
editWindow = 10 * time.Minute
)
// readerID 从会话 cookie 解出读者 ID;匿名返回 false。
// 后台管理员已登录(one_session)时直接映射为站主读者身份——
// 站主发评论不必再走一遍 GitHub 登录。
func (a *API) readerID(r *http.Request) (int64, bool) {
rd, ok, err := a.resolveReader(r)
if err != nil || !ok {
return 0, false
}
return rd.ID, true
}
// resolveReader 解出当前访客的读者身份:读者会话优先,
// 其次是后台管理员会话(自动 upsert 一个 provider=admin 的站主读者)。
func (a *API) resolveReader(r *http.Request) (model.Reader, bool, error) {
if ck, err := r.Cookie(auth.ReaderCookie); err == nil && ck.Value != "" {
if id, verr := a.ReaderSessions.Verify(ck.Value); verr == nil {
rd, gerr := a.Store.GetReader(id)
if gerr == nil {
return rd, true, nil
}
}
}
if a.AdminSessions != nil {
if ck, err := r.Cookie("one_session"); err == nil && ck.Value != "" {
if _, verr := a.AdminSessions.Verify(ck.Value); verr == nil {
rd, oerr := a.ownerReader()
if oerr == nil {
return rd, true, nil
}
}
}
}
return model.Reader{}, false, nil
}
// ownerReader 取(或创建)站主评论身份:provider=admin,名字用站点作者名。
func (a *API) ownerReader() (model.Reader, error) {
name := "站主"
if st, err := a.Store.GetSettings(); err == nil && st.AuthorName != "" {
name = st.AuthorName
}
return a.Store.UpsertReader(model.Reader{
Provider: "admin", Handle: a.Cfg.AdminUser, Name: name,
})
}
func (a *API) authMe(w http.ResponseWriter, r *http.Request) {
var user any // 匿名时 {user: null},前端判空即「未登录」
if reader, ok, err := a.resolveReader(r); err == nil && ok {
user = map[string]any{
"id": reader.ID, "name": reader.Name, "handle": reader.Handle,
"avatar_url": reader.AvatarURL, "url": reader.URL,
"provider": reader.Provider, "is_owner": reader.Provider == "admin", "banned": reader.Banned,
}
}
httpx.OK(w, map[string]any{"user": user})
}
func (a *API) authLogout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{Name: auth.ReaderCookie, Value: "", Path: "/", MaxAge: -1})
httpx.OK(w, map[string]any{"ok": true})
}
// githubLogin 跳转 GitHub 授权页。state / 回跳地址的处理抽到 startOAuth,
// 与「绑定」入口共用同一套跳转(bind 只是多打一个一次性 cookie)。
func (a *API) githubLogin(w http.ResponseWriter, r *http.Request) {
if !a.GH.Enabled() {
httpx.NotFound(w)
return
}
a.startOAuth(w, r, func(state string) string {
return a.GH.LoginURL(a.Cfg.SiteURL+"/api/auth/callback/github", state)
})
}
// githubCallback 用 code 换身份,交给统一分流(绑定 / 已绑账号 / 新读者)。
func (a *API) githubCallback(w http.ResponseWriter, r *http.Request) {
if !a.GH.Enabled() {
httpx.NotFound(w)
return
}
ip := ratelimit.SourceKey(r)
if a.authFails.Blocked(ip) {
httpx.Error(w, http.StatusTooManyRequests, "登录失败次数过多,请稍后再试")
return
}
ck, err := r.Cookie(oauthStateCook)
if err != nil || ck.Value == "" || ck.Value != r.FormValue("state") {
a.authFails.Add(ip)
httpx.BadRequest(w, "state 不匹配,请重新登录")
return
}
gh, err := a.GH.Exchange(r.Context(), r.FormValue("code"), a.Cfg.SiteURL+"/api/auth/callback/github")
if err != nil {
a.authFails.Add(ip)
httpx.ServerError(w, err)
return
}
u, err := a.GH.FetchUser(r.Context(), gh)
if err != nil {
a.authFails.Add(ip)
httpx.ServerError(w, err)
return
}
if u.Login == "" {
a.authFails.Add(ip)
httpx.ServerError(w, errors.New("github 未返回用户名"))
return
}
name := u.Name
if name == "" {
name = u.Login
}
persona := model.Reader{
Provider: "github", Handle: u.Login, Name: name,
AvatarURL: u.AvatarURL, URL: u.HTMLURL,
}
// 稳定 id 才是绑定键;老接口没返回 id 时退化为按 handle 认人
extern := strconv.FormatInt(u.ID, 10)
if u.ID == 0 {
extern = ""
}
if back := a.afterIdentity(w, r, "github", extern, u.Login, persona); back != "" {
http.Redirect(w, r, back, http.StatusFound)
}
}
func randHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// ---------- comments(评论的读取与发表) ----------
func (a *API) comments(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
a.listComments(w, r)
case http.MethodPost:
a.createComment(w, r)
default:
httpx.Error(w, http.StatusMethodNotAllowed, "GET/POST required")
}
}
func (a *API) listComments(w http.ResponseWriter, r *http.Request) {
postID := httpx.QueryInt(r, "post_id", 0)
if postID <= 0 {
httpx.BadRequest(w, "post_id required")
return
}
viewer, _ := a.readerID(r)
newest := httpx.QueryString(r, "sort") == "newest"
roots, err := a.Store.ListCommentsByPost(int64(postID), viewer, newest)
if err != nil {
httpx.ServerError(w, err)
return
}
httpx.OK(w, map[string]any{
"items": roots, "total": len(roots),
"page": 1, "size": len(roots),
})
}
// createComment 发表评论(含回复)。登录 + 未禁言 + 评论开关开着;
// 审核开关开着时新评论进「待审」。站主身份(管理员会话)不受禁言与审核约束。
func (a *API) createComment(w http.ResponseWriter, r *http.Request) {
reader, ok, err := a.resolveReader(r)
if err != nil {
httpx.Error(w, http.StatusUnauthorized, "登录已过期,刷新页面重新登录")
return
}
if !ok {
httpx.Error(w, http.StatusUnauthorized, "登录后才能评论")
return
}
isOwner := reader.Provider == "admin"
if reader.Banned && !isOwner {
httpx.Error(w, http.StatusForbidden, "你已被禁言,暂时无法评论")
return
}
// 写入限速:每读者每分钟最多 maxComments 条(站主豁免,批量回复不该被卡)
var rateKey string
if !isOwner {
rateKey = strconv.FormatInt(reader.ID, 10)
if a.commentNew.Blocked(rateKey) {
httpx.Error(w, http.StatusTooManyRequests, "发得太快了,休息一分钟再试")
return
}
}
st, err := a.Store.GetSettings()
if err != nil {
httpx.ServerError(w, err)
return
}
if !st.CommentsEnabled {
httpx.Error(w, http.StatusForbidden, "评论未开放")
return
}
var in struct {
PostID int64 `json:"post_id"`
ParentID int64 `json:"parent_id"`
BodyMd string `json:"body_md"`
}
if err := httpx.Decode(r, &in); err != nil {
httpx.BadRequest(w, "invalid body")
return
}
body := strings.TrimSpace(in.BodyMd)
if body == "" {
httpx.BadRequest(w, "评论内容不能为空")
return
}
if len([]rune(body)) > maxCommentLen {
httpx.BadRequest(w, "评论最多 500 字")
return
}
if _, err := a.Store.Get(in.PostID); err != nil {
httpx.BadRequest(w, "文章不存在")
return
}
var parent model.Comment
root := int64(0)
if in.ParentID > 0 {
p, err := a.Store.GetComment(in.ParentID)
if err != nil {
httpx.BadRequest(w, "回复的评论不存在")
return
}
if p.PostID != in.PostID {
httpx.BadRequest(w, "回复的评论不属于这篇文章")
return
}
parent = p
root = parent.RootID
if root == 0 {
root = parent.ID
}
}
status := "visible"
if st.CommentsReview && !isOwner {
status = "pending"
}
c, err := a.Store.CreateComment(model.Comment{
PostID: in.PostID, UserID: reader.ID, ParentID: in.ParentID, RootID: root,
BodyMd: body, BodyHTML: render.Markdown(body), Status: status,
})
if err != nil {
httpx.ServerError(w, err)
return
}
a.Hub.Broadcast(in.PostID)
if rateKey != "" {
a.commentNew.Add(rateKey) // 只计成功写入:空正文这类手滑不扣配额
}
httpx.Created(w, c)
}
// commentSub /api/comments/{id} 与 /api/comments/{root}/thread 的分发
func (a *API) commentSub(w http.ResponseWriter, r *http.Request) {
rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/comments/"), "/")
if rest == "" {
httpx.NotFound(w)
return
}
// {root}/thread:楼内回复翻页(当前实现全量内嵌,这里兜底返回剩余)
if strings.HasSuffix(rest, "/thread") {
rootID, err := strconv.ParseInt(strings.TrimSuffix(rest, "/thread"), 10, 64)
if err != nil {
httpx.BadRequest(w, "bad root id")
return
}
a.commentThread(w, r, rootID)
return
}
id, err := strconv.ParseInt(rest, 10, 64)
if err != nil {
httpx.BadRequest(w, "bad comment id")
return
}
switch r.Method {
case http.MethodPut:
a.editComment(w, r, id)
case http.MethodDelete:
a.deleteComment(w, r, id)
default:
httpx.Error(w, http.StatusMethodNotAllowed, "PUT/DELETE required")
}
}
func (a *API) commentThread(w http.ResponseWriter, r *http.Request, rootID int64) {
root, err := a.Store.GetComment(rootID)
if err != nil {
httpx.ServerError(w, err)
return
}
cursor := httpx.QueryInt(r, "cursor", 0)
items := []model.Comment{}
if cursor >= 0 && cursor < len(root.Replies) {
items = root.Replies[cursor:]
}
httpx.OK(w, map[string]any{"items": items, "cursor": "", "reply_count": root.ReplyCount})
}
// editComment 作者改自己的评论:10 分钟内有效,且未被禁言未删除
func (a *API) editComment(w http.ResponseWriter, r *http.Request, id int64) {
readerID, ok := a.readerID(r)
if !ok {
httpx.Error(w, http.StatusUnauthorized, "登录已过期")
return
}
c, err := a.Store.GetComment(id)
if errors.Is(err, store.ErrNotFound) {
httpx.NotFound(w)
return
}
if err != nil {
httpx.ServerError(w, err)
return
}
if c.UserID != readerID {
httpx.Error(w, http.StatusForbidden, "只能编辑自己的评论")
return
}
if c.IsDeleted {
httpx.NotFound(w)
return
}
if time.Since(mustParse(c.CreatedAt)) > editWindow {
httpx.Error(w, http.StatusForbidden, "超过可编辑时间")
return
}
var in struct {
BodyMd string `json:"body_md"`
}
if err := httpx.Decode(r, &in); err != nil {
httpx.BadRequest(w, "invalid body")
return
}
body := strings.TrimSpace(in.BodyMd)
if body == "" {
httpx.BadRequest(w, "评论内容不能为空")
return
}
if len([]rune(body)) > maxCommentLen {
httpx.BadRequest(w, "评论最多 500 字")
return
}
if err := a.Store.UpdateCommentBody(id, body, render.Markdown(body)); err != nil {
httpx.ServerError(w, err)
return
}
updated, err := a.Store.GetComment(id)
if err != nil {
httpx.ServerError(w, err)
return
}
a.Hub.Broadcast(updated.PostID)
httpx.OK(w, updated)
}
// deleteComment 作者软删自己的评论(留壳保楼层)
func (a *API) deleteComment(w http.ResponseWriter, r *http.Request, id int64) {
readerID, ok := a.readerID(r)
if !ok {
httpx.Error(w, http.StatusUnauthorized, "登录已过期")
return
}
c, err := a.Store.GetComment(id)
if errors.Is(err, store.ErrNotFound) {
httpx.NotFound(w)
return
}
if err != nil {
httpx.ServerError(w, err)
return
}
if c.UserID != readerID {
httpx.Error(w, http.StatusForbidden, "只能删除自己的评论")
return
}
if err := a.Store.DeleteComment(id); err != nil {
httpx.ServerError(w, err)
return
}
a.Hub.Broadcast(c.PostID)
httpx.OK(w, map[string]any{"ok": true})
}
func mustParse(s string) time.Time {
t, err := time.Parse(time.RFC3339, s)
if err != nil {
return time.Time{}
}
return t
}
+210
View File
@@ -0,0 +1,210 @@
// Google / Telegram 登录的 HTTP 端点。GitHub 的在 comments.go——
// 三个 Provider 共用同一套读者会话与 upsert 逻辑,只是凭据交换方式不同:
// GitHub / Google 是授权码换 token,Telegram 是官方 widget 直接带签名资料。
package api
import (
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"oneblog/internal/auth"
"oneblog/internal/httpx"
"oneblog/internal/model"
"oneblog/internal/ratelimit"
"oneblog/internal/store"
)
// authProviders 列出已配置的登录方式。
// redirect 型前端直接跳 /api/auth/{id}/login;widget 型(Telegram)
// 前端内联官方脚本,需要 bot 用户名。
func (a *API) authProviders(w http.ResponseWriter, r *http.Request) {
providers := []map[string]any{}
if a.GH.Enabled() {
providers = append(providers, map[string]any{
"id": "github", "label": "GitHub", "kind": "redirect",
})
}
if a.GG.Enabled() {
providers = append(providers, map[string]any{
"id": "google", "label": "Google", "kind": "redirect",
})
}
if a.TG.Enabled() {
providers = append(providers, map[string]any{
"id": "telegram", "label": "Telegram", "kind": "widget", "login": a.TG.Bot,
})
}
httpx.OK(w, map[string]any{"providers": providers})
}
// googleLogin 跳 Google 授权页(state 防 CSRF 同 GitHub)
func (a *API) googleLogin(w http.ResponseWriter, r *http.Request) {
if !a.GG.Enabled() {
httpx.NotFound(w)
return
}
a.startOAuth(w, r, func(state string) string {
return a.GG.LoginURL(a.Cfg.SiteURL+"/api/auth/callback/google", state)
})
}
// googleCallback 用 code 换身份,交给统一分流(绑定 / 已绑账号 / 新读者)。
func (a *API) googleCallback(w http.ResponseWriter, r *http.Request) {
if !a.GG.Enabled() {
httpx.NotFound(w)
return
}
ip := ratelimit.SourceKey(r)
if a.authFails.Blocked(ip) {
httpx.Error(w, http.StatusTooManyRequests, "登录失败次数过多,请稍后再试")
return
}
ck, err := r.Cookie(oauthStateCook)
if err != nil || ck.Value == "" || ck.Value != r.FormValue("state") {
a.authFails.Add(ip)
httpx.BadRequest(w, "state 不匹配,请重新登录")
return
}
accessToken, err := a.GG.Exchange(r.Context(), r.FormValue("code"), a.Cfg.SiteURL+"/api/auth/callback/google")
if err != nil {
a.authFails.Add(ip)
httpx.ServerError(w, err)
return
}
u, err := a.GG.FetchUser(r.Context(), accessToken)
if err != nil {
a.authFails.Add(ip)
httpx.ServerError(w, err)
return
}
// handle 优先用已验证邮箱(可读),否则退回 sub(Google 的稳定唯一 id)
handle := u.Sub
if u.EmailVerified && u.Email != "" {
handle = u.Email
}
name := u.Name
if name == "" {
name = handle
}
persona := model.Reader{
Provider: "google", Handle: handle, Name: name, AvatarURL: u.Picture,
}
if back := a.afterIdentity(w, r, "google", u.Sub, handle, persona); back != "" {
http.Redirect(w, r, back, http.StatusFound)
}
}
// telegramAuth 校验 Login Widget 回传的签名资料并登录。
// 前端把 widget 的 user 对象原样 POST 过来(见 reader.js 的 oneTelegramAuth)。
func (a *API) telegramAuth(w http.ResponseWriter, r *http.Request) {
if !a.TG.Enabled() {
httpx.NotFound(w)
return
}
// widget 回传是纯表单 POST,签名可被伪造重放——失败计数最必要的一路
ip := ratelimit.SourceKey(r)
if a.authFails.Blocked(ip) {
httpx.Error(w, http.StatusTooManyRequests, "登录失败次数过多,请稍后再试")
return
}
// 原样读 body:验签必须用收到的全部字段(官方规则),
// 身份字段再单独解一次
body, err := io.ReadAll(r.Body)
if err != nil {
httpx.BadRequest(w, "invalid body")
return
}
var fields map[string]any
if err := json.Unmarshal(body, &fields); err != nil || len(fields) == 0 {
a.authFails.Add(ip)
httpx.BadRequest(w, "invalid body")
return
}
if err := a.TG.VerifyMap(fields); err != nil {
a.authFails.Add(ip)
httpx.Error(w, http.StatusForbidden, "Telegram 登录校验失败,请重试")
return
}
var in auth.TelegramUser
if err := json.Unmarshal(body, &in); err != nil || in.IDInt() == 0 {
httpx.BadRequest(w, "invalid body")
return
}
handle := in.Username
if handle == "" {
// 没有公开 username 的用户用数字 id,保证 provider+handle 稳定唯一
handle = strconv.FormatInt(in.IDInt(), 10)
}
externUID := strconv.FormatInt(in.IDInt(), 10)
// Telegram 是 XHR + JSON 响应(不是整页跳转),所以这里走与 afterIdentity
// 同语义、但自己写响应的一份分流。
if bindRequested(r) {
if !a.adminSessionValid(r) {
clearBindCookie(w)
httpx.Unauthorized(w)
return
}
owner, err := a.Store.EnsureOwner(a.Cfg.AdminUser)
if err != nil {
clearBindCookie(w)
httpx.ServerError(w, err)
return
}
err = a.Store.BindIdentity(owner.ID, "telegram", externUID, handle)
clearBindCookie(w)
if errors.Is(err, store.ErrConflict) {
httpx.Error(w, http.StatusConflict, "该账号已绑定到其他用户")
return
}
if err != nil {
httpx.ServerError(w, err)
return
}
httpx.OK(w, map[string]any{"ok": true, "bound": "telegram"})
return
}
clearBindCookie(w)
// 已绑定的身份优先:站主用绑定的 Telegram 登录要拿到后台会话
if u, err := a.Store.GetUserByIdentity("telegram", externUID); err == nil {
if u.Role == model.RoleOwner {
a.issueAdminSession(w, r)
httpx.OK(w, map[string]any{"ok": true, "role": u.Role})
return
}
a.issueReaderSession(w, r, u.ID)
httpx.OK(w, map[string]any{"ok": true, "role": u.Role})
return
} else if !errors.Is(err, store.ErrNotFound) {
httpx.ServerError(w, err)
return
}
reader, err := a.Store.UpsertReader(model.Reader{
Provider: "telegram", Handle: handle, Name: in.DisplayName(),
AvatarURL: in.PhotoURL,
URL: tgProfileURL(in.Username),
})
if err != nil {
httpx.ServerError(w, err)
return
}
// 会话同样落 httpOnly cookie,前端 POST 完刷新 /api/auth/me 即可见
a.issueReaderSession(w, r, reader.ID)
httpx.OK(w, map[string]any{"user": map[string]any{
"id": reader.ID, "name": reader.Name, "handle": reader.Handle,
"avatar_url": reader.AvatarURL, "url": reader.URL,
"provider": reader.Provider, "is_owner": reader.Role == model.RoleOwner, "banned": reader.Banned,
}})
}
func tgProfileURL(username string) string {
if username == "" {
return ""
}
return "https://t.me/" + username
}
+52
View File
@@ -0,0 +1,52 @@
// 评论变更的 SSE 端点:GET /api/comments/stream?post_id=N。
// 事件里只有「这篇文章的评论变了」——不带任何内容,前端收到后
// 自己 refetch。这样未审核的评论内容不会经 SSE 泄给围观者。
package api
import (
"fmt"
"net/http"
"strconv"
"time"
"oneblog/internal/httpx"
)
// commentsStream 长连推送。EventSource 断线会自动重连,所以这里
// 只需要:连上即发注释行、25s 心跳防代理掐线、事件触发刷新。
func (a *API) commentsStream(w http.ResponseWriter, r *http.Request) {
postID, err := strconv.ParseInt(r.URL.Query().Get("post_id"), 10, 64)
if err != nil || postID <= 0 {
httpx.BadRequest(w, "post_id required")
return
}
fl, ok := w.(http.Flusher)
if !ok {
httpx.Error(w, http.StatusInternalServerError, "streaming unsupported")
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
// 反向代理(nginx 等)默认缓冲会吞掉流式响应
w.Header().Set("X-Accel-Buffering", "no")
fmt.Fprint(w, ": connected\n\n")
fl.Flush()
ch, off := a.Hub.Subscribe(postID)
defer off()
heartbeat := time.NewTicker(25 * time.Second)
defer heartbeat.Stop()
for {
select {
case <-r.Context().Done():
return
case <-heartbeat.C:
fmt.Fprint(w, ": ping\n\n")
fl.Flush()
case <-ch:
fmt.Fprintf(w, "event: comments\ndata: {\"post_id\":%d}\n\n", postID)
fl.Flush()
}
}
}
+142
View File
@@ -0,0 +1,142 @@
// 缩略图懒生成:GET /uploads/thumb/{key}?w=960
//
// 首次请求从存储端读一次原图,缩放编码后落盘缓存(DataDir/.thumbnail_cache),
// 之后直接供缓存——时间线首屏不再拉原图。非图片 / 解码失败 / 冷却期内
// 一律 302 回原图:前端 <img> 拿 302 是无感的。
package api
import (
"errors"
"net/http"
"os"
"strconv"
"strings"
"oneblog/internal/httpx"
"oneblog/internal/model"
"oneblog/internal/storage"
"oneblog/internal/store"
"oneblog/internal/thumbs"
)
const (
thumbDefaultW = 960
thumbMinW = 64
thumbMaxW = 1600
)
func (a *API) ThumbHandler() http.Handler {
return http.HandlerFunc(a.thumb)
}
func (a *API) thumb(w http.ResponseWriter, r *http.Request) {
if a.Thumbs == nil {
httpx.NotFound(w)
return
}
key := strings.TrimPrefix(r.URL.Path, "/uploads/thumb/")
if key == "" || strings.Contains(key, "..") {
httpx.NotFound(w)
return
}
width := thumbDefaultW
if s := r.URL.Query().Get("w"); s != "" {
if n, err := strconv.Atoi(s); err == nil && n >= thumbMinW && n <= thumbMaxW {
width = n
}
}
f, err := a.Store.GetFileByKey(key)
if errors.Is(err, store.ErrNotFound) {
httpx.NotFound(w)
return
}
if err != nil {
httpx.ServerError(w, err)
return
}
if !thumbs.Supported(f.Mime) || f.SHA256 == "" {
a.thumbFallback(w, r, f)
return
}
// 快路径:缓存命中直接供(锁外)
if p := a.Thumbs.FindCached(f.SHA256, width); p != "" {
a.serveThumbFile(w, r, p, f)
return
}
// 冷却期:近期失败过,不再尝试
if a.Thumbs.FailedRecently(f.SHA256) {
a.thumbFallback(w, r, f)
return
}
// 慢路径:同 key+宽度并发只生成一次,后来者等锁后读缓存
lk := a.Thumbs.Lock(f.SHA256, width)
lk.Lock()
defer lk.Unlock()
if p := a.Thumbs.FindCached(f.SHA256, width); p != "" {
a.serveThumbFile(w, r, p, f)
return
}
rc, _, err := a.Blobs.Open(r.Context(), f.Key)
if err != nil {
a.Thumbs.MarkFailed(f.SHA256)
a.thumbFallback(w, r, f)
return
}
src, err := thumbs.ReadAllLimited(rc, thumbs.MaxSrcBytes)
rc.Close()
if err != nil {
a.Thumbs.MarkFailed(f.SHA256)
a.thumbFallback(w, r, f)
return
}
out, outMime, _, err := thumbs.Generate(src, f.Mime, width)
if err != nil {
a.Thumbs.MarkFailed(f.SHA256)
a.thumbFallback(w, r, f)
return
}
p, err := a.Thumbs.Put(f.SHA256, width, out, outMime)
if err != nil {
// 写缓存失败不拖累本次响应:产物就在内存里
a.serveThumbBytes(w, outMime, out, f)
return
}
a.serveThumbFile(w, r, p, f)
}
// thumbFallback 回原图:R2 + 公开域名是 302 直链,本地/未配域名回 /uploads/ 路由。
func (a *API) thumbFallback(w http.ResponseWriter, r *http.Request, f model.File) {
http.Redirect(w, r, storage.FileURL(f.Store, f.Key, a.Cfg.UploadsPublicBase), http.StatusFound)
}
// serveThumbFile 用 ServeContent 供缓存文件:自带 Range/Last-Modified/304。
func (a *API) serveThumbFile(w http.ResponseWriter, r *http.Request, path string, f model.File) {
fp, err := os.Open(path)
if err != nil {
a.thumbFallback(w, r, f)
return
}
defer fp.Close()
fi, err := fp.Stat()
if err != nil {
httpx.NotFound(w)
return
}
a.Thumbs.Touch(path) // 记一次「最近用过」,供容量闸按 LRU 淘汰
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
http.ServeContent(w, r, path, fi.ModTime(), fp)
}
func (a *API) serveThumbBytes(w http.ResponseWriter, mime string, data []byte, f model.File) {
w.Header().Set("Content-Type", mime)
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
w.Header().Set("ETag", `"`+f.SHA256+`"`)
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(data)
}
+179
View File
@@ -0,0 +1,179 @@
// Package auth 提供评论区两侧的基础设施:读者会话(cookie one_reader,
// payload 带 reader: 前缀,与后台令牌不可互换)和 GitHub OAuth 客户端。
package auth
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// ReaderCookie 是读者会话的 cookie 名(与后台的 one_session 区分开)
const ReaderCookie = "one_reader"
// ReaderSessions 签发 / 校验读者会话令牌:
// base64("reader:<readerID>:<expiryUnix>") + "." + HMAC-SHA256。
type ReaderSessions struct {
secret []byte
ttl time.Duration
}
func NewReaderSessions(secret string, ttl time.Duration) *ReaderSessions {
if ttl <= 0 {
ttl = 30 * 24 * time.Hour
}
return &ReaderSessions{secret: []byte(secret), ttl: ttl}
}
var ErrBadSession = errors.New("invalid reader session")
func (s *ReaderSessions) Issue(readerID int64) (string, time.Time) {
exp := time.Now().Add(s.ttl)
payload := readerPayload(readerID, exp)
enc := base64.RawURLEncoding.EncodeToString([]byte(payload))
return enc + "." + s.sign(payload), exp
}
// TTL 是会话秒数,供 cookie 的 MaxAge 用。
// 之前两处硬编码 30*24h,改 ttl 时容易和 Issue 不同步。
func (s *ReaderSessions) TTL() int { return int(s.ttl.Seconds()) }
func (s *ReaderSessions) Verify(token string) (int64, error) {
parts := strings.Split(token, ".")
if len(parts) != 2 {
return 0, ErrBadSession
}
raw, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return 0, ErrBadSession
}
// 必须以 reader: 开头——后台令牌(base64("admin:<exp>"))复制过来也无效
if len(raw) < 10 || string(raw)[:7] != "reader:" {
return 0, ErrBadSession
}
// 余下是 "<id>:<exp>"
rest := string(raw)[7:]
i := strings.IndexByte(rest, ':')
if i <= 0 {
return 0, ErrBadSession
}
id, err := strconv.ParseInt(rest[:i], 10, 64)
if err != nil {
return 0, ErrBadSession
}
expUnix, err := strconv.ParseInt(rest[i+1:], 10, 64)
if err != nil {
return 0, ErrBadSession
}
if time.Now().After(time.Unix(expUnix, 0)) {
return 0, ErrBadSession
}
// 签名放在最后验证:payload 与 exp 都验过再比对 MAC
if !hmac.Equal([]byte(s.sign(string(raw))), []byte(parts[1])) {
return 0, ErrBadSession
}
return id, nil
}
func readerPayload(id int64, exp time.Time) string {
return "reader:" + strconv.FormatInt(id, 10) + ":" + strconv.FormatInt(exp.Unix(), 10)
}
func (s *ReaderSessions) sign(payload string) string {
mac := hmac.New(sha256.New, s.secret)
mac.Write([]byte(payload))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
// ---------- GitHub OAuth ----------
// GitHub 持有 OAuth 应用凭据。没配 = GitHub 登录不开放,
// 前台登录卡自动不显示该入口。
type GitHub struct {
ClientID string
ClientSecret string
}
func (g GitHub) Enabled() bool { return g.ClientID != "" && g.ClientSecret != "" }
const (
githubAuthURL = "https://github.com/login/oauth/authorize"
githubTokenURL = "https://github.com/login/oauth/access_token"
githubUserURL = "https://api.github.com/user"
)
// LoginURL 生成 GitHub 授权页跳转地址
func (g GitHub) LoginURL(redirectURI, state string) string {
v := url.Values{}
v.Set("client_id", g.ClientID)
v.Set("redirect_uri", redirectURI)
v.Set("scope", "read:user")
v.Set("state", state)
return githubAuthURL + "?" + v.Encode()
}
// GitHubUser 是 GitHub 用户接口里我们关心的字段
type GitHubUser struct {
// ID 是 GitHub 的数字主键:永不复用、改名不变。身份绑定必须用它,
// 用 login 的话对方一改用户名,绑定就指向了另一个人。
ID int64 `json:"id"`
Login string `json:"login"`
Name string `json:"name"`
AvatarURL string `json:"avatar_url"`
HTMLURL string `json:"html_url"`
}
// Exchange 用授权码换 access token
func (g GitHub) Exchange(ctx context.Context, code, redirectURI string) (string, error) {
v := url.Values{}
v.Set("client_id", g.ClientID)
v.Set("client_secret", g.ClientSecret)
v.Set("code", code)
v.Set("redirect_uri", redirectURI)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, githubTokenURL, strings.NewReader(v.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer res.Body.Close()
var out struct {
AccessToken string `json:"access_token"`
}
if err := json.NewDecoder(res.Body).Decode(&out); err != nil || out.AccessToken == "" {
return "", fmt.Errorf("github: token exchange failed")
}
return out.AccessToken, nil
}
// FetchUser 拉取 GitHub 用户资料
func (g GitHub) FetchUser(ctx context.Context, accessToken string) (GitHubUser, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, githubUserURL, nil)
if err != nil {
return GitHubUser{}, err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
res, err := http.DefaultClient.Do(req)
if err != nil {
return GitHubUser{}, err
}
defer res.Body.Close()
var u GitHubUser
if err := json.NewDecoder(res.Body).Decode(&u); err != nil || u.Login == "" {
return GitHubUser{}, fmt.Errorf("github: fetch user failed")
}
return u, nil
}
+95
View File
@@ -0,0 +1,95 @@
package auth
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
)
// Google OAuth2(OIDC 简化用法:openid email profile 三个 scope,
// userinfo 接口拿资料)。配置来源 .env 的 ONE_GOOGLE_CLIENT_ID / SECRET。
const (
googleAuthURL = "https://accounts.google.com/o/oauth2/v2/auth"
googleTokenURL = "https://oauth2.googleapis.com/token"
googleUserURL = "https://openidconnect.googleapis.com/v1/userinfo"
)
type Google struct {
ClientID string
ClientSecret string
}
func (g Google) Enabled() bool { return g.ClientID != "" && g.ClientSecret != "" }
func (g Google) LoginURL(redirectURI, state string) string {
v := url.Values{}
v.Set("client_id", g.ClientID)
v.Set("redirect_uri", redirectURI)
v.Set("response_type", "code")
v.Set("scope", "openid email profile")
v.Set("state", state)
return googleAuthURL + "?" + v.Encode()
}
// GoogleUser 是 userinfo 接口里我们关心的字段。
// sub 是 Google 账号的稳定唯一 id;邮箱需要已验证才当 handle 用。
type GoogleUser struct {
Sub string `json:"sub"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Name string `json:"name"`
Picture string `json:"picture"`
}
func (g Google) Exchange(ctx context.Context, code, redirectURI string) (string, error) {
v := url.Values{}
v.Set("client_id", g.ClientID)
v.Set("client_secret", g.ClientSecret)
v.Set("code", code)
v.Set("redirect_uri", redirectURI)
v.Set("grant_type", "authorization_code")
req, err := http.NewRequestWithContext(ctx, http.MethodPost, googleTokenURL, strings.NewReader(v.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
res, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer res.Body.Close()
var out struct {
AccessToken string `json:"access_token"`
}
if err := json.NewDecoder(res.Body).Decode(&out); err != nil || out.AccessToken == "" {
return "", fmt.Errorf("google: token exchange failed")
}
return out.AccessToken, nil
}
func (g Google) FetchUser(ctx context.Context, accessToken string) (GoogleUser, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, googleUserURL, nil)
if err != nil {
return GoogleUser{}, err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
res, err := http.DefaultClient.Do(req)
if err != nil {
return GoogleUser{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return GoogleUser{}, fmt.Errorf("google: userinfo %d", res.StatusCode)
}
var u GoogleUser
if err := json.NewDecoder(res.Body).Decode(&u); err != nil {
return GoogleUser{}, err
}
if u.Sub == "" {
return GoogleUser{}, fmt.Errorf("google: userinfo missing sub")
}
return u, nil
}
+277
View File
@@ -0,0 +1,277 @@
// Passkey(WebAuthn)封装。
//
// 两件事分开:注册必须在管理员会话之下发起(否则任何人都能往站主账号上塞凭据),
// 登录是公开的、发现式的(不需要先输用户名,浏览器直接问系统要凭据)。
//
// challenge 与会话放进程内存而不是签名 cookie:单进程博客够用,重启只会让
// 正在进行中的注册/登录作废(下次重点即可),不会留下可复用的状态。
// 用完即删 —— challenge 是一次性的,留着就有重放面。
package auth
import (
"bytes"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strconv"
"sync"
"time"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
"oneblog/internal/model"
)
const sessionTTL = 5 * time.Minute
var ErrSessionExpired = errors.New("passkey session expired or already used")
type Passkeys struct {
w *webauthn.WebAuthn
mu sync.Mutex
sessions map[string]*pkSession
}
type pkSession struct {
data webauthn.SessionData
userID int64 // 注册时=目标账号;登录时发现式则为 0
exp time.Time
}
// userAdapter 把我们的账号 + 凭据行喂给库的 webauthn.User 接口。
type userAdapter struct {
id int64
name string
disp string
creds []webauthn.Credential
}
func (u userAdapter) WebAuthnID() []byte { return []byte(strconv.FormatInt(u.id, 10)) }
func (u userAdapter) WebAuthnName() string { return u.name }
func (u userAdapter) WebAuthnDisplayName() string {
if u.disp != "" {
return u.disp
}
return u.name
}
func (u userAdapter) WebAuthnCredentials() []webauthn.Credential { return u.creds }
// NewPasskeys 构造一个 passkey 服务。rpID 是域名(不含 scheme/port),
// origin 是完整来源(开发时是 http://localhost:3000,两者可以不同)。
func NewPasskeys(displayName, rpID string, origins []string) (*Passkeys, error) {
w, err := webauthn.New(&webauthn.Config{
RPDisplayName: displayName,
RPID: rpID,
RPOrigins: origins,
// 站内凭据不需要证明来源可信:attestation 一律 none,
// 既避免解析各家认证报告的复杂度,也不把它当授权依据。
AttestationPreference: protocol.PreferNoAttestation,
AuthenticatorSelection: protocol.AuthenticatorSelection{
UserVerification: protocol.VerificationRequired,
// 可发现凭据:登录时不用先报用户名。
ResidentKey: protocol.ResidentKeyRequirementRequired,
},
})
if err != nil {
return nil, err
}
return &Passkeys{w: w, sessions: make(map[string]*pkSession)}, nil
}
func toCredentials(pks []model.Passkey) ([]webauthn.Credential, error) {
out := make([]webauthn.Credential, 0, len(pks))
for _, p := range pks {
key, err := base64.StdEncoding.DecodeString(p.PublicKey)
if err != nil {
return nil, fmt.Errorf("passkey %d public key: %w", p.ID, err)
}
id, err := base64.StdEncoding.DecodeString(p.CredentialID)
if err != nil {
// 兼容:credential_id 也可能是原始字符串(非 base64)
id = []byte(p.CredentialID)
}
out = append(out, webauthn.Credential{
ID: id,
PublicKey: key,
Authenticator: webauthn.Authenticator{
SignCount: p.SignCount,
},
})
}
return out, nil
}
// BeginRegistration 发起注册。返回给前端的创建参数与一次性 token。
func (p *Passkeys) BeginRegistration(userID int64, name, display string, existing []model.Passkey) (creationJSON json.RawMessage, token string, err error) {
creds, err := toCredentials(existing)
if err != nil {
return nil, "", err
}
u := userAdapter{id: userID, name: name, disp: display, creds: creds}
// 已存在的凭据要排除,免得同一台设备被重复登记
opts := []webauthn.RegistrationOption{}
if len(creds) > 0 {
exclude := make([]protocol.CredentialDescriptor, 0, len(creds))
for _, c := range creds {
exclude = append(exclude, protocol.CredentialDescriptor{
Type: protocol.PublicKeyCredentialType,
CredentialID: c.ID,
})
}
opts = append(opts, webauthn.WithExclusions(exclude))
}
creation, session, err := p.w.BeginRegistration(u, opts...)
if err != nil {
return nil, "", err
}
tok, err := randomToken()
if err != nil {
return nil, "", err
}
p.put(tok, &pkSession{data: *session, userID: userID, exp: time.Now().Add(sessionTTL)})
b, err := json.Marshal(creation)
if err != nil {
return nil, "", err
}
return b, tok, nil
}
// FinishRegistration 校验浏览器返回的凭据并交回要落库的字段。
// credJSON 是 PublicKeyCredential 的 JSON —— challenge/token 走外层字段,
// 凭据对象原样交给库解析(库自己校验 clientDataJSON.challenge 是否对得上)。
func (p *Passkeys) FinishRegistration(token string, userID int64, name, display string, existing []model.Passkey, credJSON []byte) (model.Passkey, error) {
sess, err := p.take(token, userID)
if err != nil {
return model.Passkey{}, err
}
creds, err := toCredentials(existing)
if err != nil {
return model.Passkey{}, err
}
u := userAdapter{id: userID, name: name, disp: display, creds: creds}
parsed, err := protocol.ParseCredentialCreationResponseBody(bytes.NewReader(credJSON))
if err != nil {
return model.Passkey{}, err
}
cred, err := p.w.CreateCredential(u, *sess, parsed)
if err != nil {
return model.Passkey{}, err
}
return model.Passkey{
UserID: userID,
CredentialID: base64.StdEncoding.EncodeToString(cred.ID),
PublicKey: base64.StdEncoding.EncodeToString(cred.PublicKey),
SignCount: cred.Authenticator.SignCount,
}, nil
}
// BeginLogin 发起发现式登录(不预先指定账号,由凭据自己带出身份)。
func (p *Passkeys) BeginLogin() (assertionJSON json.RawMessage, token string, err error) {
assertion, session, err := p.w.BeginDiscoverableLogin()
if err != nil {
return nil, "", err
}
tok, err := randomToken()
if err != nil {
return nil, "", err
}
p.put(tok, &pkSession{data: *session, exp: time.Now().Add(sessionTTL)})
b, err := json.Marshal(assertion)
if err != nil {
return nil, "", err
}
return b, tok, nil
}
// LoginResult 是一次 passkey 登录的结果。
// CloneWarning 透传库的判定:新计数 ≤ 已存计数时库认为凭据可能被克隆。
// 这里只上报不拦 —— 云同步的 passkey(iCloud Keychain 等)计数本就不单调,
// 硬拦会把合法用户挡在门外;但它是必须让站主看得见的信号。
type LoginResult struct {
CredentialID string // base64,交给 resolver 时用的同一个键
UserID int64
SignCount uint32
CloneWarning bool
}
// FinishLogin 完成登录:resolver 按 credential id 反查凭据(含所属账号),
// 验签通过后返回该凭据与新计数,调用方据此决定发哪种会话。
// credJSON 同 FinishRegistration,是客户端原样回传的 PublicKeyCredential JSON。
func (p *Passkeys) FinishLogin(token string, credJSON []byte, resolve func(credentialIDB64 string) (model.Passkey, error)) (LoginResult, error) {
var out LoginResult
sess, err := p.take(token, 0)
if err != nil {
return out, err
}
handler := func(rawID, userHandle []byte) (webauthn.User, error) {
pk, err := resolve(base64.StdEncoding.EncodeToString(rawID))
if err != nil {
return nil, err
}
creds, err := toCredentials([]model.Passkey{pk})
if err != nil {
return nil, err
}
out.CredentialID = pk.CredentialID
out.UserID = pk.UserID
// 名字只用于日志/报错,登录路径不展示
return userAdapter{id: pk.UserID, name: strconv.FormatInt(pk.UserID, 10), creds: creds}, nil
}
parsed, err := protocol.ParseCredentialRequestResponseBytes(credJSON)
if err != nil {
return out, err
}
cred, err := p.w.ValidateDiscoverableLogin(handler, *sess, parsed)
if err != nil {
return out, err
}
out.SignCount = cred.Authenticator.SignCount
out.CloneWarning = cred.Authenticator.CloneWarning
return out, nil
}
// put 存会话并顺手清掉过期的:map 只在这几个流程里活几分钟,
// 清扫是防泄漏而不是防攻击。
func (p *Passkeys) put(token string, s *pkSession) {
p.mu.Lock()
defer p.mu.Unlock()
now := time.Now()
for k, v := range p.sessions {
if v.exp.Before(now) {
delete(p.sessions, k)
}
}
p.sessions[token] = s
}
// take 取出并删除会话(一次性),并核对发起时的账号。
func (p *Passkeys) take(token string, userID int64) (*webauthn.SessionData, error) {
p.mu.Lock()
defer p.mu.Unlock()
s, ok := p.sessions[token]
if !ok {
return nil, ErrSessionExpired
}
delete(p.sessions, token)
if time.Now().After(s.exp) {
return nil, ErrSessionExpired
}
// 注册会话绑账号:不能拿自己发起的 challenge 去给别人的账号注册
if userID != 0 && s.userID != userID {
return nil, ErrSessionExpired
}
return &s.data, nil
}
func randomToken() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
+121
View File
@@ -0,0 +1,121 @@
package auth
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
"time"
)
// Telegram Login Widget:没有授权码流程,官方脚本把用户资料连同
// hash 一起交给前端,前端 POST 到 /api/auth/telegram,后端用 bot
// token 验签。算法见官方文档:secret = SHA256(bot_token),
// data-check-string 是除 hash 外所有收到的字段按 key 排序的 "k=v" 行。
type Telegram struct {
Bot string // bot 用户名(不含 @),下发给 widget
Token string // bot token,只用于验签,不出后端
}
func (t Telegram) Enabled() bool { return t.Bot != "" && t.Token != "" }
// telegramAuthTTL 是验签窗口:widget 回传的 auth_date 超过它视为过期
const telegramAuthTTL = 24 * time.Hour
// TelegramUser 是 widget 回传的身份字段(验签通过后从中取)。
// widget 把所有值都当字符串发(id 也不例外),但前端测试或手工
// 调用可能发数字 —— FlexStr 两种都收。
type TelegramUser struct {
ID FlexStr `json:"id"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Username string `json:"username"`
PhotoURL string `json:"photo_url"`
}
// FlexStr 兼容 JSON 里的字符串和数字
type FlexStr string
func (f *FlexStr) UnmarshalJSON(b []byte) error {
if len(b) > 0 && b[0] == '"' {
var s string
if err := json.Unmarshal(b, &s); err != nil {
return err
}
*f = FlexStr(s)
return nil
}
*f = FlexStr(b)
return nil
}
func (f FlexStr) Int64() (int64, error) {
return strconv.ParseInt(strings.Trim(string(f), `"`), 10, 64)
}
func (u TelegramUser) IDInt() int64 { id, _ := u.ID.Int64(); return id }
func (u TelegramUser) DisplayName() string {
name := strings.TrimSpace(u.FirstName + " " + u.LastName)
if name == "" {
name = u.Username
}
return name
}
// normalize 把 JSON 值规整成 widget 发送时的字符串形态
// (widget 的数值字段也是字符串,但调用方可能发真数字)
func normalize(v any) string {
switch x := v.(type) {
case string:
return x
case float64:
return strconv.FormatInt(int64(x), 10)
case json.Number:
return x.String()
case bool:
return strconv.FormatBool(x)
default:
return fmt.Sprint(x)
}
}
// VerifyMap 校验 hash 与时效。fields 是前端原样 POST 的 JSON 对象;
// exclude 里的 key(我们附加的非 Telegram 字段)不参与验签。
func (t Telegram) VerifyMap(fields map[string]any, exclude ...string) error {
hash := normalize(fields["hash"])
if hash == "" {
return fmt.Errorf("telegram: missing hash")
}
authDate, err := strconv.ParseInt(normalize(fields["auth_date"]), 10, 64)
if err != nil || authDate == 0 || time.Since(time.Unix(authDate, 0)) > telegramAuthTTL {
return fmt.Errorf("telegram: auth_date expired")
}
skip := make(map[string]bool, len(exclude)+1)
skip["hash"] = true
for _, k := range exclude {
skip[k] = true
}
keys := make([]string, 0, len(fields))
for k := range fields {
if !skip[k] {
keys = append(keys, k)
}
}
sort.Strings(keys)
lines := make([]string, 0, len(keys))
for _, k := range keys {
lines = append(lines, k+"="+normalize(fields[k]))
}
secret := sha256.Sum256([]byte(t.Token))
mac := hmac.New(sha256.New, secret[:])
mac.Write([]byte(strings.Join(lines, "\n")))
if !hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(strings.ToLower(hash))) {
return fmt.Errorf("telegram: hash mismatch")
}
return nil
}
+116
View File
@@ -3,6 +3,8 @@ package config
import (
"crypto/rand"
"encoding/hex"
"log"
"net/url"
"os"
"path/filepath"
"strings"
@@ -19,6 +21,41 @@ type Config struct {
DataDir string
SiteURL string
InsecureDev bool
// 评论区 GitHub 登录(OAuth App 凭据,站主在 GitHub 上创建后填入)
GitHubClientID string
GitHubClientSecret string
// 评论区 Google 登录(OAuth 客户端凭据,Google Cloud Console 创建)
GoogleClientID string
GoogleClientSecret string
// 评论区 Telegram 登录(Login Widget):Bot 是用户名(不含 @,下发给
// 前端 widget),Token 用于验签。两者都要,缺一该入口不开放。
TelegramBot string
TelegramToken string
// Passkey(WebAuthn)。RPID 是域名(不含端口),Origins 是允许的完整来源。
// 必须显式配 ONE_WEBAUTHN_ORIGINS 才启用(它是发放永久登录凭据的功能);
// 启用后 RPID 默认取 SiteURL 的主机名,Origin 列表会自动并入 SiteURL 本身。
// 开发时前端在 :3000、后端在 :8080,所以要把两个来源都写上:
// ONE_WEBAUTHN_ORIGINS=http://localhost:3000,http://localhost:8080
WebauthnRPID string // env: ONE_WEBAUTHN_RP_ID
WebauthnOrigins []string // env: ONE_WEBAUTHN_ORIGINS(空 = 不启用)
// 对象存储(文件上传)。变量名与站主 .env 里的写法一致(站主已整理):
// S3Api = R2 的 S3 API 端点(https://<账户ID>.r2.cloudflarestorage.com,
// 控制台 R2 概览可复制),上传走它 —— 公开域名收不了上传请求
// PublicURL = 公开访问域名(r2.dev / 绑定的自定义域名),文件直链走它
// AccessKey / SecretAccessKey / Bucket = R2 凭据与桶名
// 五项齐全 → 上传走 R2、直链走 PublicURL;缺任一项回落本地磁盘
// (DataDir/uploads),并在启动日志提示一句。
StorageDriver string // r2 | local
S3Endpoint string // env: S3Api
R2Bucket string
R2AccessKey string
R2SecretKey string
UploadsPublicBase string // env: PublicURL
}
func getenv(k, def string) string {
@@ -77,9 +114,88 @@ func Load() (*Config, error) {
c.InsecureDev = os.Getenv("ONE_ADMIN_PASSWORD") == ""
// 对象存储:变量名按站主 .env 里整理好的来(无 ONE_ 前缀)。
c.UploadsPublicBase = strings.TrimRight(getenv("PublicURL", ""), "/")
c.S3Endpoint = getenv("S3Api", "")
c.R2AccessKey = getenv("AccessKey", "")
c.R2SecretKey = getenv("SecretAccessKey", "")
c.R2Bucket = getenv("Bucket", "")
if c.S3Endpoint != "" && c.R2Bucket != "" && c.R2AccessKey != "" && c.R2SecretKey != "" {
c.StorageDriver = "r2"
} else {
c.StorageDriver = "local"
// 配了一半(有凭据没端点之类)时给一句启动日志,别让站主猜。
// 只报字段名,不报值。
var missing []string
if c.S3Endpoint == "" {
missing = append(missing, "S3Api")
}
if c.R2Bucket == "" {
missing = append(missing, "Bucket")
}
if c.R2AccessKey == "" {
missing = append(missing, "AccessKey")
}
if c.R2SecretKey == "" {
missing = append(missing, "SecretAccessKey")
}
if len(missing) > 0 {
log.Printf("storage: R2 配置缺 %s,上传回落本地磁盘", strings.Join(missing, "、"))
}
}
// 评论区 GitHub 登录(OAuth App 凭据,站主在 GitHub 上创建后填入)
c.GitHubClientID = getenv("ONE_GITHUB_CLIENT_ID", "")
c.GitHubClientSecret = getenv("ONE_GITHUB_CLIENT_SECRET", "")
c.GoogleClientID = getenv("ONE_GOOGLE_CLIENT_ID", "")
c.GoogleClientSecret = getenv("ONE_GOOGLE_CLIENT_SECRET", "")
c.TelegramBot = getenv("ONE_TELEGRAM_BOT", "")
c.TelegramToken = getenv("ONE_TELEGRAM_BOT_TOKEN", "")
// Passkey:默认跟着 SiteURL,显式配了 ONE_WEBAUTHN_ORIGINS 才算启用
// (没配就不注册相关路由,也不给前端暴露入口)。
c.WebauthnRPID, c.WebauthnOrigins = webauthnFromSite(c.SiteURL, getenv("ONE_WEBAUTHN_RP_ID", ""), getenv("ONE_WEBAUTHN_ORIGINS", ""))
return c, nil
}
// webauthnFromSite 推导 RPID 与允许的 Origin 列表。
// 只有显式配了 ONE_WEBAUTHN_ORIGINS 才返回非空列表 —— 没配就视为不启用
// (返回 nil,调用方跳过构造)。这是个发放永久登录凭据的功能,
// 默认关闭比默认开启安全;SiteURL 推导出的 origin 只在启用后作为额外来源。
func webauthnFromSite(siteURL, rpID, origins string) (string, []string) {
list := []string{}
for _, o := range strings.Split(origins, ",") {
if o = strings.TrimRight(strings.TrimSpace(o), "/"); o != "" {
list = append(list, o)
}
}
if u, err := url.Parse(strings.TrimRight(siteURL, "/")); err == nil && u.Host != "" {
if rpID == "" {
rpID = u.Hostname() // Hostname() 会去掉端口
}
}
if len(list) == 0 {
return "", nil // 未启用:RPID 一并清空,免得调用方误判成可用
}
// 启用后把站点自身来源也加上(生产环境页面就来自这里)
if u, err := url.Parse(strings.TrimRight(siteURL, "/")); err == nil && u.Host != "" {
if origin := u.Scheme + "://" + u.Host; !containsStr(list, origin) {
list = append(list, origin)
}
}
return rpID, list
}
func containsStr(list []string, s string) bool {
for _, v := range list {
if strings.EqualFold(v, s) {
return true
}
}
return false
}
type badDriver struct{ d string }
func (e *badDriver) Error() string {
+57
View File
@@ -0,0 +1,57 @@
// Package hub 是一个极简的进程内发布/订阅:按主题(文章 ID)广播
// 「有变更」信号。订阅者只拿到一个空 struct 信号——不携带内容,
// 消费方(SSE 前端)收到后自己 refetch,未审核的评论内容不会经由
// 这条通道外泄。
package hub
import "sync"
type Hub struct {
mu sync.Mutex
subs map[int64]map[chan struct{}]struct{}
}
func New() *Hub {
return &Hub{subs: make(map[int64]map[chan struct{}]struct{})}
}
// Subscribe 订阅某主题;返回信号 channel 和退订函数。
// channel 容量 1:订阅者处理不过来时新信号直接丢弃(合并刷新)。
func (h *Hub) Subscribe(topic int64) (<-chan struct{}, func()) {
// nil hub(测试/未装配场景)静默降级:永远收不到信号的通道
if h == nil {
ch := make(chan struct{})
return ch, func() {}
}
ch := make(chan struct{}, 1)
h.mu.Lock()
if h.subs[topic] == nil {
h.subs[topic] = make(map[chan struct{}]struct{})
}
h.subs[topic][ch] = struct{}{}
h.mu.Unlock()
off := func() {
h.mu.Lock()
delete(h.subs[topic], ch)
if len(h.subs[topic]) == 0 {
delete(h.subs, topic)
}
h.mu.Unlock()
}
return ch, off
}
// Broadcast 唤醒某主题的全部订阅者;积压的订阅者不阻塞。
func (h *Hub) Broadcast(topic int64) {
if h == nil {
return
}
h.mu.Lock()
defer h.mu.Unlock()
for ch := range h.subs[topic] {
select {
case ch <- struct{}{}:
default:
}
}
}
+107
View File
@@ -0,0 +1,107 @@
package linkmeta
import (
"bytes"
"strings"
"golang.org/x/text/encoding/htmlindex"
"golang.org/x/text/transform"
)
// charset.go 负责把响应字节解成 UTF-8 字符串。
//
// 为什么必须做:老派中文站点(以及不少论坛/院校站)仍在用 GBK/GB2312/Big5,
// 直接当 UTF-8 读会得到一串替换字符,卡片标题就成了乱码。与其显示乱码,
// 不如抓对——这是纯展示层的事,不该让站主去改对方的编码。
// decode 按「HTTP 头声明 → 文档里的 <meta charset> → UTF-8」的优先级解码。
// 已经是 UTF-8/ASCII 时原样返回,不绕转换管线。
func decode(raw []byte, contentType string) string {
raw = bytes.TrimPrefix(raw, []byte("\ufeff")) // UTF-8 BOM
name := charsetFromHeader(contentType)
if name == "" {
// meta 标签本身是 ASCII(GBK/Big5 都是 ASCII 超集),
// 所以从未解码的原始字节里嗅探是安全的,只看文档开头。
name = charsetFromMeta(raw)
}
if name == "" || isUTF8(name) {
return string(raw)
}
enc, err := htmlindex.Get(name)
if err != nil {
return string(raw) // 没见过的字符名:按 UTF-8 尽力而为
}
out, _, err := transform.Bytes(enc.NewDecoder(), raw)
if err != nil && len(out) == 0 {
return string(raw)
}
return string(out)
}
func charsetFromHeader(ct string) string {
for _, part := range strings.Split(ct, ";") {
kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
if len(kv) == 2 && strings.EqualFold(kv[0], "charset") {
return normalizeCharset(strings.Trim(kv[1], `"'`))
}
}
return ""
}
var (
metaCharset = []byte("charset=")
)
// charsetFromMeta 在文档开头找 <meta charset="x"> 或
// <meta http-equiv="Content-Type" content="...; charset=x">。
func charsetFromMeta(raw []byte) string {
head := raw
if len(head) > 2048 {
head = head[:2048] // 声明一定在前,不必扫全文
}
lower := bytes.ToLower(head)
i := bytes.Index(lower, metaCharset)
if i < 0 {
return ""
}
rest := head[i+len(metaCharset):]
if len(rest) == 0 {
return ""
}
// 值可能被引号包住;带引号时以配对引号收尾,不带时到引号/空格/;/ > 为止
// (不闭合的引号也当结束——http-equiv 写法里值是 content="...; charset=gbk",
// 未加引号的 charset 值正好以那个收尾引号终止)
if q := rest[0]; q == '"' || q == '\'' {
rest = rest[1:]
if end := bytes.IndexByte(rest, q); end >= 0 {
rest = rest[:end]
}
} else if end := bytes.IndexAny(rest, `"' ;>`); end >= 0 {
rest = rest[:end]
}
return normalizeCharset(string(rest))
}
func normalizeCharset(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
// HTML 标准与 IANA 的常见别名统一成 htmlindex 认得的名字
switch s {
case "gb2312", "gb_2312", "gb-2312":
return "gbk" // GBK 是 GB2312 的超集,按声明的 GB2312 解会漏字
case "x-sjis":
return "shift_jis"
case "euckr", "kr":
return "euc-kr"
case "utf8", "utf-8", "ascii", "us-ascii", "":
return s
}
return s
}
func isUTF8(name string) bool {
switch name {
case "", "utf-8", "utf8", "ascii", "us-ascii":
return true
}
return false
}
+122
View File
@@ -0,0 +1,122 @@
package linkmeta
import (
"context"
"fmt"
"net"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"golang.org/x/text/encoding/simplifiedchinese"
)
// gbkPage 把 UTF-8 字符串编成 GBK——老派中文站就是这么发的。
func gbkPage(t *testing.T, s string) []byte {
t.Helper()
out, err := simplifiedchinese.GBK.NewEncoder().Bytes([]byte(s))
if err != nil {
t.Fatal(err)
}
return out
}
func TestDecodeGBKMetaCharset(t *testing.T) {
// 只在 <meta> 里声明,HTTP 头不带 charset——这是最常见的写法
doc := `<html><head><meta charset="gbk"><meta property="og:title" content="围棋职业棋士的直播间"></head></html>`
raw := gbkPage(t, doc)
got := parse(decode(raw, "text/html"), mustBase(t, "https://example.com/"))
if got.Title != "围棋职业棋士的直播间" {
t.Fatalf("GBK 未正确解码: %q", got.Title)
}
}
func TestDecodeHTTPHeaderWins(t *testing.T) {
doc := `<html><head><title>标题测试</title></head></html>`
raw := gbkPage(t, doc)
got := parse(decode(raw, `text/html; charset=GB2312`), mustBase(t, "https://example.com/"))
if got.Title != "标题测试" {
t.Fatalf("HTTP 头声明的字符集未生效: %q", got.Title)
}
}
func TestDecodeHTTPEquivMeta(t *testing.T) {
doc := `<html><head><meta http-equiv="Content-Type" content="text/html; charset=gbk"><title>中文标题</title></head></html>`
got := parse(decode(gbkPage(t, doc), "text/html"), mustBase(t, "https://example.com/"))
if got.Title != "中文标题" {
t.Fatalf("http-equiv 形式未识别: %q", got.Title)
}
}
func TestDecodePassthroughAndUnknown(t *testing.T) {
utf8 := `<html><head><title>已经是 UTF-8</title></head></html>`
if got := decode([]byte(utf8), "text/html; charset=utf-8"); got != utf8 {
t.Error("UTF-8 不该走转换管线")
}
if got := decode([]byte(utf8), "text/html; charset=bogus-9999"); got != utf8 {
t.Error("认不出的字符集应退回原始字节而不是报错")
}
if got := decode([]byte("\ufeff"+utf8), "text/html"); got != utf8 {
t.Error("BOM 应被剥掉")
}
}
func TestCharsetFromHeader(t *testing.T) {
cases := map[string]string{
`text/html; charset=UTF-8`: "utf-8",
`text/html;charset="gbk"`: "gbk",
`text/html; charset=GB2312`: "gbk", // 别名归一:GBK 是超集
`text/html`: "",
}
for in, want := range cases {
if got := charsetFromHeader(in); got != want {
t.Errorf("charsetFromHeader(%q) = %q, want %q", in, got, want)
}
}
}
func TestCharsetFromMeta(t *testing.T) {
cases := map[string]string{
`<html><head><meta charset='Big5'>`: "big5",
`<html><head><meta charset=gb2312>`: "gbk",
`<meta http-equiv="Content-Type" content="text/html; charset=x-sjis">`: "shift_jis",
`<html><head><title>没有声明</title>`: "",
}
for in, want := range cases {
if got := charsetFromMeta([]byte(in)); got != want {
t.Errorf("charsetFromMeta(%q) = %q, want %q", in, got, want)
}
}
}
// 端到端:真发一个 GBK 页面(HTTP 头故意不写 charset),确认整条链路出正确卡片。
func TestFetcherDecodesGBKEndToEnd(t *testing.T) {
doc := `<html><head><meta charset="gbk"><meta property="og:title" content="鱼妹妹下棋"><meta property="og:site_name" content="某中文站"></head>`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html") // 不声明 charset,逼它去嗅 meta
fmt.Fprint(w, string(gbkPage(t, doc)))
}))
defer srv.Close()
f := &Fetcher{Dial: func(ctx context.Context, network, addr string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, network, addr)
}}
c, err := f.Fetch(context.Background(), srv.URL)
if err != nil {
t.Fatal(err)
}
if c.Title != "鱼妹妹下棋" || c.Site != "某中文站" {
t.Fatalf("card=%+v", c)
}
}
func mustBase(t *testing.T, s string) *url.URL {
t.Helper()
u, err := url.Parse(s)
if err != nil {
t.Fatal(err)
}
return u
}
+242
View File
@@ -0,0 +1,242 @@
// Package linkmeta 从正文里的链接抓一份「链接卡片」素材:标题、描述、站点名、封面图。
//
// 抓取由站主写作时触发(不是读者请求触发),但目标地址仍是任意公网 URL,
// 所以按对外抓取的标准对待:只放行 http/https、限时限量限跳转,
// 并在拨号那一刻解析并拒绝内网地址——防的是「服务器自己打自己」这类 SSRF,
// 以及 DNS 先返回公网 IP、拨号时换成内网 IP 的重绑定把戏。
package linkmeta
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"oneblog/internal/model"
)
const (
// maxBody 只读文档开头就够拿到 <head>,多一个字节都不多读。
maxBody = 512 << 10
// maxRedirects 跳转链上限。
maxRedirects = 3
// 浏览器形态的 UA:不少站点(X、微博、部分门户)对未知爬虫 UA 给的
// og 元数据不全或直接拒绝,对浏览器 UA 则照常输出 <head> 里的标签。
userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36"
)
// Card 是抓取结果的数据形状;定义在 model 里(要落库、要给前端),
// 这里用别名保持本包的写法。
type Card = model.LinkCard
var (
urlRe = regexp.MustCompile(`https?://[^\s<>"'\)\]]+`)
trailingCut = ".,;:!?、。)]》」》"
)
// FirstURL 取正文里第一个 http(s) 链接(Markdown 原文,含代码块里的也算,
// 站主自己不会在代码块里贴想展示的链接)。没有则空串。
func FirstURL(md string) string {
m := urlRe.FindString(md)
if m == "" {
return ""
}
return strings.TrimRight(m, trailingCut)
}
// Fetcher 抓取器。Dial 为 nil 时用带 SSRF 防护的默认拨号器;
// 测试里注入普通拨号器才能打到 httptest 的回环地址(默认会被拦掉)。
type Fetcher struct {
Dial func(ctx context.Context, network, addr string) (net.Conn, error)
Timeout time.Duration // 0 = 默认 5s
}
// Fetch 用默认安全拨号器抓 rawURL 的元信息。ctx 控制整体时限。
func Fetch(ctx context.Context, rawURL string) (*Card, error) {
return (&Fetcher{}).Fetch(ctx, rawURL)
}
// Fetch 抓 rawURL 的元信息。
func (f *Fetcher) Fetch(ctx context.Context, rawURL string) (*Card, error) {
u, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil {
return nil, fmt.Errorf("linkmeta: bad url: %w", err)
}
if u.Scheme != "http" && u.Scheme != "https" {
return nil, fmt.Errorf("linkmeta: scheme %q not allowed", u.Scheme)
}
if u.Host == "" {
return nil, errors.New("linkmeta: empty host")
}
client := f.client()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", userAgent)
req.Header.Set("Accept", "text/html,application/xhtml+xml;q=0.9,*/*;q=0.5")
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("linkmeta: fetch: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return nil, fmt.Errorf("linkmeta: status %d", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "text/html") {
return nil, fmt.Errorf("linkmeta: not html (%q)", ct)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBody))
if err != nil {
return nil, fmt.Errorf("linkmeta: read: %w", err)
}
final := resp.Request.URL // 跟完跳转后的最终地址
card := parse(decode(body, resp.Header.Get("Content-Type")), final)
if card.Empty() {
return nil, errors.New("linkmeta: no usable metadata")
}
return card, nil
}
// client 组装带 SSRF 防护的 HTTP 客户端(Fetch 与 FetchBytes 共用)
func (f *Fetcher) client() *http.Client {
dial := f.Dial
if dial == nil {
dial = safeDial
}
timeout := f.Timeout
if timeout <= 0 {
timeout = 5 * time.Second
}
return &http.Client{
Transport: &http.Transport{
DialContext: dial,
TLSHandshakeTimeout: 3 * time.Second,
// 每个跳转目标都过一遍 dial(transport 会复用),无需额外校验
ForceAttemptHTTP2: false,
},
Timeout: timeout,
CheckRedirect: limitRedirects,
}
}
// FetchBytes 抓二进制内容(外链图片转存用):与 Fetch 共用同一套
// SSRF 防护与跳转限制,字节数有 maxBytes 硬上限。
// 返回内容与 Content-Type(响应头缺失时用内容嗅探兜底)。
func FetchBytes(ctx context.Context, rawURL string, maxBytes int64) ([]byte, string, error) {
return (&Fetcher{}).fetchBytes(ctx, rawURL, maxBytes)
}
func (f *Fetcher) fetchBytes(ctx context.Context, rawURL string, maxBytes int64) ([]byte, string, error) {
u, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil {
return nil, "", fmt.Errorf("linkmeta: bad url: %w", err)
}
if u.Scheme != "http" && u.Scheme != "https" {
return nil, "", fmt.Errorf("linkmeta: scheme %q not allowed", u.Scheme)
}
if u.Host == "" {
return nil, "", errors.New("linkmeta: empty host")
}
client := f.client()
// 二进制传输放宽时限:大图慢链路 5 秒的元信息默认值不够用
if f.Timeout <= 0 {
client.Timeout = 30 * time.Second
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
return nil, "", err
}
req.Header.Set("User-Agent", userAgent)
resp, err := client.Do(req)
if err != nil {
return nil, "", fmt.Errorf("linkmeta: fetch: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return nil, "", fmt.Errorf("linkmeta: status %d", resp.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, maxBytes+1))
if err != nil {
return nil, "", fmt.Errorf("linkmeta: read: %w", err)
}
if int64(len(data)) > maxBytes {
return nil, "", fmt.Errorf("linkmeta: exceeds %d bytes", maxBytes)
}
ct := resp.Header.Get("Content-Type")
if ct == "" {
ct = http.DetectContentType(data)
}
return data, ct, nil
}
func limitRedirects(req *http.Request, via []*http.Request) error {
if len(via) > maxRedirects {
return fmt.Errorf("linkmeta: too many redirects")
}
if req.URL.Scheme != "http" && req.URL.Scheme != "https" {
return fmt.Errorf("linkmeta: redirect to %q", req.URL.Scheme)
}
return nil
}
// safeDial 解析域名后逐个筛掉内网地址,再直接拨那个 IP:
// 校验和连接之间不再重新解析,DNS 重绑定就没有窗口。
func safeDial(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
var d net.Dialer
if ip := net.ParseIP(host); ip != nil {
if blocked(ip) {
return nil, fmt.Errorf("linkmeta: %s is not a public address", ip)
}
return d.DialContext(ctx, network, addr)
}
ips, err := d.Resolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, err
}
var lastErr error
for _, ia := range ips {
if blocked(ia.IP) {
continue
}
conn, err := d.DialContext(ctx, network, net.JoinHostPort(ia.IP.String(), port))
if err == nil {
return conn, nil
}
lastErr = err
}
if lastErr != nil {
return nil, lastErr
}
return nil, fmt.Errorf("linkmeta: no public address for %s", host)
}
// blocked 报告 IP 是否属于不该由本站去连的地址段。
func blocked(ip net.IP) bool {
if ip == nil || ip.IsUnspecified() || ip.IsLoopback() || ip.IsPrivate() ||
ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || ip.IsInterfaceLocalMulticast() ||
ip.IsMulticast() {
return true
}
// 运营商级 NAT 与 6to4 前缀:IsPrivate 不覆盖,但同样不该出现在公网抓取里
if ip4 := ip.To4(); ip4 != nil {
return ip4[0] == 100 && ip4[1] >= 64 && ip4[1] <= 127 // 100.64.0.0/10
}
if ip.To16() != nil {
return ip[0] == 0x20 || ip[0] == 0x3f // 2001::/32 Teredo、3ffe::/16 等保留段
}
return false
}
+191
View File
@@ -0,0 +1,191 @@
package linkmeta
import (
"context"
"fmt"
"net"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
)
func TestFirstURL(t *testing.T) {
cases := map[string]string{
"看看 https://example.com/a?x=1 这个": "https://example.com/a?x=1",
"[Go 语言](https://go.dev/doc) 官方文档": "https://go.dev/doc",
"结尾标点要剪掉 https://a.cn/page。": "https://a.cn/page",
"没有链接就是空": "",
"两个 https://first.cn https://second.cn": "https://first.cn",
"裸 www.example.com 不算": "",
}
for in, want := range cases {
if got := FirstURL(in); got != want {
t.Errorf("FirstURL(%q) = %q, want %q", in, got, want)
}
}
}
func TestParsePriority(t *testing.T) {
doc := `<html><head>
<title>兜底标题</title>
<meta name="description" content="兜底描述">
<meta property="og:title" content="OG 标题">
<meta content="OG 描述" property="og:description">
<meta name="og:site_name" content="示例站">
<meta property="og:image" content="/pics/a.png">
</head><body><p>正文里的 <b>标签</b> 不该被当成标题</p></body></html>`
base, _ := url.Parse("https://example.com/post/1")
c := parse(doc, base)
if c.Title != "OG 标题" {
t.Errorf("title=%q 应优先 og:title", c.Title)
}
if c.Desc != "OG 描述" {
t.Errorf("desc=%q", c.Desc)
}
if c.Site != "示例站" {
t.Errorf("site=%q", c.Site)
}
if c.Image != "https://example.com/pics/a.png" {
t.Errorf("相对图片未补全: %q", c.Image)
}
}
func TestParseFallsBackToTitleTag(t *testing.T) {
base, _ := url.Parse("https://example.com/")
c := parse(`<html><head><title> 只有
标题 </title></head></html>`, base)
if c.Title != "只有 标题" {
t.Errorf("title=%q", c.Title)
}
if c.Site != "example.com" {
t.Errorf("site=%q 应退回主机名", c.Site)
}
}
func TestParseUnescapesEntities(t *testing.T) {
base, _ := url.Parse("https://example.com/")
c := parse(`<head><meta property="og:title" content="Tom &amp; Jerry &#34;quoted&#34;"></head>`, base)
if c.Title != `Tom & Jerry "quoted"` {
t.Errorf("title=%q", c.Title)
}
}
func TestBlockedAddresses(t *testing.T) {
want := map[string]bool{
"127.0.0.1": true,
"10.0.3.5": true,
"172.16.0.1": true,
"192.168.1.1": true,
"169.254.169.254": true, // 云元数据
"100.64.0.1": true, // CGNAT
"::1": true,
"fe80::1": true,
"fc00::1": true, // ULA
"93.184.216.34": false,
"2606:2800:220:1:248:1893:25c8:1946": false,
}
for s, wantBlocked := range want {
ip := net.ParseIP(s)
if ip == nil {
t.Fatalf("bad test ip %q", s)
}
if got := blocked(ip); got != wantBlocked {
t.Errorf("blocked(%s) = %v, want %v", s, got, wantBlocked)
}
}
}
// 默认拨号器必须拒掉回环地址——httptest 的服务就在 127.0.0.1,
// 这条同时验证了「防护生效」和「测试用的注入通道确实是必要的」。
func TestDefaultFetchRejectsLoopback(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, `<html><head><title>x</title></head></html>`)
}))
defer srv.Close()
_, err := Fetch(context.Background(), srv.URL)
if err == nil {
t.Fatal("默认 Fetcher 竟然后到了回环地址,SSRF 防护形同虚设")
}
if !strings.Contains(err.Error(), "not a public address") && !strings.Contains(err.Error(), "no public address") {
t.Fatalf("err=%v,应因内网地址被拒", err)
}
}
func TestFetcherParsesRealPage(t *testing.T) {
var gotUA, gotAccept string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotUA, gotAccept = r.Header.Get("User-Agent"), r.Header.Get("Accept")
if r.URL.Path == "/nope" {
w.WriteHeader(http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, `<html><head><meta property="og:title" content="标题"><meta property="og:image" content="https://cdn.example/i.png"></head>`)
}))
defer srv.Close()
f := &Fetcher{Dial: func(ctx context.Context, network, addr string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, network, addr)
}, Timeout: 2 * time.Second}
c, err := f.Fetch(context.Background(), srv.URL+"/page")
if err != nil {
t.Fatal(err)
}
if c.Title != "标题" || c.Image != "https://cdn.example/i.png" {
t.Fatalf("card=%+v", c)
}
if !strings.HasPrefix(c.URL, srv.URL) {
t.Errorf("url=%q", c.URL)
}
if gotUA != userAgent {
t.Errorf("ua=%q", gotUA)
}
if !strings.Contains(gotAccept, "text/html") {
t.Errorf("accept=%q", gotAccept)
}
if _, err := f.Fetch(context.Background(), srv.URL+"/nope"); err == nil {
t.Error("404 应报错")
}
}
func TestFetchRejectsNonHTMLAndBadScheme(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/octet-stream")
w.Write([]byte("binary"))
}))
defer srv.Close()
f := &Fetcher{Dial: func(ctx context.Context, network, addr string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, network, addr)
}}
if _, err := f.Fetch(context.Background(), srv.URL); err == nil {
t.Error("非 HTML 应拒绝")
}
if _, err := f.Fetch(context.Background(), "ftp://example.com/x"); err == nil {
t.Error("非 http(s) 协议应拒绝")
}
if _, err := f.Fetch(context.Background(), "not a url"); err == nil {
t.Error("坏 URL 应报错")
}
}
func TestCardEmpty(t *testing.T) {
if !(&Card{URL: "https://x.cn"}).Empty() {
t.Error("只有 URL 不算有内容")
}
if (&Card{URL: "https://x.cn", Title: "t"}).Empty() {
t.Error("有标题不该判空")
}
var nilCard *Card
if !nilCard.Empty() {
t.Error("nil 应判空")
}
}
+106
View File
@@ -0,0 +1,106 @@
package linkmeta
import (
"html"
"net/url"
"regexp"
"strings"
)
var (
headRe = regexp.MustCompile(`(?is)<head[^>]*>(.*?)</head>`)
metaRe = regexp.MustCompile(`(?is)<meta\b[^>]*?>`)
attrRe = regexp.MustCompile(`(?is)\b(property|name|http-equiv|content)\s*=\s*("([^"]*)"|'([^']*)'|([^\s>"']+))`)
titleRe = regexp.MustCompile(`(?is)<title[^>]*>(.*?)</title>`)
tagRe = regexp.MustCompile(`(?s)<[^>]*>`)
wsRe = regexp.MustCompile(`\s+`)
)
// parse 从 HTML 里挑出卡片字段。优先 Open Graph,其次 Twitter Card,最后
// 退到 <title> 与 description——绝大多数站点至少满足其中一个。
func parse(doc string, base *url.URL) *Card {
c := &Card{URL: base.String(), Site: base.Hostname()}
head := doc
if m := headRe.FindStringSubmatch(doc); m != nil {
head = m[1]
} else if len(head) > 64<<10 {
head = head[:64<<10] // 没有闭合 <head> 时也别整篇扫
}
// 同一个 key 出现多次时先出现的赢(后面的往往是重复声明)
meta := map[string]string{}
for _, tag := range metaRe.FindAllString(head, -1) {
key, val := metaTag(tag)
if key == "" || val == "" {
continue
}
if _, seen := meta[key]; !seen {
meta[key] = val
}
}
c.Title = first(meta["og:title"], meta["twitter:title"])
if c.Title == "" {
if m := titleRe.FindStringSubmatch(head); m != nil {
c.Title = clean(m[1])
}
}
c.Desc = first(meta["og:description"], meta["twitter:description"], meta["description"])
if site := meta["og:site_name"]; site != "" {
c.Site = site
}
if img := first(meta["og:image"], meta["twitter:image"], meta["twitter:image:src"]); img != "" {
c.Image = resolve(img, base)
}
// X(Twitter)对非白名单爬虫只回一张全站通用灰图占位符,
// 拿它当封面只会显示一张无意义的底图——当作没有图处理。
// 真推文图只发给 Twitterbot 等白名单 UA,服务端拿不到,不硬来。
if strings.Contains(c.Image, "abs.twimg.com/rweb/ssr/default") {
c.Image = ""
}
return c
}
// metaTag 从单个 <meta> 标签里取 (key, content),属性顺序不限。
func metaTag(tag string) (string, string) {
var key, val string
for _, a := range attrRe.FindAllStringSubmatch(tag, -1) {
v := first(a[3], a[4], a[5])
switch strings.ToLower(a[1]) {
case "property", "name", "http-equiv":
key = strings.ToLower(v)
case "content":
val = v
}
}
return key, clean(val)
}
// clean 去标签、解实体、压空白:抓来的文本可能带内联标签或连续换行。
func clean(s string) string {
s = html.UnescapeString(tagRe.ReplaceAllString(s, " "))
return strings.TrimSpace(wsRe.ReplaceAllString(s, " "))
}
// resolve 把相对的图片地址补成绝对 URL;解析不了就丢掉这个字段。
func resolve(ref string, base *url.URL) string {
ref = strings.TrimSpace(ref)
if ref == "" || strings.HasPrefix(ref, "data:") {
return ""
}
u, err := url.Parse(ref)
if err != nil {
return ""
}
return base.ResolveReference(u).String()
}
func first(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
+148
View File
@@ -29,6 +29,26 @@ type Post struct {
// ContentLen 是正文字符数:列表接口不返回全文,但后台列表要显示字数。
ContentLen int64 `json:"content_len"`
Tags []string `json:"tags"`
// Images 是短文配图(快发盒上传,≤4 张,Twitter 式网格展示),
// 以 JSON 数组存在 posts.images 列里,正文 content_md 不含它们。
Images []string `json:"images"`
// LinkCard 是正文里第一个外链的预览卡片(发布时抓一次,存 posts.link_card)。
// nil = 没有链接或抓取失败,前台不渲染卡片。
LinkCard *LinkCard `json:"link_card,omitempty"`
}
// LinkCard 是链接预览卡片:由 internal/linkmeta 抓取,以 JSON 存在 posts 表里。
type LinkCard struct {
URL string `json:"url"`
Title string `json:"title,omitempty"`
Desc string `json:"desc,omitempty"`
Site string `json:"site,omitempty"`
Image string `json:"image,omitempty"`
}
// Empty 报告卡片有没有可展示的字段(只有 URL 不算)。
func (c *LinkCard) Empty() bool {
return c == nil || (c.Title == "" && c.Desc == "" && c.Image == "")
}
type PostInput struct {
@@ -42,6 +62,10 @@ type PostInput struct {
PublishedAt string `json:"published_at"`
Tags []string `json:"tags"`
ReadingMinutes *int `json:"reading_minutes"`
// Images 是短文配图 URL(快发盒上传),nil = 不修改、空数组 = 清空
Images []string `json:"images"`
// LinkCard 由服务端在保存时按正文内容重算(站主不必发这个字段)
LinkCard *LinkCard `json:"link_card,omitempty"`
}
type Tag struct {
@@ -100,6 +124,47 @@ type Page struct {
Size int `json:"size"`
}
// File 是一条上传文件的索引行。内容本体在对象存储里(store 字段记来源:
// r2 | local),key 是存储端的对象名,URL 由 API 层按「来源 + PublicBase」
// 在响应时解析——切存储端不破坏存量链接。
type File struct {
ID int64 `json:"id"`
Key string `json:"key"`
Name string `json:"name"`
Mime string `json:"mime"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
Store string `json:"store"`
URL string `json:"url"`
CreatedAt string `json:"created_at"`
}
// FilePage 是文件管理的分页容器(Items 用 File,与文章的 Page 区分开)。
type FilePage struct {
Items []File `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
}
// FileRef 是「谁在用这个文件」的一处记录,供删除前的引用检查。
// Kind 取 post / project / avatar;avatar 没有可跳转的行,ID 为 0。
type FileRef struct {
Kind string `json:"kind"`
ID int64 `json:"id"`
Title string `json:"title"`
Slug string `json:"slug"`
Status string `json:"status"`
}
// CommentPage 是后台评论管理的分页容器(含文章标题与发表者)。
type CommentPage struct {
Items []Comment `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
}
// Dashboard is the snapshot rendered on /admin (homepage).
type Dashboard struct {
TotalPosts int `json:"total_posts"`
@@ -138,6 +203,10 @@ type Settings struct {
// SocialLinks 以 JSON 数组形式存在 settings KV 里(key: social_links),
// 解析失败/为空时前台拿到空数组,区块自动隐藏。
SocialLinks []SocialLink `json:"social_links"`
// 评论开关(comments_enabled):关着时前台整个评论区不渲染。
CommentsEnabled bool `json:"comments_enabled"`
// 审核开关(comments_review):开着时新评论先进「待审」,站主通过后才公开。
CommentsReview bool `json:"comments_review"`
// LightSkinID is the front-end skin used when the client (or system)
// prefers light. Valid values: paper / sage / rose.
// Dark side is fixed to ink for now — kept implicit so we can add
@@ -162,4 +231,83 @@ type Settings struct {
// of either UI, never on /admin. Stored raw — it's the owner's own code,
// sanitizing it would only break the snippet.
CustomJS string `json:"custom_js"`
// AuthorAvatarKey 是站主头像在 files 表里的 key(不是 URL):换存储端不破坏
// 存量链接,和 images/link_card 一样属于「存标识、读时解析」。
// 故意不并进 UpdateSettings 的全量替换 —— 后台「站点设置」PUT 不该顺手清掉
// 账户页设置的头像,两者写入路径分开。
AuthorAvatarKey string `json:"author_avatar_key,omitempty"`
// AuthorAvatarURL 由 API 层按 AuthorAvatarKey + 存储端解析出来,
// 不落库;key 指向的文件已删除时为空串。
AuthorAvatarURL string `json:"author_avatar_url"`
}
// 账号角色。owner 全库唯一(站主),reader 是评论区登录进来的访客。
const (
RoleOwner = "owner"
RoleReader = "reader"
)
// Reader 是一条评论区身份:既包括站主(role=owner,provider=admin),
// 也包括通过 GitHub / Google / Telegram 登录进来的访客(role=reader)。
// Banned = 禁言中。
type Reader struct {
ID int64 `json:"id"`
Provider string `json:"provider"`
Handle string `json:"handle"`
Name string `json:"name"`
AvatarURL string `json:"avatar_url"`
URL string `json:"url"`
Banned bool `json:"banned"`
// Role 区分站主与访客:绑定到 owner 的第三方身份登录时会话升级为管理员。
Role string `json:"role"`
// CommentCount 是累计评论数(后台用户列表展示用)
CommentCount int64 `json:"comment_count"`
CreatedAt string `json:"created_at"`
}
// UserIdentity 是「这个账号绑定了哪个第三方身份」。
// ExternUID 用各平台的稳定主键(GitHub 数字 id / Google sub / Telegram id),
// 不用可改的用户名;Display 只是列表里给人看的标签。
type UserIdentity struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Provider string `json:"provider"`
ExternUID string `json:"extern_uid"`
Display string `json:"display"`
CreatedAt string `json:"created_at"`
}
// Passkey 是一把已注册的 WebAuthn 凭据。PublicKey 以 base64 存
// (model 包保持零依赖,字节解码在 auth 层做);SignCount 用于检测
// 凭据被克隆(计数回退即异常),LastUsedAt 给管理页显示「上次使用」。
type Passkey struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
CredentialID string `json:"credential_id"`
PublicKey string `json:"public_key"`
SignCount uint32 `json:"sign_count"`
Name string `json:"name"`
CreatedAt string `json:"created_at"`
LastUsedAt string `json:"last_used_at"`
}
// Comment 是一条评论。回复扁平存储(parent_id/root_id),渲染时挂到 replies。
// User 是发表者快照;is_deleted = 软删(留壳显示「已删除」,保住楼层上下文)。
type Comment struct {
ID int64 `json:"id"`
PostID int64 `json:"post_id"`
ParentID int64 `json:"parent_id"`
RootID int64 `json:"root_id"`
User *Reader `json:"user"`
UserID int64 `json:"user_id"`
BodyMd string `json:"body_md"`
BodyHTML string `json:"body_html"`
Status string `json:"status"`
IsDeleted bool `json:"is_deleted"`
CreatedAt string `json:"created_at"`
EditedAt string `json:"edited_at"`
Replies []Comment `json:"replies"`
ReplyCount int `json:"reply_count"`
// PostTitle 是文章标题(后台评论列表联表带出,仅管理接口填充)
PostTitle string `json:"post_title"`
}
+73
View File
@@ -0,0 +1,73 @@
// Package ratelimit 是一个极小的按来源 IP 滑动窗口限速器,
// 后台登录与读者登录 / 评论写入共用同一实现。
// 只计失败/写入这类「不该高频」的事件;键取连接层地址,
// X-Forwarded-For 可伪造,不作为限速键。
package ratelimit
import (
"net"
"net/http"
"sync"
"time"
)
type Window struct {
mu sync.Mutex
hits map[string][]time.Time
max int
window time.Duration
}
func New(max int, window time.Duration) *Window {
return &Window{hits: make(map[string][]time.Time), max: max, window: window}
}
// Blocked 报告 key 在窗口内是否已达上限。
func (l *Window) Blocked(key string) bool {
l.mu.Lock()
defer l.mu.Unlock()
return len(l.recent(key, time.Now())) >= l.max
}
// Add 记录一次事件。
func (l *Window) Add(key string) {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
l.hits[key] = append(l.recent(key, now), now)
}
// Reset 清空 key 的计数(如登录成功后)。
func (l *Window) Reset(key string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.hits, key)
}
// recent 返回窗口内的事件时间;调用方必须持有 l.mu。
func (l *Window) recent(key string, now time.Time) []time.Time {
hs := l.hits[key]
cut := now.Add(-l.window)
i := 0
for ; i < len(hs); i++ {
if hs[i].After(cut) {
break
}
}
if i > 0 {
hs = hs[i:]
l.hits[key] = hs
}
if len(hs) == 0 {
delete(l.hits, key)
}
return hs
}
// SourceKey 取连接层来源地址(IPv6 去掉端口)。
func SourceKey(r *http.Request) string {
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}
+92
View File
@@ -0,0 +1,92 @@
// 盘古之白:在 CJK 与拉丁字母/数字之间补一个空格,中文混排的阅读观感
// 差别很大(「把MVP搬进仓库」→「把 MVP 搬进仓库」)。
// 在 HTML 层做:标签原样保留,<code>/<pre>/<script>/<style> 内的文本
// 一律不动(代码语义容不得我们加空格),其余文本段做补空格。
package render
import (
"regexp"
"strings"
"unicode"
)
var tagRe = regexp.MustCompile(`<[^>]*>`)
// code 上下文里的文本不处理
var codeOpenRe = regexp.MustCompile(`(?i)<(code|pre|script|style|kbd|samp)\b[^>]*>`)
var codeCloseRe = regexp.MustCompile(`(?i)</(code|pre|script|style|kbd|samp)>`)
// CJK 统一表意区 + 扩展A/兼容 + 日文假名已在 2E80-9FFF 覆盖,
// 再补全角标点区(FF00-FFEF)与 CJK 标点(3000-303F)
var cjkRe = regexp.MustCompile(`[\x{2E80}-\x{9FFF}\x{3000}-\x{303F}\x{F900}-\x{FAFF}\x{FF00}-\x{FFEF}]`)
var latinTailRe = regexp.MustCompile(`([\x{2E80}-\x{9FFF}\x{3000}-\x{303F}\x{F900}-\x{FAFF}\x{FF00}-\x{FFEF}])([A-Za-z0-9_$@])`)
var latinHeadRe = regexp.MustCompile(`([A-Za-z0-9+%,.;:!?%)\]])([\x{2E80}-\x{9FFF}\x{3000}-\x{303F}\x{F900}-\x{FAFF}\x{FF00}-\x{FFEF}])`)
func isCJK(r rune) bool {
return cjkRe.MatchString(string(r))
}
// panguText 对纯文本做补空格。已经隔着空格的不动(正则天然要求相邻)。
func panguText(s string) string {
if !hasCJKAndLatin(s) {
return s
}
prev := ""
for prev != s {
prev = s
s = latinTailRe.ReplaceAllString(s, "$1 $2")
s = latinHeadRe.ReplaceAllString(s, "$1 $2")
}
return s
}
func hasCJKAndLatin(s string) bool {
var cjk, latin bool
for _, r := range s {
if isCJK(r) {
cjk = true
} else if unicode.IsLetter(r) || unicode.IsNumber(r) {
latin = true
}
if cjk && latin {
return true
}
}
return false
}
// PanguHTML 给渲染出的正文 HTML 做盘古之白。流式扫描:标签原样输出,
// 普通文本段补空格,代码上下文里的文本跳过。
func PanguHTML(html string) string {
if !strings.Contains(html, "<") {
return html
}
var b strings.Builder
inCode := false
for {
loc := tagRe.FindStringIndex(html)
if loc == nil {
rest := html
if !inCode {
rest = panguText(rest)
}
b.WriteString(rest)
return b.String()
}
head, tag := html[:loc[0]], html[loc[0]:loc[1]]
if !inCode {
head = panguText(head)
}
b.WriteString(head)
b.WriteString(tag)
if !inCode {
if m := codeOpenRe.FindStringSubmatch(tag); m != nil {
// 自闭合或行内 code 直接开着也按进/出配对处理(</code> 总会出现)
inCode = true
}
} else if codeCloseRe.MatchString(tag) {
inCode = false
}
html = html[loc[1]:]
}
}
+40
View File
@@ -0,0 +1,40 @@
package render
import "testing"
func TestPanguText(t *testing.T) {
cases := map[string]string{
"把MVP搬进仓库": "把 MVP 搬进仓库",
"今天写了3个小时代码": "今天写了 3 个小时代码",
"使用Go和Vue3开发": "使用 Go 和 Vue3 开发",
"纯中文没有混排": "纯中文没有混排",
"已经隔了空格的 Go 语言": "已经隔了空格的 Go 语言",
"数字100在前": "数字 100 在前",
"英文后接标点。followed": "英文后接标点。 followed",
}
for in, want := range cases {
if got := panguText(in); got != want {
t.Errorf("panguText(%q) = %q, want %q", in, got, want)
}
}
}
func TestPanguHTML(t *testing.T) {
cases := map[string]string{
// 普通文本:处理
"<p>把MVP搬进仓库</p>": "<p>把 MVP 搬进仓库</p>",
// 行内 code:不动
"<p>运行<code>go build ./...</code>即可</p>": "<p>运行<code>go build ./...</code>即可</p>",
// pre 代码块:整个内部不动
"<pre><code>把MVP搬进仓库\nfmt.Println(1)</code></pre>": "<pre><code>把MVP搬进仓库\nfmt.Println(1)</code></pre>",
// 属性里不是文本:不动(src 含字母数字混合不受影响)
`<img alt="测试图1" src="a1.png">`: `<img alt="测试图1" src="a1.png">`,
// 嵌套:code 结束后恢复处理
"<p>先<code>跑起来</code>再看结果100%</p>": "<p>先<code>跑起来</code>再看结果 100%</p>",
}
for in, want := range cases {
if got := PanguHTML(in); got != want {
t.Errorf("PanguHTML(%q) = %q, want %q", in, got, want)
}
}
}
+79
View File
@@ -0,0 +1,79 @@
package storage
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
// LocalStore 把文件落在本地磁盘(DataDir/uploads),零配置兜底。
// key 允许带「2026/09/」这类前缀,实现里做路径清洗防目录穿越。
type LocalStore struct {
Root string
}
func NewLocal(root string) *LocalStore {
return &LocalStore{Root: root}
}
func (s *LocalStore) path(key string) (string, error) {
clean := filepath.Clean("/" + strings.TrimPrefix(key, "/"))[1:] // 锚到根再去掉,防 ../
if clean == "" || strings.HasPrefix(clean, "..") {
return "", fmt.Errorf("bad key: %q", key)
}
return filepath.Join(s.Root, filepath.FromSlash(clean)), nil
}
func (s *LocalStore) Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error {
dst, err := s.path(key)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(dst), ".upload-*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if _, err := io.Copy(tmp, r); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), dst)
}
func (s *LocalStore) Open(ctx context.Context, key string) (io.ReadCloser, int64, error) {
dst, err := s.path(key)
if err != nil {
return nil, 0, err
}
f, err := os.Open(dst)
if err != nil {
return nil, 0, err
}
st, err := f.Stat()
if err != nil {
f.Close()
return nil, 0, err
}
return f, st.Size(), nil
}
func (s *LocalStore) Delete(ctx context.Context, key string) error {
dst, err := s.path(key)
if err != nil {
return err
}
if err := os.Remove(dst); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
+74
View File
@@ -0,0 +1,74 @@
package storage
import (
"context"
"fmt"
"io"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
)
// R2Store 走 Cloudflare R2 的 S3 兼容 API。endpoint 是账户级端点
// (https://<accountid>.r2.cloudflarestorage.com),R2 两种寻址都支持,
// 这里用 path-style 最稳。
//
// 端点 URL 里的路径段(如果有)不交给 SDK —— path-style 下它会被折进
// 对象 key,导致「数据库 key」和「实际对象 key」对不上(直链 404)。
// 这里只取 scheme://host 当端点;路径段由 EndpointKeyPrefix 暴露给
// 上传 handler 拼进 key,三处(存储 / 数据库 / 直链)从此一致。
type R2Store struct {
client *s3.Client
bucket string
}
func NewR2(endpoint, bucket, accessKey, secretKey string) (*R2Store, error) {
if endpoint == "" || bucket == "" {
return nil, fmt.Errorf("r2: endpoint/bucket required")
}
// R2 不认 region,SDK 又必须有——官方文档给的值就是 "auto"。
cfg, err := config.LoadDefaultConfig(context.TODO(),
config.WithRegion("auto"),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")),
config.WithBaseEndpoint(StripEndpointPath(endpoint)),
)
if err != nil {
return nil, fmt.Errorf("r2: load aws config: %w", err)
}
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
o.UsePathStyle = true
})
return &R2Store{client: client, bucket: bucket}, nil
}
func (s *R2Store) Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error {
_, err := s.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
Body: r,
ContentLength: aws.Int64(size),
ContentType: aws.String(contentType),
})
return err
}
func (s *R2Store) Open(ctx context.Context, key string) (io.ReadCloser, int64, error) {
out, err := s.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
})
if err != nil {
return nil, 0, err
}
return out.Body, *out.ContentLength, nil
}
func (s *R2Store) Delete(ctx context.Context, key string) error {
_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
})
return err
}
+54
View File
@@ -0,0 +1,54 @@
// Package storage 是文件上传的存储抽象。R2(S3 兼容 API)是第一个实现,
// 本地磁盘是零配置兜底;以后接其他存储端 = 再写一个 BlobStore 实现,
// 从 FromEnv 的选择逻辑里加一个分支,调用方(admin API / 公开路由)不变。
//
// URL 不在存储层决定:文件行里记 store 来源,读取时按「来源 + PublicBase」
// 解析直链或后端代理路由,因此切换存储端不破坏存量链接。
package storage
import (
"context"
"io"
"net/url"
"strings"
)
// BlobStore 是对象存储的最小面。size 只是提示(S3 PutObject 需要预知长度,
// 用临时文件或内存缓冲满足),实现方不得依赖它做校验——类型与大小上限
// 在上传 handler 里统一把关。
type BlobStore interface {
Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error
Open(ctx context.Context, key string) (io.ReadCloser, int64, error)
Delete(ctx context.Context, key string) error
}
// FileURL 解析文件的公开访问地址:R2 上的文件且配了公开域名(PublicBase,
// 尾部不带 /)走 CDN 直链;其余(本地兜底、或没配公开域名)走后端的
// /uploads/key 流式路由。调用方传入文件行里的 store 来源。
func FileURL(storeName, key, publicBase string) string {
if storeName == "r2" && publicBase != "" {
return publicBase + "/" + key
}
return "/uploads/" + key
}
// EndpointKeyPrefix 提取端点 URL 里的路径段(去首尾斜杠)作为上传 key 的
// 前缀:S3Api 写成 https://host/oss 时,对象 key 会带上 oss/ 前缀,
// 数据库 / 直链 / 存储端三方都用这个前缀对齐。没有路径段返回空串。
func EndpointKeyPrefix(endpoint string) string {
u, err := url.Parse(strings.TrimSpace(endpoint))
if err != nil {
return ""
}
return strings.Trim(u.Path, "/")
}
// StripEndpointPath 去掉端点 URL 的路径,只留 scheme://host[:port]:
// SDK 的 BaseEndpoint 用它(路径段已经折进 key 前缀,见 EndpointKeyPrefix)。
func StripEndpointPath(endpoint string) string {
u, err := url.Parse(strings.TrimSpace(endpoint))
if err != nil {
return endpoint
}
return u.Scheme + "://" + u.Host
}
+180
View File
@@ -0,0 +1,180 @@
package store
import (
"errors"
"testing"
"oneblog/internal/model"
)
func TestEnsureOwner(t *testing.T) {
s := openTestStore(t)
owner, err := s.EnsureOwner("admin")
if err != nil {
t.Fatal(err)
}
if owner.Role != model.RoleOwner || owner.Provider != "admin" {
t.Fatalf("owner row wrong: %+v", owner)
}
// 幂等:再取一次是同一行
again, err := s.EnsureOwner("admin")
if err != nil {
t.Fatal(err)
}
if again.ID != owner.ID {
t.Fatalf("重复 EnsureOwner 建了两行: %d vs %d", owner.ID, again.ID)
}
// 改了环境变量里的用户名,handle 要跟上
got, err := s.EnsureOwner("newname")
if err != nil {
t.Fatal(err)
}
if got.ID != owner.ID || got.Handle != "newname" {
t.Fatalf("handle 未同步: %+v", got)
}
// 全库只应有一个 owner
all, err := s.ListReaders()
if err != nil {
t.Fatal(err)
}
n := 0
for _, r := range all {
if r.Role == model.RoleOwner {
n++
}
}
if n != 1 {
t.Fatalf("owner 行数 = %d, want 1", n)
}
}
func TestOwnerNotClobberedByReaderUpsert(t *testing.T) {
// 站主行是 provider=admin,OAuth 登录建的是 provider=github 行,
// 两者不能互相覆盖 —— 这是「站主能自定义资料」成立的前提。
s := openTestStore(t)
owner, err := s.EnsureOwner("admin")
if err != nil {
t.Fatal(err)
}
if _, err := s.UpsertReader(model.Reader{Provider: "github", Handle: "someone", Name: "路人"}); err != nil {
t.Fatal(err)
}
got, err := s.GetOwner()
if err != nil {
t.Fatal(err)
}
if got.ID != owner.ID || got.Provider != "admin" {
t.Fatalf("站主行被改动: %+v", got)
}
}
func TestBindIdentityGuards(t *testing.T) {
s := openTestStore(t)
owner, _ := s.EnsureOwner("admin")
other, err := s.UpsertReader(model.Reader{Provider: "github", Handle: "intruder", Name: "n"})
if err != nil {
t.Fatal(err)
}
if err := s.BindIdentity(owner.ID, "github", "12345", "littleckin"); err != nil {
t.Fatal(err)
}
// 重复绑同一个:幂等
if err := s.BindIdentity(owner.ID, "github", "12345", "littleckin"); err != nil {
t.Fatalf("重复绑定应幂等,got %v", err)
}
// 关键护栏:同一个外部账号不能被第二个用户占走
if err := s.BindIdentity(other.ID, "github", "12345", "hijack"); !errors.Is(err, ErrConflict) {
t.Fatalf("抢占他人身份应 ErrConflict, got %v", err)
}
// 换平台可以(UNIQUE 是 user_id+provider)
if err := s.BindIdentity(owner.ID, "telegram", "999", "tg"); err != nil {
t.Fatal(err)
}
ids, err := s.ListIdentities(owner.ID)
if err != nil {
t.Fatal(err)
}
if len(ids) != 2 {
t.Fatalf("绑定数 = %d, want 2", len(ids))
}
// 登录查找:按外部身份找到站主,且 role 正确
u, err := s.GetUserByIdentity("github", "12345")
if err != nil {
t.Fatal(err)
}
if u.ID != owner.ID || u.Role != model.RoleOwner {
t.Fatalf("身份反查结果错: %+v", u)
}
if _, err := s.GetUserByIdentity("github", "nope"); !errors.Is(err, ErrNotFound) {
t.Fatalf("未绑定应 ErrNotFound, got %v", err)
}
// 解绑
if err := s.UnbindIdentity(owner.ID, "github"); err != nil {
t.Fatal(err)
}
if _, err := s.GetUserByIdentity("github", "12345"); !errors.Is(err, ErrNotFound) {
t.Fatal("解绑后仍能查到")
}
if err := s.UnbindIdentity(owner.ID, "github"); !errors.Is(err, ErrNotFound) {
t.Fatalf("重复解绑应 ErrNotFound, got %v", err)
}
}
func TestPasskeyLifecycle(t *testing.T) {
s := openTestStore(t)
owner, _ := s.EnsureOwner("admin")
other, _ := s.UpsertReader(model.Reader{Provider: "github", Handle: "x", Name: "x"})
p, err := s.AddPasskey(model.Passkey{UserID: owner.ID, CredentialID: "cred-1", PublicKey: "base64key", Name: "MacBook", SignCount: 3})
if err != nil {
t.Fatal(err)
}
if p.ID == 0 {
t.Fatal("没拿到自增 id")
}
// 同一把凭据不能注册两次
if _, err := s.AddPasskey(model.Passkey{UserID: other.ID, CredentialID: "cred-1", PublicKey: "k", Name: "dup"}); err == nil {
t.Fatal("重复 credential_id 应被拒")
}
list, err := s.ListPasskeys(owner.ID)
if err != nil {
t.Fatal(err)
}
if len(list) != 1 || list[0].Name != "MacBook" {
t.Fatalf("list=%+v", list)
}
if list[0].PublicKey != "" {
t.Fatal("列表接口不该回传公钥")
}
got, err := s.GetPasskeyByCredentialID("cred-1")
if err != nil {
t.Fatal(err)
}
if got.PublicKey != "base64key" || got.SignCount != 3 {
t.Fatalf("got=%+v", got)
}
if err := s.TouchPasskey(got.ID, 9); err != nil {
t.Fatal(err)
}
after, _ := s.GetPasskeyByCredentialID("cred-1")
if after.SignCount != 9 || after.LastUsedAt == "" {
t.Fatalf("TouchPasskey 未生效: %+v", after)
}
// 越权删除必须失败:id 是站主的,user_id 给别人
if err := s.DeletePasskey(after.ID, other.ID); !errors.Is(err, ErrNotFound) {
t.Fatalf("跨用户删除应 ErrNotFound, got %v", err)
}
if err := s.DeletePasskey(after.ID, owner.ID); err != nil {
t.Fatal(err)
}
if rest, _ := s.ListPasskeys(owner.ID); len(rest) != 0 {
t.Fatalf("删除后仍有 %d 条", len(rest))
}
}
+109
View File
@@ -0,0 +1,109 @@
package store
import (
"testing"
"oneblog/internal/model"
)
func countKind(refs []model.FileRef, kind string) int {
n := 0
for _, r := range refs {
if r.Kind == kind {
n++
}
}
return n
}
// 同一个 key 在正文里的三种形态都要认出来:R2 直链、本地 /uploads、缩略图路径。
func TestFileReferencesMatchesKeyNotURL(t *testing.T) {
s := openTestStore(t)
const key = "2026/09/deadbeefcafe.png"
if _, err := s.Create(model.PostInput{
Kind: model.KindLong, Title: "直链引用", Slug: "r2",
CoverURL: "https://cdn.example.com/" + key,
ContentMd: "正文配图 ![图](https://cdn.example.com/" + key + ")",
Status: model.StatusPublished,
}); err != nil {
t.Fatal(err)
}
if _, err := s.Create(model.PostInput{
Kind: model.KindShort, Slug: "local",
ContentMd: "本地形态 ![](/uploads/" + key + ") 和缩略图 ![](/uploads/thumb/" + key + "?w=480)",
Status: model.StatusDraft,
}); err != nil {
t.Fatal(err)
}
refs, err := s.FileReferences(key)
if err != nil {
t.Fatal(err)
}
// 两篇文章各算一处:同一行里多次命中不重复计
if len(refs) != 2 {
t.Fatalf("want 2 refs, got %+v", refs)
}
if countKind(refs, "post") != 2 {
t.Fatalf("两条都该是 post: %+v", refs)
}
// 草稿也要报出来(发布后就会图裂)
if refs[1].Status != model.StatusDraft {
t.Fatalf("草稿状态没带出来: %+v", refs[1])
}
}
func TestFileReferencesProjectsAndAvatar(t *testing.T) {
s := openTestStore(t)
const key = "2026/09/111122223333.webp"
if _, err := s.CreateProject(model.ProjectInput{
Title: "ONE", Slug: "one", CoverURL: "/uploads/" + key, Status: model.StatusPublished,
}); err != nil {
t.Fatal(err)
}
if err := s.SetSetting("owner_avatar_key", key); err != nil {
t.Fatal(err)
}
refs, err := s.FileReferences(key)
if err != nil {
t.Fatal(err)
}
if countKind(refs, "project") != 1 {
t.Fatalf("项目封面没查到: %+v", refs)
}
if countKind(refs, "avatar") != 1 {
t.Fatalf("站主头像没查到: %+v", refs)
}
}
// 没被引用、以及 key 为空时都不能误报——空 pattern 会 LIKE '%%' 命中全库。
func TestFileReferencesNoFalsePositive(t *testing.T) {
s := openTestStore(t)
if _, err := s.Create(model.PostInput{
Kind: model.KindLong, Title: "无关", Slug: "x",
ContentMd: "正文里没有这张图", CoverURL: "/uploads/2026/09/aaaa.png",
Status: model.StatusPublished,
}); err != nil {
t.Fatal(err)
}
// 同内容不同月上传出的 key 只是月份段不同,不该互相命中
refs, err := s.FileReferences("2026/10/aaaa.png")
if err != nil {
t.Fatal(err)
}
if len(refs) != 0 {
t.Fatalf("换月的 key 误报: %+v", refs)
}
empty, err := s.FileReferences("")
if err != nil {
t.Fatal(err)
}
if len(empty) != 0 {
t.Fatalf("空 key 不该匹配任何东西,得到 %+v", empty)
}
}
+852 -21
View File
@@ -23,6 +23,9 @@ func readingMinutes(md string) int { return render.ReadingMinutes(md) }
var ErrNotFound = errors.New("not found")
// ErrConflict 表示要建的唯一键已被占(例如某个外部身份已绑到别的账号)。
var ErrConflict = errors.New("conflict")
type Store struct {
db *db.DB
}
@@ -86,6 +89,64 @@ func (s *Store) migrate() error {
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`, ai),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS files (
id %s,
key TEXT NOT NULL,
name TEXT NOT NULL,
mime TEXT NOT NULL DEFAULT '',
size INTEGER NOT NULL DEFAULT 0,
sha256 TEXT NOT NULL DEFAULT '',
store TEXT NOT NULL DEFAULT 'local',
created_at TEXT NOT NULL
)`, ai),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS users (
id %s,
provider TEXT NOT NULL,
handle TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
avatar_url TEXT NOT NULL DEFAULT '',
url TEXT NOT NULL DEFAULT '',
banned INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
UNIQUE(provider, handle)
)`, ai),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS comments (
id %s,
post_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
parent_id INTEGER NOT NULL DEFAULT 0,
root_id INTEGER NOT NULL DEFAULT 0,
body_md TEXT NOT NULL DEFAULT '',
body_html TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'visible',
is_deleted INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
edited_at TEXT NOT NULL DEFAULT ''
)`, ai),
// 第三方身份绑定:一个账号每个平台只能绑一条(UNIQUE(user_id,provider)),
// 同一个外部账号也只能属于一个用户(UNIQUE(provider,extern_uid))——
// 后者是防接管的关键:不能靠「先用我的 GitHub 登录、再把你的账号绑上来」占位。
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS user_identities (
id %s,
user_id INTEGER NOT NULL,
provider TEXT NOT NULL,
extern_uid TEXT NOT NULL,
display TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
UNIQUE(provider, extern_uid),
UNIQUE(user_id, provider)
)`, ai),
// Passkey 一人可多把(笔记本 + 手机),所以不加 UNIQUE(user_id)
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS passkeys (
id %s,
user_id INTEGER NOT NULL,
credential_id TEXT NOT NULL UNIQUE,
public_key TEXT NOT NULL,
sign_count INTEGER NOT NULL DEFAULT 0,
name TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
last_used_at TEXT NOT NULL DEFAULT ''
)`, ai),
}
for _, q := range stmts {
if _, err := s.db.Exec(s.db.Q(q)); err != nil {
@@ -99,6 +160,12 @@ func (s *Store) migrate() error {
columnAdds := []string{
`ALTER TABLE posts ADD COLUMN cover_url TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE tags ADD COLUMN color TEXT NOT NULL DEFAULT ''`,
// 短文配图(快发盒上传,Twitter 式网格展示),JSON 数组
`ALTER TABLE posts ADD COLUMN images TEXT NOT NULL DEFAULT '[]'`,
// 正文首个外链的预览卡片(og 标题/描述/封面),JSON 对象或空串
`ALTER TABLE posts ADD COLUMN link_card TEXT NOT NULL DEFAULT ''`,
// 账号角色:owner(站主,全库唯一)/ reader(评论区访客)
`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'reader'`,
}
for _, q := range columnAdds {
if _, err := s.db.Exec(s.db.Q(q)); err != nil && !strings.Contains(err.Error(), "already exists") &&
@@ -114,6 +181,13 @@ func (s *Store) migrate() error {
{"idx_tags_slug", `CREATE UNIQUE INDEX IF NOT EXISTS idx_tags_slug ON tags(slug)`},
{"idx_post_tags_tag", `CREATE INDEX IF NOT EXISTS idx_post_tags_tag ON post_tags(tag_id)`},
{"idx_projects_slug", `CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_slug ON projects(slug)`},
{"idx_files_key", `CREATE UNIQUE INDEX IF NOT EXISTS idx_files_key ON files(key)`},
{"idx_files_created", `CREATE INDEX IF NOT EXISTS idx_files_created ON files(created_at DESC)`},
{"idx_comments_post", `CREATE INDEX IF NOT EXISTS idx_comments_post ON comments(post_id, created_at)`},
{"idx_comments_user", `CREATE INDEX IF NOT EXISTS idx_comments_user ON comments(user_id)`},
{"idx_comments_status", `CREATE INDEX IF NOT EXISTS idx_comments_status ON comments(status, created_at DESC)`},
{"idx_identities_user", `CREATE INDEX IF NOT EXISTS idx_identities_user ON user_identities(user_id)`},
{"idx_passkeys_user", `CREATE INDEX IF NOT EXISTS idx_passkeys_user ON passkeys(user_id)`},
}
for _, ix := range indexes {
if _, err := s.db.Exec(s.db.Q(ix.ddl)); err != nil && !strings.Contains(err.Error(), "already exists") {
@@ -126,13 +200,15 @@ func (s *Store) migrate() error {
func (s *Store) seedSettings() error {
defs := map[string]string{
"site_title": "ONE · 一个博客",
"site_desc": "长文与短文,同一种节奏。",
"author_name": "ONE",
"author_bio": "写点长的,也写点短的。",
"footer_note": "© ONE · 一个博客",
"icp": "",
"posts_per_page": "10",
"site_title": "ONE · 一个博客",
"site_desc": "长文与短文,同一种节奏。",
"author_name": "ONE",
"author_bio": "写点长的,也写点短的。",
"footer_note": "© ONE · 一个博客",
"icp": "",
"posts_per_page": "10",
"comments_enabled": "1",
"comments_review": "0",
}
for k, v := range defs {
if s.db.Dialect == db.Postgres {
@@ -195,6 +271,10 @@ func settingsFromMap(m map[string]string) model.Settings {
st.CustomCSS = decodeCSSMap(m["custom_css"])
st.CustomJS = m["custom_js"]
st.SocialLinks = decodeSocialLinks(m["social_links"])
st.AuthorAvatarKey = m["owner_avatar_key"]
// 开关类:'1' / 'true' 都算开,其余(含空)算关
st.CommentsEnabled = m["comments_enabled"] == "1" || strings.EqualFold(m["comments_enabled"], "true")
st.CommentsReview = m["comments_review"] == "1" || strings.EqualFold(m["comments_review"], "true")
if n := atoi(m["posts_per_page"]); n > 0 {
st.PostsPerPage = n
}
@@ -377,8 +457,11 @@ func (s *Store) UpdateSettings(st model.Settings) error {
"custom_css": encodeCSSMap(st.CustomCSS),
// 原样存:站主自己的代码,不做任何转义/清洗。
"custom_js": st.CustomJS,
// 空数组存空串:KV 里不留 "null"。
// 社交 / 源码链接:JSON 数组,空数组存 "[]"。
"social_links": encodeSocialLinks(st.SocialLinks),
// 开关统一存 '1' / '0'。
"comments_enabled": b2s(st.CommentsEnabled),
"comments_review": b2s(st.CommentsReview),
}
for k, v := range sets {
if s.db.Dialect == db.Postgres {
@@ -396,6 +479,19 @@ func (s *Store) UpdateSettings(st model.Settings) error {
return nil
}
// SetSetting 只写一个 KV,绕开 UpdateSettings 的全量替换。
// 账户页改头像用它的自己的键,免得「站点设置」保存时被顺带清掉。
func (s *Store) SetSetting(key, value string) error {
if s.db.Dialect == db.Postgres {
_, err := s.db.Exec(s.db.Q(`INSERT INTO settings(key,value) VALUES (?,?)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`), key, value)
return err
}
_, err := s.db.Exec(s.db.Q(`INSERT INTO settings(key,value) VALUES (?,?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value`), key, value)
return err
}
// ---------- posts ----------
type ListOptions struct {
@@ -428,21 +524,71 @@ func sanitizeOrder(o string) string {
}
const postCols = `id, kind, title, slug, summary, cover_url, content_md, content_html, status,
published_at, created_at, updated_at, reading_minutes, LENGTH(content_md)`
published_at, created_at, updated_at, reading_minutes, LENGTH(content_md), images, link_card`
// listCols 用于列表/时间线:不传 content_md(前端不用),
// 长文 content_html 只截 600 字符供无摘要时提取纯文本,短文保留全文渲染。
const listCols = `id, kind, title, slug, summary, cover_url,
'' AS content_md,
CASE WHEN kind = 'short' THEN content_html ELSE substr(content_html, 1, 600) END AS content_html,
status, published_at, created_at, updated_at, reading_minutes, LENGTH(content_md)`
status, published_at, created_at, updated_at, reading_minutes, LENGTH(content_md), images, link_card`
// images 列的 JSON 编解码(列存 '[]',Go 侧 []string;坏数据静默为空)。
func decodeImages(s string) []string {
out := []string{}
if strings.TrimSpace(s) == "" {
return out
}
if err := json.Unmarshal([]byte(s), &out); err != nil {
return []string{}
}
return out
}
func encodeImages(imgs []string) string {
if len(imgs) == 0 {
return "[]"
}
b, err := json.Marshal(imgs)
if err != nil {
return "[]"
}
return string(b)
}
// link_card 列的 JSON 编解码:空串/坏数据一律当作「没有卡片」。
func decodeLinkCard(s string) *model.LinkCard {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
var c model.LinkCard
if err := json.Unmarshal([]byte(s), &c); err != nil || c.Empty() {
return nil
}
return &c
}
func encodeLinkCard(c *model.LinkCard) string {
if c.Empty() {
return ""
}
b, err := json.Marshal(c)
if err != nil {
return ""
}
return string(b)
}
func scanPost(rows interface{ Scan(...any) error }) (model.Post, error) {
var p model.Post
var imgs, card string
err := rows.Scan(&p.ID, &p.Kind, &p.Title, &p.Slug, &p.Summary, &p.CoverURL,
&p.ContentMd, &p.ContentHTML, &p.Status, &p.PublishedAt, &p.CreatedAt, &p.UpdatedAt,
&p.ReadingMinutes, &p.ContentLen)
&p.ReadingMinutes, &p.ContentLen, &imgs, &card)
p.Tags = []string{}
p.Images = decodeImages(imgs)
p.LinkCard = decodeLinkCard(card)
return p, err
}
@@ -615,9 +761,10 @@ func (s *Store) GetBySlug(slug string) (model.Post, error) {
}
func scanPostInto(row *sql.Row, p *model.Post) error {
var imgs, card string
err := row.Scan(&p.ID, &p.Kind, &p.Title, &p.Slug, &p.Summary, &p.CoverURL,
&p.ContentMd, &p.ContentHTML, &p.Status, &p.PublishedAt, &p.CreatedAt, &p.UpdatedAt,
&p.ReadingMinutes, &p.ContentLen)
&p.ReadingMinutes, &p.ContentLen, &imgs, &card)
if err == sql.ErrNoRows {
return ErrNotFound
}
@@ -625,6 +772,8 @@ func scanPostInto(row *sql.Row, p *model.Post) error {
return err
}
p.Tags = []string{}
p.Images = decodeImages(imgs)
p.LinkCard = decodeLinkCard(card)
return nil
}
@@ -869,17 +1018,19 @@ func (s *Store) Create(in model.PostInput) (model.Post, error) {
var id int64
q := s.db.Q(`INSERT INTO posts (kind,title,slug,summary,cover_url,content_md,content_html,status,
published_at,created_at,updated_at,reading_minutes)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`)
published_at,created_at,updated_at,reading_minutes,images,link_card)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
if s.db.Dialect == db.Postgres {
err := s.db.QueryRow(q, p.Kind, p.Title, p.Slug, p.Summary, p.CoverURL, p.ContentMd, p.ContentHTML,
p.Status, p.PublishedAt, p.CreatedAt, p.UpdatedAt, p.ReadingMinutes).Scan(&id)
p.Status, p.PublishedAt, p.CreatedAt, p.UpdatedAt, p.ReadingMinutes, encodeImages(in.Images),
encodeLinkCard(in.LinkCard)).Scan(&id)
if err != nil {
return p, err
}
} else {
res, err := s.db.Exec(q, p.Kind, p.Title, p.Slug, p.Summary, p.CoverURL, p.ContentMd, p.ContentHTML,
p.Status, p.PublishedAt, p.CreatedAt, p.UpdatedAt, p.ReadingMinutes)
p.Status, p.PublishedAt, p.CreatedAt, p.UpdatedAt, p.ReadingMinutes, encodeImages(in.Images),
encodeLinkCard(in.LinkCard))
if err != nil {
return p, err
}
@@ -889,7 +1040,12 @@ func (s *Store) Create(in model.PostInput) (model.Post, error) {
}
}
p.ID = id
if err := s.setTags(id, in.Tags); err != nil {
// 标签是长文的组织方式;短文(类推微博)一律不打标签
tags := in.Tags
if p.Kind == model.KindShort {
tags = nil
}
if err := s.setTags(id, tags); err != nil {
return p, err
}
return s.Get(id)
@@ -927,6 +1083,14 @@ func (s *Store) Update(id int64, in model.PostInput) (model.Post, error) {
// current value (empty string included), so any update round-trips with
// whatever the user last saved.
p.CoverURL = strings.TrimSpace(in.CoverURL)
// Images 同理:nil = 不修改(快发盒只发新帖,后台编辑器全量回传)
if in.Images != nil {
p.Images = in.Images
}
// LinkCard 由 admin 层按正文重算后传入;空卡片(含只有 URL)= 清空
if in.LinkCard != nil {
p.LinkCard = in.LinkCard
}
p.UpdatedAt = now()
if in.ReadingMinutes != nil && *in.ReadingMinutes > 0 {
p.ReadingMinutes = *in.ReadingMinutes
@@ -935,13 +1099,18 @@ func (s *Store) Update(id int64, in model.PostInput) (model.Post, error) {
}
if _, err := s.db.Exec(s.db.Q(`UPDATE posts SET kind=?,title=?,slug=?,summary=?,cover_url=?,content_md=?,
content_html=?,status=?,published_at=?,updated_at=?,reading_minutes=? WHERE id=?`),
content_html=?,status=?,published_at=?,updated_at=?,reading_minutes=?,images=?,link_card=? WHERE id=?`),
p.Kind, p.Title, p.Slug, p.Summary, p.CoverURL, p.ContentMd, p.ContentHTML, p.Status,
p.PublishedAt, p.UpdatedAt, p.ReadingMinutes, id); err != nil {
p.PublishedAt, p.UpdatedAt, p.ReadingMinutes, encodeImages(p.Images), encodeLinkCard(p.LinkCard), id); err != nil {
return p, err
}
if in.Tags != nil {
if err := s.setTags(id, in.Tags); err != nil {
// 短文一律无标签:切换类型或直接保存时都把旧标签清掉
if in.Tags != nil || p.Kind == model.KindShort {
tags := in.Tags
if p.Kind == model.KindShort {
tags = nil
}
if err := s.setTags(id, tags); err != nil {
return p, err
}
}
@@ -1391,3 +1560,665 @@ func (s *Store) scanPostsInto(query string, args []any, dst *[]model.Post) error
*dst = out
return nil
}
// ---------- files ----------
func scanFile(sc interface{ Scan(...any) error }) (model.File, error) {
var f model.File
err := sc.Scan(&f.ID, &f.Key, &f.Name, &f.Mime, &f.Size, &f.SHA256, &f.Store, &f.CreatedAt)
return f, err
}
func (s *Store) CreateFile(f model.File) (model.File, error) {
f.CreatedAt = now()
q := s.db.Q(`INSERT INTO files (key,name,mime,size,sha256,store,created_at) VALUES (?,?,?,?,?,?,?)`)
var id int64
if s.db.Dialect == db.Postgres {
err := s.db.QueryRow(q+` RETURNING id`, f.Key, f.Name, f.Mime, f.Size, f.SHA256, f.Store, f.CreatedAt).Scan(&id)
if err != nil {
return model.File{}, err
}
} else {
res, err := s.db.Exec(q, f.Key, f.Name, f.Mime, f.Size, f.SHA256, f.Store, f.CreatedAt)
if err != nil {
return model.File{}, err
}
if id, err = res.LastInsertId(); err != nil {
return model.File{}, err
}
}
f.ID = id
return f, nil
}
// ListFiles 按创建时间倒序分页,q 模糊匹配原始文件名 / key。
func (s *Store) ListFiles(page, size int, q string) (model.FilePage, error) {
if page < 1 {
page = 1
}
if size < 1 || size > 100 {
size = 20
}
where := ""
var args []any
if query := strings.TrimSpace(q); query != "" {
where = ` WHERE name LIKE ? OR key LIKE ?`
like := "%" + query + "%"
args = append(args, like, like)
}
var total int
if err := s.db.QueryRow(s.db.Q(`SELECT COUNT(*) FROM files`+where), args...).Scan(&total); err != nil {
return model.FilePage{}, err
}
args = append(args, size, (page-1)*size)
rows, err := s.db.Query(s.db.Q(`SELECT id,key,name,mime,size,sha256,store,created_at FROM files`+
where+` ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`), args...)
if err != nil {
return model.FilePage{}, err
}
defer rows.Close()
out := []model.File{}
for rows.Next() {
f, err := scanFile(rows)
if err != nil {
return model.FilePage{}, err
}
out = append(out, f)
}
return model.FilePage{Items: out, Total: total, Page: page, Size: size}, rows.Err()
}
func (s *Store) GetFile(id int64) (model.File, error) {
f, err := scanFile(s.db.QueryRow(s.db.Q(`SELECT id,key,name,mime,size,sha256,store,created_at FROM files WHERE id = ?`), id))
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return model.File{}, ErrNotFound
}
return model.File{}, err
}
return f, nil
}
func (s *Store) GetFileByKey(key string) (model.File, error) {
f, err := scanFile(s.db.QueryRow(s.db.Q(`SELECT id,key,name,mime,size,sha256,store,created_at FROM files WHERE key = ?`), key))
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return model.File{}, ErrNotFound
}
return model.File{}, err
}
return f, nil
}
// FileReferences 反查谁在用这个文件:文章(封面或正文)、项目封面、站主头像。
// 匹配的键是 files.key 本身而不是完整 URL——本地存成 /uploads/{key}、R2 存成
// {publicBase}/{key}、缩略图是 /uploads/thumb/{key},三种形态都以 key 结尾,
// 换存储端后正文里的老链接也照样能查到。
// 用 LIKE 现扫而不维护计数表:写入口有编辑器、外链转存、短文、项目、头像好几处,
// 计数一旦漂移就再也信不过;删文件是低频操作,扫全表几十毫秒换一个永远正确的答案。
func (s *Store) FileReferences(key string) ([]model.FileRef, error) {
if key == "" {
return []model.FileRef{}, nil
}
like := "%" + key + "%"
out := []model.FileRef{}
rows, err := s.db.Query(s.db.Q(`SELECT id,title,slug,status FROM posts
WHERE cover_url LIKE ? OR content_md LIKE ? OR content_html LIKE ?
ORDER BY published_at DESC LIMIT 100`), like, like, like)
if err != nil {
return nil, err
}
for rows.Next() {
var r model.FileRef
r.Kind = "post"
if err := rows.Scan(&r.ID, &r.Title, &r.Slug, &r.Status); err != nil {
rows.Close()
return nil, err
}
out = append(out, r)
}
if err := rows.Err(); err != nil {
rows.Close()
return nil, err
}
rows.Close()
proj, err := s.db.Query(s.db.Q(`SELECT id,title,slug,status FROM projects WHERE cover_url LIKE ? LIMIT 100`), like)
if err != nil {
return nil, err
}
for proj.Next() {
var r model.FileRef
r.Kind = "project"
if err := proj.Scan(&r.ID, &r.Title, &r.Slug, &r.Status); err != nil {
proj.Close()
return nil, err
}
out = append(out, r)
}
if err := proj.Err(); err != nil {
proj.Close()
return nil, err
}
proj.Close()
// 头像存的就是 key(不是 URL),等值比较
if st, err := s.GetSettings(); err != nil {
return nil, err
} else if st.AuthorAvatarKey == key {
out = append(out, model.FileRef{Kind: "avatar"})
}
return out, nil
}
// DeleteFile 删索引行并返回被删的行(调用方负责先删对象存储里的本体)。
func (s *Store) DeleteFile(id int64) (model.File, error) {
f, err := s.GetFile(id)
if err != nil {
return model.File{}, err
}
res, err := s.db.Exec(s.db.Q(`DELETE FROM files WHERE id = ?`), id)
if err != nil {
return model.File{}, err
}
if n, _ := res.RowsAffected(); n == 0 {
return model.File{}, ErrNotFound
}
return f, nil
}
// b2s 布尔转 KV 开关值
func b2s(b bool) string {
if b {
return "1"
}
return "0"
}
// ---------- readers(评论区的登录用户) ----------
// UpsertReader 按 (provider, handle) 找人:找到就更新资料,找不到就建档。
// banned 是状态位,不随资料更新覆盖。
func (s *Store) UpsertReader(r model.Reader) (model.Reader, error) {
r.CreatedAt = now()
q := s.db.Q(`INSERT INTO users (provider,handle,name,avatar_url,url,banned,created_at)
VALUES (?,?,?,?,?,0,?)
ON CONFLICT (provider,handle) DO UPDATE SET
name=excluded.name, avatar_url=excluded.avatar_url, url=excluded.url`)
// SQLite 的 ON CONFLICT 语法 Postgres 也认(现代版);老库退化走下面分支
if s.db.Dialect == db.Postgres {
if _, err := s.db.Exec(s.db.Q(`INSERT INTO users (provider,handle,name,avatar_url,url,banned,created_at)
VALUES (?,?,?,?,?,0,?) ON CONFLICT (provider,handle) DO UPDATE SET
name=excluded.name, avatar_url=excluded.avatar_url, url=excluded.url`),
r.Provider, r.Handle, r.Name, r.AvatarURL, r.URL, r.CreatedAt); err != nil {
return model.Reader{}, err
}
return s.GetReaderByProviderHandle(r.Provider, r.Handle)
}
if _, err := s.db.Exec(q, r.Provider, r.Handle, r.Name, r.AvatarURL, r.URL, r.CreatedAt); err != nil {
return model.Reader{}, err
}
return s.GetReaderByProviderHandle(r.Provider, r.Handle)
}
// userCols 是 users 表的读取列清单。列在多处 SELECT 复用,抽出来免得加一列
// 就要同步改一遍(漏一处就是扫错位置)。
const userCols = `id,provider,handle,name,avatar_url,url,banned,role,created_at`
// userColsU 是 JOIN 查询里带 u. 前缀的同一份列清单。和 userCols 成对改。
const userColsU = `u.id,u.provider,u.handle,u.name,u.avatar_url,u.url,u.banned,u.role,u.created_at`
func (s *Store) GetReader(id int64) (model.Reader, error) {
r, err := scanReader(s.db.QueryRow(s.db.Q(`SELECT `+userCols+` FROM users WHERE id = ?`), id))
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return model.Reader{}, ErrNotFound
}
return model.Reader{}, err
}
return r, nil
}
func (s *Store) GetReaderByProviderHandle(provider, handle string) (model.Reader, error) {
r, err := scanReader(s.db.QueryRow(s.db.Q(`SELECT `+userCols+` FROM users WHERE provider = ? AND handle = ?`), provider, handle))
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return model.Reader{}, ErrNotFound
}
return model.Reader{}, err
}
return r, nil
}
func (s *Store) SetReaderBanned(id int64, banned bool) error {
res, err := s.db.Exec(s.db.Q(`UPDATE users SET banned = ? WHERE id = ?`), b2i(banned), id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// ListReaders 后台的用户列表:带评论数,站主最前、禁言中的排前面
func (s *Store) ListReaders() ([]model.Reader, error) {
rows, err := s.db.Query(s.db.Q(`SELECT u.id,u.provider,u.handle,u.name,u.avatar_url,u.url,u.banned,u.role,u.created_at,
(SELECT COUNT(*) FROM comments c WHERE c.user_id = u.id AND c.is_deleted = 0) AS cnt
FROM users u ORDER BY CASE WHEN u.role = 'owner' THEN 0 ELSE 1 END, u.banned DESC, u.created_at DESC`))
if err != nil {
return nil, err
}
defer rows.Close()
out := []model.Reader{}
for rows.Next() {
var r model.Reader
var cnt int64
if err := rows.Scan(&r.ID, &r.Provider, &r.Handle, &r.Name, &r.AvatarURL, &r.URL, &r.Banned, &r.Role, &r.CreatedAt, &cnt); err != nil {
return nil, err
}
r.CommentCount = cnt
out = append(out, r)
}
return out, rows.Err()
}
// UpdateProfile 改站主行的显示名。只碰 name —— provider/handle/role 是身份锚,
// 不随资料编辑变动。
func (s *Store) UpdateProfile(userID int64, name string) (model.Reader, error) {
if _, err := s.db.Exec(s.db.Q(`UPDATE users SET name = ? WHERE id = ?`), name, userID); err != nil {
return model.Reader{}, err
}
return s.GetReader(userID)
}
// ---------- 站主账号 / 身份绑定 / passkey ----------
// EnsureOwner 取(或创建)站主账号行:provider=admin、role=owner。
// handle 跟着 ONE_ADMIN_USER 走——改了环境变量后这里会同步,
// 但 role=owner 只此一行,是「哪些身份登录算管理员」的锚点。
func (s *Store) EnsureOwner(handle string) (model.Reader, error) {
cur, err := s.GetOwner()
if err == nil {
if cur.Handle != handle {
if _, uerr := s.db.Exec(s.db.Q(`UPDATE users SET handle = ? WHERE id = ?`), handle, cur.ID); uerr != nil {
return model.Reader{}, uerr
}
cur.Handle = handle
}
return cur, nil
}
if !errors.Is(err, ErrNotFound) {
return model.Reader{}, err
}
if _, err := s.db.Exec(s.db.Q(`INSERT INTO users (provider,handle,name,avatar_url,url,banned,role,created_at)
VALUES (?,?,?,?,?,0,'owner',?)`), "admin", handle, "", "", "", now()); err != nil {
return model.Reader{}, err
}
return s.GetOwner()
}
// GetOwner 取站主行。role='owner' 全库唯一,按 provider 兜底兼容老数据
// (老库里站主行只有 provider='admin',没有 role)。
func (s *Store) GetOwner() (model.Reader, error) {
r, err := scanReader(s.db.QueryRow(s.db.Q(
`SELECT ` + userCols + ` FROM users WHERE role = 'owner' OR provider = 'admin' ORDER BY id LIMIT 1`)))
if errors.Is(err, sql.ErrNoRows) {
return model.Reader{}, ErrNotFound
}
return r, err
}
// BindIdentity 把 (provider, extern_uid) 绑到某个用户上。
// 外部账号已被别人占用时返回 ErrConflict —— 调用方必须原样拒绝,
// 不能「后来者覆盖」,否则任何人都能抢先把别人的 GitHub 账号登记成自己的。
func (s *Store) BindIdentity(userID int64, provider, externUID, display string) error {
owner, err := s.GetUserByIdentity(provider, externUID)
if err == nil {
if owner.ID == userID {
return nil // 重复绑定同一个,幂等放过
}
return ErrConflict
}
if !errors.Is(err, ErrNotFound) {
return err
}
_, err = s.db.Exec(s.db.Q(`INSERT INTO user_identities (user_id,provider,extern_uid,display,created_at)
VALUES (?,?,?,?,?)`), userID, provider, externUID, display, now())
return err
}
// UnbindIdentity 解绑某平台的绑定。站主始终还有环境变量密码这条退路,
// 所以这里不需要「不能解绑唯一登录方式」的护栏。
func (s *Store) UnbindIdentity(userID int64, provider string) error {
res, err := s.db.Exec(s.db.Q(`DELETE FROM user_identities WHERE user_id = ? AND provider = ?`), userID, provider)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) ListIdentities(userID int64) ([]model.UserIdentity, error) {
rows, err := s.db.Query(s.db.Q(`SELECT id,user_id,provider,extern_uid,display,created_at
FROM user_identities WHERE user_id = ? ORDER BY created_at`), userID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []model.UserIdentity{}
for rows.Next() {
var it model.UserIdentity
if err := rows.Scan(&it.ID, &it.UserID, &it.Provider, &it.ExternUID, &it.Display, &it.CreatedAt); err != nil {
return nil, err
}
out = append(out, it)
}
return out, rows.Err()
}
// GetUserByIdentity 按外部身份找用户:登录时先问它,命中即知道该发哪种会话。
func (s *Store) GetUserByIdentity(provider, externUID string) (model.Reader, error) {
r, err := scanReader(s.db.QueryRow(s.db.Q(
`SELECT `+userColsU+`
FROM user_identities i JOIN users u ON u.id = i.user_id
WHERE i.provider = ? AND i.extern_uid = ?`), provider, externUID))
if errors.Is(err, sql.ErrNoRows) {
return model.Reader{}, ErrNotFound
}
return r, err
}
// ---------- passkey ----------
func (s *Store) AddPasskey(p model.Passkey) (model.Passkey, error) {
p.CreatedAt = now()
res, err := s.db.Exec(s.db.Q(`INSERT INTO passkeys (user_id,credential_id,public_key,sign_count,name,created_at,last_used_at)
VALUES (?,?,?,?,?,?,?)`), p.UserID, p.CredentialID, p.PublicKey, int64(p.SignCount), p.Name, p.CreatedAt, "")
if err != nil {
return model.Passkey{}, err
}
p.ID, _ = res.LastInsertId()
return p, nil
}
// ListPasskeys 不返回 public_key:管理页只列名字与时间,凭据公钥
// 没必要顺着列表接口到处走。
func (s *Store) ListPasskeys(userID int64) ([]model.Passkey, error) {
rows, err := s.db.Query(s.db.Q(`SELECT id,user_id,credential_id,'',sign_count,name,created_at,last_used_at
FROM passkeys WHERE user_id = ? ORDER BY created_at`), userID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []model.Passkey{}
for rows.Next() {
var p model.Passkey
var sc int64
if err := rows.Scan(&p.ID, &p.UserID, &p.CredentialID, &p.PublicKey, &sc, &p.Name, &p.CreatedAt, &p.LastUsedAt); err != nil {
return nil, err
}
p.SignCount = uint32(sc)
out = append(out, p)
}
return out, rows.Err()
}
func (s *Store) GetPasskeyByCredentialID(credentialID string) (model.Passkey, error) {
var p model.Passkey
var sc int64
err := s.db.QueryRow(s.db.Q(`SELECT id,user_id,credential_id,public_key,sign_count,name,created_at,last_used_at
FROM passkeys WHERE credential_id = ?`), credentialID).
Scan(&p.ID, &p.UserID, &p.CredentialID, &p.PublicKey, &sc, &p.Name, &p.CreatedAt, &p.LastUsedAt)
if errors.Is(err, sql.ErrNoRows) {
return model.Passkey{}, ErrNotFound
}
p.SignCount = uint32(sc)
return p, err
}
// TouchPasskey 回写签名计数与使用时间。计数只增不减:
// 新计数比库里记的小,说明凭据被克隆到多个 authenticator 上用过。
func (s *Store) TouchPasskey(id int64, signCount uint32) error {
_, err := s.db.Exec(s.db.Q(`UPDATE passkeys SET sign_count = ?, last_used_at = ? WHERE id = ?`),
int64(signCount), now(), id)
return err
}
// DeletePasskey 带 user_id 条件删:免得拿别人的 id 越权删凭据。
func (s *Store) DeletePasskey(id, userID int64) error {
res, err := s.db.Exec(s.db.Q(`DELETE FROM passkeys WHERE id = ? AND user_id = ?`), id, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func b2i(b bool) int64 {
if b {
return 1
}
return 0
}
func scanReader(sc interface{ Scan(...any) error }) (model.Reader, error) {
var r model.Reader
var banned int64
err := sc.Scan(&r.ID, &r.Provider, &r.Handle, &r.Name, &r.AvatarURL, &r.URL, &banned, &r.Role, &r.CreatedAt)
r.Banned = banned == 1
return r, err
}
// ---------- comments ----------
const commentCols = `c.id, c.post_id, c.parent_id, c.root_id, c.user_id, c.body_md, c.body_html,
c.status, c.is_deleted, c.created_at, c.edited_at,
u.id, u.provider, u.handle, u.name, u.avatar_url, u.url, u.banned, u.created_at`
func scanComment(sc interface{ Scan(...any) error }) (model.Comment, error) {
var c model.Comment
var u model.Reader
var banned int64
err := sc.Scan(&c.ID, &c.PostID, &c.ParentID, &c.RootID, &c.UserID, &c.BodyMd, &c.BodyHTML,
&c.Status, &c.IsDeleted, &c.CreatedAt, &c.EditedAt,
&u.ID, &u.Provider, &u.Handle, &u.Name, &u.AvatarURL, &u.URL, &banned, &u.CreatedAt)
if err != nil {
return c, err
}
c.User = &model.Reader{ID: u.ID, Provider: u.Provider, Handle: u.Handle, Name: u.Name,
AvatarURL: u.AvatarURL, URL: u.URL, Banned: banned == 1, CreatedAt: u.CreatedAt}
c.Replies = []model.Comment{}
return c, nil
}
// CreateComment 新建评论;parent/root 归属与审核状态由调用方决定
func (s *Store) CreateComment(c model.Comment) (model.Comment, error) {
c.CreatedAt = now()
q := s.db.Q(`INSERT INTO comments (post_id,user_id,parent_id,root_id,body_md,body_html,status,created_at)
VALUES (?,?,?,?,?,?,?,?)`)
var id int64
if s.db.Dialect == db.Postgres {
err := s.db.QueryRow(q+` RETURNING id`, c.PostID, c.UserID, c.ParentID, c.RootID,
c.BodyMd, c.BodyHTML, c.Status, c.CreatedAt).Scan(&id)
if err != nil {
return model.Comment{}, err
}
} else {
res, err := s.db.Exec(q, c.PostID, c.UserID, c.ParentID, c.RootID, c.BodyMd, c.BodyHTML, c.Status, c.CreatedAt)
if err != nil {
return model.Comment{}, err
}
if id, err = res.LastInsertId(); err != nil {
return model.Comment{}, err
}
}
return s.GetComment(id)
}
// GetComment 单条(含用户)
func (s *Store) GetComment(id int64) (model.Comment, error) {
c, err := scanComment(s.db.QueryRow(s.db.Q(`SELECT `+commentCols+` FROM comments c
JOIN users u ON u.id = c.user_id WHERE c.id = ?`), id))
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return model.Comment{}, ErrNotFound
}
return model.Comment{}, err
}
return c, nil
}
// ListCommentsByPost 一篇文章的公开评论树:顶层可见 + 访客自己的待审;
// 每条顶层内嵌全部可见回复。deleted 的行保留(墓碑:正文清空,楼层不塌)。
func (s *Store) ListCommentsByPost(postID, viewerID int64, newestFirst bool) ([]model.Comment, error) {
order := `ASC`
if newestFirst {
order = `DESC`
}
rows, err := s.db.Query(s.db.Q(`SELECT `+commentCols+` FROM comments c
JOIN users u ON u.id = c.user_id
WHERE c.post_id = ? AND c.parent_id = 0
AND (c.status = 'visible' OR (c.status = 'pending' AND c.user_id = ?))
ORDER BY c.created_at `+order), postID, viewerID)
if err != nil {
return nil, err
}
defer rows.Close()
roots := []model.Comment{}
idx := map[int64]int{}
for rows.Next() {
c, err := scanComment(rows)
if err != nil {
return nil, err
}
c.Replies = []model.Comment{}
idx[c.ID] = len(roots)
roots = append(roots, c)
}
if err := rows.Err(); err != nil {
return nil, err
}
// 回复:可见的(+访客自己的待审),按时间正序挂在各自根下
rows2, err := s.db.Query(s.db.Q(`SELECT `+commentCols+` FROM comments c
JOIN users u ON u.id = c.user_id
WHERE c.post_id = ? AND c.parent_id <> 0
AND (c.status = 'visible' OR (c.status = 'pending' AND c.user_id = ?))
ORDER BY c.created_at ASC`), postID, viewerID)
if err != nil {
return nil, err
}
defer rows2.Close()
for rows2.Next() {
c, err := scanComment(rows2)
if err != nil {
return nil, err
}
if at, ok := idx[c.RootID]; ok {
roots[at].Replies = append(roots[at].Replies, c)
}
}
for i := range roots {
roots[i].ReplyCount = len(roots[i].Replies)
}
return roots, rows2.Err()
}
// ListCommentsAdmin 后台的评论列表(平铺,含用户与文章标题),status 过滤
func (s *Store) ListCommentsAdmin(status string, page, size int) (model.CommentPage, error) {
if page < 1 {
page = 1
}
if size < 1 || size > 100 {
size = 20
}
where := ""
var args []any
switch status {
case "pending", "visible":
where = ` WHERE c.status = '` + status + `' AND c.is_deleted = 0`
default:
where = ` WHERE c.is_deleted = 0`
}
var total int
if err := s.db.QueryRow(s.db.Q(`SELECT COUNT(*) FROM comments c`+where), args...).Scan(&total); err != nil {
return model.CommentPage{}, err
}
args = append(args, size, (page-1)*size)
rows, err := s.db.Query(s.db.Q(`SELECT `+commentCols+`, COALESCE(p.title, '') FROM comments c
JOIN users u ON u.id = c.user_id
JOIN posts p ON p.id = c.post_id`+where+`
ORDER BY c.created_at DESC LIMIT ? OFFSET ?`), args...)
if err != nil {
return model.CommentPage{}, err
}
defer rows.Close()
out := []model.Comment{}
for rows.Next() {
c, title, err := scanCommentAdmin(rows)
if err != nil {
return model.CommentPage{}, err
}
c.PostTitle = title
out = append(out, c)
}
return model.CommentPage{Items: out, Total: total, Page: page, Size: size}, rows.Err()
}
// CommentPage 后台评论管理的分页容器
type modelCommentPageAlias = struct{}
func scanCommentAdmin(sc interface{ Scan(...any) error }) (model.Comment, string, error) {
var c model.Comment
var u model.Reader
var title string
var banned int64
err := sc.Scan(&c.ID, &c.PostID, &c.ParentID, &c.RootID, &c.UserID, &c.BodyMd, &c.BodyHTML,
&c.Status, &c.IsDeleted, &c.CreatedAt, &c.EditedAt,
&u.ID, &u.Provider, &u.Handle, &u.Name, &u.AvatarURL, &u.URL, &banned, &u.CreatedAt, &title)
c.User = &model.Reader{ID: u.ID, Provider: u.Provider, Handle: u.Handle, Name: u.Name,
AvatarURL: u.AvatarURL, URL: u.URL, Banned: banned == 1, CreatedAt: u.CreatedAt}
return c, title, err
}
// SetCommentStatus 审核通过 / 退回待审
func (s *Store) SetCommentStatus(id int64, status string) error {
res, err := s.db.Exec(s.db.Q(`UPDATE comments SET status = ? WHERE id = ?`), status, id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// DeleteComment 软删:留壳(「该评论已删除」),正文清空
func (s *Store) DeleteComment(id int64) error {
res, err := s.db.Exec(s.db.Q(`UPDATE comments SET is_deleted = 1, body_md = '', body_html = '' WHERE id = ?`), id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// UpdateCommentBody 编辑后的正文回写
func (s *Store) UpdateCommentBody(id int64, md, html string) error {
res, err := s.db.Exec(s.db.Q(`UPDATE comments SET body_md = ?, body_html = ?, edited_at = ? WHERE id = ?`),
md, html, now(), id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
+377
View File
@@ -0,0 +1,377 @@
// Package thumbs 懒生成上传图片的缩略图并落盘缓存(memos 模式):
// 首次请求时从存储端读一次原图,缩放编码后进缓存,此后不再碰原文件;
// 生成失败写 .failed 标记,1 小时内不反复重试。
package thumbs
import (
"bytes"
"fmt"
"image"
"image/gif"
"image/jpeg"
"image/png"
"io"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
const (
// MaxSrcBytes 超过此大小的原图不生成(防止大图拖垮请求),调用方回原图。
MaxSrcBytes = 25 << 20 // 25MB
// maxDim 单边像素上限,超过视为畸形图(解压炸弹防线)。
maxDim = 10000
// JPEGQuality 转码输出质量。
JPEGQuality = 82
// failedTTL 失败后的重试冷却。
failedTTL = time.Hour
)
// Supported 报告该 mime 是否走缩略图管线(标准库能完整解码的静图)。
func Supported(mime string) bool {
switch mime {
case "image/jpeg", "image/png", "image/gif":
return true
}
return false
}
// Generate 把原图字节缩放到宽 w(只缩不放)。已够小的原生格式图原样直通
// (返回 passThrough=true,调用方直接落盘 src),其余统一转 JPEG——
// 缩略图不需要 alpha,透明区铺白底。
func Generate(src []byte, mime string, w int) (out []byte, outMime string, passThrough bool, err error) {
cfg, _, err := image.DecodeConfig(bytes.NewReader(src))
if err != nil {
return nil, "", false, fmt.Errorf("decode config: %w", err)
}
if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width > maxDim || cfg.Height > maxDim {
return nil, "", false, fmt.Errorf("bad dimensions %dx%d", cfg.Width, cfg.Height)
}
if cfg.Width <= w && Supported(mime) {
return src, mime, true, nil
}
var img image.Image
switch mime {
case "image/jpeg":
img, err = jpeg.Decode(bytes.NewReader(src))
case "image/png":
img, err = png.Decode(bytes.NewReader(src))
case "image/gif":
img, err = gif.Decode(bytes.NewReader(src)) // 首帧,缩略图不做动画
default:
return nil, "", false, fmt.Errorf("unsupported mime %q", mime)
}
if err != nil {
return nil, "", false, fmt.Errorf("decode: %w", err)
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, Resize(img, w), &jpeg.Options{Quality: JPEGQuality}); err != nil {
return nil, "", false, err
}
return buf.Bytes(), "image/jpeg", false, nil
}
// Resize 盒均值(box-average)缩放到宽 nw(等比,只缩不放)。
// 非预乘 RGBA 按 alpha 加权平均,透明像素不计入颜色、铺白底。纯标准库。
func Resize(src image.Image, nw int) *image.RGBA {
b := src.Bounds()
ow, oh := b.Dx(), b.Dy()
if nw <= 0 || nw >= ow {
nw = ow
}
nh := (oh*nw + ow/2) / ow
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
flat := flatten(src, b)
for y := 0; y < nh; y++ {
y0 := y * oh / nh
y1 := (y+1)*oh/nh + 1
if y1 > oh {
y1 = oh
}
if y1 <= y0 {
y1 = y0 + 1
}
for x := 0; x < nw; x++ {
x0 := x * ow / nw
x1 := (x+1)*ow/nw + 1
if x1 > ow {
x1 = ow
}
if x1 <= x0 {
x1 = x0 + 1
}
var r, g, bl, a, n uint64
for sy := y0; sy < y1; sy++ {
row := sy * ow * 4
for sx := x0; sx < x1; sx++ {
i := row + sx*4
al := uint64(flat[i+3])
r += uint64(flat[i]) * al
g += uint64(flat[i+1]) * al
bl += uint64(flat[i+2]) * al
a += al
n++
}
}
d := y*nw*4 + x*4
if a == 0 || n == 0 {
dst.Pix[d], dst.Pix[d+1], dst.Pix[d+2], dst.Pix[d+3] = 255, 255, 255, 255
continue
}
dst.Pix[d] = uint8(r / a)
dst.Pix[d+1] = uint8(g / a)
dst.Pix[d+2] = uint8(bl / a)
dst.Pix[d+3] = uint8(a / n)
}
}
return dst
}
// flatten 把任意 image.Image 转成 w*h 扁平非预乘 RGBA 字节。
func flatten(src image.Image, b image.Rectangle) []byte {
w, h := b.Dx(), b.Dy()
out := make([]byte, w*h*4)
i := 0
for y := b.Min.Y; y < b.Max.Y; y++ {
for x := b.Min.X; x < b.Max.X; x++ {
r, g, bl, a := src.At(x, y).RGBA() // 0..0xffff,预乘
pa := a >> 8
if pa == 0 {
out[i], out[i+1], out[i+2], out[i+3] = 0, 0, 0, 0
} else {
out[i] = uint8(min255((r >> 8) * 0xff / pa))
out[i+1] = uint8(min255((g >> 8) * 0xff / pa))
out[i+2] = uint8(min255((bl >> 8) * 0xff / pa))
out[i+3] = uint8(pa)
}
i += 4
}
}
return out
}
func min255(v uint32) uint32 {
if v > 0xff {
return 0xff
}
return v
}
// Store 是缩略图磁盘缓存:
//
// {Dir}/{sha[:2]}/{sha[:32]}-{w}.jpg 成功产物(直通可能是 .png/.gif)
// {Dir}/{sha[:2]}/{sha[:32]}.failed 失败标记
//
// 文件名以原图内容哈希(files.sha256)为键:内容变则键变,缓存天然失效。
// 缓存只增不减会慢慢吃磁盘,所以 Put 之后按 MaxBytes 做容量闸淘汰(LRU:
// 命中时 Touch 更新 mtime,淘汰时先下手最久没人要的)。
type Store struct {
Dir string
MaxBytes int64
mu sync.Mutex
locks map[string]*sync.Mutex
lastSweep time.Time
sweeping bool
}
// DefaultMaxBytes 是缓存总量上限:约 1700 张 150KB 的缩略图,
// 远超单用户博客的实际存量,纯粹是防多年以后无限涨。
const DefaultMaxBytes = 256 << 20
// sweepInterval 限制扫描频率——淘汰是后台维护,不该拖慢写缓存的请求。
const sweepInterval = 10 * time.Minute
func NewStore(dir string) *Store {
return &Store{Dir: dir, MaxBytes: DefaultMaxBytes, locks: make(map[string]*sync.Mutex)}
}
func (s *Store) dir(sha string) string { return filepath.Join(s.Dir, sha[:2]) }
func (s *Store) jpegPath(sha string, w int) string {
return filepath.Join(s.dir(sha), fmt.Sprintf("%s-%d.jpg", sha[:32], w))
}
func (s *Store) failedPath(sha string) string {
return filepath.Join(s.dir(sha), sha[:32]+".failed")
}
// FindCached 返回已存在的缓存文件(jpg 优先,其次直通的 png/gif),没有则空串。
func (s *Store) FindCached(sha string, w int) string {
base := strings.TrimSuffix(s.jpegPath(sha, w), ".jpg")
for _, ext := range []string{".jpg", ".png", ".gif"} {
if fi, err := os.Stat(base + ext); err == nil && !fi.IsDir() {
return base + ext
}
}
return ""
}
// Put 原子写入缓存文件(内容直通时保留原生格式),成功则清掉失败标记。
func (s *Store) Put(sha string, w int, data []byte, mime string) (string, error) {
p := s.jpegPath(sha, w)
if mime != "image/jpeg" {
p = strings.TrimSuffix(p, ".jpg") + extFor(mime)
}
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
return "", err
}
tmp := p + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return "", err
}
if err := os.Rename(tmp, p); err != nil {
os.Remove(tmp)
return "", err
}
_ = os.Remove(s.failedPath(sha))
s.maybeSweep()
return p, nil
}
// Touch 把缓存文件的 mtime 拨到现在:命中一次就等于「最近还要用」,
// 供容量闸按 LRU 淘汰。失败无所谓(只影响淘汰顺序)。
func (s *Store) Touch(path string) {
now := time.Now()
_ = os.Chtimes(path, now, now)
}
// Purge 清掉某个原图(按内容哈希)的全部缩略图与失败标记。
// 删除上传文件时调用,否则缓存会一直留着已删图片的产物。
func (s *Store) Purge(sha string) {
if len(sha) < 32 {
return
}
entries, err := os.ReadDir(s.dir(sha))
if err != nil {
return
}
for _, e := range entries {
if strings.HasPrefix(e.Name(), sha[:32]) { // {sha}-{w}.jpg / {sha}.failed
_ = os.Remove(filepath.Join(s.dir(sha), e.Name()))
}
}
_ = os.Remove(s.dir(sha)) // 目录空了就收掉
}
// FailedRecently 报告是否在冷却期(近期生成失败过)。
func (s *Store) FailedRecently(sha string) bool {
fi, err := os.Stat(s.failedPath(sha))
return err == nil && time.Since(fi.ModTime()) < failedTTL
}
// MarkFailed 落失败标记。
func (s *Store) MarkFailed(sha string) {
if err := os.MkdirAll(s.dir(sha), 0o755); err == nil {
_ = os.WriteFile(s.failedPath(sha), []byte("thumbs: generation failed\n"), 0o644)
}
}
// Lock 取该(哈希, 宽度)的互斥锁:并发首请求只生成一次,其余等待后读缓存。
func (s *Store) Lock(sha string, w int) *sync.Mutex {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.locks) > lockCacheMax {
clear(s.locks) // 粗粒度回收:正在持有的锁不受影响(调用方拿着指针),
} // 清掉的只是历史条目,最坏情况是同一图并发重生成一次
k := fmt.Sprintf("%s-%d", sha[:32], w)
l, ok := s.locks[k]
if !ok {
l = &sync.Mutex{}
s.locks[k] = l
}
return l
}
// lockCacheMax 限制锁表大小(每张图最多 3 种宽度),博客体量下足够。
const lockCacheMax = 4096
// maybeSweep 在写入新缓存后检查总量;超上限就按 mtime 从旧到新删,
// 删到 3/4 水位(留滞回,避免每次写入都刚好卡在边界反复删)。
// 每 sweepInterval 最多跑一次,且同一时刻只有一个在跑。
func (s *Store) maybeSweep() {
if s.MaxBytes <= 0 {
return
}
s.mu.Lock()
if s.sweeping || time.Since(s.lastSweep) < sweepInterval {
s.mu.Unlock()
return
}
s.sweeping, s.lastSweep = true, time.Now()
s.mu.Unlock()
defer func() {
s.mu.Lock()
s.sweeping = false
s.mu.Unlock()
}()
var files []fileInfo
var total int64
_ = filepath.WalkDir(s.Dir, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
// 半途而废的临时文件(进程被杀)直接清
if strings.HasSuffix(path, ".tmp") {
_ = os.Remove(path)
return nil
}
fi, err := d.Info()
if err != nil {
return nil
}
total += fi.Size()
files = append(files, fileInfo{path: path, size: fi.Size(), mtime: fi.ModTime()})
return nil
})
if total <= s.MaxBytes {
return
}
sort.Slice(files, func(i, j int) bool { return files[i].mtime.Before(files[j].mtime) })
lowWater := s.MaxBytes * 3 / 4
for _, f := range files {
if total <= lowWater {
break
}
if err := os.Remove(f.path); err == nil {
total -= f.size
}
}
}
type fileInfo struct {
path string
size int64
mtime time.Time
}
func extFor(mime string) string {
switch mime {
case "image/png":
return ".png"
case "image/gif":
return ".gif"
}
return ".jpg"
}
// ReadAllLimited 读至多 max 字节,超限报错(防止把 25MB+ 原图吞进内存)。
func ReadAllLimited(r io.Reader, max int64) ([]byte, error) {
data, err := io.ReadAll(io.LimitReader(r, max+1))
if err != nil {
return nil, err
}
if int64(len(data)) > max {
return nil, fmt.Errorf("source exceeds %d bytes", max)
}
return data, nil
}
+172
View File
@@ -0,0 +1,172 @@
package thumbs
import (
"bytes"
"image"
"image/color"
"image/jpeg"
"image/png"
"io/fs"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func makePNG(t *testing.T, w, h int, transparent bool) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
// 左红右绿两半,方便验证盒均值没把通道混错
c := color.RGBA{255, 0, 0, 255}
if x >= w/2 {
c = color.RGBA{0, 255, 0, 255}
}
if transparent {
c = color.RGBA{0, 0, 0, 0}
}
img.Set(x, y, c)
}
}
var b bytes.Buffer
if err := png.Encode(&b, img); err != nil {
t.Fatal(err)
}
return b.Bytes()
}
func TestGenerateDownscale(t *testing.T) {
src := makePNG(t, 800, 400, false)
out, mime, pass, err := Generate(src, "image/png", 400)
if err != nil {
t.Fatal(err)
}
if pass {
t.Fatal("800px 图在 w=400 不应直通")
}
if mime != "image/jpeg" {
t.Fatalf("mime=%s", mime)
}
cfg, _, err := image.DecodeConfig(bytes.NewReader(out))
if err != nil {
t.Fatal(err)
}
if cfg.Width != 400 || cfg.Height != 200 {
t.Fatalf("got %dx%d, want 400x200", cfg.Width, cfg.Height)
}
img, err := jpeg.Decode(bytes.NewReader(out))
if err != nil {
t.Fatal(err)
}
// 盒均值后左右两半的中心仍是红/绿(容差给 JPEG 质量 82 留余量)
r, g, _, _ := img.At(100, 100).RGBA()
if r>>8 < 200 || g>>8 > 60 {
t.Fatalf("左半应偏红: r=%d g=%d", r>>8, g>>8)
}
r, g, _, _ = img.At(300, 100).RGBA()
if g>>8 < 200 || r>>8 > 60 {
t.Fatalf("右半应偏绿: r=%d g=%d", r>>8, g>>8)
}
}
func TestGeneratePassThrough(t *testing.T) {
src := makePNG(t, 300, 150, false)
out, mime, pass, err := Generate(src, "image/png", 960)
if err != nil {
t.Fatal(err)
}
if !pass || mime != "image/png" || !bytes.Equal(out, src) {
t.Fatalf("小图应原样直通 pass=%v mime=%s", pass, mime)
}
}
func TestGenerateRejectsGarbage(t *testing.T) {
if _, _, _, err := Generate([]byte("not an image"), "image/jpeg", 400); err == nil {
t.Fatal("垃圾字节应报错")
}
}
func TestFlattenAlphaUnpremultiply(t *testing.T) {
// 50% 透明的纯红:alpha=128 预乘 R=128,还原应为 R≈255
img := image.NewNRGBA(image.Rect(0, 0, 1, 1))
img.Set(0, 0, color.NRGBA{255, 0, 0, 128})
flat := flatten(img, img.Bounds())
if flat[0] < 240 || flat[3] != 128 {
t.Fatalf("unpremultiply wrong: r=%d a=%d", flat[0], flat[3])
}
}
func TestSweepEvictsOldest(t *testing.T) {
s := NewStore(t.TempDir())
s.MaxBytes = 2500 // 每个文件 1000B,留得下 2 个
sha := func(i int) string { return strings.Repeat(string(rune('a'+i)), 64) }
// 三个宽度:写入顺序即 mtime 顺序(下面再显式拨时间)
for i := 0; i < 3; i++ {
if _, err := s.Put(sha(i), 480, bytes.Repeat([]byte("x"), 1000), "image/jpeg"); err != nil {
t.Fatal(err)
}
}
// 跳过节流,让这次 Put 真的触发扫描
s.lastSweep = time.Time{}
oldest := time.Now().Add(-3 * time.Hour)
for i := 0; i < 2; i++ { // 前两个假装很久没人访问
os.Chtimes(s.jpegPath(sha(i), 480), oldest, oldest)
}
if _, err := s.Put(sha(3), 480, bytes.Repeat([]byte("x"), 1000), "image/jpeg"); err != nil {
t.Fatal(err)
}
var total int64
var kept []string
filepath.WalkDir(s.Dir, func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
fi, _ := d.Info()
total += fi.Size()
kept = append(kept, d.Name())
return nil
})
if total > s.MaxBytes {
t.Fatalf("sweep 后仍超限: total=%d max=%d files=%v", total, s.MaxBytes, kept)
}
if len(kept) >= 4 {
t.Fatalf("一个都没淘汰,说明扫描没跑: files=%v", kept)
}
// 最新写入的那个必须还在
if s.FindCached(sha(3), 480) == "" {
t.Fatalf("刚写入的缓存被误删: %v", kept)
}
}
func TestPurgeRemovesAllEntriesForFile(t *testing.T) {
s := NewStore(t.TempDir())
sha := strings.Repeat("f", 64)
for _, w := range []int{480, 960} {
if _, err := s.Put(sha, w, []byte("data"), "image/jpeg"); err != nil {
t.Fatal(err)
}
}
s.MarkFailed(sha)
other := strings.Repeat("a", 64)
if _, err := s.Put(other, 480, []byte("keep"), "image/jpeg"); err != nil {
t.Fatal(err)
}
s.Purge(sha)
for _, w := range []int{480, 960} {
if p := s.FindCached(sha, w); p != "" {
t.Fatalf("purge 后仍残留 %s", p)
}
}
if s.FailedRecently(sha) {
t.Fatal("失败标记没清掉")
}
if s.FindCached(other, 480) == "" {
t.Fatal("误删了别的文件的缓存")
}
}
+66 -2
View File
@@ -18,9 +18,13 @@ import (
"oneblog/internal/admin"
"oneblog/internal/api"
"oneblog/internal/auth"
"oneblog/internal/config"
"oneblog/internal/db"
"oneblog/internal/hub"
"oneblog/internal/storage"
"oneblog/internal/store"
"oneblog/internal/thumbs"
)
func main() {
@@ -46,12 +50,64 @@ func main() {
log.Fatalf("store: %v", err)
}
public := &api.API{Store: st, Cfg: cfg}
adminAPI := admin.NewAPI(st, cfg, admin.NewSessions(cfg.SessionSec, 7*24*time.Hour))
// 文件上传的存储后端:R2 配置齐全用 R2,否则本地磁盘兜底。
// 两个 API 共享同一实例;公开访问路由 /uploads/ 也走它。
var blobs storage.BlobStore
switch cfg.StorageDriver {
case "r2":
blobs, err = storage.NewR2(cfg.S3Endpoint, cfg.R2Bucket, cfg.R2AccessKey, cfg.R2SecretKey)
if err != nil {
log.Fatalf("storage: %v", err)
}
default:
blobs = storage.NewLocal(filepath.Join(cfg.DataDir, "uploads"))
}
// 评论区的读者会话(cookie one_reader),30 天有效
readerSessions := auth.NewReaderSessions(cfg.SessionSec, 30*24*time.Hour)
// 后台管理员会话(cookie one_session),前台评论也用它识别站主身份
adminSessions := admin.NewSessions(cfg.SessionSec, 7*24*time.Hour)
// 评论变更广播(SSE):公开端订阅、双端发布,共用同一实例
commentHub := hub.New()
// 缩略图懒生成 + 磁盘缓存(首次请求生成一次,之后直接供缓存)
thumbCache := thumbs.NewStore(filepath.Join(cfg.DataDir, ".thumbnail_cache"))
// Passkey(WebAuthn):只有显式配了 ONE_WEBAUTHN_ORIGINS 才启用。
// 没配就不构造 —— 相关端点保持 404,前端也不会显示入口。
// 显示名固定 "ONE":站点标题存在 settings 表里,为浏览器弹窗去读库不值当。
var passkeys *auth.Passkeys
if cfg.WebauthnRPID != "" && len(cfg.WebauthnOrigins) > 0 {
passkeys, err = auth.NewPasskeys("ONE", cfg.WebauthnRPID, cfg.WebauthnOrigins)
if err != nil {
log.Fatalf("passkeys: %v", err)
}
log.Printf("passkey 登录已启用 (rp_id=%s origins=%s)", cfg.WebauthnRPID, strings.Join(cfg.WebauthnOrigins, ","))
}
public := &api.API{
Store: st,
Cfg: cfg,
Blobs: blobs,
Thumbs: thumbCache,
ReaderSessions: readerSessions,
GH: auth.GitHub{ClientID: cfg.GitHubClientID, ClientSecret: cfg.GitHubClientSecret},
GG: auth.Google{ClientID: cfg.GoogleClientID, ClientSecret: cfg.GoogleClientSecret},
TG: auth.Telegram{Bot: cfg.TelegramBot, Token: cfg.TelegramToken},
AdminSessions: adminSessions,
Passkeys: passkeys,
Hub: commentHub,
}
adminAPI := admin.NewAPI(st, cfg, adminSessions)
adminAPI.Hub = commentHub
adminAPI.Blobs = blobs
adminAPI.Thumbs = thumbCache // 删文件时连带清掉它的缩略图
adminAPI.Passkeys = passkeys
root := http.NewServeMux()
root.Handle("/api/admin/", adminAPI.Routes())
public.Mount(root)
// 更长的前缀优先匹配:/uploads/thumb/ 走缩略图,其余 /uploads/ 走原图流式
root.Handle("/uploads/thumb/", public.ThumbHandler())
root.Handle("/uploads/", public.UploadsHandler())
root.Handle("/", spaHandler(cfg.WebDist))
srv := &http.Server{
@@ -149,6 +205,14 @@ type statusRecorder struct {
func (s *statusRecorder) WriteHeader(code int) { s.status = code; s.ResponseWriter.WriteHeader(code) }
// Flush 透传给底层 writer——SSE 这类流式响应靠它判定可刷新,
// 包装器不实现 Flusher 的话流式端点会直接不可用。
func (s *statusRecorder) Flush() {
if f, ok := s.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
func placeholderPage() string {
return `<!doctype html><meta charset="utf-8"><title>ONE</title>
<style>body{font-family:-apple-system,"PingFang SC",sans-serif;background:#faf7f1;color:#33302b;
+6
View File
@@ -7,7 +7,13 @@
<meta name="theme-color" content="#1f1d1a" media="(prefers-color-scheme: dark)" />
<title>ONE · 一个博客</title>
<meta name="description" content="长文与短文,同一种节奏。" />
<!-- 方案 A「一横」:SVG 优先(内部有 prefers-color-scheme,深浅皮肤各自变色),
PNG 只给不认 SVG 的老浏览器;apple-touch-icon 必须 PNG(iOS 不支持 SVG,
且会把透明压成黑底,所以那张铺了纸色实底)。 -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="alternate" type="application/rss+xml" title="RSS" href="/rss.xml" />
<!--
UI 版本在 <html> 上以 data-ui 表达,服务端设置要等 /api/site 才知道。
+5
View File
@@ -10,6 +10,11 @@
"preview": "vite preview"
},
"dependencies": {
"@codemirror/commands": "^6.11.1",
"@codemirror/lang-markdown": "^6.5.2",
"@codemirror/language": "^6.12.4",
"@codemirror/state": "^6.7.6",
"@codemirror/view": "^6.43.13",
"@milkdown/crepe": "^7.22.1",
"dompurify": "^3.1.6",
"vue": "^3.4.0",
+63 -48
View File
@@ -8,6 +8,21 @@ importers:
.:
dependencies:
'@codemirror/commands':
specifier: ^6.11.1
version: 6.11.1
'@codemirror/lang-markdown':
specifier: ^6.5.2
version: 6.5.2
'@codemirror/language':
specifier: ^6.12.4
version: 6.12.4
'@codemirror/state':
specifier: ^6.7.6
version: 6.7.6
'@codemirror/view':
specifier: ^6.43.13
version: 6.43.13
'@milkdown/crepe':
specifier: ^7.22.1
version: 7.22.1(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.4)(typescript@5.9.3)
@@ -140,14 +155,14 @@ packages:
'@codemirror/search@6.7.2':
resolution: {integrity: sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==}
'@codemirror/state@6.7.5':
resolution: {integrity: sha512-QjLbZmY1Au3JiRrDVYFLRD0BZ3SOKS9pR3yjIkd7u27YY8TFD9/Q9fhPnLV5l1mHFSo3hHU/N31vpwEJOx4owQ==}
'@codemirror/state@6.7.6':
resolution: {integrity: sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==}
'@codemirror/theme-one-dark@6.1.3':
resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==}
'@codemirror/view@6.43.12':
resolution: {integrity: sha512-Nv0vxQ19NAqvB/c2pFzjIzFlzzJl7jmdtNkwOwGbn0Ks9mFAzibvumz7cQem5cRsFA2cEw2fg+uHZGbcHupLQQ==}
'@codemirror/view@6.43.13':
resolution: {integrity: sha512-sihaFrUzAsYBQsL9J2t69y8nfMQGwcYmggAZsk+kjPbjYZMyuf2hU8tUNTZ+P+isb6XRr8JE22TZlJxBoVdH1A==}
'@esbuild/aix-ppc64@0.21.5':
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
@@ -1118,15 +1133,15 @@ snapshots:
'@codemirror/autocomplete@6.20.3':
dependencies:
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5
'@codemirror/view': 6.43.12
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
'@lezer/common': 1.5.2
'@codemirror/commands@6.11.1':
dependencies:
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5
'@codemirror/view': 6.43.12
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
'@lezer/common': 1.5.2
'@codemirror/lang-angular@0.1.4':
@@ -1147,7 +1162,7 @@ snapshots:
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5
'@codemirror/state': 6.7.6
'@lezer/common': 1.5.2
'@lezer/css': 1.3.6
@@ -1155,7 +1170,7 @@ snapshots:
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5
'@codemirror/state': 6.7.6
'@lezer/common': 1.5.2
'@lezer/go': 1.0.1
@@ -1165,8 +1180,8 @@ snapshots:
'@codemirror/lang-css': 6.3.1
'@codemirror/lang-javascript': 6.2.5
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5
'@codemirror/view': 6.43.12
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
'@lezer/common': 1.5.2
'@lezer/css': 1.3.6
'@lezer/html': 1.3.13
@@ -1181,8 +1196,8 @@ snapshots:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
'@codemirror/lint': 6.9.7
'@codemirror/state': 6.7.5
'@codemirror/view': 6.43.12
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
'@lezer/common': 1.5.2
'@lezer/javascript': 1.5.5
@@ -1191,8 +1206,8 @@ snapshots:
'@codemirror/autocomplete': 6.20.3
'@codemirror/lang-html': 6.4.12
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5
'@codemirror/view': 6.43.12
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
@@ -1215,8 +1230,8 @@ snapshots:
'@codemirror/autocomplete': 6.20.3
'@codemirror/lang-html': 6.4.12
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5
'@codemirror/view': 6.43.12
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
@@ -1226,8 +1241,8 @@ snapshots:
'@codemirror/autocomplete': 6.20.3
'@codemirror/lang-html': 6.4.12
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5
'@codemirror/view': 6.43.12
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
'@lezer/common': 1.5.2
'@lezer/markdown': 1.7.2
@@ -1235,7 +1250,7 @@ snapshots:
dependencies:
'@codemirror/lang-html': 6.4.12
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5
'@codemirror/state': 6.7.6
'@lezer/common': 1.5.2
'@lezer/php': 1.0.6
@@ -1243,7 +1258,7 @@ snapshots:
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5
'@codemirror/state': 6.7.6
'@lezer/common': 1.5.2
'@lezer/python': 1.1.19
@@ -1256,7 +1271,7 @@ snapshots:
dependencies:
'@codemirror/lang-css': 6.3.1
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5
'@codemirror/state': 6.7.6
'@lezer/common': 1.5.2
'@lezer/sass': 1.1.0
@@ -1264,7 +1279,7 @@ snapshots:
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5
'@codemirror/state': 6.7.6
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
@@ -1289,8 +1304,8 @@ snapshots:
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5
'@codemirror/view': 6.43.12
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
'@lezer/common': 1.5.2
'@lezer/xml': 1.0.6
@@ -1298,7 +1313,7 @@ snapshots:
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5
'@codemirror/state': 6.7.6
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
@@ -1332,8 +1347,8 @@ snapshots:
'@codemirror/language@6.12.4':
dependencies:
'@codemirror/state': 6.7.5
'@codemirror/view': 6.43.12
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
@@ -1345,30 +1360,30 @@ snapshots:
'@codemirror/lint@6.9.7':
dependencies:
'@codemirror/state': 6.7.5
'@codemirror/view': 6.43.12
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
crelt: 1.0.7
'@codemirror/search@6.7.2':
dependencies:
'@codemirror/state': 6.7.5
'@codemirror/view': 6.43.12
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
crelt: 1.0.7
'@codemirror/state@6.7.5':
'@codemirror/state@6.7.6':
dependencies:
'@marijn/find-cluster-break': 1.0.4
'@codemirror/theme-one-dark@6.1.3':
dependencies:
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5
'@codemirror/view': 6.43.12
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
'@lezer/highlight': 1.2.3
'@codemirror/view@6.43.12':
'@codemirror/view@6.43.13':
dependencies:
'@codemirror/state': 6.7.5
'@codemirror/state': 6.7.6
crelt: 1.0.7
style-mod: 4.1.4
w3c-keyname: 2.2.8
@@ -1550,11 +1565,11 @@ snapshots:
'@marijn/find-cluster-break@1.0.4': {}
'@milkdown/components@7.22.1(@codemirror/language@6.12.4)(@codemirror/state@6.7.5)(@codemirror/view@6.43.12)(typescript@5.9.3)':
'@milkdown/components@7.22.1(@codemirror/language@6.12.4)(@codemirror/state@6.7.6)(@codemirror/view@6.43.13)(typescript@5.9.3)':
dependencies:
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5
'@codemirror/view': 6.43.12
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
'@floating-ui/dom': 1.8.0
'@milkdown/core': 7.22.1
'@milkdown/ctx': 7.22.1
@@ -1594,10 +1609,10 @@ snapshots:
'@codemirror/commands': 6.11.1
'@codemirror/language': 6.12.4
'@codemirror/language-data': 6.5.2
'@codemirror/state': 6.7.5
'@codemirror/state': 6.7.6
'@codemirror/theme-one-dark': 6.1.3
'@codemirror/view': 6.43.12
'@milkdown/kit': 7.22.1(@codemirror/language@6.12.4)(@codemirror/state@6.7.5)(@codemirror/view@6.43.12)(typescript@5.9.3)
'@codemirror/view': 6.43.13
'@milkdown/kit': 7.22.1(@codemirror/language@6.12.4)(@codemirror/state@6.7.6)(@codemirror/view@6.43.13)(typescript@5.9.3)
'@types/lodash-es': 4.17.12
clsx: 2.1.1
codemirror: 6.0.2
@@ -1621,9 +1636,9 @@ snapshots:
'@milkdown/exception@7.22.1': {}
'@milkdown/kit@7.22.1(@codemirror/language@6.12.4)(@codemirror/state@6.7.5)(@codemirror/view@6.43.12)(typescript@5.9.3)':
'@milkdown/kit@7.22.1(@codemirror/language@6.12.4)(@codemirror/state@6.7.6)(@codemirror/view@6.43.13)(typescript@5.9.3)':
dependencies:
'@milkdown/components': 7.22.1(@codemirror/language@6.12.4)(@codemirror/state@6.7.5)(@codemirror/view@6.43.12)(typescript@5.9.3)
'@milkdown/components': 7.22.1(@codemirror/language@6.12.4)(@codemirror/state@6.7.6)(@codemirror/view@6.43.13)(typescript@5.9.3)
'@milkdown/core': 7.22.1
'@milkdown/ctx': 7.22.1
'@milkdown/exception': 7.22.1
@@ -2036,8 +2051,8 @@ snapshots:
'@codemirror/language': 6.12.4
'@codemirror/lint': 6.9.7
'@codemirror/search': 6.7.2
'@codemirror/state': 6.7.5
'@codemirror/view': 6.43.12
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
commander@15.0.0: {}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 700 B

+10 -32
View File
@@ -1,32 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 128.81 128.17">
<defs>
<style>
.cls-1 {
fill: url(#gradient3);
}
.cls-2 {
fill: #000;
}
@media (prefers-color-scheme: dark) {
.cls-2 {
fill: #fff;
}
}
</style>
<linearGradient id="gradient3" x1="57.54" y1="31.44" x2="128.81" y2="31.44" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#ff4d4d"/>
<stop offset=".99" stop-color="#f9cb28"/>
</linearGradient>
</defs>
<g id="b">
<g>
<path class="cls-2" d="M56.32,100.47c0,15.98-11.84,27.61-27.85,27.61S.33,116.45,.33,100.47s11.83-27.45,28.08-27.45,27.91,11.63,27.91,27.45Z"/>
<path class="cls-2" d="M120,101.26v19.31c-4.87,4.63-12.9,7.6-21.65,7.6-18.33,0-30-10.49-30-26.7s12.92-28.34,31.07-28.34v28.13h20.58Z"/>
<polygon class="cls-1" points="119.7 21.48 128.81 38.51 83.27 62.88 57.54 14.79 85.17 0 101.79 31.06 119.7 21.48"/>
<polygon class="cls-2" points="0 63.11 16.54 32.82 .68 8.17 55.98 8.17 40.12 32.82 56.65 63.11 0 63.11"/>
</g>
</g>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="ONE">
<!-- 方案 A「一横」:墨圆 + 一道白横,既是字母 O 也是汉字「一」。
横杠用 fill-rule="evenodd" 挖成真洞(不是叠一块纸色矩形),
所以它在任何底色上都是透明的——不需要为深浅皮肤各出一版。 -->
<style>
.m { fill: #33302b }
@media (prefers-color-scheme: dark) { .m { fill: #e8e3d9 } }
</style>
<path class="m" fill-rule="evenodd" d="M32 4a28 28 0 1 0 0 56 28 28 0 1 0 0-56Zm-16 23.5h32a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H16a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 648 B

+29 -1
View File
@@ -1,13 +1,39 @@
<script setup>
import { computed, watch } from 'vue'
import { computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRoute } from 'vue-router'
import TopBar from './components/TopBar.vue'
import ThemeSwitcher from './components/ThemeSwitcher.vue'
import ImgLightbox from './components/ImgLightbox.vue'
import YohakuTop from './ui/yohaku/YohakuTop.vue'
import YohakuFoot from './ui/yohaku/YohakuFoot.vue'
import { site, setAdminScope } from './site'
const route = useRoute()
// 正文代码块复制按钮(事件委托:复制按钮是 enhanceProse 注入的静态 HTML)
function onCopyClick(e) {
const btn = e.target.closest?.('.pre-copy')
if (!btn) return
const pre = btn.closest('.pre-wrap')?.querySelector('pre')
if (!pre) return
navigator.clipboard
.writeText(pre.innerText.replace(/\n$/, ''))
.then(() => {
btn.textContent = '已复制'
btn.classList.add('done')
setTimeout(() => {
btn.textContent = '复制'
btn.classList.remove('done')
}, 1600)
})
.catch(() => {
btn.textContent = '复制失败'
setTimeout(() => (btn.textContent = '复制'), 1600)
})
}
onMounted(() => document.addEventListener('click', onCopyClick))
onBeforeUnmount(() => document.removeEventListener('click', onCopyClick))
const isAdmin = computed(() => route.path.startsWith('/admin'))
// vivid 只在公开前台生效:后台永远走 classic 那套(--admin-* token + 原 data-ui/data-theme),
// 不受 ui_id 影响。vivid.css 会命中 .btn / .input 这类全局类,后台表单正在用,
@@ -36,6 +62,8 @@ watch(
<RouterView />
<YohakuFoot v-if="isVivid" />
<!-- classic 的页脚信息在 RightRail 卡片下方的小字区(Twitter 式侧栏页脚) -->
<!-- 图片预览层:时间线 / 详情页的配图点开时全屏展示 -->
<ImgLightbox />
<!-- 桌面端:左栏底部放 inline 形态;窄屏:左栏收起,浮动按钮兜底。
vivid 的顶部导航自带切换器,浮动那个就不重复出现了。 -->
<ThemeSwitcher v-if="!isVivid" variant="floating" class="theme-floating-only" />
+362
View File
@@ -0,0 +1,362 @@
<script setup>
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { adminApi } from '../api'
import { toastOk, toastErr } from './toast'
import { isSupported as waSupported, register as waRegister } from '../webauthn'
// 账户页:我是谁(头像 / 昵称 / 简介)+ 我能用什么方式登录
// (密码走环境变量、第三方身份绑定、passkey)。
//
// 和「设置」的分工是刻意的:站点的事在设置,账号的事在这里。
// 作者昵称/简介以前在设置里,已移过来 —— 同一字段两处编辑迟早漂移。
const acct = ref(null)
const loading = ref(true)
const saving = ref(false)
const uploading = ref(false)
const regBusy = ref(false)
const fileInput = ref(null)
const route = useRoute()
const router = useRouter()
async function load() {
loading.value = true
try {
acct.value = await adminApi.account()
} catch (e) {
toastErr(e.message || '读取账户信息失败')
} finally {
loading.value = false
}
}
// 绑定完成是从 provider 整页跳回来的,用 query 带平台名:
// 提示一次就把参数抹掉,免得刷新页面重复弹。
async function reportBindResult() {
const p = route.query.bound
if (typeof p !== 'string' || !p) return
toastOk(p === 'github' ? '已绑定 GitHub' : '已绑定 ' + p)
router.replace({ path: route.path })
}
async function save(patch) {
saving.value = true
try {
acct.value = await adminApi.saveAccount(patch)
toastOk('已保存')
} catch (e) {
toastErr(e.message || '保存失败')
} finally {
saving.value = false
}
}
async function onPickAvatar(ev) {
const file = ev.target.files && ev.target.files[0]
ev.target.value = '' // 允许连续选同一个文件
if (!file) return
uploading.value = true
try {
const data = await adminApi.uploadFile(file)
const f = Array.isArray(data) ? data[0] : null
if (!f || !f.key) throw new Error('上传失败')
await save({ avatar_key: f.key })
} catch (e) {
toastErr(e.message || '头像上传失败')
} finally {
uploading.value = false
}
}
async function removeAvatar() {
await save({ avatar_key: '' })
}
function startBind(provider) {
// 整页跳转:OAuth 回来后由后端重定向回本页并带 ?bound=
window.location.assign('/api/auth/' + provider + '/bind')
}
async function unbind(provider) {
const label = provider === 'github' ? 'GitHub' : provider
if (!window.confirm('解绑 ' + label + '?之后就不能再用它登录后台了。')) return
try {
await adminApi.unbindIdentity(provider)
toastOk('已解绑')
await load()
} catch (e) {
toastErr(e.message || '解绑失败')
}
}
async function addPasskey() {
if (!waSupported()) {
toastErr('这个浏览器不支持 passkey')
return
}
const name = window.prompt('给这把凭据起个名字(如「MacBook 指纹」)', '')
if (name === null) return
regBusy.value = true
try {
const { options, token } = await adminApi.passkeyBegin()
const cred = await waRegister(options)
await adminApi.passkeyFinish({ token, name: name.trim(), credential: cred })
toastOk('passkey 已添加')
await load()
} catch (e) {
toastErr(e.message || 'passkey 注册失败')
} finally {
regBusy.value = false
}
}
async function removePasskey(pk) {
if (!window.confirm('删除 passkey「' + (pk.name || '未命名') + '」?')) return
try {
await adminApi.deletePasskey(pk.id)
toastOk('已删除')
await load()
} catch (e) {
toastErr(e.message || '删除失败')
}
}
function fmtDate(s) {
if (!s) return ''
const d = new Date(s)
return Number.isNaN(d.getTime()) ? s : d.toLocaleDateString('zh-CN')
}
const providerLabel = { github: 'GitHub', google: 'Google', telegram: 'Telegram' }
onMounted(async () => {
await load()
reportBindResult()
})
</script>
<template>
<section v-if="loading" class="loading">载入中…</section>
<section v-else-if="acct">
<h1 style="font-family: var(--serif); font-size: 22px; margin-bottom: 4px;">账户</h1>
<p class="field-hint" style="margin-bottom: 20px;">
头像、昵称与登录方式。站点标题、皮肤这些站点级配置在
<router-link to="/admin/settings">设置</router-link>。
</p>
<!-- ---------- 资料 ---------- -->
<div class="panel">
<div class="panel-title"><h2>公开资料</h2></div>
<div class="field">
<label>头像</label>
<div class="avatar-row">
<span class="avatar-box">
<img v-if="acct.avatar_url" :src="acct.avatar_url" alt="" />
<span v-else class="avatar-ph">无</span>
</span>
<div class="avatar-acts">
<input ref="fileInput" type="file" accept="image/png,image/jpeg,image/gif" hidden @change="onPickAvatar" />
<button class="btn" :disabled="uploading" @click="fileInput.click()">
{{ uploading ? '上传中…' : (acct.avatar_key ? '换一个' : '上传头像') }}
</button>
<button v-if="acct.avatar_key" class="btn" :disabled="saving" @click="removeAvatar">移除</button>
</div>
</div>
<p class="field-hint">
头像会出现在时间线、文章页与关于页。没设置时那些位置显示站标。
</p>
</div>
<div class="field">
<label>昵称</label>
<input v-model="acct.name" class="input" maxlength="40" spellcheck="false" />
<p class="field-hint">评论区与文章署名用的名字。</p>
</div>
<div class="field">
<label>简介</label>
<textarea v-model="acct.bio" class="textarea" rows="2" maxlength="200" />
</div>
<div class="field">
<label>用户名(handle)</label>
<input :value="acct.handle" class="input" disabled />
<p class="field-hint">
由环境变量 <code>ONE_ADMIN_USER</code> 决定,不在这里改 ——
它是账号的身份锚点,改了要连密码一起换。
</p>
</div>
<button class="btn btn-primary" :disabled="saving" @click="save({ name: acct.name, bio: acct.bio })">
{{ saving ? '保存中…' : '保存资料' }}
</button>
</div>
<!-- ---------- 登录方式 ---------- -->
<div class="panel" style="margin-top: 18px;">
<div class="panel-title"><h2>登录方式</h2></div>
<div class="method">
<div class="method-head">
<b>密码</b>
<span class="tag on">始终可用</span>
</div>
<p class="field-hint">
站主密码由环境变量 <code>ONE_ADMIN_PASSWORD</code> 管理,不存在数据库里,
因此也就无法在这里被改掉或解绑。这条退路保证:哪怕解绑了所有第三方、
删光所有 passkey,你仍然能登录进来。
</p>
</div>
<div class="method">
<div class="method-head">
<b>第三方账号</b>
<span v-if="acct.identities.length" class="tag on">已绑定 {{ acct.identities.length }}</span>
<span v-else class="tag">未绑定</span>
</div>
<ul v-if="acct.identities.length" class="list">
<li v-for="it in acct.identities" :key="it.id">
<span>{{ providerLabel[it.provider] || it.provider }}</span>
<code>{{ it.display }}</code>
<span class="when">绑于 {{ fmtDate(it.created_at) }}</span>
<button class="btn btn-sm" @click="unbind(it.provider)">解绑</button>
</li>
</ul>
<p v-else class="field-hint">还没有绑定任何第三方账号。</p>
<div v-if="acct.providers.length" class="bind-acts">
<button
v-for="p in acct.providers"
:key="p"
class="btn btn-sm"
:disabled="acct.identities.some((i) => i.provider === p)"
@click="startBind(p)"
>
绑定 {{ providerLabel[p] || p }}
</button>
</div>
<p class="field-hint">
绑定后,用该账号 OAuth 登录会<b>直接进入后台</b>(等于站主身份)。
所以绑定动作必须在已登录后台时发起,且一个外部账号只能属于一个用户。
</p>
</div>
<div class="method">
<div class="method-head">
<b>Passkey</b>
<span v-if="acct.passkeys.length" class="tag on">{{ acct.passkeys.length }} 把</span>
<span v-else class="tag">未添加</span>
</div>
<ul v-if="acct.passkeys.length" class="list">
<li v-for="pk in acct.passkeys" :key="pk.id">
<span>{{ pk.name || '未命名设备' }}</span>
<span class="when">
加于 {{ fmtDate(pk.created_at) }}<template v-if="pk.last_used_at"> · 上次用 {{ fmtDate(pk.last_used_at) }}</template>
</span>
<button class="btn btn-sm" @click="removePasskey(pk)">删除</button>
</li>
</ul>
<button class="btn btn-sm" :disabled="regBusy" @click="addPasskey">
{{ regBusy ? '等待浏览器…' : '添加 passkey' }}
</button>
<p class="field-hint">
用设备指纹 / 面容 / 安全密钥登录,无需密码,也无需先输用户名。
<template v-if="!acct.passkeys.length && !acct.providers.length">
服务端需配置 <code>ONE_WEBAUTHN_ORIGINS</code> 才会开放此入口。
</template>
</p>
</div>
</div>
</section>
</template>
<style scoped>
.avatar-row {
display: flex;
align-items: center;
gap: 14px;
}
.avatar-box {
width: 64px;
height: 64px;
border-radius: 50%;
overflow: hidden;
background: var(--admin-sunken, #f3efe6);
display: flex;
align-items: center;
justify-content: center;
flex: none;
}
.avatar-box img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.avatar-ph {
font-size: 12px;
color: var(--admin-muted);
}
.avatar-acts {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.method {
padding: 14px 0;
border-top: 1px solid var(--admin-line, #e6e0d4);
}
.method:first-of-type {
border-top: 0;
padding-top: 0;
}
.method-head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 6px;
}
.tag {
font-size: 11px;
padding: 1px 7px;
border-radius: 999px;
border: 1px solid var(--admin-line, #e6e0d4);
color: var(--admin-muted);
}
.tag.on {
border-color: var(--accent-line, rgba(61, 127, 156, 0.35));
color: var(--admin-accent, #3d7f9c);
}
.list {
list-style: none;
margin: 0 0 10px;
padding: 0;
}
.list li {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 0;
font-size: 13.5px;
}
.list code {
font-size: 12px;
color: var(--admin-muted);
}
.when {
margin-left: auto;
font-size: 12px;
color: var(--admin-muted);
}
.bind-acts {
display: flex;
gap: 8px;
margin: 8px 0;
}
.btn-sm {
padding: 4px 12px;
font-size: 12.5px;
}
</style>
+31 -3
View File
@@ -1,5 +1,6 @@
<script setup>
import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue'
import ToastStack from './ToastStack.vue'
import { useRoute, useRouter, RouterLink, RouterView } from 'vue-router'
import { checkAuth } from './auth'
import { adminApi, session } from '../api'
@@ -13,6 +14,7 @@ const route = useRoute()
const ready = ref(false)
const paletteOpen = ref(false)
const shortcutsOpen = ref(false)
const sideOpen = ref(false)
const counts = ref({ published: 0, draft: 0, tags: 0 })
async function loadCounts() {
@@ -59,6 +61,8 @@ const crumb = computed(() => {
const p = route.path
if (p.endsWith('/new')) return '写新的'
if (/^\/admin\/\d+/.test(p)) return '编辑文章'
if (p.endsWith('/files')) return '文件'
if (p.endsWith('/comments')) return '评论'
if (p.endsWith('/tags')) return '标签'
if (p.endsWith('/settings')) return '设置'
if (p === '/admin' || p === '/admin/') return '总览'
@@ -72,6 +76,8 @@ function onKeydown(e) {
paletteOpen.value = true
return
}
// Esc 收起手机端抽屉
if (e.key === 'Escape') sideOpen.value = false
// ?
if (e.key === '?' && !e.metaKey && !e.ctrlKey && !e.altKey) {
const t = e.target
@@ -88,13 +94,17 @@ function onKeydown(e) {
onMounted(() => window.addEventListener('keydown', onKeydown))
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
// refresh counts when route changes (posts / tags edited)
watch(() => route.path, () => loadCounts())
// refresh counts when route changes (posts / tags edited); 顺手收起手机端抽屉
watch(() => route.path, () => {
sideOpen.value = false
loadCounts()
})
</script>
<template>
<div v-if="ready" class="admin-shell">
<aside class="admin-side">
<ToastStack />
<aside class="admin-side" :class="{ open: sideOpen }">
<div class="brand">
<RouterLink to="/admin" class="name">{{ site.site_title || 'ONE' }}</RouterLink>
<span class="label">· 后台</span>
@@ -119,6 +129,14 @@ watch(() => route.path, () => loadCounts())
<span class="ic">◈</span>
<span>作品</span>
</RouterLink>
<RouterLink to="/admin/files" class="item">
<span class="ic">▣</span>
<span>文件</span>
</RouterLink>
<RouterLink to="/admin/comments" class="item">
<span class="ic">❝</span>
<span>评论</span>
</RouterLink>
<div class="group">操作</div>
<RouterLink to="/admin/new" class="item">
<span class="ic">✎</span>
@@ -128,6 +146,10 @@ watch(() => route.path, () => loadCounts())
<span class="ic">⚙</span>
<span>设置</span>
</RouterLink>
<RouterLink to="/admin/account" class="item">
<span class="ic">◉</span>
<span>账户</span>
</RouterLink>
<div class="group">前台</div>
<a href="/" target="_blank" class="item">
<span class="ic">↗</span>
@@ -146,12 +168,16 @@ watch(() => route.path, () => loadCounts())
<div class="admin-main">
<header class="admin-topbar">
<div class="crumbs">
<!-- 手机端唤出侧栏抽屉(≤860px 显示,桌面隐藏) -->
<button class="menu-btn" type="button" aria-label="后台菜单" @click="sideOpen = !sideOpen">☰</button>
<strong>{{ crumb }}</strong>
</div>
<div class="right">
<button class="cmd-hint" @click="paletteOpen = true">
搜索 / 命令 <kbd>⌘K</kbd>
</button>
<!-- 手机端侧栏进抽屉后,退出固定放顶栏;桌面隐藏,退出仍走侧栏 footer -->
<button class="btn leave-m" @click="logout">退出</button>
</div>
</header>
<main class="admin-content">
@@ -159,6 +185,8 @@ watch(() => route.path, () => loadCounts())
<RouterView :key="route.name === 'admin-edit' ? route.params.id : undefined" />
</main>
</div>
<!-- 手机端抽屉遮罩:点空白收回 -->
<div class="side-scrim" :class="{ on: sideOpen }" @click="sideOpen = false"></div>
<Transition name="pop">
<CommandPalette v-if="paletteOpen" @close="paletteOpen = false" />
+218
View File
@@ -0,0 +1,218 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { toastOk, toastErr } from './toast'
import { useRoute, useRouter } from 'vue-router'
import { adminApi } from '../api'
import { relativeDate, stripTags } from '../utils'
// 评论管理:待审队列优先,通过 / 删除 / 禁言都在行内完成。
// 删除走软删(墓碑保楼层);禁言按读者维度,禁言中的行上有标记。
const route = useRoute()
const router = useRouter()
const items = ref([])
const total = ref(0)
const loading = ref(true)
const error = ref('')
const status = ref(route.query.status === 'any' || route.query.status === 'visible' ? route.query.status : 'pending')
const page = computed(() => Number(route.query.page || 1))
const size = 20
let seq = 0
async function load() {
const my = ++seq
loading.value = true
error.value = ''
try {
const data = await adminApi.comments({ status: status.value, page: page.value, size })
if (my !== seq) return
items.value = data.items || []
total.value = data.total || 0
} catch (e) {
if (my !== seq) return
error.value = e.message || '加载失败'
items.value = []
} finally {
if (my === seq) loading.value = false
}
}
onMounted(load)
watch([status, page], load)
function setStatus(v) {
status.value = v
router.replace({ query: { ...route.query, status: v === 'pending' ? undefined : v, page: 1 } })
}
async function approve(id) {
await adminApi.approveComment(id)
toastOk('已通过,前台可见')
load()
}
async function remove(id) {
if (!window.confirm('删除这条评论?正文清空,楼层保留为「已删除」。')) return
await adminApi.deleteComment(id)
toastOk('已删除')
load()
}
async function banToggle(c) {
const banned = !c.user?.banned
if (!window.confirm(banned ? `禁言「${c.user?.name || '该用户'}」?禁言后其无法再发表评论。` : '解除禁言?')) return
await adminApi.setReaderBanned(c.user?.id, banned)
toastOk(banned ? '已禁言' : '已解除禁言')
c.user = { ...(c.user || {}), banned }
}
const tabs = [
{ label: '待审', value: 'pending' },
{ label: '已通过', value: 'visible' },
{ label: '全部', value: 'any' }
]
</script>
<template>
<section>
<header style="display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 14px;">
<h1 style="font-family: var(--serif); font-size: 22px;">
评论 <span style="font-family: var(--sans); font-size: 13px; color: var(--admin-muted); font-weight: 400;">{{ total }}</span>
</h1>
</header>
<div class="posts-toolbar" style="margin-bottom: 16px;">
<button class="chip" :class="{ on: status === 'pending' }" @click="setStatus('pending')">待审</button>
<button class="chip" :class="{ on: status === 'visible' }" @click="setStatus('visible')">已通过</button>
<button class="chip" :class="{ on: status === 'any' }" @click="setStatus('any')">全部</button>
</div>
<div v-if="loading" class="loading">载入中…</div>
<div v-else-if="error" class="empty">{{ error }}</div>
<div v-else-if="!items.length" class="empty">
{{ status === 'pending' ? '没有待审的评论。' : '还没有评论。' }}
</div>
<template v-else>
<div v-for="c in items" :key="c.id" class="cm-row">
<div class="cm-ava" aria-hidden="true">{{ (c.user?.name || '?').slice(0, 1) }}</div>
<div class="cm-body-col">
<div class="cm-row-head">
<span class="cm-name" :title="c.user?.handle || ''">{{ c.user?.name || '匿名' }}</span>
<span v-if="c.user?.banned" class="cm-flag">禁言中</span>
<span v-if="c.status === 'pending'" class="cm-flag pend">待审</span>
<span class="cm-time" :title="c.created_at">{{ relativeDate(c.created_at) }}</span>
</div>
<div class="cm-text">{{ stripTags(c.body_html) }}</div>
<div class="cm-post">on {{ c.post_title || '无题' }}</div>
</div>
<div class="cm-acts">
<a v-if="c.status === 'pending'" href="#" @click.prevent="approve(c.id)">通过</a>
<a href="#" class="danger" @click.prevent="remove(c.id)">删除</a>
<a href="#" @click.prevent="banToggle(c)">{{ c.user?.banned ? '解除禁言' : '禁言' }}</a>
</div>
</div>
<nav
v-if="pageCount > 1"
aria-label="分页"
style="display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 18px;"
>
<button class="btn" :disabled="page <= 1" @click="router.replace({ query: { ...route.query, page: page - 1 } })" aria-label="上一页">
← 上一页
</button>
<span style="font-size: 13px; color: var(--admin-muted);" aria-live="polite">第 {{ page }} / {{ pageCount }} 页</span>
<button class="btn" :disabled="page >= pageCount" @click="router.replace({ query: { ...route.query, page: page + 1 } })" aria-label="下一页">
下一页 →
</button>
</nav>
</template>
</section>
</template>
<style scoped>
.cm-row {
display: grid;
grid-template-columns: 40px minmax(0, 1fr) auto;
gap: 12px;
padding: 14px 4px;
border-bottom: 1px solid var(--admin-line-soft);
}
.cm-ava {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--admin-paper-sunken);
color: var(--admin-ink);
display: grid;
place-items: center;
font-family: var(--serif);
font-size: 16px;
user-select: none;
}
.cm-row-head {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 8px;
font-size: 13px;
color: var(--admin-muted);
}
.cm-name {
font-weight: 600;
color: var(--admin-ink);
}
.cm-flag {
padding: 0 5px;
border: 1px solid var(--admin-line);
border-radius: 2px;
font-size: 11px;
color: var(--admin-muted);
}
.cm-flag.pend {
color: #9a5b45;
border-color: #ddcdc2;
}
.cm-time {
color: var(--admin-faint);
}
.cm-text {
margin-top: 4px;
font-size: 14.5px;
line-height: 1.75;
color: var(--admin-ink);
overflow-wrap: anywhere;
}
.cm-post {
margin-top: 4px;
font-size: 12px;
color: var(--admin-faint);
}
.cm-acts {
display: flex;
gap: 12px;
align-items: baseline;
font-size: 12.5px;
}
.cm-acts a {
color: var(--admin-muted);
}
.cm-acts a:hover {
color: var(--admin-accent);
}
.cm-acts a.danger:hover {
color: #b4553f;
}
</style>
+16 -3
View File
@@ -59,7 +59,7 @@ const chartMax = computed(() => {
</div>
</div>
<div style="display: grid; grid-template-columns: 1.4fr 1fr; gap: 16px;">
<div class="dash-cols">
<div class="panel">
<div class="panel-title">
<h2>最近 12 个月发布</h2>
@@ -107,7 +107,7 @@ const chartMax = computed(() => {
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 16px;">
<div class="dash-cols even">
<div class="panel">
<div class="panel-title">
<h2>最近发布</h2>
@@ -137,4 +137,17 @@ const chartMax = computed(() => {
</div>
</div>
</section>
</template>
</template>
<style scoped>
/* 两块并排面板:桌面 1.4:1 / 1:1,手机(≤720px)单列——写死在行内样式里
会被 min-content 顶出横向滚动(量过:390 视口下 docW 418)。 */
.dash-cols {display: grid;
grid-template-columns: 1.4fr 1fr;
gap: 16px;}
.dash-cols.even {grid-template-columns: 1fr 1fr;
margin-top: 16px;}
@media (max-width: 720px) {
.dash-cols,
.dash-cols.even {grid-template-columns: 1fr;}
}
</style>
+396 -50
View File
@@ -1,10 +1,16 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { toast, toastOk, toastErr, toastUpdate, toastDone } from './toast'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import { adminApi } from '../api'
import { site } from '../site'
import { Crepe, CrepeFeature } from '@milkdown/crepe'
import '@milkdown/crepe/theme/common/style.css'
import '@milkdown/crepe/theme/frame.css'
import { EditorView as CMView, keymap } from '@codemirror/view'
import { EditorState } from '@codemirror/state'
import { markdown as markdownLang } from '@codemirror/lang-markdown'
import { history, defaultKeymap, historyKeymap, indentWithTab } from '@codemirror/commands'
const route = useRoute()
const router = useRouter()
@@ -141,6 +147,11 @@ async function initEditor() {
featureConfigs: {
[CrepeFeature.Placeholder]: {
text: form.kind === 'short' ? '写点什么…' : '开始写,或按 / 唤出命令菜单'
},
// 图片上传:wysiwyg 里的粘贴 / 拖拽 / 插图按钮全部走后台文件上传,
// onUpload 返回 URL 后由 Crepe 自己插节点
[CrepeFeature.ImageBlock]: {
onUpload: (file) => uploadOne(file)
}
}
})
@@ -179,6 +190,10 @@ onBeforeUnmount(() => {
crepe.destroy()
crepe = null
}
if (cmView) {
cmView.destroy()
cmView = null
}
})
// ---------- mode switching ----------
@@ -190,6 +205,10 @@ async function switchMode(next) {
crepe.destroy()
crepe = null
}
// CM 懒挂载;已挂载就把 wysiwyg 期间产生的 markdown 灌回来
await nextTick()
mountCM()
syncCM()
} else {
// MD → WYSIWYG: spin up crepe with the current markdown
await nextTick()
@@ -262,6 +281,9 @@ async function autosave() {
function applySaved(saved) {
form.slug = saved.slug
form.status = saved.status
// 自动摘要/自动封面在服务端补齐的,回写表单让站主看见(清空仍会保留为空)
if (typeof saved.summary === 'string') form.summary = saved.summary
if (typeof saved.cover_url === 'string') form.cover_url = saved.cover_url
publishedLocal.value = isoToLocal(saved.published_at)
}
@@ -298,7 +320,7 @@ function buildPayload() {
async function save(status) {
if (status) form.status = status
if (!isEdit.value && !form.content_md.trim() && !form.title.trim()) {
error.value = '先写点什么再保存'
toastErr('先写点什么再保存')
return
}
saving.value = true
@@ -312,12 +334,17 @@ async function save(status) {
const created = await adminApi.createPost(payload)
localStorage.removeItem(DRAFT_KEY)
dirty.value = false
toastOk('已创建')
router.replace(`/admin/${created.id}`)
return
}
dirty.value = false
savedAt.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
// 手动保存(按钮/⌘S/发布)给回音;自动保存静默,不打扰
const label = status === 'published' ? '已发布' : status === 'draft' ? '已转回草稿' : '已保存'
toastOk(label + ' · ' + savedAt.value)
} catch (e) {
toastErr(e.message || '保存失败')
error.value = e.message || '保存失败'
} finally {
saving.value = false
@@ -360,6 +387,14 @@ onBeforeRouteLeave(() => {
// ---------- 标签 ----------
// 标签是长文的组织方式;切到短文时把已选的清掉(后端落库时也会强制丢弃)
watch(
() => form.kind,
(k) => {
if (k === 'short') form.tags = []
}
)
function addTag() {
const v = tagInput.value.trim().replace(/[,,]$/, '')
if (!v) return
@@ -386,6 +421,82 @@ function clearCover() {
// ---------- 工具栏:Markdown 模式插入 + 通用动作 ----------
const mdPane = ref(null)
// ---------- CodeMirror 源码模式 ----------
// md 页签不是裸 textarea:语法高亮 + 行内历史(⌘Z),粘贴/拖图片直接上传。
// markdown 仍是唯一真相源——CM 的改动写回 form.content_md,
// 外部改动(转存替换、wysiwyg 切回)用 syncCM 全量灌回。
let cmView = null
const cmTheme = CMView.theme({
'&': { color: 'var(--admin-ink)', backgroundColor: 'transparent', fontSize: '13.5px' },
'.cm-content': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', lineHeight: '1.75', padding: '12px 0 40vh' },
'.cm-scroller': { overflow: 'auto', maxHeight: '70vh' },
'.cm-line': { padding: '0 2px' },
'&.cm-focused': { outline: 'none' },
'.cm-cursor': { borderLeftColor: 'var(--admin-accent)' },
'.cm-selectionBackground, ::selection': { backgroundColor: 'color-mix(in srgb, var(--admin-accent) 18%, transparent) !important' },
'.cm-activeLine': { backgroundColor: 'color-mix(in srgb, var(--admin-accent) 6%, transparent)' },
'.cm-heading, .cm-header': { color: 'var(--admin-accent)', fontWeight: '600' },
'.cm-link, .cm-url': { color: 'var(--admin-accent)', textDecoration: 'underline' },
'.cm-emphasis': { fontStyle: 'italic' },
'.cm-strong': { fontWeight: '700' },
'.cm-code, .cm-monospace': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', color: 'var(--admin-muted)' }
})
function handleEditorFiles(files, view) {
const list = [...files].filter((f) => f.type.startsWith('image/'))
if (!list.length) return false
view.dispatch({
changes: { from: view.state.selection.main.from, insert: ' ' }
})
;[...list].forEach((f) => uploadOne(f, { insertImage: true }))
return true
}
function mountCM() {
if (cmView || !mdPane.value) return
cmView = new CMView({
parent: mdPane.value,
state: EditorState.create({
doc: form.content_md,
extensions: [
history(),
keymap.of([
{ key: 'Mod-b', run: () => { tbBold(); return true } },
{ key: 'Mod-i', run: () => { tbItalic(); return true } },
{ key: 'Mod-e', run: () => { tbCode(); return true } },
{ key: 'Mod-k', run: () => { openLink(); return true } }
]),
keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]),
markdownLang(),
cmTheme,
CMView.updateListener.of((u) => {
if (u.docChanged) form.content_md = u.state.doc.toString()
}),
CMView.domEventHandlers({
paste: (e, view) => handleEditorFiles(e.clipboardData?.files, view),
drop: (e, view) => {
if (e.dataTransfer?.files?.length) {
e.preventDefault()
return handleEditorFiles(e.dataTransfer.files, view)
}
return false
}
})
]
})
})
}
// 用 form.content_md 的当前值整段灌回(进入 md 页签、转存替换后调用)
function syncCM() {
if (!cmView) return
const cur = cmView.state.doc.toString()
if (cur === form.content_md) return
cmView.dispatch({
changes: { from: 0, to: cur.length, insert: form.content_md }
})
}
const showLinkInput = ref(false)
const showImageInput = ref(false)
const linkText = ref('')
@@ -394,22 +505,31 @@ const imageUrl = ref('')
const imageAlt = ref('')
function insertAtCursor(text, selStart, selEnd) {
if (cmView) {
if (selStart == null) {
const s = cmView.state.selection.main
selStart = s.from
selEnd = s.to
}
cmView.dispatch({
changes: { from: selStart, to: selEnd, insert: text },
selection: { anchor: selStart + text.length }
})
cmView.focus()
return
}
const before = form.content_md.slice(0, selStart)
const after = form.content_md.slice(selEnd)
form.content_md = before + text + after
nextTick(() => {
if (!mdPane.value) return
const newPos = selStart + text.length
mdPane.value.focus()
mdPane.value.setSelectionRange(newPos, newPos)
})
}
function withSelection(fn) {
const el = mdPane.value
const start = el ? el.selectionStart : form.content_md.length
const end = el ? el.selectionEnd : form.content_md.length
fn(start, end)
if (cmView) {
const sel = cmView.state.selection.main
fn(sel.from, sel.to)
return
}
fn(form.content_md.length, form.content_md.length)
}
function tbBold() {
@@ -494,6 +614,15 @@ function tbTask() {
function tbHr() {
withSelection((s, e) => insertAtCursor('\n---\n', s, e))
}
function tbTable() {
withSelection((s, e) =>
insertAtCursor(
'\n| 列 A | 列 B |\n| --- | --- |\n| 内容 | 内容 |\n| 内容 | 内容 |\n',
s,
e
)
)
}
function openLink() {
showLinkInput.value = true
@@ -520,20 +649,201 @@ function commitImage() {
showImageInput.value = false
}
// ---------- 上传(封面 / 编辑器插图共用) ----------
// toast 用共享的 ./toast(ToastStack 渲染在 AdminLayout)。
// 后台「高级 → 自定义 JS」无关;走 /api/admin/files(FormData)。
const uploadingImage = ref(false)
// 与后端 allowFileExt / 50MB 上限一致:上传前本地校验,省一趟白跑
const ALLOW_UPLOAD_EXT = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'avif', 'pdf', 'zip', 'txt']
const MAX_UPLOAD_MB = 50
function validateFile(file) {
const ext = (file.name.split('.').pop() || '').toLowerCase()
if (!ALLOW_UPLOAD_EXT.includes(ext)) {
return `不支持的类型 .${ext}(允许:${ALLOW_UPLOAD_EXT.join('/')})`
}
if (file.size > MAX_UPLOAD_MB * 1024 * 1024) {
return `${file.name} 超过 ${MAX_UPLOAD_MB}MB 上限`
}
return ''
}
// opts.cmView 传了就把结果以 markdown 图片插到该编辑器光标处(md 页签粘贴/拖拽)
async function uploadOne(file, opts = {}) {
const bad = validateFile(file)
if (bad) {
toast(bad, 'err')
throw new Error(bad)
}
const tid = toast(`上传 ${file.name} 0%`, 'info', 60000)
try {
const data = await adminApi.uploadFile(file, (p) =>
toastUpdate(tid, `上传 ${file.name} ${Math.round(p * 100)}%`)
)
const f = Array.isArray(data) ? data[0] : null
if (!f || !f.url) throw new Error('上传失败')
toastDone(tid, `${file.name} 上传完成`)
if (opts.cmView) {
const v = opts.cmView
const pos = v.state.selection.main.from
v.dispatch({ changes: { from: pos, insert: `![](${f.url})\n` } })
}
return f.url
} catch (e) {
toastUpdate(tid, `${file.name} 上传失败:${e.message || '未知错误'}`, 'err')
error.value = e.message || '上传失败'
throw e
}
}
// ---------- 外链图片转存 ----------
// xLog 的 Cloud:正文里的外链 <img> 一键抓回自己的存储(后端走 SSRF
// 防护拨号 + 同一套白名单/去重),成功后原地替换 markdown 里的 URL。
const importing = ref(false)
function externalImages(md) {
const own = [site.uploads_public_base, location.origin].filter(Boolean)
const out = []
const re = /!\[[^\]]*\]\((https?:\/\/[^)\s]+)\)/g
let m
while ((m = re.exec(md))) {
const u = m[1]
if (!own.some((b) => u.startsWith(b)) && !u.startsWith('/uploads/')) out.push(u)
}
return [...new Set(out)]
}
async function importExternal() {
if (importing.value) return
const urls = externalImages(form.content_md)
if (!urls.length) {
toast('正文里没有外链图片', 'info')
return
}
importing.value = true
const tid = toast(`转存外链图片 0/${urls.length}…`, 'info', 120000)
try {
const data = await adminApi.importFiles(urls)
// 后端按原址回:source -> 转存后的文件(同内容去重时可能是已有行)
const ok = new Map((data.files || []).map((e) => [e.source, e.file]))
const errs = data.errors || {}
let done = 0
let changed = false
form.content_md = form.content_md.replace(
/!\[([^\]]*)\]\((https?:\/\/[^)\s]+)\)/g,
(whole, alt, u) => {
if (!ok.has(u)) return whole
done++
toastUpdate(tid, `转存外链图片 ${done}/${urls.length}…`)
changed = true
return `![${alt}](${ok.get(u).url})`
}
)
if (changed) syncCM()
const fail = Object.keys(errs).length
if (fail) {
toastUpdate(tid, `转存完成:${done} 成功,${fail} 失败(${Object.values(errs)[0]})`, fail ? 'err' : 'ok')
} else {
toastDone(tid, `转存完成:${done} 张已入自己的存储`)
}
} catch (e) {
toastUpdate(tid, '转存失败:' + (e.message || ''), 'err')
} finally {
importing.value = false
}
}
const coverInput = ref(null)
async function onCoverPick(e) {
const file = e.target.files && e.target.files[0]
e.target.value = '' // 允许重复选同一个文件
if (!file) return
uploadingImage.value = true
try {
form.cover_url = await uploadOne(file)
} finally {
uploadingImage.value = false
}
}
const imageInput = ref(null)
async function onImagePick(e) {
const file = e.target.files && e.target.files[0]
e.target.value = ''
if (!file) return
uploadingImage.value = true
try {
imageUrl.value = await uploadOne(file)
if (!imageAlt.value) imageAlt.value = file.name.replace(/\.[^.]+$/, '')
commitImage()
} finally {
uploadingImage.value = false
}
}
// md 面板的粘贴 / 拖拽图片:上传后在原光标处插入 markdown。
// 上传是异步的,先记光标位置,逐张插入时用 pos 追踪偏移。
function captureSel() {
const el = mdPane.value
const s = el ? el.selectionStart : form.content_md.length
const e = el ? el.selectionEnd : s
return { s, e: Math.max(s, e) }
}
async function onMdFiles(files, sel) {
let pos = sel.s
for (const f of files) {
try {
const url = await uploadOne(f)
const md = `![](${url})\n`
insertAtCursor(md, pos, pos)
pos += md.length
} catch (_) {
/* uploadOne 已把错误写进 error */
}
}
}
function onMdPaste(e) {
const files = [...(e.clipboardData?.files || [])].filter((f) => f.type.startsWith('image/'))
if (!files.length) return
e.preventDefault()
onMdFiles(files, captureSel())
}
function onMdDrop(e) {
const files = [...(e.dataTransfer?.files || [])].filter((f) => f.type.startsWith('image/'))
if (!files.length) return
e.preventDefault()
onMdFiles(files, captureSel())
}
function insertDate() {
withSelection((s, e) => insertAtCursor(new Date().toISOString().slice(0, 10), s, e))
}
// 工具栏按钮的统一定义(用作 v-for 渲染)
// 快捷键的展示形态(xLog 同款):Mod 在 Mac 上显示 ⌘,其余显示 Ctrl
const isMac = typeof navigator !== 'undefined' && /Mac|iP(hone|ad|od)/.test(navigator.platform || '')
function keyDisplay(shortcut) {
if (!shortcut) return ''
return '(' + shortcut
.replace('Mod', isMac ? '⌘' : 'Ctrl')
.replace(/-([a-z])$/i, (_, c) => c.toUpperCase()) + ')'
}
const tbGroups = computed(() => [
{
label: '格式',
items: [
{ key: 'b', label: 'B', title: '加粗', run: tbBold, show: mode.value === 'md' },
{ key: 'i', label: 'I', title: '斜体', run: tbItalic, italic: true, show: mode.value === 'md' },
{ key: 's', label: 'S', title: '删除线', run: tbStrike, show: mode.value === 'md' },
{ key: 'code', label: '<>', title: '行内代码', run: tbCode, mono: true, show: mode.value === 'md' },
{ key: 'cb', label: '```', title: '代码块', run: tbCodeBlock, mono: true, show: mode.value === 'md' }
{ key: 'b', title: '加粗', shortcut: 'Mod-b', run: tbBold, show: mode.value === 'md',
icon: 'M6 4h8a4 4 0 0 1 0 8H6zM6 12h9a4 4 0 0 1 0 8H6z', fill: true },
{ key: 'i', title: '斜体', shortcut: 'Mod-i', run: tbItalic, show: mode.value === 'md',
icon: 'M19 4h-9M14 20H5M15 4L9 20' },
{ key: 's', title: '删除线', run: tbStrike, show: mode.value === 'md',
icon: 'M16 4H9a3 3 0 0 0-2.83 4M14 12a4 4 0 0 1 0 8H6M4 12h16' },
{ key: 'code', title: '行内代码', shortcut: 'Mod-e', run: tbCode, show: mode.value === 'md',
icon: 'M16 18l6-6-6-6M8 6l-6 6 6 6' },
{ key: 'cb', title: '代码块', run: tbCodeBlock, show: mode.value === 'md',
icon: 'M9 10l-2 2.5L9 15M15 10l2 2.5-2 2.5M4 5h16a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1z' }
]
},
{
@@ -541,19 +851,29 @@ const tbGroups = computed(() => [
items: [
{ key: 'h2', label: 'H2', title: '二级标题', run: tbH2, show: mode.value === 'md' },
{ key: 'h3', label: 'H3', title: '三级标题', run: tbH3, show: mode.value === 'md' },
{ key: 'q', label: '"', title: '引用', run: tbQuote, show: mode.value === 'md' },
{ key: 'ul', label: '•', title: '无序列表', run: tbUl, show: mode.value === 'md' },
{ key: 'ol', label: '1.', title: '有序列表', run: tbOl, show: mode.value === 'md' },
{ key: 'task', label: '☐', title: '任务列表', run: tbTask, show: mode.value === 'md' },
{ key: 'hr', label: '—', title: '分隔线', run: tbHr, show: mode.value === 'md' }
{ key: 'q', title: '引用', run: tbQuote, show: mode.value === 'md',
icon: 'M3 21c3 0 7-1 7-8V5c0-1.25-.76-2.02-2-2H4c-1.25 0-2 .75-2 1.97V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .01-1 1.03V20c0 1 0 1 1 1zM15 21c3 0 7-1 7-8V5c0-1.25-.76-2.02-2-2h-4c-1.25 0-2 .75-2 1.97V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .01-1 1.03V20c0 1 0 1 1 1z', fill: true },
{ key: 'ul', title: '无序列表', run: tbUl, show: mode.value === 'md',
icon: 'M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01' },
{ key: 'ol', title: '有序列表', run: tbOl, show: mode.value === 'md',
icon: 'M10 6h11M10 12h11M10 18h11M4 6h1v4M4 10h2M6 18H4c0-1 2-2 2-3s-1-1.5-2-1' },
{ key: 'task', title: '任务列表', run: tbTask, show: mode.value === 'md',
icon: 'M9 11l3 3L22 4M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11' },
{ key: 'hr', title: '分隔线', run: tbHr, show: mode.value === 'md',
icon: 'M5 12h14' }
]
},
{
label: '插入',
items: [
{ key: 'link', label: '🔗', title: '链接', run: openLink, show: mode.value === 'md' },
{ key: 'img', label: '🖼', title: '图片', run: openImage, show: mode.value === 'md' },
{ key: 'date', label: '📅', title: '插入今天日期', run: insertDate, show: mode.value === 'md' }
{ key: 'link', title: '链接', shortcut: 'Mod-k', run: openLink, show: mode.value === 'md',
icon: 'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71' },
{ key: 'img', title: '图片', run: openImage, show: mode.value === 'md',
icon: 'M21 15l-5-5L5 21M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zM8.5 8.5h.01' },
{ key: 'table', title: '表格', run: tbTable, show: mode.value === 'md',
icon: 'M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zM3 10h18M10 21V10' },
{ key: 'date', title: '插入今天日期', run: insertDate, show: mode.value === 'md',
icon: 'M16 2v4M8 2v4M3 10h18M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z' }
]
}
])
@@ -590,16 +910,24 @@ const tbGroups = computed(() => [
<div class="editor-grid">
<div>
<!-- kind switch + title -->
<div style="display: flex; gap: 6px; align-items: center; margin-bottom: 10px;">
<div class="editor-top-row">
<button class="chip" :class="{ on: form.kind === 'long' }" @click="form.kind = 'long'">长文</button>
<button class="chip" :class="{ on: form.kind === 'short' }" @click="form.kind = 'short'">短文</button>
<span style="font-size: 12px; color: var(--admin-muted); margin-left: 4px;">
<span class="kind-hint">
{{ form.kind === 'short' ? '一两段话,时间线里不显示标题' : '有标题、有结构,适合讲完整一件事' }}
</span>
<span style="flex: 1;"></span>
<span class="spacer"></span>
<div class="editor-mode">
<button :class="{ on: mode === 'wysiwyg' }" @click="switchMode('wysiwyg')">富文本</button>
<button :class="{ on: mode === 'md' }" @click="switchMode('md')">Markdown</button>
<button
class="btn"
:disabled="importing"
title="把正文里的外链图片抓回自己的存储"
@click="importExternal"
>
{{ importing ? '转存中…' : '⇲ 转存外链图' }}
</button>
</div>
</div>
@@ -619,11 +947,18 @@ const tbGroups = computed(() => [
:key="it.key"
type="button"
class="tb-btn"
:class="{ italic: it.italic, mono: it.mono }"
:title="it.title"
:title="it.title + keyDisplay(it.shortcut)"
:aria-label="it.title"
@click="it.run()"
>{{ it.label }}</button>
>
<svg v-if="it.icon" viewBox="0 0 24 24" aria-hidden="true"
:fill="it.fill ? 'currentColor' : 'none'"
:stroke="it.fill ? 'none' : 'currentColor'" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path :d="it.icon" />
</svg>
<span v-else class="tb-text">{{ it.label }}</span>
</button>
</div>
</div>
@@ -670,20 +1005,25 @@ const tbGroups = computed(() => [
aria-label="图片 URL"
@keydown.enter="commitImage"
/>
<button class="btn btn-primary" @click="commitImage">插入</button>
<button class="btn" @click="showImageInput = false">取消</button>
<div style="display: flex; gap: 6px;">
<button class="btn btn-primary" style="flex: 1;" @click="commitImage">插入</button>
<button class="btn" style="flex: 1;" :disabled="uploadingImage" @click="imageInput.click()">
{{ uploadingImage ? '上传中…' : '上传' }}
</button>
<button class="btn" @click="showImageInput = false">取消</button>
</div>
<input ref="imageInput" type="file" accept="image/*" hidden @change="onImagePick" />
</div>
<!-- editor area -->
<div v-show="mode === 'wysiwyg'" ref="editorEl"></div>
<textarea
v-show="mode === 'md'"
ref="mdPane"
class="md-pane"
v-model="form.content_md"
placeholder="直接写 Markdown…"
aria-label="Markdown 正文"
></textarea>
<!-- md 页签:CodeMirror 6(语法高亮/历史/粘贴拖拽上传),markdown 唯一真相源 -->
<div v-show="mode === 'md'" ref="mdPane" class="md-pane" aria-label="Markdown 正文"></div>
<!-- 轻提示栈 -->
<div class="toast-stack" aria-live="polite">
<div v-for="t in toasts" :key="t.id" class="toast" :class="t.type">{{ t.text }}</div>
</div>
<div class="editor-stats">
<span><strong>{{ cjkChars }}</strong> 汉字</span>
@@ -715,15 +1055,21 @@ const tbGroups = computed(() => [
<div v-if="form.cover_url" class="cover-mini" :style="{ backgroundImage: `url(${form.cover_url})` }">
<button class="x" @click="clearCover" aria-label="移除封面">×</button>
</div>
<input
id="cover"
v-model="form.cover_url"
placeholder="封面图 URL(可省)"
type="url"
inputmode="url"
spellcheck="false"
aria-label="封面图 URL"
/>
<div class="cover-row">
<input
id="cover"
v-model="form.cover_url"
placeholder="封面图 URL(可省)"
type="url"
inputmode="url"
spellcheck="false"
aria-label="封面图 URL"
/>
<button class="btn" type="button" :disabled="uploadingImage" @click="coverInput.click()">
{{ uploadingImage ? '上传中…' : '上传' }}
</button>
</div>
<input ref="coverInput" type="file" accept="image/*" hidden @change="onCoverPick" />
<p style="margin: 4px 0 0; font-size: 12px; color: var(--admin-muted);">回车或失焦即生效;前台卡片与详情页头图会用到。</p>
</div>
@@ -749,7 +1095,7 @@ const tbGroups = computed(() => [
></textarea>
</div>
<div class="field">
<div v-if="form.kind !== 'short'" class="field">
<label>标签</label>
<div v-if="form.tags.length" style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px;">
<span v-for="t in form.tags" :key="t" class="chip">
+320
View File
@@ -0,0 +1,320 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { toastOk, toastErr } from './toast'
import { useRoute, useRouter } from 'vue-router'
import { adminApi } from '../api'
import { relativeDate } from '../utils'
// 文件管理:上传(点击 / 拖拽,多选)、网格缩略图、复制链接、删除。
// 缩略图与链接都用后端解析好的 f.url(R2 直链或 /uploads 流式路由)。
const route = useRoute()
const router = useRouter()
const items = ref([])
const total = ref(0)
const loading = ref(true)
const error = ref('')
const page = computed(() => Number(route.query.page || 1))
const size = 24
const uploading = ref(false)
const uploadError = ref('')
const dragOver = ref(false)
const copiedId = ref(0)
const fileInput = ref(null)
let seq = 0
async function load() {
const my = ++seq
loading.value = true
error.value = ''
try {
const data = await adminApi.files({ page: page.value, q: String(route.query.q || '') })
if (my !== seq) return
items.value = data.items || []
total.value = data.total || 0
} catch (e) {
if (my !== seq) return
error.value = e.message || '加载失败'
items.value = []
} finally {
if (my === seq) loading.value = false
}
}
onMounted(load)
watch(() => route.query.page, load)
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / size)))
// ---------- 上传 ----------
async function upload(files) {
if (!files || !files.length || uploading.value) return
uploading.value = true
uploadError.value = ''
try {
const fd = new FormData()
for (const f of files) fd.append('file', f)
await adminApi.uploadFiles(fd)
toastOk(`已上传 ${files.length} 个文件`)
await load()
} catch (e) {
toastErr(e.message || '上传失败')
uploadError.value = e.message || '上传失败'
} finally {
uploading.value = false
}
}
function onPick(e) {
upload(e.target.files)
e.target.value = '' // 允许重复选同一个文件
}
function onDrop(e) {
dragOver.value = false
upload(e.dataTransfer.files)
}
// ---------- 操作 ----------
async function copyUrl(f) {
try {
await navigator.clipboard.writeText(f.url)
toastOk('链接已复制')
copiedId.value = f.id
setTimeout(() => {
if (copiedId.value === f.id) copiedId.value = 0
}, 2000)
} catch (_) {
// 非 https 环境没有 clipboard API:退回手动复制
window.prompt('复制此链接:', f.url)
}
}
// 引用位置的展示文案:文章/项目带标题,头像没有标题可指。
// 草稿也列出来——现在没发布,删了图将来发出来就是裂的。
function refLabel(x) {
if (x.kind === 'avatar') return '站主头像'
const head = x.kind === 'project' ? '项目' : '文章'
const name = x.title || x.slug || '未命名'
return `${head}《${name}》${x.status === 'draft' ? '(草稿)' : ''}`
}
async function remove(f) {
// 删除前查一次引用:后端默认会挡住被引用的文件(409),这里先把清单摊出来,
// 用户看完仍然要删才带 force=1 过去。
let refs = []
try {
refs = (await adminApi.fileRefs(f.id)).items || []
} catch (_) {
// 查询失败就按无引用走:真被引用时后端会返回 409,不会静默删掉在用文件
}
let tip = `删除「${f.name}」?存储里的文件会一并删除。`
if (refs.length) {
const list = refs.slice(0, 3).map(refLabel).join('、')
tip = `删除「${f.name}」?\n它正被 ${refs.length} 处引用:${list}${refs.length > 3 ? ' 等' : ''}。\n删掉这些地方会图裂。`
}
if (!window.confirm(tip)) return
try {
await adminApi.deleteFile(f.id, refs.length > 0)
toastOk(`已删除「${f.name}」`)
// 当前页删空时退一页,其余直接重载
if (items.value.length === 1 && page.value > 1) router.replace({ query: { ...route.query, page: page.value - 1 } })
else load()
} catch (e) {
toastErr(e.message || '删除失败')
error.value = e.message || '删除失败'
}
}
// ---------- 展示助手 ----------
function humanSize(n) {
if (n < 1024) return n + ' B'
if (n < 1048576) return (n / 1024).toFixed(1) + ' KB'
if (n < 1073741824) return (n / 1048576).toFixed(1) + ' MB'
return (n / 1073741824).toFixed(2) + ' GB'
}
function ext(name) {
const i = name.lastIndexOf('.')
return i < 0 ? '文件' : name.slice(i + 1).toUpperCase()
}
// 拖拽悬停态在离开窗口时复位
function onWindowDragEnd() {
dragOver.value = false
}
onMounted(() => window.addEventListener('dragend', onWindowDragEnd))
onBeforeUnmount(() => window.removeEventListener('dragend', onWindowDragEnd))
</script>
<template>
<section>
<header style="display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 14px;">
<h1 style="font-family: var(--serif); font-size: 22px;">
文件 <span style="font-family: var(--sans); font-size: 13px; color: var(--admin-muted); font-weight: 400;">{{ total }}</span>
</h1>
</header>
<div
class="dropzone"
:class="{ over: dragOver, uploading }"
role="button"
tabindex="0"
aria-label="上传文件"
@click="fileInput && fileInput.click()"
@keydown.enter="fileInput && fileInput.click()"
@dragover.prevent="dragOver = true"
@dragleave.prevent="dragOver = false"
@drop.prevent="onDrop"
>
<input ref="fileInput" type="file" multiple hidden accept=".jpg,.jpeg,.png,.webp,.gif,.avif,.pdf,.zip,.txt" @change="onPick" />
<span v-if="uploading">上传中…</span>
<span v-else>点击选择或把文件拖到这里上传 · 图片 / pdf / zip / txt · 单文件 ≤ 50MB</span>
</div>
<p v-if="uploadError" class="upload-error" role="alert">{{ uploadError }}</p>
<div v-if="loading" class="loading">载入中…</div>
<div v-else-if="error" class="empty">{{ error }}</div>
<div v-else-if="!items.length" class="empty">还没有上传过文件。</div>
<template v-else>
<div class="file-grid">
<div v-for="f in items" :key="f.id" class="file-card">
<div class="thumb">
<img
v-if="f.mime.startsWith('image/')"
:src="f.url"
alt=""
loading="lazy"
decoding="async"
referrerpolicy="no-referrer"
/>
<span v-else class="ph" aria-hidden="true">{{ ext(f.name) }}</span>
</div>
<div class="name" :title="f.name">{{ f.name }}</div>
<div class="meta">
<span>{{ humanSize(f.size) }}</span>
<span style="margin: 0 5px;" aria-hidden="true">·</span>
<span :title="f.created_at">{{ relativeDate(f.created_at) }}</span>
</div>
<div class="actions">
<a href="#" @click.prevent="copyUrl(f)">{{ copiedId === f.id ? '已复制 ✓' : '复制链接' }}</a>
<a :href="f.url" target="_blank" rel="noopener">打开</a>
<a href="#" class="danger" @click.prevent="remove(f)">删除</a>
</div>
</div>
</div>
<nav
v-if="pageCount > 1"
aria-label="分页"
style="display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 18px;"
>
<button class="btn" :disabled="page <= 1" @click="router.replace({ query: { ...route.query, page: page - 1 } })" aria-label="上一页">
← 上一页
</button>
<span style="font-size: 13px; color: var(--admin-muted);" aria-live="polite">第 {{ page }} / {{ pageCount }} 页</span>
<button class="btn" :disabled="page >= pageCount" @click="router.replace({ query: { ...route.query, page: page + 1 } })" aria-label="下一页">
下一页 →
</button>
</nav>
</template>
</section>
</template>
<style scoped>
.dropzone {
border: 1.5px dashed var(--admin-line);
border-radius: 6px;
padding: 22px;
text-align: center;
color: var(--admin-muted);
font-size: 13px;
margin-bottom: 16px;
cursor: pointer;
transition: border-color 0.2s, background-color 0.2s;
}
.dropzone:hover,
.dropzone.over {
border-color: var(--admin-accent);
background: color-mix(in srgb, var(--admin-accent) 6%, transparent);
}
.dropzone.uploading {
cursor: wait;
}
.upload-error {
color: var(--admin-danger);
font-size: 13px;
margin: -8px 0 12px;
}
.file-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
}
.file-card {
border: 1px solid var(--admin-line);
border-radius: 6px;
background: var(--admin-card);
padding: 10px;
}
.thumb {
height: 110px;
border-radius: 4px;
background: var(--admin-paper-sunken);
display: grid;
place-items: center;
overflow: hidden;
margin-bottom: 8px;
}
.thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.thumb .ph {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.05em;
color: var(--admin-faint);
}
.name {
font-size: 13px;
font-weight: 600;
color: var(--admin-ink);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta {
font-size: 11.5px;
color: var(--admin-muted);
margin: 3px 0 8px;
}
.actions {
display: flex;
gap: 10px;
font-size: 12.5px;
}
.actions a {
color: var(--admin-muted);
}
.actions a:hover {
color: var(--admin-accent);
}
.actions a.danger:hover {
color: var(--admin-danger);
}
</style>
+5
View File
@@ -1,5 +1,7 @@
<script setup>
import { ref } from 'vue'
import { toastOk, toastErr } from './toast'
import ToastStack from './ToastStack.vue'
import { useRouter } from 'vue-router'
import { adminApi, session } from '../api'
import { site } from '../site'
@@ -17,8 +19,10 @@ async function submit() {
const data = await adminApi.login(username.value, password.value)
session.user = username.value
void data
toastOk('登录成功')
router.push('/admin')
} catch (e) {
toastErr('用户名或密码不对')
error.value = '用户名或密码不对'
} finally {
busy.value = false
@@ -27,6 +31,7 @@ async function submit() {
</script>
<template>
<ToastStack />
<div class="page">
<form class="card" @submit.prevent="submit">
<p class="eyebrow">后台</p>
+3 -2
View File
@@ -1,5 +1,6 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { toastOk, toastErr } from './toast'
import { useRoute, useRouter } from 'vue-router'
import { adminApi } from '../api'
import { formatDateShort, relativeDate } from '../utils'
@@ -125,7 +126,7 @@ async function bulk(action) {
await adminApi.bulkPosts([...selected.value], action)
await load()
} catch (e) {
alert(e.message || '操作失败')
toastErr(e.message || '操作失败')
}
}
@@ -135,7 +136,7 @@ async function remove(id, title) {
await adminApi.deletePost(id)
await load()
} catch (e) {
alert(e.message || '删除失败')
toastErr(e.message || '删除失败')
}
}
+7 -6
View File
@@ -1,5 +1,6 @@
<script setup>
import { onMounted, ref } from 'vue'
import { toastOk, toastErr } from './toast'
import { adminApi } from '../api'
const projects = ref([])
@@ -40,7 +41,7 @@ onMounted(load)
async function create() {
if (!form.value.title.trim()) {
alert('请填写作品名称')
toastErr('请填写作品名称')
return
}
try {
@@ -56,7 +57,7 @@ async function create() {
form.value = blank()
await load()
} catch (e) {
alert(e.message || '创建失败')
toastErr(e.message || '创建失败')
}
}
@@ -81,7 +82,7 @@ function cancelEdit() {
async function saveEdit() {
if (!draft.value.title.trim()) {
alert('请填写作品名称')
toastErr('请填写作品名称')
return
}
try {
@@ -97,7 +98,7 @@ async function saveEdit() {
editing.value = null
await load()
} catch (e) {
alert(e.message || '保存失败')
toastErr(e.message || '保存失败')
}
}
@@ -107,7 +108,7 @@ async function toggleStatus(p) {
await adminApi.updateProject(p.id, { status: next })
await load()
} catch (e) {
alert(e.message || '更新失败')
toastErr(e.message || '更新失败')
}
}
@@ -117,7 +118,7 @@ async function remove(p) {
await adminApi.deleteProject(p.id)
await load()
} catch (e) {
alert(e.message || '删除失败')
toastErr(e.message || '删除失败')
}
}
</script>
+29 -24
View File
@@ -1,5 +1,6 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { toastOk, toastErr } from './toast'
import { adminApi } from '../api'
import { SKIN_CONSTANTS, UI_CONSTANTS } from '../site'
import { SECTIONS } from '../ui/sections'
@@ -35,14 +36,11 @@ function textToSocial(text) {
.filter(Boolean)
}
// 这里只能选亮色皮肤(paper / sage / rose)。暗色端固定 ink,由访客在前台
// 右下角 ThemeSwitcher 切到「深色」或「自动 + 系统暗」时启用。
// 皮肤已精简:亮端固定纸感,暗端固定墨色(2026 重设计)。下面只做展示。
const lightSkins = [
{ id: 'paper', label: '纸感(默认)', bg: '#faf7f1', fg: '#3d7f9c' },
{ id: 'sage', label: '鼠尾草', bg: '#eef0e6', fg: '#5f7b5c' },
{ id: 'rose', label: '玫瑰', bg: '#f7eee6', fg: '#a55a4a' }
{ id: 'paper', label: '纸感(默认)', bg: '#faf7f1', fg: '#3d7f9c' }
]
const darkSkin = { id: 'ink', label: '墨色', bg: '#1f1d1a', fg: '#c3b58f' }
const darkSkin = { id: 'ink', label: '墨色', bg: '#191817', fg: '#d9b97c' }
// ---------- 界面与自定义(第二套 UI) ----------
@@ -147,6 +145,8 @@ async function load() {
settings.value.ui_id = UI_CONSTANTS.DEFAULT
}
socialText.value = socialToText(settings.value.social_links)
settings.value.comments_enabled = !!settings.value.comments_enabled
settings.value.comments_review = !!settings.value.comments_review
// 服务端保证非 null,这里再兜一层,并预建分区键,
// 这样 v-model 绑定的文本框一开始就有值可写。
if (!settings.value.custom_css || typeof settings.value.custom_css !== 'object') {
@@ -184,7 +184,9 @@ async function save() {
}
}
savedAt.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
toastOk('设置已保存')
} catch (e) {
toastErr('保存失败:' + (e.message || ''))
error.value = e.message || '保存失败'
} finally {
saving.value = false
@@ -218,18 +220,17 @@ async function save() {
<label>站点副标题</label>
<input v-model="settings.site_desc" class="input" spellcheck="false" />
</div>
<div class="field">
<label>作者昵称</label>
<input v-model="settings.author_name" class="input" spellcheck="false" />
</div>
<div class="field">
<label>作者简介</label>
<textarea v-model="settings.author_bio" class="textarea" rows="3" />
</div>
<!-- 作者昵称/简介不在这里改:它们和头像、登录方式同属「账户」,
两处编辑同一个字段迟早漂移,所以收归账户页独占。 -->
<div class="field">
<label>页脚备注</label>
<input v-model="settings.footer_note" class="input" spellcheck="false" />
</div>
<p class="field-hint">
作者昵称、简介与头像在
<RouterLink to="/admin/account">账户</RouterLink>
页设置(它们和登录方式属于同一件事,两处编辑同一个字段迟早漂移)。
</p>
</div>
<!-- theme -->
@@ -239,19 +240,12 @@ async function save() {
<div class="field">
<label>亮色皮肤</label>
<div class="theme-swatches">
<div
v-for="sk in lightSkins"
:key="sk.id"
class="sw"
:class="{ on: settings.light_skin_id === sk.id }"
:style="{ background: sk.bg, borderColor: settings.light_skin_id === sk.id ? sk.fg : 'var(--admin-line)' }"
@click="settings.light_skin_id = sk.id"
>
<span :style="{ background: sk.fg }">{{ sk.label }}</span>
<div class="sw on" :style="{ background: lightSkins[0].bg, borderColor: lightSkins[0].fg }" :title="lightSkins[0].label + '(固定)'">
<span :style="{ background: lightSkins[0].fg }">{{ lightSkins[0].label }}</span>
</div>
</div>
<p style="margin: 6px 0 0; font-size: 12px; color: var(--admin-muted);">
访客在前台选择「自动」且系统偏好浅色时使用。
亮端固定为纸感。访客在前台选择「自动」且系统偏好浅色时使用。
</p>
</div>
@@ -382,6 +376,17 @@ async function save() {
<label>ICP 备案号</label>
<input v-model="settings.icp" class="input" spellcheck="false" placeholder="如 京 ICP 备 12345678 号" />
</div>
<div class="field">
<label>评论</label>
<label style="display: flex; align-items: center; gap: 8px; font-size: 13.5px; color: var(--admin-ink); cursor: pointer;">
<input v-model="settings.comments_enabled" type="checkbox" />
开放评论(关着时前台整个评论区不渲染)
</label>
<label style="display: flex; align-items: center; gap: 8px; font-size: 13.5px; color: var(--admin-ink); cursor: pointer; margin-top: 6px;">
<input v-model="settings.comments_review" type="checkbox" />
新评论需审核,通过后公开
</label>
</div>
<div class="field">
<label>社交链接(每行一条:名称 | 链接)</label>
<textarea
+6 -5
View File
@@ -1,5 +1,6 @@
<script setup>
import { onMounted, ref } from 'vue'
import { toastOk, toastErr } from './toast'
import { adminApi } from '../api'
const tags = ref([])
@@ -30,7 +31,7 @@ async function create() {
newColor.value = ''
await load()
} catch (e) {
alert(e.message || '创建失败')
toastErr(e.message || '创建失败')
}
}
@@ -39,7 +40,7 @@ async function update(t) {
await adminApi.updateTag(t.id, { name: t.name, color: t.color })
await load()
} catch (e) {
alert(e.message || '更新失败')
toastErr(e.message || '更新失败')
}
}
@@ -49,7 +50,7 @@ async function remove(t) {
await adminApi.deleteTag(t.id)
await load()
} catch (e) {
alert(e.message || '删除失败')
toastErr(e.message || '删除失败')
}
}
@@ -69,7 +70,7 @@ function cancelMerge() {
async function doMerge(fromTag) {
const toId = Number(mergeInto.value)
if (!toId || toId === fromTag.id) {
alert('选一个不同的目标标签')
toastErr('选一个不同的目标标签')
return
}
if (!window.confirm(`把「${fromTag.name}」合并到选中的标签?此操作不可撤销。`)) return
@@ -78,7 +79,7 @@ async function doMerge(fromTag) {
cancelMerge()
await load()
} catch (e) {
alert(e.message || '合并失败')
toastErr(e.message || '合并失败')
}
}
</script>
+11
View File
@@ -0,0 +1,11 @@
<script setup>
// 后台共享 toast 的渲染层:挂在 AdminLayout(所有管理页)与 LoginView。
// 样式在全局 styles.css(.toast-stack / .toast),与编辑器共用一份。
import { toasts } from './toast'
</script>
<template>
<div class="toast-stack" aria-live="polite">
<div v-for="t in toasts" :key="t.id" class="toast" :class="t.type">{{ t.text }}</div>
</div>
</template>
+47
View File
@@ -0,0 +1,47 @@
import { reactive } from 'vue'
// 后台共享的轻提示:所有视图的操作反馈(成功/失败/进度)从这里发,
// ToastStack.vue 负责渲染(挂在 AdminLayout 与 LoginView)。
// EditorView 的上传/转存也走它——原先那套局部实现已并入。
export const toasts = reactive([])
let seq = 0
export function toast(text, type = 'info', ttl = 2600) {
const id = ++seq
toasts.push({ id, text, type })
setTimeout(() => {
const i = toasts.findIndex((t) => t.id === id)
if (i >= 0) toasts.splice(i, 1)
}, ttl)
return id
}
export function toastOk(text) {
return toast(text, 'ok', 2200)
}
export function toastErr(text) {
return toast(text, 'err', 4200)
}
// 更新一条已存在的 toast(上传进度用)
export function toastUpdate(id, text, type = 'info') {
const t = toasts.find((x) => x.id === id)
if (t) {
t.text = text
t.type = type
}
}
// 标记成功并很快消失(进度条收尾)
export function toastDone(id, text) {
toastUpdate(id, text, 'ok')
const idx = toasts.findIndex((t) => t.id === id)
if (idx >= 0) {
const t = toasts[idx]
setTimeout(() => {
const i = toasts.indexOf(t)
if (i >= 0) toasts.splice(i, 1)
}, 1600)
}
}
+77 -1
View File
@@ -82,7 +82,83 @@ export const adminApi = {
updateProject: (id, body) => request('/api/admin/projects/' + id, { method: 'PUT', body }),
deleteProject: (id) => request('/api/admin/projects/' + id, { method: 'DELETE' }),
settings: () => request('/api/admin/settings'),
saveSettings: (body) => request('/api/admin/settings', { method: 'PUT', body })
saveSettings: (body) => request('/api/admin/settings', { method: 'PUT', body }),
// ---------- 账户(资料 / 身份绑定 / passkey) ----------
account: () => request('/api/admin/account'),
saveAccount: (body) => request('/api/admin/account', { method: 'PATCH', body }),
unbindIdentity: (provider) =>
request('/api/admin/account/identities/' + encodeURIComponent(provider), { method: 'DELETE' }),
passkeyBegin: () => request('/api/admin/account/passkeys/begin', { method: 'POST' }),
passkeyFinish: (body) => request('/api/admin/account/passkeys/finish', { method: 'POST', body }),
deletePasskey: (id) => request('/api/admin/account/passkeys/' + id, { method: 'DELETE' }),
// 前台 passkey 登录(公开端点)
passkeyLoginBegin: () => request('/api/auth/passkey/begin', { method: 'POST' }),
passkeyLoginFinish: (body) => request('/api/auth/passkey/finish', { method: 'POST', body }),
// ---------- 评论管理 ----------
comments: (params = {}) => request('/api/admin/comments?' + new URLSearchParams(params)),
approveComment: (id) =>
request('/api/admin/comments/' + id, { method: 'PUT', body: { status: 'visible' } }),
deleteComment: (id) => request('/api/admin/comments/' + id, { method: 'DELETE' }),
readers: (params = {}) => request('/api/admin/readers?' + new URLSearchParams(params)),
setReaderBanned: (id, banned) =>
request('/api/admin/readers/' + id + '/ban', { method: 'POST', body: { banned } }),
// ---------- 文件上传 ----------
files: (params = {}) => request('/api/admin/files?' + new URLSearchParams(params)),
// 删除前查引用(被哪些文章/项目/站主头像用着);force 用于明知有引用仍要删
fileRefs: (id) => request('/api/admin/files/' + id + '/refs'),
deleteFile: (id, force = false) =>
request('/api/admin/files/' + id + (force ? '?force=1' : ''), { method: 'DELETE' }),
// 上传走 FormData:request() 是 JSON helper,这里单独 fetch。
// 401 同样广播 one:unauthorized,错误消息从 JSON body 里取(与 request 一致)。
// 单文件上传(XHR):fetch 拿不到上传进度,编辑器的进度提示走这里
uploadFile: (file, onProgress) =>
new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open('POST', base + '/api/admin/files')
xhr.withCredentials = true
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && onProgress) onProgress(e.loaded / e.total)
}
xhr.onload = () => {
if (xhr.status === 401) {
window.dispatchEvent(new CustomEvent('one:unauthorized'))
reject(new Error('未登录或登录已过期'))
return
}
let data = {}
try {
data = JSON.parse(xhr.responseText)
} catch {}
if (xhr.status >= 200 && xhr.status < 300) resolve(data)
else reject(new Error(data.error || `上传失败(${xhr.status})`))
}
xhr.onerror = () => reject(new Error('网络错误,上传中止'))
const fd = new FormData()
fd.append('file', file)
xhr.send(fd)
}),
// 外链转存:把正文里的外链图片抓回自己的存储,返回 { files, errors }
importFiles: (urls) => request('/api/admin/files/import', { method: 'POST', body: { urls } }),
uploadFiles: async (formData) => {
const res = await fetch(base + '/api/admin/files', {
method: 'POST',
credentials: 'include',
body: formData
})
if (res.status === 401) {
window.dispatchEvent(new CustomEvent('one:unauthorized'))
const err = new Error('未登录或登录已过期')
err.status = 401
throw err
}
const data = await res.json().catch(() => ({}))
if (!res.ok) {
const err = new Error(data.error || `上传失败(${res.status})`)
err.status = res.status
throw err
}
return data
}
}
// 读者(评论区)身份。会话是服务端 httpOnly cookie(one_reader),
+30
View File
@@ -0,0 +1,30 @@
<script setup>
import { computed } from 'vue'
import { site } from '../site'
import { thumbURL } from '../utils'
import LogoMark from './LogoMark.vue'
// 作者头像位:站主设了头像就用图,没设就回落到站标「一横」。
// 尺寸由外层容器决定(和 LogoMark 一样靠 width/height:100%)。
//
// 走缩略图路由:头像位最大也才 64px,没必要拉原图。
// thumbURL 的签名是 (url, width) —— 公开域名由 site.js 经 setThumbBase 注入,
// 不在参数里传。
const url = computed(() => thumbURL(site.author_avatar_url, 128))
</script>
<template>
<img v-if="url" class="author-avatar" :src="url" alt="" decoding="async" />
<LogoMark v-else />
</template>
<style scoped>
.author-avatar {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
flex-shrink: 0;
border-radius: 50%;
}
</style>
+211
View File
@@ -0,0 +1,211 @@
<script setup>
import { onBeforeUnmount, watch } from 'vue'
import { lightboxState as st, closeLightbox, stepLightbox } from '../lightbox'
// 图片预览层(手机重做版):App.vue 全局挂一次。
//
// 手机上出过两次事故,这次的实现原则:
// 1. 不碰 body 的 overflow——之前用 inline overflow 锁背景,把样式表的
// overflow-x: clip 覆盖成 hidden,横向从「裁剪」变「可滚动容器」,
// 整页被拖宽。现在滚动封锁靠遮罩自己的 @wheel/@touchmove.prevent:
// 事件不落到页面里,背景自然滚不动,滚动位置也天然保留。
// 2. 图片尺寸全部用百分比(max-width/height: 100%),不引入 vw/vh/dvh——
// 这些单位在全面屏 URL 栏收展时各有各的坑,遮罩本身就是视口大小。
// 3. 手势全归遮罩:touch-action: none + touchend 位移判滑动翻页,
// 双击缩放/长按菜单/下拉刷新这些浏览器手势都不会来捣乱。
// 4. 桌面能力不变:Esc / 方向键 / 点空白关闭 / 悬停箭头。
function onKey(e) {
if (e.key === 'Escape') closeLightbox()
if (e.key === 'ArrowLeft') stepLightbox(-1)
if (e.key === 'ArrowRight') stepLightbox(1)
}
// 吸顶栏带 backdrop-filter,真机上其合成层会盖在遮罩之上
// (z-index 更高也拦不住)——开预览时整个藏掉
watch(
() => st.open,
(v) => {
if (v) window.addEventListener('keydown', onKey)
else window.removeEventListener('keydown', onKey)
document.body.classList.toggle('lb-open', v)
}
)
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKey)
document.body.classList.remove('lb-open')
})
// 滑动翻页:横向位移超 44px 且明显大于纵向才算一次滑动
let touchX = 0
let touchY = 0
function onTouchStart(e) {
touchX = e.touches[0].clientX
touchY = e.touches[0].clientY
}
function onTouchEnd(e) {
if (st.list.length < 2) return
const dx = e.changedTouches[0].clientX - touchX
const dy = e.changedTouches[0].clientY - touchY
if (Math.abs(dx) > 44 && Math.abs(dx) > Math.abs(dy) * 1.4) {
stepLightbox(dx < 0 ? 1 : -1)
}
}
</script>
<template>
<Teleport to="body">
<Transition name="lb" :duration="220">
<div
v-if="st.open"
class="lb-overlay"
@click="closeLightbox"
@touchstart.passive="onTouchStart"
@touchend.passive="onTouchEnd"
@wheel.prevent
@touchmove.prevent
>
<img class="lb-img" :src="st.list[st.index]" alt="" @click.stop />
<button v-if="st.list.length > 1" class="lb-nav prev" type="button" aria-label="上一张" @click.stop="stepLightbox(-1)">‹</button>
<button v-if="st.list.length > 1" class="lb-nav next" type="button" aria-label="下一张" @click.stop="stepLightbox(1)">›</button>
<button class="lb-close" type="button" aria-label="关闭预览" @click="closeLightbox">×</button>
<span v-if="st.list.length > 1" class="lb-count">{{ st.index + 1 }} / {{ st.list.length }}</span>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.lb-overlay {
position: fixed;
inset: 0;
z-index: 130;
background: rgba(12, 11, 10, 0.92);
display: flex;
align-items: center;
justify-content: center;
/* 遮罩全权处理手势:杜绝双击缩放/长按菜单/下拉刷新来添乱 */
touch-action: none;
}
/* 百分比尺寸:遮罩即视口,图片永远不会超出屏幕半像素 */
.lb-img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: 6px;
box-shadow: 0 30px 80px -20px rgba(0, 0, 0, 0.6);
user-select: none;
-webkit-user-drag: none;
}
.lb-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 44px;
height: 44px;
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.85);
font-size: 26px;
line-height: 1;
cursor: pointer;
display: grid;
place-items: center;
padding-bottom: 4px;
transition: background-color 0.15s, color 0.15s;
}
.lb-nav:hover {
background: rgba(255, 255, 255, 0.18);
color: #fff;
}
.lb-nav.prev {
left: 22px;
}
.lb-nav.next {
right: 22px;
}
.lb-close {
position: absolute;
top: 18px;
right: 22px;
width: 38px;
height: 38px;
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.85);
font-size: 17px;
line-height: 1;
cursor: pointer;
transition: background-color 0.15s, color 0.15s;
}
.lb-close:hover {
background: rgba(255, 255, 255, 0.18);
color: #fff;
}
.lb-count {
position: absolute;
bottom: 18px;
left: 50%;
transform: translateX(-50%);
color: rgba(255, 255, 255, 0.7);
font-size: 12.5px;
letter-spacing: 0.08em;
font-variant-numeric: tabular-nums;
}
/* 手机:滑动翻页为主,箭头贴边缩小;位置全走安全区 */
@media (pointer: coarse) {
.lb-img {
border-radius: 0;
}
.lb-nav {
width: 40px;
height: 40px;
font-size: 22px;
background: rgba(255, 255, 255, 0.12);
}
.lb-nav.prev {
left: calc(6px + env(safe-area-inset-left));
}
.lb-nav.next {
right: calc(6px + env(safe-area-inset-right));
}
.lb-close {
top: calc(10px + env(safe-area-inset-top));
right: calc(10px + env(safe-area-inset-right));
width: 42px;
height: 42px;
}
.lb-count {
bottom: calc(14px + env(safe-area-inset-bottom));
}
}
.lb-enter-active {
transition: opacity 0.2s ease;
}
.lb-leave-active {
transition: opacity 0.18s ease;
}
.lb-enter-from,
.lb-leave-to {
opacity: 0;
}
</style>
+8 -3
View File
@@ -3,6 +3,7 @@ import { computed } from 'vue'
import { site } from '../site'
import { socialIcon } from '../socialIcons'
import ThemeSwitcher from './ThemeSwitcher.vue'
import LogoMark from './LogoMark.vue'
// vivid 用顶部导航(VividNav)代替左栏。这里直接不渲染。
const isVivid = computed(() => site.ui_id === 'vivid')
@@ -15,7 +16,7 @@ const social = computed(() => (site.social_links || []).filter((s) => s && s.url
<aside v-if="!isVivid" class="rail-left">
<div class="inner">
<RouterLink to="/" class="brand">
<span class="mark">○</span>
<span class="mark"><LogoMark /></span>
<span class="name">{{ site.site_title || 'ONE' }}</span>
</RouterLink>
<p class="desc">{{ site.site_desc }}</p>
@@ -86,9 +87,13 @@ const social = computed(() => (site.social_links || []).filter((s) => s && s.url
gap: 8px;
}
/* 原来这里是一个排版字符 ○ 充当标记,换成真站标。
flex:none:它是 .brand(flex)的子项,不锁住会被长站名压成椭圆 */
.mark {
color: var(--accent);
font-size: 18px;
width: 22px;
height: 22px;
flex: none;
color: var(--ink);
}
.name {
+127
View File
@@ -0,0 +1,127 @@
<script setup>
import { ref } from 'vue'
// 链接预览卡片:正文里第一个外链在发布时由后端抓好(posts.link_card),
// 这里只负责展示。没有可展示内容时父组件根本不会渲染它。
//
// 用 div[role=link] 而不是 <a>:vivid 的整条短文包在 RouterLink(一个 <a>)里,
// 再嵌一个 <a> 是非法 HTML。也因此点击必须 preventDefault——
// 只 stop 的话祖先 <a> 的默认激活行为照样会把点击变成跳转。
const props = defineProps({
card: { type: Object, required: true }
})
const imgBroken = ref(false)
function open() {
window.open(props.card.url, '_blank', 'noopener,noreferrer')
}
</script>
<template>
<div
class="link-card"
role="link"
tabindex="0"
:title="card.url"
@click.prevent.stop="open"
@keydown.enter.prevent="open"
>
<div class="lc-text">
<span v-if="card.site" class="lc-site">{{ card.site }}</span>
<span v-if="card.title" class="lc-title">{{ card.title }}</span>
<span v-if="card.desc" class="lc-desc">{{ card.desc }}</span>
</div>
<img
v-if="card.image && !imgBroken"
class="lc-img"
:src="card.image"
alt=""
loading="lazy"
decoding="async"
referrerpolicy="no-referrer"
@error="imgBroken = true"
/>
</div>
</template>
<style scoped>
/* 颜色一律写成「vivid 变量优先、classic 变量兜底」:同一份组件在两套 UI 里
各归各的调色板,不必再维护第二份样式表。 */
.link-card {
display: flex;
align-items: stretch;
gap: 12px;
margin-top: 10px;
padding: 10px 12px;
border: 1px solid var(--v-line, var(--line-soft));
border-radius: var(--v-radius-sm, 10px);
background: var(--v-surface, var(--card));
cursor: pointer;
transition: border-color 0.15s ease, background 0.15s ease;
}
.link-card:hover {
border-color: var(--v-line-strong, var(--accent-line));
}
.link-card:focus-visible {
outline: 2px solid var(--v-accent, var(--accent));
outline-offset: 2px;
}
.lc-text {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
flex: 1;
}
.lc-site {
font-size: 11.5px;
letter-spacing: 0.04em;
text-transform: lowercase;
color: var(--v-muted, var(--faint));
/* 主机名是最典型的不可断行长 token(子域一长串),不给它 anywhere
就会把卡片左列顶宽、整行溢出 */
overflow-wrap: anywhere;
}
.lc-title {
font-size: 15px;
line-height: 1.45;
font-weight: 600;
color: var(--v-text, var(--ink));
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.lc-desc {
font-size: 13px;
line-height: 1.6;
color: var(--v-text-soft, var(--muted));
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.lc-img {
flex: 0 0 auto;
width: 84px;
height: 84px;
object-fit: cover;
border-radius: var(--v-radius-sm, 8px);
background: var(--v-surface-2, var(--paper-sunken));
}
@media (max-width: 520px) {
.lc-img {
width: 64px;
height: 64px;
}
}
</style>
+26
View File
@@ -0,0 +1,26 @@
<script setup>
// 站标「一横」:墨圆 + 一道白横 —— 字母 O 与汉字「一」同形,对应站名 ONE / 一个博客。
//
// 横杠是 fill-rule="evenodd" 挖出来的真洞,不是叠一块纸色矩形,所以任何底色
// (纸感 / 墨色暗端 / 后台卡片)都直接透过去,不必为皮肤各出一版。
// 颜色跟随 currentColor,尺寸由外层容器决定。
</script>
<template>
<svg class="logo-mark" viewBox="0 0 64 64" aria-hidden="true" focusable="false">
<path
fill="currentColor"
fill-rule="evenodd"
d="M32 4a28 28 0 1 0 0 56 28 28 0 1 0 0-56Zm-16 23.5h32a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H16a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1Z"
/>
</svg>
</template>
<style scoped>
.logo-mark {
display: block;
width: 100%;
height: 100%;
flex-shrink: 0;
}
</style>
+121 -20
View File
@@ -1,7 +1,13 @@
<script setup>
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { site } from '../site'
import { relativeDate, stripTags, minutesLabel, sanitizeHtml } from '../utils'
import { openLightbox } from '../lightbox'
import { relativeDate, stripTags, minutesLabel, sanitizeHtml, thumbURL, thumbifyHtml } from '../utils'
import LinkCard from './LinkCard.vue'
import AuthorAvatar from './AuthorAvatar.vue'
const router = useRouter()
const props = defineProps({
post: { type: Object, required: true }
@@ -9,10 +15,18 @@ const props = defineProps({
const isShort = computed(() => props.post.kind === 'short')
// 时间线里短文不铺全文,超过 5 行折叠;长文只显示摘要
// 整卡可点进详情(Twitter 式);标签 / 配图自己会停冒泡不误跳。
// 用户在选中文字时不跳转(复制内容是第一意图)。
function openPost() {
if (window.getSelection()?.toString()) return
router.push(`/post/${props.post.slug}`)
}
// 时间线里短文不铺全文,超过 5 行折叠;长文只显示摘要。
// 正文里的 <img> 换缩略图并补懒加载(详情仍是原图)。
const body = computed(() => {
if (!isShort.value) return props.post.summary || stripTags(props.post.content_html || '')
return sanitizeHtml(props.post.content_html)
return sanitizeHtml(thumbifyHtml(props.post.content_html))
})
// 长文没写摘要时,从正文里截一段纯文本
@@ -21,12 +35,13 @@ const summaryText = computed(() => {
return text.length > 110 ? text.slice(0, 110) + '…' : text
})
const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUpperCase())
// 配图缩略图宽度:单图全宽格取宽一些,多图小格 480 足够(2x 屏也清晰)
const gridW = computed(() => (props.post.images && props.post.images.length === 1 ? 720 : 480))
</script>
<template>
<article class="row-feed" :class="{ short: isShort }">
<div class="avatar" aria-hidden="true">{{ initial }}</div>
<article class="row-feed" :class="{ short: isShort }" @click="openPost">
<div class="avatar"><AuthorAvatar /></div>
<div class="body">
<div class="byline">
@@ -42,13 +57,38 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
<h2 class="title">{{ post.title || '无题' }}</h2>
</RouterLink>
<!-- 长文:摘要;短文:正文直接铺开 -->
<!-- 长文:摘要 + 封面媒体位;短文:正文直接铺开 -->
<div v-if="isShort" class="prose short-body" v-html="body"></div>
<div v-else class="summary">{{ summaryText }}</div>
<template v-else>
<div class="summary">{{ summaryText }}</div>
<div v-if="post.cover_url" class="imgs cover">
<img :src="thumbURL(post.cover_url, 640)" alt="" loading="lazy" decoding="async" />
</div>
</template>
<!-- 短文配图:Twitter 式网格(1 全宽 / 2 并排 / 3 左大右二 / 4 四宫格);点图开预览 -->
<div
v-if="isShort && post.images && post.images.length"
class="imgs"
:class="'n' + Math.min(post.images.length, 4)"
>
<img
v-for="(u, i) in post.images.slice(0, 4)"
:key="i"
:src="thumbURL(u, gridW)"
alt=""
loading="lazy"
decoding="async"
@click.stop="openLightbox(post.images, i)"
/>
</div>
<!-- 外链预览卡片:正文里第一个链接,发布时后端已抓好 -->
<LinkCard v-if="isShort && post.link_card" :card="post.link_card" />
<div class="meta">
<RouterLink :to="`/post/${post.slug}`" class="read">
{{ isShort ? '查看' : '继续阅读' }} →
<RouterLink :to="`/post/${post.slug}`" class="read" :title="isShort ? '查看' : '阅读全文'">
<time :datetime="post.published_at">{{ relativeDate(post.published_at) }}</time>
</RouterLink>
<span v-if="!isShort" class="mins">{{ minutesLabel(post) }}</span>
<span v-if="post.tags && post.tags.length" class="tags">
@@ -57,6 +97,7 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
:key="t"
:to="`/tag/${encodeURIComponent(t)}`"
class="tag-chip"
@click.stop
>
{{ t }}
</RouterLink>
@@ -76,6 +117,7 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
padding: 14px 30px;
margin: 0 -16px;
border-bottom: 1px solid var(--line-soft);
cursor: pointer;
transition: background 0.15s ease;
}
@@ -83,18 +125,12 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
background: var(--paper-sunken);
}
/* 站标本身就是个圆,所以原来那层 accent-soft 圆底 / 衬线首字母都不需要了,
只留尺寸与颜色。想换回降饱和蓝只需改 color 一行。 */
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--accent-soft);
color: var(--accent);
display: flex;
align-items: center;
justify-content: center;
font-family: var(--serif);
font-size: 17px;
user-select: none;
color: var(--ink);
}
.byline {
@@ -154,6 +190,57 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
margin: 0;
}
/* 短文配图:Twitter 式网格。1 张全宽(限高裁切),2/4 等分,3 张左大右二 */
.imgs {
display: grid;
gap: 3px;
margin-top: 10px;
border-radius: 12px;
overflow: hidden;
}
.imgs img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
cursor: zoom-in;
}
.imgs.n1 img {
max-height: 440px;
}
.imgs.n2,
.imgs.n4 {
grid-template-columns: 1fr 1fr;
}
.imgs.n3 {
grid-template-columns: 1fr 1fr;
}
.imgs.n2 img,
.imgs.n4 img {
aspect-ratio: 1;
}
.imgs.n3 img {
aspect-ratio: 1;
}
.imgs.n3 img:first-child {
grid-row: span 2;
aspect-ratio: auto;
height: 100%;
}
/* 长文封面:与短文配图同款圆角媒体位,但点击语义是进文章而非预览 */
.imgs.cover img {
max-height: 340px;
cursor: pointer;
}
.meta {
display: flex;
align-items: center;
@@ -164,7 +251,13 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
color: var(--faint);
}
/* 时间戳即入口(整卡可点后的键盘 / 视觉兜底):平时弱化,hover 亮起 */
.read {
color: var(--faint);
transition: color 0.15s ease;
}
.read:hover {
color: var(--accent);
}
@@ -174,11 +267,19 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
gap: 6px;
}
/* 出血必须跟 .shell 的页边距同步:styles.css 里 .shell 在 640px 断点从 20px
收到 12px,这里就得同时从 -16px 收到 -12px。之前写的是 520px,于是
521~640 这段(窄窗口 / 横屏手机)出血比父容器内边距多 4px,整页横向溢出。 */
@media (max-width: 640px) {
.row-feed {
margin: 0 -12px;
}
}
@media (max-width: 520px) {
.row-feed {
grid-template-columns: 34px minmax(0, 1fr);
padding: 16px 12px;
margin: 0 -12px;
}
.avatar {
+144 -13
View File
@@ -1,6 +1,7 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { adminApi } from '../api'
import AuthorAvatar from './AuthorAvatar.vue'
// Twitter 式短文发布盒,挂在时间线顶部。
//
@@ -17,20 +18,49 @@ import { adminApi } from '../api'
const emit = defineEmits(['published'])
const shown = ref(false)
const userName = ref('')
const draft = ref('')
const publishing = ref(false)
const error = ref('')
const sheetOpen = ref(false)
const initial = computed(() => (userName.value || 'O').trim().slice(0, 1).toUpperCase())
const canPost = computed(() => draft.value.trim().length > 0 && !publishing.value)
// 配图:选图即上传(走 /api/admin/files),最多 4 张,发布时随帖子带上
const images = ref([]) // [{ url, name }]
const MAX_IMAGES = 4
const imgInput = ref(null)
const imgUploading = ref(false)
function pickImages() {
imgInput.value?.click()
}
async function onImagesPick(e) {
const files = [...(e.target.files || [])].slice(0, MAX_IMAGES - images.value.length)
e.target.value = '' // 允许分批续选
if (!files.length) return
imgUploading.value = true
error.value = ''
try {
const fd = new FormData()
for (const f of files) fd.append('file', f)
const data = await adminApi.uploadFiles(fd)
for (const f of data) images.value.push({ url: f.url, name: f.name })
} catch (e2) {
error.value = e2.message || '图片上传失败'
} finally {
imgUploading.value = false
}
}
function removeImage(i) {
images.value.splice(i, 1)
}
const canPost = computed(
() => (draft.value.trim().length > 0 || images.value.length > 0) && !publishing.value
)
onMounted(async () => {
try {
const d = await adminApi.me()
if (d.user) {
userName.value = String(d.user)
shown.value = true
}
} catch {
@@ -53,9 +83,11 @@ async function publish() {
await adminApi.createPost({
kind: 'short',
content_md: draft.value.trim(),
status: 'published'
status: 'published',
images: images.value.map((i) => i.url)
})
draft.value = ''
images.value = []
sheetOpen.value = false
emit('published')
} catch (e) {
@@ -76,16 +108,33 @@ async function publish() {
<!-- 桌面:内联 -->
<div class="qs-inline">
<div class="qs-card">
<span class="qs-av">{{ initial }}</span>
<span class="qs-av"><AuthorAvatar /></span>
<div class="qs-body">
<textarea v-model="draft" class="qs-input" rows="2" placeholder="记点短的…" @keydown="onKey" />
<div v-if="images.length" class="qs-thumbs">
<div v-for="(im, i) in images" :key="im.url" class="qs-thumb">
<img :src="im.url" alt="">
<button type="button" class="x" :aria-label="`移除 ${im.name}`" @click="removeImage(i)">×</button>
</div>
</div>
<div class="qs-acts">
<button
type="button"
class="qs-img-btn"
:disabled="images.length >= MAX_IMAGES || imgUploading"
title="添加图片(最多 4 张)"
aria-label="添加图片"
@click="pickImages"
>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>
</button>
<span v-if="error" class="qs-err">{{ error }}</span>
<span v-else class="qs-tip">Ctrl / ⌘ + Enter 发布</span>
<button type="button" class="qs-send" :disabled="!canPost" @click="publish">
{{ publishing ? '发布中…' : '发布' }}
</button>
</div>
<input ref="imgInput" type="file" accept="image/*" multiple hidden @change="onImagesPick" />
</div>
</div>
</div>
@@ -102,13 +151,30 @@ async function publish() {
<button type="button" class="qs-close" aria-label="关闭" @click="sheetOpen = false">✕</button>
</div>
<textarea v-model="draft" class="qs-input" rows="4" placeholder="记点短的…" @keydown="onKey" />
<div v-if="images.length" class="qs-thumbs">
<div v-for="(im, i) in images" :key="im.url" class="qs-thumb">
<img :src="im.url" alt="">
<button type="button" class="x" :aria-label="`移除 ${im.name}`" @click="removeImage(i)">×</button>
</div>
</div>
<div class="qs-acts">
<button
type="button"
class="qs-img-btn"
:disabled="images.length >= MAX_IMAGES || imgUploading"
title="添加图片(最多 4 张)"
aria-label="添加图片"
@click="pickImages"
>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>
</button>
<span v-if="error" class="qs-err">{{ error }}</span>
<span v-else class="qs-tip">Ctrl / ⌘ + Enter 发布</span>
<button type="button" class="qs-send" :disabled="!canPost" @click="publish">
{{ publishing ? '发布中…' : '发布' }}
</button>
</div>
<input ref="imgInput" type="file" accept="image/*" multiple hidden @change="onImagesPick" />
</div>
</div>
</Transition>
@@ -129,17 +195,12 @@ async function publish() {
background: var(--card);
}
/* 站标自身是圆,去掉原来的 accent-soft 圆底与首字母字号;flex:none 防被压扁 */
.qs-av {
flex: none;
width: 34px;
height: 34px;
border-radius: 50%;
background: var(--accent-soft);
color: var(--accent);
display: grid;
place-items: center;
font-weight: 700;
font-size: 14px;
color: var(--ink);
}
.qs-body {
@@ -201,6 +262,76 @@ async function publish() {
cursor: default;
}
/* 配图:缩略图条 + 添加按钮(最多 4 张,Twitter 同款) */
.qs-thumbs {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.qs-thumb {
position: relative;
width: 64px;
height: 64px;
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--line);
}
.qs-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.qs-thumb .x {
position: absolute;
top: 2px;
right: 2px;
width: 16px;
height: 16px;
border: 0;
border-radius: 50%;
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: 11px;
line-height: 1;
cursor: pointer;
display: grid;
place-items: center;
}
.qs-img-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 0;
border-radius: 6px;
background: none;
color: var(--muted);
cursor: pointer;
transition: color 0.15s, background-color 0.15s;
}
.qs-img-btn:hover:not(:disabled) {
color: var(--accent);
background: var(--accent-soft);
}
.qs-img-btn:disabled {
opacity: 0.4;
cursor: default;
}
.qs-img-btn svg {
width: 18px;
height: 18px;
fill: currentColor;
}
/* 手机:FAB 常驻右下,点开上拉 sheet */
.qs-fab {
display: none;
+125 -20
View File
@@ -1,13 +1,42 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { publicApi } from '../api'
import { site } from '../site'
import { socialIcon } from '../socialIcons'
import { relativeDate } from '../utils'
import { relativeDate, stripTags, thumbURL } from '../utils'
// vivid 是单栏布局,右栏整体不渲染(省掉 latest/tags 两次请求)。
const isVivid = computed(() => site.ui_id === 'vivid')
// 文章页目录(PostView 传入 headings;空数组时不渲染这张卡)。
// 高亮跟随滚动:视口上方最近的那个标题算「当前」。
const props = defineProps({
toc: { type: Array, default: () => [] }
})
const activeId = ref('')
let tocObserver = null
function watchToc() {
if (tocObserver) tocObserver.disconnect()
if (!props.toc.length) return
tocObserver = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (e.isIntersecting) activeId.value = e.target.id
}
},
{ rootMargin: '0px 0px -65% 0px' }
)
nextTick(() => {
for (const t of props.toc) {
const el = document.getElementById(t.id)
if (el) tocObserver.observe(el)
}
})
}
watch(() => props.toc, watchToc, { deep: false })
onBeforeUnmount(() => tocObserver && tocObserver.disconnect())
// 项目源码仓库是固定入口(本站 git remote),不来自后台配置 —— 和版权一样
// 固定渲染在自己的那排;后台填的社交账号单独一排,URL 撞车的去重。
const PROJECT_REPO = 'https://git.gopher.ink/mirrors2/ONE'
@@ -27,6 +56,26 @@ const links = [
const latest = ref([])
const tags = ref([])
// 短文没有标题,侧栏拿正文开头当标识(flomo/Twitter 的做法),
// 纯图片短文显示占位;有配图时右侧带一张小缩略图。
const EXCERPT_LEN = 22
function excerpt(p) {
if (p.kind !== 'short') return p.title || '无题'
// 先剥 <a>:贴链接的短文开头是 URL 本身,摘出来没有信息量;
// 剥完没字了(纯链接贴)就退到链接卡片的标题
const noLinks = (p.content_html || '').replace(/<a\b[^>]*>[\s\S]*?<\/a>/gi, ' ')
const text = stripTags(noLinks).replace(/\s+/g, ' ').trim()
if (text) return text.length > EXCERPT_LEN ? text.slice(0, EXCERPT_LEN) + '…' : text
if (p.link_card && p.link_card.title) {
const t = p.link_card.title
return t.length > EXCERPT_LEN ? t.slice(0, EXCERPT_LEN) + '…' : t
}
return p.images && p.images.length ? '📷 图片' : '短文'
}
function thumbOf(p) {
return p.kind === 'short' && p.images && p.images.length ? thumbURL(p.images[0], 160) : ''
}
onMounted(async () => {
if (isVivid.value) return
try {
@@ -42,6 +91,15 @@ onMounted(async () => {
<template>
<aside v-if="!isVivid" class="rail-right">
<section v-if="toc.length" class="card toc-card">
<p class="eyebrow">目录</p>
<ul class="toc-list">
<li v-for="t in toc" :key="t.id" :class="{ h3: t.level >= 3, on: activeId === t.id }">
<a :href="'#' + t.id" :class="{ on: activeId === t.id }">{{ t.text }}</a>
</li>
</ul>
</section>
<section class="card">
<p class="eyebrow">关于这里</p>
<p class="bio">{{ site.author_bio || site.site_desc }}</p>
@@ -52,12 +110,22 @@ onMounted(async () => {
<p class="eyebrow">最近更新</p>
<ul class="list">
<li v-for="p in latest" :key="p.id">
<RouterLink :to="`/post/${p.slug}`" class="row">
<span class="title">
{{ p.kind === 'short' ? '短文' : p.title || '无题' }}
<span v-if="p.kind === 'short'" class="badge">短</span>
<RouterLink :to="`/post/${p.slug}`" class="row" :class="{ withThumb: thumbOf(p) }">
<span class="row-main">
<span class="title">
{{ excerpt(p) }}
<span v-if="p.kind === 'short'" class="badge">短</span>
</span>
<span class="date">{{ relativeDate(p.published_at) }}</span>
</span>
<span class="date">{{ relativeDate(p.published_at) }}</span>
<img
v-if="thumbOf(p)"
class="thumb"
:src="thumbOf(p)"
alt=""
loading="lazy"
decoding="async"
/>
</RouterLink>
</li>
</ul>
@@ -78,15 +146,6 @@ onMounted(async () => {
已删:右栏在窄屏会堆到信息流下方,卡片之后正好接页脚小字,
所有宽度都不缺位。 -->
<footer class="rail-foot">
<nav class="foot-links" aria-label="页脚导航">
<template v-for="(l, i) in links" :key="l.to">
<span v-if="i" class="dot">·</span>
<RouterLink :to="l.to">{{ l.label }}</RouterLink>
</template>
<span class="dot">·</span>
<a href="/rss.xml">RSS</a>
</nav>
<!-- 后台填的社交账号:单独一排 -->
<div v-if="social.length" class="social-row">
<a
v-for="s in social"
@@ -102,6 +161,14 @@ onMounted(async () => {
<span v-else>{{ s.label }}</span>
</a>
</div>
<nav class="foot-links" aria-label="页脚导航">
<template v-for="(l, i) in links" :key="l.to">
<span v-if="i" class="dot">·</span>
<RouterLink :to="l.to">{{ l.label }}</RouterLink>
</template>
<span class="dot">·</span>
<a href="/rss.xml">RSS</a>
</nav>
<!-- 项目信息:固定的源码图标 + 版权,永远在 -->
<div class="foot-meta">
<a class="social-icon" :href="PROJECT_REPO" title="项目源码" aria-label="项目源码" target="_blank" rel="noopener">
@@ -145,6 +212,24 @@ onMounted(async () => {
color: var(--accent);
}
/* 文章目录:当前小节高亮,层级缩进 */
.toc-list {
list-style: none;
margin: 8px 0 0;
padding: 0;
font-size: 13px;
line-height: 1.6;
}
.toc-list li {margin: 2px 0;
border-left: 2px solid transparent;
padding-left: 8px;}
.toc-list li.h3 {padding-left: 20px;
font-size: 12.5px;}
.toc-list li.on {border-left-color: var(--accent);}
.toc-list a {color: var(--muted);
text-decoration: none;}
.toc-list li.on a, .toc-list a:hover {color: var(--accent);}
.list {
list-style: none;
margin: 8px 0 0;
@@ -152,7 +237,9 @@ onMounted(async () => {
}
.row {
display: block;
display: flex;
align-items: center;
gap: 10px;
padding: 7px 0;
border-bottom: 1px dashed var(--line-soft);
}
@@ -161,10 +248,27 @@ onMounted(async () => {
border-bottom: 0;
}
/* 文字列吃满剩余宽度,缩略图固定在右 */
.row-main {
flex: 1;
min-width: 0;
}
.title {
display: block;
font-size: 14px;
line-height: 1.7;
overflow-wrap: anywhere;
}
.thumb {
flex: 0 0 auto;
width: 34px;
height: 34px;
object-fit: cover;
border-radius: 6px;
background: var(--card);
border: 1px solid var(--line-soft);
}
.badge {
@@ -219,12 +323,13 @@ onMounted(async () => {
opacity: 0.6;
}
/* 图标 + 版权同排:源码等入口是单色小图标,hover 才亮成强调色 */
/* 社交按钮:页脚第一排居中 */
.social-row {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 2px;
justify-content: center;
gap: 6px;
margin-bottom: 10px;
}
.foot-meta {
+33 -9
View File
@@ -3,6 +3,7 @@ import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { site } from '../site'
import NavBurger from './NavBurger.vue'
import LogoMark from './LogoMark.vue'
// 手机端导航。原来把 5 个链接平铺在顶栏里:375px 挤成一排小字,
// 触点只有十几像素高,320px 的屏还会折行。改成 brand + 汉堡钮,
@@ -27,26 +28,36 @@ function isOn(l) {
return l.to === '/' ? route.path === '/' : route.path.startsWith(l.to)
}
// 下滑藏顶栏、上滑唤回。dead zone 滤掉手指微抖;滚回页首(没滚出顶栏多远)
// 或菜单开着时强制显示 —— 纸片锚在顶栏下面,顶栏藏了纸片会悬空。
// 上滑唤回要累计上移 40px 才算数:页面加载后字体/内容稳定时高度会收缩,
// 浏览器把滚动位置回弹几十像素,那种「假上滑」不该把顶栏拉回来。
// 下滑藏顶栏、上滑唤回。迟滞用「同向累计」:往复拖动时正负抵消,
// 持续同向滚动超过阈值才切换状态——之前的单事件方向判定
// (下滑 4px 就藏、回弹就现身)在阈值附近徘徊时会抽搐。
// 滚回页首(y ≤ 80)或菜单开着时强制显示 —— 纸片锚在顶栏下面,
// 顶栏藏了纸片会悬空。
let lastY = typeof window === 'undefined' ? 0 : window.scrollY
let hideY = 0
let acc = 0
function onScroll() {
const y = window.scrollY
if (open.value || y <= 80) {
hidden.value = false
lastY = y
acc = 0
return
}
const delta = y - lastY
lastY = y
if (delta > 4) {
if (delta === 0) return
// 同向累计;反向则从新方向重新累计(小幅往复互相抵消)
if (acc === 0 || (delta > 0) === (acc > 0)) {
acc += delta
} else {
acc = delta
}
if (!hidden.value && acc > 80 && y > 120) {
hidden.value = true
hideY = y
} else if (delta < -4 && hideY - y > 40) {
acc = 0
} else if (hidden.value && acc < -60) {
hidden.value = false
acc = 0
}
}
@@ -95,7 +106,10 @@ onBeforeUnmount(() => {
<template>
<!-- 窄屏时左栏收起,导航回到顶部;下滑隐藏、上滑唤回 -->
<header class="topbar" :class="{ hidden }">
<RouterLink to="/" class="brand">{{ site.site_title || 'ONE' }}</RouterLink>
<RouterLink to="/" class="brand">
<span class="mk"><LogoMark /></span>
<span>{{ site.site_title || 'ONE' }}</span>
</RouterLink>
<NavBurger
ref="burger"
class="burger"
@@ -169,6 +183,16 @@ onBeforeUnmount(() => {
font-family: var(--serif);
font-size: 17px;
color: var(--ink);
display: flex;
align-items: center;
gap: 8px;
}
.brand .mk {
width: 20px;
height: 20px;
flex: none;
color: var(--ink);
}
.burger {
@@ -25,14 +25,14 @@ const props = defineProps({
const s = props.api.s
const EDIT_WINDOW_MS = 10 * 60 * 1000
const name = computed(() => (props.c.author && props.c.author.name) || '已注销用户')
const name = computed(() => (props.c.user && props.c.user.name) || '已注销用户')
const relativeTime = computed(() => relativeDate(props.c.created_at))
const isOwner = computed(() => !!(props.c.author && props.c.author.is_owner))
const isMine = computed(() => !!(reader.user && props.c.author && reader.user.id === props.c.author.id))
const isOwner = computed(() => !!(props.c.user && props.c.user.provider === 'admin'))
const isMine = computed(() => !!(reader.user && props.c.user && reader.user.id === props.c.user.id))
// 外链:优先作者自填的 url,其次 GitHub 主页
const authorHref = computed(() => {
const a = props.c.author
const a = props.c.user
if (!a) return ''
if (a.url) return a.url
if (a.provider === 'github' && a.handle) return `https://github.com/${a.handle}`
@@ -41,12 +41,11 @@ const authorHref = computed(() => {
const canEdit = computed(() => {
if (props.c.is_deleted) return false
if (isOwner.value) return true
if (!isMine.value) return false
const t = new Date(props.c.created_at).getTime()
return Number.isFinite(t) && Date.now() - t <= EDIT_WINDOW_MS
})
const canDelete = computed(() => !props.c.is_deleted && (isMine.value || isOwner.value))
const canDelete = computed(() => !props.c.is_deleted && isMine.value)
const replying = computed(() => s.replyTo && s.replyTo.id === props.c.id)
const editing = computed(() => s.editing && s.editing.id === props.c.id)
@@ -118,7 +117,7 @@ const atInline = computed(
function replyNameOf(r) {
if (!r.parent_id || r.parent_id === props.c.id) return ''
const t = (props.c.replies || []).find((x) => x.id === r.parent_id)
return t ? (t.author && t.author.name) || '' : ''
return t ? (t.user && t.user.name) || '' : ''
}
</script>
@@ -126,15 +125,15 @@ function replyNameOf(r) {
<li class="cm-item" :class="{ 'is-deleted': c.is_deleted, 'is-reply': depth > 0 }">
<div class="cm-av" :class="{ 'is-owner': isOwner }">
<img
v-if="c.author && c.author.avatar_url"
:src="c.author.avatar_url"
v-if="c.user && c.user.avatar_url"
:src="c.user.avatar_url"
:alt="`${name} 的头像`"
loading="lazy"
>
<span v-else class="cm-av-fb">{{ name.slice(0, 1) }}</span>
<i v-if="c.author && c.author.provider" class="cm-pv" :title="PROVIDER_LABEL[c.author.provider] || c.author.provider">
<svg v-if="BRANDS[c.author.provider]" viewBox="0 0 24 24" aria-hidden="true">
<path :d="BRANDS[c.author.provider]" />
<i v-if="c.user && c.user.provider" class="cm-pv" :title="PROVIDER_LABEL[c.user.provider] || c.user.provider">
<svg v-if="BRANDS[c.user.provider]" viewBox="0 0 24 24" aria-hidden="true">
<path :d="BRANDS[c.user.provider]" />
</svg>
</i>
</div>
@@ -1,5 +1,5 @@
<script setup>
import { computed, nextTick, onMounted, ref } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { site } from '../../site'
import { reader, loadReader, signIn, signOut } from '../../reader'
import { readerApi } from '../../api'
@@ -20,7 +20,19 @@ const props = defineProps({
})
const api = useComments(props.postId)
const { s, remaining, load, setSort, submit, saveEdit, remove, loadMoreReplies, startReply, startEdit, cancel } = api
const { s, remaining, load, refresh, setSort, submit, saveEdit, remove, loadMoreReplies, startReply, startEdit, cancel } = api
// 新评论实时出现:订阅这篇文章的 SSE 频道,后端广播「评论变了」就静默刷新。
// EventSource 自带断线重连;路由换文章时关旧开新。
let stream = null
function openStream() {
if (stream) stream.close()
if (!props.postId || typeof EventSource === 'undefined') return
stream = new EventSource(`/api/comments/stream?post_id=${props.postId}`)
stream.addEventListener('comments', () => refresh())
}
watch(() => props.postId, openStream, { immediate: true })
onBeforeUnmount(() => stream && stream.close())
// 总开关在站点设置里。后端阶段二才会下发 comments_enabled —— 在那之前整个
// 区块不渲染,线上访客不会看到一个请求 404 的半成品;后端一加就自动出现。
@@ -117,7 +129,7 @@ onMounted(() => {
</div>
</div>
<!-- 登录卡 / 输入框 -->
<!-- 登录卡 / 禁言 / 输入框 -->
<div v-if="!reader.loaded" class="cm-gate cm-gate-skel" aria-hidden="true" />
<div v-else-if="!reader.user" class="cm-gate">
<p class="cm-gate-t">使用社交账号登录</p>
@@ -141,6 +153,10 @@ onMounted(() => {
<!-- Telegram 官方按钮落到这里 -->
<div ref="tgBox" class="cm-tg" />
</div>
<div v-else-if="reader.user.banned" class="cm-gate cm-gate-banned">
<p class="cm-gate-t">你已被禁言,暂时无法参与评论。</p>
<p class="cm-gate-hint">如有疑问请联系站主。</p>
</div>
<div v-else class="cm-compose">
<div class="cm-av cm-av-me">
<img v-if="reader.user.avatar_url" :src="reader.user.avatar_url" alt="" loading="lazy">
+19
View File
@@ -59,6 +59,24 @@ export function useComments(postId) {
load(true)
}
// SSE 触发的静默刷新:回到第一页按当前排序重拉,不亮加载态——
// 别人发了新评论时页面只是「悄悄长出」新内容,不该闪一下。
// 返回 true 表示列表真的变了(顶层条数有差异)。
async function refresh() {
try {
const data = await readerApi.comments(postId, { sort: s.sort, page: 1, size: PAGE_SIZE })
if (s.loading || s.loadingMore || s.replyTo || s.editing) return false
const items = (data && data.items) || []
const changed = (data && data.total) !== s.total || items.some((c, i) => !s.items[i] || s.items[i].id !== c.id || s.items[i].edited_at !== c.edited_at)
s.items = items
s.page = 1
s.total = (data && data.total) || 0
return changed
} catch {
return false
}
}
// 发表 / 回复。成功后直接插进列表,不整页重拉 —— 位置按当前排序决定,
// 用户刚发的东西必须马上看得见。
async function submit(bodyMd, parent = null) {
@@ -167,6 +185,7 @@ export function useComments(postId) {
s,
remaining,
load,
refresh,
setSort,
submit,
saveEdit,
+33
View File
@@ -0,0 +1,33 @@
import { reactive } from 'vue'
// 全站通用的图片预览(lightbox)。任何地方的配图都可以
// openLightbox(list, index) 打开:list 是同组图片的 URL 数组,index 是
// 点击的那张。Esc / 点空白 / 关闭钮收起,左右键或箭头在同组图片间切换。
//
// 注意:这里绝不能碰 body 的 inline overflow——样式表的 overflow-x: clip
// 会被整个 overflow: hidden 覆盖,横向从「裁剪」变「可滚动容器」,
// 手机上整页能被拖宽(真实事故)。背景滚动由 ImgLightbox 遮罩自己的
// @wheel/@touchmove.prevent 拦截,滚动位置天然保留。
const state = reactive({
list: [],
index: 0,
open: false
})
export const lightboxState = state
export function openLightbox(list, index = 0) {
if (!list || !list.length) return
state.list = list
state.index = Math.max(0, Math.min(index, list.length - 1))
state.open = true
}
export function closeLightbox() {
state.open = false
}
export function stepLightbox(delta) {
if (!state.open || state.list.length < 2) return
state.index = (state.index + delta + state.list.length) % state.list.length
}
+4 -1
View File
@@ -17,10 +17,13 @@ const routes = [
{ path: '', name: 'admin-dashboard', component: () => import('./admin/DashboardView.vue') },
{ path: 'posts', name: 'admin-posts', component: () => import('./admin/PostsView.vue') },
{ path: 'new', name: 'admin-new', component: () => import('./admin/EditorView.vue') },
{ path: 'files', name: 'admin-files', component: () => import('./admin/FilesView.vue') },
{ path: 'comments', name: 'admin-comments', component: () => import('./admin/CommentsView.vue') },
{ path: ':id', name: 'admin-edit', component: () => import('./admin/EditorView.vue') },
{ path: 'tags', name: 'admin-tags', component: () => import('./admin/TagsView.vue') },
{ path: 'projects', name: 'admin-projects', component: () => import('./admin/ProjectsView.vue') },
{ path: 'settings', name: 'admin-settings', component: () => import('./admin/SettingsView.vue') }
{ path: 'settings', name: 'admin-settings', component: () => import('./admin/SettingsView.vue') },
{ path: 'account', name: 'admin-account', component: () => import('./admin/AccountView.vue') }
]
},
+16 -6
View File
@@ -1,10 +1,11 @@
import { reactive, watch } from 'vue'
import { publicApi } from './api'
import { setThumbBase } from './utils'
// 暗色皮肤固定为 ink —— 是当前唯一支持的暗端皮肤。
const DARK_SKIN = 'ink'
// 亮色皮肤的合法集合(后台 Settings 也只让站主从这里选)
const LIGHT_SKINS = ['paper', 'sage', 'rose']
const LIGHT_SKINS = ['paper']
// 客户端显示模式 —— 持久化在 localStorage
const MODE_KEY = 'one.themeMode'
@@ -52,7 +53,7 @@ function systemPrefersDark() {
// 模式 → 实际 data-theme 值
//
// light / auto 亮端 → site.light_skin_id(站主在后台选的"基础亮色皮肤")
// light / auto 亮端 → 固定纸感(paper);皮肤选择已精简,暗端固定墨色
// dark → ink(强制暗色)
//
// 后台的"基础亮色皮肤"是访客在任何「浅色偏好」下都会看到的;
@@ -96,11 +97,16 @@ export const site = reactive({
custom_css: {},
// 站主自定义 JS(统计脚本等),公开页注入一次,/admin 不注入
custom_js: '',
// 上传文件的公开域名(R2 直链前缀)。前端靠它判断哪些图片可以转本站缩略图,
// 也用它解析头像 URL。
uploads_public_base: '',
// 站主头像的解析结果(后端按 owner_avatar_key + 存储端算出来;空=未设置)
author_avatar_url: '',
themeMode: readMode(),
loaded: false
})
// data-theme → 前台皮肤。classic: paper/sage/rose/ink;vivid: vivid/vivid-dark
// data-theme → 前台皮肤。classic: paper/ink(亮端固定纸感);vivid: vivid/vivid-dark
// data-admin-theme → 后台明暗(light / dark),跟 site.themeMode 同步
// data-ui → 整站 UI 版本(classic / vivid)
//
@@ -128,7 +134,7 @@ function applyTheme(animate = false) {
setTimeout(() => el.classList.remove('theme-anim'), 300)
}
const isDark = site.themeMode === 'dark' || (site.themeMode === 'auto' && systemPrefersDark())
// vivid 自带亮/暗两套调色板,不复用 paper/sage/rose —— 否则两套 token 会互相打架。
// vivid 自带亮/暗两套调色板,不复用 classic 的纸感/墨色 —— 否则两套 token 会互相打架。
const vivid = !adminScope && site.ui_id === 'vivid'
const theme = vivid
? (isDark ? 'vivid-dark' : 'vivid')
@@ -152,8 +158,12 @@ if (typeof window !== 'undefined' && window.matchMedia) {
export async function loadSite() {
try {
const data = await publicApi.site()
Object.assign(site, data)
const resp = await publicApi.site()
// 响应结构:{ settings: {…}, uploads_public_base }
const { uploads_public_base, settings } = resp || {}
setThumbBase(uploads_public_base)
site.uploads_public_base = uploads_public_base || ''
Object.assign(site, settings)
// 防御性:服务端返回的 light_skin_id 必须是白名单之一,否则兜底 paper
if (!isValidLightSkin(site.light_skin_id)) site.light_skin_id = 'paper'
// ui_id 同理;同时回写本地缓存,让下一次首屏不用等接口就知道该用哪套 UI
+303 -84
View File
@@ -31,56 +31,34 @@
--col-main: 640px;
--col-right: 264px;
--gutter: 28px;}
:root[data-theme='ink'] {--paper: #1f1d1a;
--card: #25221e;
--paper-sunken: #2b2722;
--ink: #ece5d2;
--ink-soft: #c3b58f;
--muted: #8c8270;
--faint: #6a6155;
--line: #3a352e;
--line-soft: #2f2b25;
--accent: #c3a972;
--accent-soft: rgba(195, 169, 114, 0.14);
--accent-line: rgba(195, 169, 114, 0.4);}
:root[data-theme='sage'] {--paper: #eef0e6;
--card: #f6f7ef;
--paper-sunken: #e2e6d6;
--ink: #2a3127;
--ink-soft: #475042;
--muted: #6f7a68;
--faint: #94a08d;
--line: #d6dcc9;
--line-soft: #e6e9d9;
--accent: #5f7b5c;
--accent-soft: rgba(95, 123, 92, 0.12);
--accent-line: rgba(95, 123, 92, 0.38);}
:root[data-theme='rose'] {--paper: #f7eee6;
--card: #fdf5ee;
--paper-sunken: #ecdfd3;
--ink: #3a2922;
--ink-soft: #5e463d;
--muted: #9a7c6f;
--faint: #b89c91;
--line: #ead9cb;
--line-soft: #f0e2d5;
--accent: #a55a4a;
--accent-soft: rgba(165, 90, 74, 0.1);
--accent-line: rgba(165, 90, 74, 0.38);}
:root[data-admin-theme='dark'] {--admin-bg: #1a1815;
:root[data-theme='ink'] {/* 墨:暖炭底、暖白字、鎏金点缀。比旧版更中性(褪掉黄褐相),
层次靠三档灰阶拉开;强调金提亮提纯,暗底上对比更足 */
--paper: #191817;
--card: #201e1c;
--paper-sunken: #262420;
--ink: #e7e3da;
--ink-soft: #b8b2a6;
--muted: #867f73;
--faint: #5f594f;
--line: #34302b;
--line-soft: #292623;
--accent: #d9b97c;
--accent-soft: rgba(217, 185, 124, 0.12);
--accent-line: rgba(217, 185, 124, 0.4);}
:root[data-admin-theme='dark'] {--admin-bg: #191817;
--admin-paper: var(--admin-bg);
--admin-card: #25221e;
--admin-paper-sunken: #2f2b25;
--admin-ink: #ece5d2;
--admin-ink-soft: #c3b58f;
--admin-muted: #9a8f7e;
--admin-faint: #6e6557;
--admin-line: #3a352e;
--admin-line-soft: #2f2b25;
--admin-accent: #c3a972;
--admin-accent-soft: rgba(195, 169, 114, 0.16);
--admin-accent-line: rgba(195, 169, 114, 0.42);
--admin-on-accent: #1a1815;
--admin-card: #201e1c;
--admin-paper-sunken: #262420;
--admin-ink: #e7e3da;
--admin-ink-soft: #b8b2a6;
--admin-muted: #948c7f;
--admin-faint: #635c51;
--admin-line: #34302b;
--admin-line-soft: #292623;
--admin-accent: #d9b97c;
--admin-accent-soft: rgba(217, 185, 124, 0.16);
--admin-accent-line: rgba(217, 185, 124, 0.42);
--admin-on-accent: #191817;
--admin-danger: #d68d75;
--admin-shadow: 0 4px 14px rgba(0, 0, 0, 0.4);
--admin-overlay: rgba(0, 0, 0, 0.6);}
@@ -222,9 +200,18 @@ html.topbar-hidden {--topbar-h: 0px;}
}
.btn:disabled {opacity: 0.5;
cursor: not-allowed;}
/* 编辑器封面:URL 输入框 + 上传按钮同行 */
.cover-row {display: flex;
gap: 6px;
align-items: center;}
.cover-row > input {flex: 1;
min-width: 0;}
.prose {font-size: 16.5px;
line-height: var(--lh);
color: var(--ink);}
color: var(--ink);
/* anywhere 而非 break-word:长 URL/长 token 要参与 min-content 计算,
否则时间线正文里的裸链接会把列撑破(真机上浏览器缩放适配后整页缩小+右侧留白) */
overflow-wrap: anywhere;}
.prose > :first-child {margin-top: 22px;}
.prose p {margin: 0 0 1.1em;}
.prose > p:first-of-type::first-letter {float: left;
@@ -248,6 +235,17 @@ html.topbar-hidden {--topbar-h: 0px;}
margin-top: 8px;
background: var(--accent);}
.prose h4 {font-size: 16.5px;}
/* 悬停浮现的标题锚点(enhanceProse 注入,指向 heading id) */
.prose .h-anchor {opacity: 0;
margin-left: 8px;
font-family: var(--mono);
font-size: 0.72em;
color: var(--faint);
text-decoration: none;
border-bottom: 0;
transition: opacity .2s;}
.prose h2:hover .h-anchor, .prose h3:hover .h-anchor, .prose h4:hover .h-anchor {opacity: 1;}
.prose .h-anchor:hover {color: var(--accent);}
.prose blockquote {margin: 1.4em 0;
padding: 2px 0 2px 18px;
border-left: 2px solid var(--accent-line);
@@ -267,12 +265,32 @@ html.topbar-hidden {--topbar-h: 0px;}
background: var(--paper-sunken);
padding: 1px 5px;
border-radius: 3px;}
.prose .pre-wrap {position: relative;}
.pre-copy {position: absolute;
top: 8px;
right: 8px;
z-index: 1;
padding: 3px 10px;
border: 0;
border-radius: 5px;
font-size: 12px;
color: inherit;
background: color-mix(in srgb, var(--ink) 14%, transparent);
opacity: 0;
cursor: pointer;
transition: opacity .15s, transform .08s;}
.pre-copy:hover {opacity: 1;}
.pre-wrap:hover .pre-copy {opacity: 0.85;}
.pre-copy:active {transform: translateY(2px);}
.pre-copy.done {opacity: 1;
color: var(--accent);}
.prose pre {background: var(--paper-sunken);
border: 1px solid var(--line-soft);
border-radius: 4px;
padding: 14px 16px;
overflow-x: auto;
line-height: 1.7;}
border-radius: 10px;
padding: 16px 18px;
overflow: auto;
line-height: 1.7;
max-height: 30.5em;}
.prose pre code {background: none;
padding: 0;
font-size: 13.5px;}
@@ -283,14 +301,22 @@ html.topbar-hidden {--topbar-h: 0px;}
background: var(--line);
margin: 2em 0;}
.prose table {width: 100%;
border-collapse: collapse;
margin: 1.4em 0;
font-size: 15px;}
.prose th, .prose td {border: 1px solid var(--line);
padding: 7px 10px;
border-collapse: separate;
border-spacing: 0;
margin: 1.6em 0;
font-size: 14.5px;
border: 1px solid var(--line);
border-radius: 10px;
overflow: hidden;}
.prose th, .prose td {border: 0;
border-bottom: 1px solid var(--line-soft);
padding: 11px 16px;
text-align: left;}
.prose tr > * + * {border-left: 1px solid var(--line-soft);}
.prose tbody tr:last-child td {border-bottom: 0;}
.prose th {background: var(--paper-sunken);
font-weight: 600;}
.prose tbody tr:nth-child(even) {background: color-mix(in srgb, var(--paper-sunken) 55%, transparent);}
.prose-short {font-size: 20px;
line-height: 1.9;}
.prose-short > p:first-of-type::first-letter {float: none;
@@ -298,6 +324,32 @@ html.topbar-hidden {--topbar-h: 0px;}
line-height: inherit;
margin: 0;
color: inherit;}
/* 时间线里的短文同样不放大首字:一句话的纸条压个大字像截断
(详情页 .prose-short 同此;长文详情的首字下沉保留) */
.prose.short-body > p:first-of-type::first-letter {float: none;
font-size: inherit;
line-height: inherit;
font-family: inherit;
margin: 0;
color: inherit;}
/* 短文配图:Twitter 式网格(快发盒上传,≤4 张;详情页用) */
.post-imgs {display: grid;
gap: 3px;
margin-top: 18px;
border-radius: 6px;
overflow: hidden;}
.post-imgs img {display: block;
width: 100%;
height: 100%;
object-fit: cover;}
.post-imgs.n2, .post-imgs.n4 {grid-template-columns: 1fr 1fr;}
.post-imgs.n3 {grid-template-columns: 1fr 1fr;}
.post-imgs.n2 img, .post-imgs.n4 img {aspect-ratio: 1;}
.post-imgs.n3 img {aspect-ratio: 1;}
.post-imgs.n3 img:first-child {grid-row: span 2;
aspect-ratio: auto;
height: 100%;}
.post-imgs.n1 img {max-height: 560px;}
.empty {padding: 48px 0;
text-align: center;
color: var(--muted);
@@ -450,15 +502,12 @@ html.topbar-hidden {--topbar-h: 0px;}
width: 100%;
margin: 0 auto;}
@media (max-width: 860px) {
.admin-shell {grid-template-columns: 1fr;}
.admin-side {position: static;
height: auto;
border-right: 0;
border-bottom: 1px solid var(--admin-line);}
.admin-side .nav {flex-direction: row;
flex-wrap: wrap;
padding: 8px;}
.admin-side .group {display: none;}
/* minmax(0,1fr) 而非裸 1fr:裸 1fr 带 min-content 下限,内容宽的页面会把
整条轨道连页面一起撑出横向滚动。align-content:start:shell 的
min-height:100dvh 会把 auto 行拉伸填满屏幕,行高回到内容高度。
侧栏本身在 ≤860px 变成抽屉(规则在文件末尾「后台 · 手机端适配」段)。 */
.admin-shell {grid-template-columns: minmax(0, 1fr);
align-content: start;}
.admin-content {padding: 20px 16px 60px;}
}
@media (max-width: 780px) {
@@ -685,12 +734,21 @@ html.topbar-hidden {--topbar-h: 0px;}
cursor: pointer;}
.post-card.has-check {grid-template-columns: 24px 96px minmax(0, 1fr) 110px 90px;}
@media (max-width: 760px) {
/* has-check 必须一起覆写:它(0,2,0)写在上面,单列 .post-card(0,1,0) 压不过它,
5 列会被 390px 挤成一字一行。操作链接保留(藏掉就删不了文章),
挪到正文下面一行;字数/时长列是次要信息,手机上藏掉。 */
.post-card {grid-template-columns: 64px minmax(0, 1fr);}
.post-card.has-check {grid-template-columns: 24px 64px minmax(0, 1fr);}
.post-card .cover {width: 64px;
height: 64px;}
.post-card .stats, .post-card .actions {display: none;}
.post-card .stats {display: none;}
.post-card .actions {grid-column: 2 / -1;
grid-row: 2;
flex-direction: row;
gap: 14px;}
}
.bulk-bar {display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
padding: 10px 14px;
@@ -803,24 +861,44 @@ html.topbar-hidden {--topbar-h: 0px;}
0%, 100% {opacity: 1;}
50% {opacity: 0.3;}
}
/* md 页签容器现在是 CodeMirror 挂载点(原为 textarea) */
.md-pane {width: 100%;
min-height: 460px;
border: 1px solid var(--admin-line);
border-radius: 3px;
background: var(--admin-card);
padding: 18px 22px;
font-family: var(--mono);
font-size: 14px;
line-height: 1.7;
padding: 6px 14px;
color: var(--admin-ink);
resize: vertical;
overflow: hidden;}
.md-pane:focus-within {border-color: var(--admin-accent);}
/* 轻提示栈:上传进度 / 转存结果 / 校验失败 */
.toast-stack {position: fixed;
right: 18px;
bottom: 18px;
z-index: 90;
display: flex;
flex-direction: column;
gap: 8px;
max-width: 360px;}
.toast {padding: 9px 14px;
border-radius: 6px;
font-size: 13px;
line-height: 1.6;
color: var(--admin-ink);
background: var(--admin-card);
border: 1px solid var(--admin-line);
box-shadow: 0 6px 24px rgba(0,0,0,.12);
overflow-wrap: anywhere;}
.toast.ok {border-color: color-mix(in srgb, var(--admin-accent) 55%, transparent);}
.toast.err {border-color: #c26a55;
color: #b4553f;
outline: none;}
.md-pane:focus {border-color: var(--admin-accent);}
.toolbar {display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
padding: 6px 8px;
gap: 0 10px;
padding: 4px 6px;
border: 1px solid var(--admin-line);
border-bottom: 0;
border-radius: 3px 3px 0 0;
@@ -836,12 +914,13 @@ html.topbar-hidden {--topbar-h: 0px;}
color: var(--admin-muted);
margin-right: 4px;
text-transform: uppercase;}
.tb-btn {width: 28px;
height: 28px;
/* xLog 式工具栏按钮:方形圆角、线性图标、悬停出底框 */
.tb-btn {width: 32px;
height: 32px;
border: 1px solid transparent;
border-radius: 3px;
border-radius: 6px;
background: transparent;
color: var(--admin-ink-soft);
color: var(--admin-muted);
cursor: pointer;
font-size: 12.5px;
display: inline-flex;
@@ -854,9 +933,12 @@ html.topbar-hidden {--topbar-h: 0px;}
border-color: var(--admin-line);
color: var(--admin-ink);}
}
.tb-btn.italic {font-style: italic;}
.tb-btn.mono {font-family: var(--mono);
font-size: 11.5px;}
.tb-btn svg {width: 17px;
height: 17px;}
.tb-text {font-family: var(--mono);
font-size: 11.5px;
font-weight: 700;
color: inherit;}
.tb-popover {display: flex;
gap: 6px;
padding: 8px;
@@ -1351,4 +1433,141 @@ a.cm-name-a:hover {color: var(--accent);}
.cm-link {padding: 8px 2px;}
.cm-more-r {padding: 10px 0;}
.cm-input {font-size: 16px;}
}
}
/* 编辑器图片缩放把手:Milkdown 图片块 hover 底部出现拖拽条(上下拖调高度,
比例存进 markdown 的 alt)。默认半透明让访客知道它在那儿。 */
.admin-shell .image-resize-handle {opacity: 0.45;}
.admin-shell .image-wrapper:hover .image-resize-handle {opacity: 1;}
/* lightbox 打开时藏掉吸顶/固定栏:一是全屏观感,二是绕开真机上
backdrop-filter 元素合成层盖过遮罩的 Chromium 绘制问题 */
body.lb-open .sticky-head,
body.lb-open .topbar,
body.lb-open .reading-bar {visibility: hidden;}
/* classic 文章页阅读进度条:视口顶部细线,vivid 的同款能力 */
.reading-bar {position: fixed;
top: 0;
left: 0;
height: 2px;
width: 0;
background: var(--accent);
z-index: 60;
transition: width .1s linear;
pointer-events: none;}
/* ==========================================================================
后台 · 手机端适配
--------------------------------------------------------------------------
放在文件末尾:上面的 .editor-mode / .tb-btn / .tb-popover .input / .chip
等基础定义同特指度、位置更靠后,手机端覆写写在前面会被反压(chrome.css
同款老坑)。
========================================================================== */
/* 顶栏退出按钮:桌面隐藏,退出走侧栏 footer;≤860px 侧栏进抽屉后
退出固定在顶栏(显示规则在下面的 ≤860px 块)。 */
.admin-topbar .leave-m {display: none;
padding: 5px 12px;
font-size: 12.5px;
color: var(--admin-muted);
border: 1px solid var(--admin-line);
background: var(--admin-paper);
border-radius: 6px;
cursor: pointer;}
/* 顶栏 ☰:唤出侧栏抽屉,桌面隐藏。 */
.admin-topbar .menu-btn {display: none;
width: 36px;
height: 36px;
align-items: center;
justify-content: center;
padding: 0;
border: 1px solid var(--admin-line);
border-radius: 6px;
background: var(--admin-paper);
color: var(--admin-ink);
font-size: 15px;
cursor: pointer;}
.admin-topbar .crumbs {display: flex;
align-items: center;
gap: 10px;}
/* ≤860px:侧栏整体收成抽屉。横铺一行要横向滚、换行三行吃掉 230px+ 才见
内容,竖排抽屉两头都不占——☰ 唤出、点遮罩或 Esc 或跳转后收回。
宽度 264px 比桌面 224px 略宽:手机上项的可点区域更足。 */
@media (max-width: 860px) {
.admin-side {position: fixed;
top: 0;
bottom: 0;
left: 0;
width: 264px;
height: auto;
z-index: 120;
border-right: 1px solid var(--admin-line);
border-bottom: 0;
transform: translateX(-105%);
transition: transform 0.28s ease;}
.admin-side.open {transform: none;
box-shadow: var(--admin-shadow);}
.admin-side .group {display: block;}
.side-scrim {position: fixed;
inset: 0;
z-index: 110;
background: var(--admin-overlay);
opacity: 0;
pointer-events: none;
transition: opacity 0.25s ease;}
.side-scrim.on {opacity: 1;
pointer-events: auto;}
.admin-topbar {padding: 0 14px;}
.admin-topbar .menu-btn {display: inline-flex;}
.admin-topbar .leave-m {display: inline-block;}
.admin-content {padding: 16px 12px 60px;}
}
/* 手机端(≤720px)编辑器细节:⌘K 对触屏无意义藏掉 kbd;类型/模式切换条
收满一行,避免「转存外链图」被压成一字一行。 */
@media (max-width: 720px) {
.admin-topbar .cmd-hint kbd {display: none;}
/* Crepe 主题给左侧 +/拖拽手柄留了 120px 槽位,手机上吃掉三分之一宽度;
上下也各留 60px,第一行文字离头顶太远。触屏上把手柄藏掉(插块走 /
斜杠菜单),四边收紧到 16px,正文贴边、第一行上移;特指度压过 crepe
frame 主题的 .milkdown .editor。手柄的 display:flex 是插件运行时写在
内联样式上的,必须 !important 才压得住。 */
.admin-content .milkdown .ProseMirror {padding: 16px;}
.admin-content .milkdown-block-handle {display: none !important;}
.editor-top-row .kind-hint,
.editor-top-row .spacer {display: none;}
.editor-mode {display: flex;
width: 100%;}
.editor-mode button {flex: 1;
white-space: nowrap;}
/* 链接/图片输入条:一行塞不下两组输入框,改上下堆叠 */
.tb-popover {flex-wrap: wrap;}
.tb-popover .input {flex: 1 1 100%;}
.tb-popover > .btn {flex: 1;}
/* 字数统计条放得下、不挤爆 */
.editor-stats {flex-wrap: wrap;
row-gap: 4px;}
}
/* 触屏:iOS 聚焦时字号 <16px 的输入框会触发页面自动放大——
CodeMirror 正文与链接/图片输入条都显式压过字号,这里抬回 16px。
顺带把工具栏按钮提到 40px,够得上指腹。 */
@media (pointer: coarse) {
.tb-btn {width: 38px;
height: 36px;}
.tb-btn svg {width: 18px;
height: 18px;}
.tb-popover .input {font-size: 16px;}
.md-pane .cm-content {font-size: 16px;}
.chip {padding: 8px 14px;}
}
/* 编辑器顶部行:改用类而不是行内样式,手机端才有得调(上面的 ≤720px 规则) */
.editor-top-row {display: flex;
gap: 6px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 10px;}
.editor-top-row .kind-hint {font-size: 12px;
color: var(--admin-muted);
margin-left: 4px;}
.editor-top-row .spacer {flex: 1;}
/* 富文本空态只有 64px 高,跟 Markdown 页签(.md-pane min-height:460px)差一截;
对齐到 460px,白底画在 .milkdown 上,min-height 就够撑起整块。 */
.admin-content .milkdown {min-height: 460px;}
+24 -2
View File
@@ -1,7 +1,9 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { sanitizeHtml } from '../../utils'
import { applyImageRatio, sanitizeHtml } from '../../utils'
import { openLightbox } from '../../lightbox'
import CommentSection from '../../components/comments/CommentSection.vue'
import LinkCard from '../../components/LinkCard.vue'
// 余白文章页。对应设计稿 viewPost() 的三栏「纸」:
// 左 side-l : 时间线(当前篇前后各两篇,标签来自位置)
@@ -166,7 +168,27 @@ function onTocClick(e) {
<!-- 短文不渲染标题:正文第一句就是全部,再给个截断标题是重复 -->
<h1 v-if="isShort" class="sr-only">{{ title }}</h1>
<div class="prose" :class="{ 'prose-note': isShort }" v-html="sanitizeHtml(props.contentHtml)" />
<div class="prose" :class="{ 'prose-note': isShort }" v-html="applyImageRatio(sanitizeHtml(props.contentHtml))" />
<!-- 短文配图:Twitter 式网格(快发盒上传);点图开预览 -->
<div
v-if="isShort && props.post.images && props.post.images.length"
class="post-imgs"
:class="'n' + Math.min(props.post.images.length, 4)"
>
<img
v-for="(u, i) in props.post.images.slice(0, 4)"
:key="i"
:src="u"
alt=""
loading="lazy"
decoding="async"
@click="openLightbox(props.post.images, i)"
/>
</div>
<!-- 外链预览卡片:正文里第一个链接,发布时后端已抓好 -->
<LinkCard v-if="isShort && props.post.link_card" :card="props.post.link_card" />
<div class="end">
<RouterLink v-for="t in props.post.tags || []" :key="t" class="tg" to="/tags">{{ t }}</RouterLink>
+26 -4
View File
@@ -1,5 +1,8 @@
<script setup>
import { RouterLink } from 'vue-router'
import { openLightbox } from '../../lightbox'
import { thumbURL, thumbifyHtml } from '../../utils'
import LinkCard from '../../components/LinkCard.vue'
// 余白列表。对应设计稿的 <div class="feed">:长文是卡片(封面 + 标题 + 摘要 + 元信息 + 标签),
// 短文是纸条(直接铺正文,元信息在正文后)。两种密度混在同一条时间线里,这是设计稿的主张。
@@ -34,7 +37,7 @@ const shortTitle = (p) => plain(p.content_html).slice(0, 40) + '…'
<!-- 长文:卡片 -->
<RouterLink v-if="p.kind === 'long'" class="card" :to="`/post/${encodeURIComponent(p.slug)}`">
<div v-if="p.cover_url" class="cov">
<img :src="p.cover_url" alt="" loading="lazy">
<img :src="thumbURL(p.cover_url, 640)" alt="" loading="lazy">
</div>
<div class="body">
<h2>{{ p.title }}</h2>
@@ -51,9 +54,28 @@ const shortTitle = (p) => plain(p.content_html).slice(0, 40) + '…'
</div>
</RouterLink>
<!-- 短文:纸条,正文直接铺开 -->
<RouterLink v-else class="note" :to="`/post/${encodeURIComponent(p.slug)}`">
<div class="body" v-html="p.content_html" />
<!-- 短文:纸条,正文直接铺开;带图时桌面端左右分栏(.split,见 home.css) -->
<RouterLink v-else class="note" :class="{ split: p.images && p.images.length > 0 }" :to="`/post/${encodeURIComponent(p.slug)}`">
<div class="body" v-html="thumbifyHtml(p.content_html)" />
<!-- 短文配图:Twitter 式网格(快发盒上传);点图开预览(不跳详情) -->
<div
v-if="p.images && p.images.length"
class="imgs"
:class="'n' + Math.min(p.images.length, 4)"
>
<img
v-for="(u, i) in p.images.slice(0, 4)"
:key="i"
:src="thumbURL(u, p.images.length === 1 ? 720 : 480)"
alt=""
loading="lazy"
decoding="async"
@click.prevent.stop="openLightbox(p.images, i)"
/>
</div>
<!-- 外链预览卡片:正文里第一个链接,发布时后端已抓好 -->
<LinkCard v-if="p.link_card" :card="p.link_card" />
<div class="meta">
<span class="badge short">SHORT</span>
<time :datetime="p.published_at">{{ fmtDateTime(p.published_at) }}</time>
+29 -1
View File
@@ -1,6 +1,13 @@
<script setup>
import { RouterLink } from 'vue-router'
import { site } from '../../site'
import { socialIcon } from '../../socialIcons'
// 社交排只放站主自己的账号;源码仓库是「项目信息」,和 classic 页脚一样单独
// 成行挂在版权旁边(RightRail 的 .foot-meta)。混在一起会让人以为这是本站的
// 社交身份之一,而它其实是代码仓库入口。
const PROJECT_REPO = 'https://git.gopher.ink/mirrors2/ONE'
const social = (site.social_links || []).filter((s) => s && s.url && s.url !== PROJECT_REPO)
// 余白页脚。对应设计稿的 <footer class="foot">。
//
@@ -21,7 +28,20 @@ const links = [
<template>
<footer class="foot">
<div class="quote">What&rsquo;s left unsaid holds the most weight.</div>
<div>{{ site.footer_note }}<template v-if="site.icp"> · {{ site.icp }}</template></div>
<div v-if="social.length" class="foot-social">
<a
v-for="s in social"
:key="s.label + s.url"
:href="s.url"
:title="s.label"
:aria-label="s.label"
:target="s.url.startsWith('http') ? '_blank' : undefined"
:rel="s.url.startsWith('http') ? 'noopener' : undefined"
>
<svg v-if="socialIcon(s.url)" viewBox="0 0 24 24" aria-hidden="true"><path :d="socialIcon(s.url)" /></svg>
<span v-else>{{ s.label }}</span>
</a>
</div>
<div class="foot-nav">
<template v-for="(l, i) in links" :key="l.to">
<span v-if="i" class="sep">·</span>
@@ -30,5 +50,13 @@ const links = [
<span class="sep">·</span>
<a href="/rss.xml">RSS</a>
</div>
<!-- 项目信息:固定的源码入口 + 版权,压在最后。和上面那排社交账号分开——
它是代码仓库入口,不是站主的社交身份(顺序同 classic 的 RightRail) -->
<div class="foot-meta">
<a :href="PROJECT_REPO" title="项目源码" aria-label="项目源码" target="_blank" rel="noopener">
<svg viewBox="0 0 24 24" aria-hidden="true"><path :d="socialIcon(PROJECT_REPO)" /></svg>
</a>
<span>{{ site.footer_note }}<template v-if="site.icp"> · {{ site.icp }}</template></span>
</div>
</footer>
</template>
+2 -1
View File
@@ -4,6 +4,7 @@ import YohakuHero from './YohakuHero.vue'
import YohakuToolbar from './YohakuToolbar.vue'
import YohakuFeed from './YohakuFeed.vue'
import YohakuPager from './YohakuPager.vue'
import { thumbURL } from '../../utils'
// 首页装配。对应设计稿 viewHome() 的 DOM 顺序:
// hero → wrap(toolbar + feed + empty + pager) → showcase(作品前三件 + 查看所有)。
@@ -81,7 +82,7 @@ const emit = defineEmits(['update:kind', 'update:tag', 'update:query', 'go'])
to="/projects"
>
<div v-if="p.cover_url" class="cov">
<img :src="p.cover_url" alt="" loading="lazy">
<img :src="thumbURL(p.cover_url, 480)" alt="" loading="lazy">
<span v-if="p.status" class="st">{{ p.status }}</span>
</div>
<div class="body">
+5 -5
View File
@@ -3,6 +3,7 @@ import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { site } from '../../site'
import NavBurger from '../../components/NavBurger.vue'
import AuthorAvatar from '../../components/AuthorAvatar.vue'
// 余白顶栏。对应设计稿的 <header class="top">:品牌 / 导航 / 操作三块,
// 滚动过 8px 加 .scrolled(设计稿的「四态里的第一态」)。
@@ -27,7 +28,6 @@ const links = [
{ to: '/about', label: '关于', en: 'About' }
]
const initial = () => (site.author_name || site.site_title || 'O').trim().slice(0, 1).toUpperCase()
const title = () => site.site_title?.split(/[·|]/)[0]?.trim() || 'ONE'
function isOn(l) {
@@ -94,7 +94,7 @@ function toggleTheme() {
<header class="top" :class="{ scrolled }">
<div class="top-in">
<RouterLink class="brand" to="/">
<span class="av">{{ initial() }}</span>
<span class="av"><AuthorAvatar /></span>
<span>{{ title() }}</span>
</RouterLink>
<nav class="nav" aria-label="站点导航">
@@ -111,11 +111,11 @@ function toggleTheme() {
</div>
</header>
<!-- 窄屏:底部悬浮 dock(品牌 + 汉堡),常驻拇指区。桌面 display:none。
必须待在 .top 外面:.top 有 backdrop-filter,会给 fixed 后代当包含块。 -->
<!-- 窄屏:底部悬浮 dock(站名 + 汉堡),常驻拇指区。桌面 display:none。
必须待在 .top 外面:.top 有 backdrop-filter,会给 fixed 后代当包含块。
站标「一横」不在这里出现——手机上导航只要文字和按钮,圆标只留桌面顶栏。 -->
<div class="dock">
<RouterLink class="dock-brand" to="/">
<span class="av">{{ initial() }}</span>
<span>{{ title() }}</span>
</RouterLink>
<NavBurger
+25 -2
View File
@@ -96,8 +96,12 @@ html[data-ui='vivid'] .prose code {font-family:var(--mono);font-size:.88em;
padding:2px 7px;border-radius:6px;}
html[data-ui='vivid'] .prose pre {background:color-mix(in srgb,var(--ink) 92%,var(--accent));
color:#e8e8f0;padding:18px 20px;border-radius:12px;
overflow-x:auto;font-size:13.5px;line-height:1.7;
box-shadow:var(--shadow-lg);}
overflow:auto;font-size:13.5px;line-height:1.7;
box-shadow:var(--shadow-lg);
max-height:30.5em;}
/* 复制按钮(vivid 配色:浅字浮在深底上) */
html[data-ui='vivid'] .pre-copy {color:#e8e8f0;
background:rgba(255,255,255,.14);}
/* 暗色下「92% ink」混出的是浅底,浅底配浅字没法读;暗端换深底微混强调色 */
html[data-ui='vivid'][data-theme='vivid-dark'] .prose pre {
background:color-mix(in srgb,var(--paper) 86%,var(--accent));
@@ -130,6 +134,11 @@ html[data-ui='vivid'] .paper > .end {margin-top:40px;padding-top:24px;border-top
display:flex;gap:14px;flex-wrap:wrap;align-items:center;}
html[data-ui='vivid'] .paper > .end .tg {padding:6px 0}
/* 短文配图:Twitter 式网格(快发盒上传,≤4 张;跟 prose 同宽,圆角跟 vivid 走) */
html[data-ui='vivid'] .post-imgs {margin-top: 6px;
border-radius: var(--radius);}
html[data-ui='vivid'] .post-imgs.n1 img {max-height: 520px;}
/* ==========================================================================
把 classic 的 .prose 挡回去
--------------------------------------------------------------------------
@@ -210,3 +219,17 @@ html[data-ui='vivid'] .sr-only {position: absolute;
white-space: nowrap;
border: 0;}
html[data-ui='vivid'] .toc a:active { color: var(--accent); }
/* ---- 表格(xLog 式:圆角容器 + 斑马纹,替代满格线) ---- */
html[data-ui='vivid'] .prose table {width:100%;
border-collapse:separate;border-spacing:0;
margin:1.8em 0;font-size:14px;
border:1px solid var(--line);border-radius:12px;overflow:hidden;}
html[data-ui='vivid'] .prose th, html[data-ui='vivid'] .prose td {border:0;
border-bottom:1px solid var(--line-soft);
padding:12px 18px;text-align:left;}
html[data-ui='vivid'] .prose tr > * + * {border-left:1px solid var(--line-soft);}
html[data-ui='vivid'] .prose tbody tr:last-child td {border-bottom:0;}
html[data-ui='vivid'] .prose th {background:var(--accent-soft);font-weight:600;}
html[data-ui='vivid'] .prose tbody tr:nth-child(even) {background:var(--card);}
/* 标题锚点 + 复制按钮沿用 classic 的 .h-anchor/.pre-copy 基础样式 */
+28 -18
View File
@@ -15,15 +15,8 @@ html[data-ui='vivid'] .brand {display:flex;align-items:center;gap:10px;
@media (hover: hover) and (pointer: fine) {
html[data-ui='vivid'] .brand:hover {transform:scale(1.04)}
}
/* .av 品牌标记:顶栏一枚,菜单里那枚随菜单头一起撤了(顶栏浮在菜单上) */
html[data-ui='vivid'] .brand .av {width:30px;height:30px;border-radius:50%;
background:linear-gradient(135deg,var(--accent),var(--accent-deep));
display:grid;place-items:center;color:#fff;font-size:13px;font-weight:800;
box-shadow:0 0 0 2px var(--paper),0 0 0 3.5px var(--accent-soft);
position:relative;}
html[data-ui='vivid'] .brand .av::after {content:"";position:absolute;right:-1px;bottom:-1px;
width:9px;height:9px;border-radius:50%;
background:#34c759;border:2px solid var(--paper);}
/* .av 品牌标记:原来这里是一条 .brand .av 规则,但它被下面 :is(.brand,.dock-brand)
那条同特指度、且位置更靠后的规则完整覆盖了——等于死规则。合并成一条,避免两处漂移。 */
html[data-ui='vivid'] .nav {display:flex;gap:2px;flex:1;justify-content:center}
html[data-ui='vivid'] .nav a {position:relative;font-size:13.5px;font-weight:600;color:var(--soft);
padding:8px 14px;border-radius:999px;
@@ -68,20 +61,18 @@ html[data-ui='vivid'] .dock {position:fixed;z-index:90;
backdrop-filter:blur(20px) saturate(1.5);
-webkit-backdrop-filter:blur(20px) saturate(1.5);
box-shadow:var(--shadow-lg);}
html[data-ui='vivid'] .dock-brand {display:flex;align-items:center;gap:9px;
html[data-ui='vivid'] .dock-brand {display:flex;align-items:center;
font-weight:700;font-size:14px;letter-spacing:.02em;color:var(--ink);}
/* 窄屏才把 dock 摆出来;这条必须压在基准规则(display:none)之后 */
@media (max-width:720px) {
html[data-ui='vivid'] .dock {display:flex}
}
html[data-ui='vivid'] :is(.brand, .dock-brand) .av {width:30px;height:30px;border-radius:50%;
background:linear-gradient(135deg,var(--accent),var(--accent-deep));
display:grid;place-items:center;color:#fff;font-size:13px;font-weight:800;
box-shadow:0 0 0 2px var(--paper),0 0 0 3.5px var(--accent-soft);
position:relative;}
html[data-ui='vivid'] :is(.brand, .dock-brand) .av::after {content:"";position:absolute;right:-1px;bottom:-1px;
width:9px;height:9px;border-radius:50%;
background:#34c759;border:2px solid var(--paper);}
/* 品牌位只管尺寸:头像本身已裁成圆(AuthorAvatar 的 img),外面不加光环、
右下角也不加在线点——顶栏要的就是一张干净的圆头像。color 供 LogoMark
回落(未设头像时)着色。只作用于桌面顶栏 .brand:手机 dock 里已经不放
站标(YohakuTop 的 dock 分支),选择器不需要 :is() 兼顾两边。 */
html[data-ui='vivid'] .brand .av {width:30px;height:30px;flex:none;
color:var(--ink);}
/* 上拉菜单:圆角毛玻璃贴着 dock 上方长出来,最高 70vh,内容多就内部滚 */
html[data-ui='vivid'] .menu-root {position:fixed;inset:0;z-index:110;}
@@ -169,6 +160,25 @@ html[data-ui='vivid'] .foot {max-width:1100px;margin:40px auto 0;padding:28px 28
border-top:1px solid var(--line);
text-align:center;color:var(--muted);font-size:12.5px;line-height:2;}
html[data-ui='vivid'] .foot a {color:var(--accent);font-weight:600;transition:opacity .25s}
/* 页脚社交排:图标居中一行,与 classic 页脚同步 */
html[data-ui='vivid'] .foot-social {display:flex;justify-content:center;align-items:center;
gap:18px;margin:14px 0 18px;}
html[data-ui='vivid'] .foot-social a {color:var(--soft);font-weight:400;
display:inline-flex;align-items:center;justify-content:center;
width:30px;height:30px;border-radius:8px;transition:color .25s,background .25s;}
html[data-ui='vivid'] .foot-social a:hover {color:var(--accent);background:var(--accent-soft);}
html[data-ui='vivid'] .foot-social svg {width:17px;height:17px;fill:currentColor;}
html[data-ui='vivid'] .foot-social span {font-size:12px;}
/* 项目信息行:源码入口 + 版权。刻意不并进 .foot-social——那一排是站主的社交
身份,源码是仓库入口,混在一起会让人以为它也是一个社交账号。
放在 .foot a 规则之后:两者特指度相同,靠后者才能压掉默认的强调色。 */
html[data-ui='vivid'] .foot-meta {display:flex;justify-content:center;align-items:center;
gap:8px;margin:16px 0 0;color:var(--muted);}
html[data-ui='vivid'] .foot-meta a {display:inline-flex;align-items:center;justify-content:center;
width:26px;height:26px;border-radius:7px;color:var(--soft);font-weight:400;
transition:color .25s,background .25s;}
html[data-ui='vivid'] .foot-meta a:hover {color:var(--accent);background:var(--accent-soft);}
html[data-ui='vivid'] .foot-meta svg {width:15px;height:15px;fill:currentColor;}
@media (hover: hover) and (pointer: fine) {
html[data-ui='vivid'] .foot a:hover {opacity:.75}
}
+44 -1
View File
@@ -121,13 +121,56 @@ html[data-ui='vivid'] .note {display:block;background:transparent;
}
html[data-ui='vivid'] .note .meta {display:flex;gap:10px;align-items:center;
font-size:12px;color:var(--muted);font-weight:600;margin-top:16px;}
html[data-ui='vivid'] .note .body {margin:0;font-size:15.5px;line-height:1.8;color:var(--text);}
html[data-ui='vivid'] .note .body {margin:0;font-size:15.5px;line-height:1.8;color:var(--text);
/* 短文正文用的是 .body 而不是 .prose,所以拿不到 styles.css 里 .prose 的
overflow-wrap:anywhere——裸链接这种不可断行的长 token 会把整列撑到 550px+,
手机上表现为页面横向能滚、右侧留白。用 anywhere 而非 break-word:
要让它参与 min-content 计算才真正压得住列宽。 */
overflow-wrap:anywhere;}
html[data-ui='vivid'] .note .body a {color:var(--accent);font-weight:600;
border-bottom:1px solid color-mix(in srgb,var(--accent) 40%,transparent);
transition:border-color .25s;}
@media (hover: hover) and (pointer: fine) {
html[data-ui='vivid'] .note .body a:hover {border-bottom-color:var(--accent)}
}
/* 短文配图:Twitter 式网格(快发盒上传,≤4 张)。
高度由容器按 宽:高 = 2:1 定,不再让格子各自 aspect-ratio:1 往下撑——
信息流列宽有 1044px,1:1 的 n3 网格实测 981px 高,一条带图短文顶掉
一整屏。格子统一 object-fit:cover 居中裁,全图点开 lightbox 看。
n1 没有网格比例可依,学详情页 .post-imgs.n1 给个 520px 上限。 */
html[data-ui='vivid'] .note .imgs {display:grid;gap:3px;margin-top:10px;
border-radius:var(--radius);overflow:hidden;}
html[data-ui='vivid'] .note .imgs img {display:block;width:100%;height:100%;
object-fit:cover;cursor:zoom-in;}
html[data-ui='vivid'] .note .imgs.n1 img {max-height:520px;}
html[data-ui='vivid'] .note .imgs.n2,
html[data-ui='vivid'] .note .imgs.n3,
html[data-ui='vivid'] .note .imgs.n4 {grid-template-columns:1fr 1fr;
aspect-ratio:2/1;}
/* 行高必须 minmax(0,1fr):裸 1fr 自带 min-content 下限,图片的自然高度会把行
撑回 733px+,容器 aspect-ratio 定出的 491px 装不下,n3/n4 的第二行整行被
裁出可视区。0 下限让行严格对半分,图片交给 object-fit:cover 裁。 */
html[data-ui='vivid'] .note .imgs.n2 {grid-template-rows:minmax(0,1fr);}
html[data-ui='vivid'] .note .imgs.n3,
html[data-ui='vivid'] .note .imgs.n4 {grid-template-rows:minmax(0,1fr) minmax(0,1fr);}
html[data-ui='vivid'] .note .imgs.n3 img:first-child {grid-row:span 2;}
/* 桌面端带图短文:文字、图片各占一半,图片不再因全宽铺开而吃高度——
半宽下沿用各 n* 自己的 2:1 容器比例,高度自动减半。meta/标签铺整行垫在
卡片底部:若让图跨多行去对齐左列内容,grid 会把跨行项的富余高度摊到
各 auto 行里,左列出现行间空隙。n1 没有自己的容器比例,这里补 2:1。
窄屏退回上下堆叠(图紧跟正文),≤720px 不分栏。 */
@media (min-width:721px) {
html[data-ui='vivid'] .note.split {display:grid;grid-template-columns:2fr 3fr;
column-gap:18px;row-gap:10px;}
html[data-ui='vivid'] .note.split .body {grid-column:1;grid-row:1;}
html[data-ui='vivid'] .note.split .imgs {grid-column:2;grid-row:1;
margin-top:0;align-self:start;}
html[data-ui='vivid'] .note.split .imgs.n1 {aspect-ratio:2/1;
grid-template-rows:minmax(0,1fr);}
html[data-ui='vivid'] .note.split .link-card,
html[data-ui='vivid'] .note.split .meta,
html[data-ui='vivid'] .note.split .tags {grid-column:1 / -1;margin-top:0;}
}
html[data-ui='vivid'] .note .tags {display:flex;gap:14px;flex-wrap:wrap;margin-top:8px}
html[data-ui='vivid'] .pager {display:flex;gap:8px;align-items:center;justify-content:center;
margin-top:36px;flex-wrap:wrap;}
+9 -1
View File
@@ -99,12 +99,20 @@ html[data-ui='vivid'] {/* ---- 设计稿的令牌名 → 全部指回 --v-* ----
--mono: 'SF Mono', 'JetBrains Mono', ui-monospace, Menlo, Consolas, monospace;
--measure: var(--v-measure);}
html[data-ui='vivid'] * {box-sizing: border-box;}
html[data-ui='vivid'] {scroll-behavior: auto;}
html[data-ui='vivid'] {scroll-behavior: auto;
/* 滚到顶再继续下拉,整页会跟着手指往下移,把上边缘以外的画布露出来——手机上
看着就是「顶栏没贴到最顶」。禁掉根滚动条的弹性溢出(顺带也禁掉下拉刷新)。
背景色写在 html 上:iOS 的弹性溢出这条压不住时,露出来的那一条至少是同一个纸色。 */
overscroll-behavior-y: none;
background: var(--paper);}
html[data-ui='vivid'] body {margin: 0;
font-family: var(--sans);
color: var(--text);
background: var(--paper);
min-height: 100vh;
/* dvh 是「当前可见高度」:100vh 在 iOS 上等于收起地址栏后的大视口,页面内容
不多时也会凭空多出一截可滚的距离。老浏览器不认 dvh,会自动退回上一行。 */
min-height: 100dvh;
-webkit-font-smoothing: antialiased;
transition: background 0.4s var(--ease), color 0.4s var(--ease);}
html[data-ui='vivid'] a {color: inherit;
+71 -1
View File
@@ -3,7 +3,77 @@ import DOMPurify from 'dompurify'
// 后端 goldmark 已转义原始 HTML,这里再过一道 DOMPurify 作纵深防御,
// 所有 v-html 出口必须经过它。
export function sanitizeHtml(html) {
return DOMPurify.sanitize(html || '')
return enhanceProse(DOMPurify.sanitize(html || ''))
}
// ---------- 缩略图 ----------
// 时间线只展示小图:把我们存储上的图片直链改写为 /uploads/thumb/{key}?w=,
// 后端懒生成并永久缓存。识别规则:以 uploads_public_base 开头的直链,
// 或同源的 /uploads/ 相对路径(本地存储兜底)。外链(图床/头像)原样返回。
// base 由 site.js 拉到站点设置后经 setThumbBase 注入(不走 import 避免循环依赖)。
const IMG_EXT = /\.(jpe?g|png|gif)([?#].*)?$/i
let thumbBase = ''
export function setThumbBase(base) {
thumbBase = (base || '').replace(/\/+$/, '')
}
export function thumbURL(u, w = 480) {
if (!u || typeof u !== 'string') return u
let key = ''
if (thumbBase && u.startsWith(thumbBase + '/')) key = u.slice(thumbBase.length + 1)
else if (u.startsWith('/uploads/') && !u.startsWith('/uploads/thumb/')) key = u.slice('/uploads/'.length)
else return u
if (!IMG_EXT.test(key)) return u
return '/uploads/thumb/' + key + '?w=' + w
}
// ---------- xLog 式排版增强 ----------
// 对消毒后的正文 HTML 做三件展示层增强(markdown/库里内容不动):
// 1) pre 包一层容器并注入悬停复制按钮;
// 2) 带 id 的标题(withHeadingIds 生成)尾部加 # 锚点;
// 3) (盘古之白在后端渲染出口做,见 backend/internal/render/pangu.go)
export function enhanceProse(html) {
if (!html) return html
// 复制按钮:包在 .pre-wrap 里,按钮悬浮于 pre 右上
html = html
.replace(/<pre\b/g, '<div class="pre-wrap"><button type="button" class="pre-copy" aria-label="复制代码">复制</button><pre')
.replace(/<\/pre>/g, '</pre></div>')
// 标题锚点:h2-h4 且有 id 的,末尾补一个 # 链接(悬停浮现)
html = html.replace(
/<h([2-4])([^>]*\bid="([^"]+)"[^>]*)>([\s\S]*?)<\/h\1>/g,
(m, lvl, attrs, id, inner) =>
`<h${lvl}${attrs}>${inner}<a class="h-anchor" href="#${id}" aria-label="标题链接">#</a></h${lvl}>`
)
return html
}
// 编辑器拖拽缩放的比例展示:Milkdown 图片块把高度比例写进 markdown 的 alt
// (![0.6](url)),渲染出的 <img alt="0.6"> 在这里还原成对应的宽度并居中。
// 纯数字 alt 才生效(>=1 的当 100% 处理),正常图片的描述性 alt 不受影响。
export function applyImageRatio(html) {
if (!html) return html
return html.replace(/<img\b[^>]*>/gi, (tag) => {
const m = tag.match(/\balt="(\d*\.?\d+)"/i)
if (!m) return tag
const ratio = Number.parseFloat(m[1])
if (!Number.isFinite(ratio) || ratio <= 0 || ratio >= 1) return tag
const style = `display:block;width:${(ratio * 100).toFixed(1)}%;max-width:100%;height:auto;margin-inline:auto;`
if (/\bstyle="/i.test(tag)) return tag.replace(/\bstyle="/i, 'style="' + style)
return tag.replace(/^<img/i, '<img style="' + style + '"')
})
}
// 时间线正文 HTML(短文全文 / 长文 600 字符截断)里的 <img> 换缩略图 + 补懒加载。
// 只在展示层重写,库里存的正文不动。截断的 HTML 可能带半截标签,
// 正则只匹配完整的 <img ...src="..."> ,坏尾巴交给 sanitize/浏览器容错。
export function thumbifyHtml(html, w = 480) {
if (!html) return html
return html.replace(/<img\b[^>]*?>/gi, (tag) => {
let out = tag.replace(/\bsrc="([^"]*)"/i, (m, src) => 'src="' + thumbURL(src, w) + '"')
if (!/\bloading=/i.test(out)) out = out.replace(/^<img/i, '<img loading="lazy"')
return out
})
}
// 用 Intl.DateTimeFormat — locale 感知,未来要 i18n 只换 locale 即可
+45 -15
View File
@@ -1,13 +1,16 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import LeftNav from '../components/LeftNav.vue'
import RightRail from '../components/RightRail.vue'
import YohakuArticle from '../ui/yohaku/YohakuArticle.vue'
import CommentSection from '../components/comments/CommentSection.vue'
import LinkCard from '../components/LinkCard.vue'
import AuthorAvatar from '../components/AuthorAvatar.vue'
import { openLightbox } from '../lightbox'
import { publicApi } from '../api'
import { site, applyDocTitle } from '../site'
import { formatDate, minutesLabel, sanitizeHtml } from '../utils'
import { applyImageRatio, formatDate, minutesLabel, sanitizeHtml } from '../utils'
const route = useRoute()
const post = ref(null)
@@ -31,6 +34,14 @@ function withHeadingIds(html) {
const contentHtml = ref('')
const headings = ref([])
// 顶部阅读进度条(classic):滚动过正文才算数,短文太短不显示
const progress = ref(0)
function onScroll() {
const doc = document.documentElement
const total = doc.scrollHeight - window.innerHeight
progress.value = total > 300 ? Math.min(100, (window.scrollY / total) * 100) : 0
}
async function load() {
loading.value = true
error.value = ''
@@ -40,7 +51,7 @@ async function load() {
try {
post.value = await publicApi.post(route.params.slug)
const prepared = withHeadingIds(post.value.content_html)
contentHtml.value = prepared.html
contentHtml.value = applyImageRatio(prepared.html)
headings.value = prepared.headings
applyDocTitle(post.value.kind === 'short' ? '短文' : post.value.title)
} catch (e) {
@@ -50,11 +61,14 @@ async function load() {
}
}
onMounted(load)
onMounted(() => {
load()
window.addEventListener('scroll', onScroll, { passive: true })
})
onBeforeUnmount(() => window.removeEventListener('scroll', onScroll))
watch(() => route.params.slug, load)
const isShort = computed(() => post.value && post.value.kind === 'short')
const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUpperCase())
</script>
<template>
@@ -69,6 +83,8 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
/>
<div v-else class="shell">
<!-- 阅读进度:贴在视口顶部的细线 -->
<div class="reading-bar" :style="{ width: progress + '%' }" aria-hidden="true"></div>
<div class="layout">
<!-- LeftNav / RightRail 之前 import 了却没渲染,导致整页掉进 236px 的左列。
补上,和 HomeView 的三栏一致。 -->
@@ -84,7 +100,7 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
<article v-else class="wrap" :class="{ short: isShort }">
<header class="head">
<div class="byline">
<div class="avatar">{{ initial }}</div>
<div class="avatar"><AuthorAvatar /></div>
<div class="who">
<span class="author">{{ site.author_name || 'ONE' }}</span>
<span class="sub">
@@ -106,6 +122,26 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
v-html="sanitizeHtml(contentHtml)"
></div>
<!-- 短文配图:Twitter 式网格(快发盒上传);点图开预览 -->
<div
v-if="isShort && post.images && post.images.length"
class="post-imgs"
:class="'n' + Math.min(post.images.length, 4)"
>
<img
v-for="(u, i) in post.images.slice(0, 4)"
:key="i"
:src="u"
alt=""
loading="lazy"
decoding="async"
@click="openLightbox(post.images, i)"
/>
</div>
<!-- 外链预览卡片:正文里第一个链接,发布时后端已抓好 -->
<LinkCard v-if="isShort && post.link_card" :card="post.link_card" />
<footer class="foot">
<div v-if="post.tags && post.tags.length" class="tags">
<RouterLink
@@ -124,7 +160,7 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
<!-- 评论区(和 vivid 共用同一个组件,见 components/comments/) -->
<CommentSection v-if="post" :post-id="post.id" />
</main>
<RightRail />
<RightRail :toc="isShort ? [] : headings" />
</div>
</div>
</template>
@@ -152,17 +188,11 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
gap: 12px;
}
/* 站标自身是圆,原来的 accent-soft 圆底与衬线首字母一并去掉,只留尺寸与颜色 */
.avatar {
width: 44px;
height: 44px;
border-radius: 50%;
background: var(--accent-soft);
color: var(--accent);
display: flex;
align-items: center;
justify-content: center;
font-family: var(--serif);
font-size: 18px;
color: var(--ink);
}
.who {
+78
View File
@@ -0,0 +1,78 @@
// WebAuthn 的浏览器侧胶水。
//
// 存在的理由只有一句话:navigator.credentials 要 ArrayBuffer,
// 后端 go-webauthn 要 base64url 字符串,两边不会自动转换。
// JSON.stringify 一个 PublicKeyCredential 会把 ArrayBuffer 变成 {},
// 所以响应必须手工组装成 base64url。
//
// 只用标准 Web API,不引 @simplewebauthn/browser 之类的包。
export function isSupported() {
return typeof window !== 'undefined' && !!window.PublicKeyCredential && !!navigator.credentials
}
// base64url 字符串 -> ArrayBuffer
function toBuf(s) {
const bin = atob(s.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(s.length / 4) * 4, '='))
const out = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i)
return out.buffer
}
// ArrayBuffer -> base64url(无填充)
function toB64(buf) {
const bytes = new Uint8Array(buf)
let bin = ''
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i])
return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
// 服务端给的 options 里这些字段是 base64url,要用前逐个还原成 ArrayBuffer
function decodeOptions(pk) {
const out = { ...pk }
out.challenge = toBuf(pk.challenge)
if (pk.user) {
out.user = { ...pk.user, id: toBuf(pk.user.id) }
}
if (Array.isArray(pk.excludeCredentials)) {
out.excludeCredentials = pk.excludeCredentials.map((c) => ({ ...c, id: toBuf(c.id) }))
}
if (Array.isArray(pk.allowCredentials)) {
out.allowCredentials = pk.allowCredentials.map((c) => ({ ...c, id: toBuf(c.id) }))
}
return out
}
// 浏览器返回的凭据 -> 后端能解析的 JSON(全部 base64url)
function encodeResponse(cred) {
const r = cred.response
const pick = ['clientDataJSON', 'attestationObject', 'authenticatorData', 'signature']
const response = {}
for (const k of pick) {
if (r[k] != null) response[k] = toB64(r[k])
}
// userHandle 可能是 null(发现式登录未回填时),保留 null 而不是转成字符串
if ('userHandle' in r) response.userHandle = r.userHandle ? toB64(r.userHandle) : null
return {
id: cred.id,
rawId: toB64(cred.rawId),
type: cred.type,
authenticatorAttachment: cred.authenticatorAttachment || undefined,
clientExtensionResults: cred.getClientExtensionResults ? cred.getClientExtensionResults() : {},
response
}
}
// register 注册一把新凭据。options 是后端 BeginRegistration 的产物。
export async function register(options) {
if (!isSupported()) throw new Error('这个浏览器不支持 passkey')
const cred = await navigator.credentials.create({ publicKey: decodeOptions(options.publicKey || options) })
return encodeResponse(cred)
}
// assert 用已有凭据登录。options 是后端 BeginLogin 的产物。
export async function assert(options) {
if (!isSupported()) throw new Error('这个浏览器不支持 passkey')
const cred = await navigator.credentials.get({ publicKey: decodeOptions(options.publicKey || options) })
return encodeResponse(cred)
}
+2
View File
@@ -16,6 +16,8 @@ export default defineConfig({
port: 3000,
proxy: {
'/api': { target, changeOrigin: true },
// 上传文件与缩略图路由都在后端(/uploads/、/uploads/thumb/)
'/uploads': { target, changeOrigin: true },
'/rss.xml': { target, changeOrigin: true },
'/feed': { target, changeOrigin: true },
},