Compare commits

..
61 Commits
Author SHA1 Message Date
Sakurasan 125107d1c9 时间线署名去掉硬编码的 @oneblog:登录用户名不该公开展示,作者昵称已能表明身份 2026-10-02 01:28:37 +08:00
Sakurasan 0fb897c0b9 修复升级后站主降级为 reader:EnsureOwner 纠正历史身份行的角色
多用户之前以 admin 会话评论会留下 provider=admin、role=reader 的历史行,
升级后 EnsureOwner 找到该行时未纠正角色,站主登录即拿 reader 角色,
用户管理 403。现在 GetOwner 优先取 owner 行,EnsureOwner 登录即把
历史行纠正成 owner。存量会话角色每请求查库,重启后重登一次即恢复。
2026-10-01 23:33:16 +08:00
Sakurasan 1b5022aefb 去掉首字下沉:classic 与 vivid 的正文首字放大样式及短文补偿规则一并移除 2026-10-01 23:18:25 +08:00
Sakurasan 4bb2ff4145 多用户与角色:owner / admin / reader 三级,用户管理页 + 密码上库
- users 表加 password_hash 列;后台账号(owner+admin)密码 bcrypt 存行内,
  首次登录把 env / settings 引导凭据自迁移成行哈希
- 会话 token 从用户名改为携带用户 ID,角色与停用状态每请求查库,
  改角色 / 停用账号即时生效(存量会话立即 401)
- 登录:先查 users 表,再走 settings 哈希 / env 引导链;
  admin/admin 开发模式在任何账号设过密码后失效
- 权限:系统设置、用户管理仅 owner;内容管理 admin+owner;
  admin 后台新增 用户 页(创建 / 重置密码 / 停用 / 删除),
  设置页「登录与存储」tab 对管理员隐藏
- 账户页加修改密码表单(旧密码校验,OAuth/Passkey 首设免旧密码);
  评论区管理员身份跟随各自账号,不再统一挂站主名下
- 修复:providers 为 nil 时账户页白屏(Go nil slice 序列化成 null)
2026-10-01 22:35:37 +08:00
Sakurasan e5dee4daf1 vivid 手机端顶栏下滑自动隐藏:滚过 80px 向下滑收起、向上滑或回页首即回现,2px 抖动过滤,抽屉打开期间强制可见 2026-10-01 17:32:54 +08:00
Sakurasan 00e85bf667 vivid 时间线加操作行(回复/赞/查看),长文文字边距对齐短文 8px
- 卡片/纸条本体保持整块链接,操作行是链接外的兄弟节点(<a> 里不嵌交互元素)
- 回复 → 文章页 #comments 锚点;查看 → 文章页;赞是本机赞
  (无登录体系,状态存 localStorage,后续要做全局计数再加表和端点)
- Phosphor 图标:对话气泡 / 心形 / 眼睛,按下有缩放反馈,liked 态 accent 高亮
- .card .body 横向边距 26px→8px,与纸条文字同一条竖线
2026-10-01 17:28:24 +08:00
Sakurasan 17b1143183 vivid 短文纸条上下内边距归零 2026-10-01 17:15:17 +08:00
Sakurasan d3ec39f27b vivid 短文文字缩进 12px→8px 2026-10-01 17:12:51 +08:00
Sakurasan 8315af0f0c vivid 短文:文字类内容缩进 12px(字不贴边线),图片网格全宽贴边并跟随纸条圆角裁切,外链卡片内缩 12px 2026-10-01 17:10:47 +08:00
Sakurasan 251de21d5b a.note 内边距删掉:短文正文与 12px 页边线齐平,淡底块紧贴内容 2026-10-01 17:05:57 +08:00
Sakurasan c81d56e4fb vivid 短文清掉多余边距:纸条内边距对齐 12px、meta 上方 16px→10px,手机端时间线条目间隔 18px→14px 2026-10-01 17:01:56 +08:00
Sakurasan a780da5c3a vivid 短文标记去线改淡底块:背景不占布局宽度,正文与卡片左缘对齐,区分信号 = accent 淡底 + SHORT 徽标 2026-10-01 16:59:09 +08:00
Sakurasan 2fea889515 vivid 手机端全站统一离边框 12px:hero/wrap/page-head/foot/showcase 对齐正文列与评论区(原 20/28px 混用),顶栏品牌左缘同步对齐,页脚底部留白收到 56px 2026-10-01 16:51:10 +08:00
Sakurasan 5cd0e63ae6 vivid 短文纸条贴边:左线 3px→2px,去掉 12px 左外边距、内边距 18/24→14/16,左侧总占位从近 60px 收到 38px 2026-10-01 16:45:33 +08:00
Sakurasan 75548bf07f 抽屉菜单打磨:面板收窄到 min(66vw,250px),字重 800→500 配 Phosphor 图标,当前项改淡色胶囊高亮(引入 @phosphor-icons/vue 按需引入) 2026-10-01 16:36:39 +08:00
Sakurasan b407547db9 抽屉菜单去掉英文脚注:为多语言留路,栏目只保留本地化名称 2026-10-01 16:14:15 +08:00
Sakurasan f618bc48f5 vivid 手机端导航改抽屉:顶部玻璃条(品牌+汉堡)+ 右侧滑入面板,大字栏目带 mono 英文脚注与当前项短杠,Esc/scrim/路由变更自动收起 2026-10-01 15:55:45 +08:00
Sakurasan d3fe454cbe vivid 手机端标签栏挪回顶部:与桌面顶栏同构(全宽毛玻璃 + 发丝线),body 顶部让位 63px,QuickShort FAB 回落底部 2026-10-01 15:44:49 +08:00
Sakurasan 8fb15861fe vivid 手机端导航重做:底部玻璃标签栏一次直达,替掉 dock + 汉堡 + 上拉菜单
- 五栏目平铺常驻拇指区,当前项分段高亮(与首页工具条分段控件同语言),
  44px 触达高度 + :active 缩放反馈,safe-area 适配保留
- 去掉菜单里的 01-05 编号、逐行分隔线与站点描述,导航只做导航
- 明暗切换:手机端挪到页脚 foot-nav 末尾的 ◐(≤720px 显示),
  桌面仍走顶栏主题键;QuickShort 的 FAB 底部给标签栏让位
2026-10-01 15:22:00 +08:00
Sakurasan 2bd38551a4 总览长标题撑破屏幕修复:dash-cols 的 grid 子项加 min-width:0,面板可收缩后标题 ellipsis 才生效(375 视口 docW 4181→375) 2026-10-01 14:58:31 +08:00
Sakurasan 3eaf2c6042 手机端后台主题切换收进顶栏:浮动按钮压总览图表右下角,顶栏 34px 胶囊与 ☰/退出同排;后台路由不再挂全局浮动按钮 2026-10-01 14:46:05 +08:00
Sakurasan f72d1107e0 后台设置页手机端:tab 列表收成可换行胶囊横排,去掉 sticky(单列布局里会粘住盖住下方面板) 2026-10-01 12:40:05 +08:00
Sakurasan e68400b389 系统设置迁入后台:站点地址 / OAuth 凭据 / R2 存储 / 管理员账号,保存即生效无需重启
- 生效规则统一为「后台填了用后台的,没填回落环境变量」,老部署不改 env 照常跑
- config.Resolver:短缓存解析有效配置,存储后端按配置签名热重建;后台保存主动失效
- 秘密项(client secret / bot token / R2 密钥 / 管理员密码)接口永不回显明文,
  只报「是否已配置、来自哪里」;留空保存 = 保持现值
- 管理员密码 bcrypt 入库,DB 哈希优先、显式设置的 env 密码作解锁后路;
  后台改过密码后 admin/admin 开发模式立即失效
- 设置页新增「登录与存储」标签,基础信息加站点地址;秘密项带来源提示
- 监听地址 / 数据库 / 目录 / ONE_SECRET / Passkey 仍留环境变量(启动期依赖)
2026-10-01 12:14:10 +08:00
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
105 changed files with 9369 additions and 852 deletions

No files matched your search

+12 -3
View File
@@ -4,10 +4,19 @@ S3Api="https://<account_id>.r2.cloudflarestorage.com"
PublicURL= PublicURL=
Bucket= Bucket=
ONE_GITHUB_CLIENT_ID=Iv1.xxxxxxxx
ONE_GITHUB_CLIENT_SECRET=ghp_xxxxxxxx
# GitHub OAuth(评论登录):https://github.com/settings/developers # GitHub OAuth(评论登录):https://github.com/settings/developers
# 回调地址填 http://localhost:8080/api/auth/callback/github(线上换成正式域名) # 回调地址填 http://localhost:8080/api/auth/callback/github(线上换成正式域名)
ONE_GITHUB_CLIENT_ID= ONE_GITHUB_CLIENT_ID=
ONE_GITHUB_CLIENT_SECRET= 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 # 开发进程日志 / pid
.run/ .run/
# 其他 agent 工具的本地草稿(计划、忽略清单),不进仓库
.zcode/
.zcodeignore
# 二进制 # 二进制
one-server one-server
backend/one-server backend/one-server
+28 -6
View File
@@ -35,19 +35,40 @@ ONE_ADMIN_USER=admin ONE_ADMIN_PASSWORD=换一个 make start
登录态是服务端签发的 httpOnly cookie(HMAC-SHA256,7 天有效),同时支持 `Authorization: Bearer <token>`,方便用 curl / 脚本写文章。 登录态是服务端签发的 httpOnly cookie(HMAC-SHA256,7 天有效),同时支持 `Authorization: Bearer <token>`,方便用 curl / 脚本写文章。
## 环境变量 ## 设置:后台优先,环境变量兜底
站点地址、评论区第三方登录(GitHub / Google / Telegram)凭据、R2 对象存储、
管理员用户名与密码,都在后台 **设置 → 登录与存储**(站点地址在 **基础信息**)里配置,
保存后立即生效,无需重启。这些值存在 `settings` 表里,生效规则是
「后台填了用后台的,没填回落到环境变量」——老部署不改 env 也能照常跑。
秘密项(client secret、Bot token、R2 密钥)在后台只显示
「是否已配置、来自哪里」,永不回显明文;留空保存 = 保持现值。
**多用户与角色**:站主(owner,全库唯一)可在后台 **用户** 页创建内容管理员
(admin),管理员能写文章、管理评论与文件,但系统设置、用户管理与第三方凭据
只有站主动。密码 bcrypt 存在 users 表上,各账号在「账户」页自行修改;
显式设置过的 `ONE_ADMIN_PASSWORD` 保留作站主的解锁后路,
admin/admin 开发模式在任何账号设置过密码后立即失效。
环境变量只剩启动期必需项:
| 变量 | 默认 | 说明 | | 变量 | 默认 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| `ONE_ADDR` | `:8080` | 监听地址 | | `ONE_ADDR` | `:8080` | 监听地址 |
| `ONE_DB_DRIVER` | `sqlite` | `sqlite` 或 `postgres` | | `ONE_DB_DRIVER` | `sqlite` | `sqlite` 或 `postgres` |
| `ONE_DB_DSN` | `./data/one.db` | 数据库连接串(Postgres 示例:`postgres://user:pass@localhost/one?sslmode=disable`) | | `ONE_DB_DSN` | `./data/one.db` | 数据库连接串(Postgres 示例:`postgres://user:pass@localhost/one?sslmode=disable`) |
| `ONE_ADMIN_USER` | `admin` | 后台用户名 |
| `ONE_ADMIN_PASSWORD` | `admin` | 后台密码 |
| `ONE_SECRET` | 随机生成 | 会话签名密钥;不设的话重启后登录态失效 | | `ONE_SECRET` | 随机生成 | 会话签名密钥;不设的话重启后登录态失效 |
| `ONE_SITE_URL` | `http://localhost:8080` | RSS 里的站点地址,部署时务必改成真实域名 |
| `ONE_WEB_DIST` | `./frontend/dist` | 前端构建产物目录 | | `ONE_WEB_DIST` | `./frontend/dist` | 前端构建产物目录 |
| `ONE_DATA_DIR` | `./data` | SQLite 数据目录 | | `ONE_DATA_DIR` | `./data` | SQLite 数据目录 |
| `ONE_ADMIN_USER` | `admin` | 初始后台用户名(后台可改) |
| `ONE_ADMIN_PASSWORD` | `admin` | 初始后台密码(后台可改;不设即 admin/admin 的开发模式) |
| `ONE_SITE_URL` | `http://localhost:8080` | 站点地址兜底值(后台可改) |
| `ONE_GITHUB_CLIENT_ID` / `_SECRET` | — | GitHub 登录兜底值(后台可改) |
| `ONE_GOOGLE_CLIENT_ID` / `_SECRET` | — | Google 登录兜底值(后台可改) |
| `ONE_TELEGRAM_BOT` / `_TOKEN` | — | Telegram 登录兜底值(后台可改) |
| `S3Api` / `Bucket` / `AccessKey` / `SecretAccessKey` / `PublicURL` | — | R2 存储兜底值(后台可改) |
| `ONE_WEBAUTHN_RP_ID` / `_ORIGINS` | — | Passkey 登录;显式配 origins 才启用 |
## 界面:classic / vivid ## 界面:classic / vivid
@@ -177,12 +198,13 @@ PUT /api/admin/settings
```bash ```bash
make build # 产出 ./one-server,前端已打进 web/dist make build # 产出 ./one-server,前端已打进 web/dist
ONE_ADDR=:8080 \ ONE_ADDR=:8080 \
ONE_SITE_URL=https://your.domain \
ONE_DB_DRIVER=postgres \ ONE_DB_DRIVER=postgres \
ONE_DB_DSN="postgres://user:pass@127.0.0.1/one?sslmode=disable" \ ONE_DB_DSN="postgres://user:pass@127.0.0.1/one?sslmode=disable" \
ONE_SECRET=一串随机长字符串 \ ONE_SECRET=一串随机长字符串 \
ONE_ADMIN_PASSWORD=强密码 \ ONE_ADMIN_PASSWORD=强密码 \
./one-server ./one-server
# 首次启动后登录后台,把站点地址、第三方登录、对象存储在「设置」页里配好,
# 之后改这些就不再需要碰环境变量或重启。
``` ```
要点: 要点:
@@ -200,7 +222,7 @@ After=network.target
[Service] [Service]
WorkingDirectory=/opt/one WorkingDirectory=/opt/one
Environment=ONE_ADDR=:8080 ONE_SITE_URL=https://your.domain ONE_SECRET=xxx ONE_ADMIN_PASSWORD=xxx Environment=ONE_ADDR=:8080 ONE_SECRET=xxx ONE_ADMIN_PASSWORD=xxx
ExecStart=/opt/one/one-server ExecStart=/opt/one/one-server
Restart=always Restart=always
+15 -6
View File
@@ -1,18 +1,22 @@
module oneblog module oneblog
go 1.24 go 1.24.0
require ( 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/lib/pq v1.10.9
github.com/yuin/goldmark v1.7.13 github.com/yuin/goldmark v1.7.13
golang.org/x/crypto v0.43.0
golang.org/x/text v0.30.0
modernc.org/sqlite v1.39.0 modernc.org/sqlite v1.39.0
) )
require ( require (
github.com/aws/aws-sdk-go-v2 v1.47.1 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 // indirect
github.com/aws/aws-sdk-go-v2/config v1.33.6 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.20.6 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.1 // 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/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/endpoints/v2 v2.8.4 // indirect
@@ -21,19 +25,24 @@ require (
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.5 // 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/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/internal/s3shared v1.20.4 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.113.4 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.10.1 // 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/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/ssooidc v1.43.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.51.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/aws/smithy-go v1.28.1 // indirect
github.com/dustin/go-humanize v1.0.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/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // 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/libc v1.66.3 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect
+36 -8
View File
@@ -34,8 +34,22 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.51.1 h1:0HOqZXRvMytH6bFHVIc0oJX07sZj
github.com/aws/aws-sdk-go-v2/service/sts v1.51.1/go.mod h1:26zA0GhDrLo+yiLI2yXWxqB1PdsShfLikoI7GOEgugM= 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 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ=
github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= 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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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 h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 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= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -46,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/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 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 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 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= 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 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/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.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= 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 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
+258
View File
@@ -0,0 +1,258 @@
// 账户页的后端:站主资料、身份绑定列表、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 {
// 多用户后密码统一在 users 行上(bcrypt),在账户页修改。
// 首次登录时 env / settings 的引导凭据会自迁移成行内哈希。
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{
// Providers 必须非 nil:一个第三方都没配时 Go 会序列化成 null,
// 前端 acct.providers.length 直接炸(真实事故:新库未配 OAuth 时账户页白屏)
Providers: []string{},
// 昵称以站主行的 name 为准;老数据里它是空的,回落到站点设置的作者名。
Name: firstNonEmptyStr(owner.Name, st.AuthorName),
Bio: st.AuthorBio,
AvatarKey: st.AuthorAvatarKey,
AvatarURL: a.avatarURL(st.AuthorAvatarKey),
Handle: owner.Handle,
Password: passwordInfo{ManagedBy: "account", 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 {
c := a.cfg()
switch name {
case "github":
return c.GitHubClientID != "" && c.GitHubClientSecret != ""
case "google":
return c.GoogleClientID != "" && c.GoogleClientSecret != ""
case "telegram":
return c.TelegramBot != "" && c.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 ""
}
+170
View File
@@ -0,0 +1,170 @@
package admin
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"oneblog/internal/model"
)
// doAs 带着有效后台会话(owner)发一个请求。会话 token 只带用户 ID,
// 所以先确保 owner 行存在,再按真实 ID 签。
func doAs(t *testing.T, a *API, method, path string, body string) *httptest.ResponseRecorder {
t.Helper()
owner, err := a.Store.EnsureOwner("admin")
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(method, path, strings.NewReader(body))
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
sess := NewSessions("test-secret", time.Hour)
tok, _ := sess.Issue(owner.ID)
req.AddCookie(&http.Cookie{Name: cookieName, Value: tok})
rec := httptest.NewRecorder()
a.Routes().ServeHTTP(rec, req)
return rec
}
func TestAccountGET(t *testing.T) {
a, _ := newTestAPI(t)
if _, err := a.Store.EnsureOwner("admin"); err != nil {
t.Fatal(err)
}
rec := doAs(t, a, 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, _ := newTestAPI(t)
if _, err := a.Store.EnsureOwner("admin"); err != nil {
t.Fatal(err)
}
rec := doAs(t, a, 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, _ := newTestAPI(t)
if _, err := a.Store.EnsureOwner("admin"); err != nil {
t.Fatal(err)
}
// 不存在的 key 必须拒:否则会存下一个永远解析不出的头像
rec := doAs(t, a, 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, a, 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, a, 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, _ := newTestAPI(t)
if _, err := a.Store.EnsureOwner("admin"); err != nil {
t.Fatal(err)
}
rec := doAs(t, a, 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, _ := newTestAPI(t)
if a.Passkeys != nil {
t.Skip("测试构造里不该有 Passkeys")
}
rec := doAs(t, a, http.MethodPost, "/api/admin/account/passkeys/begin", "")
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("got %d, want 503", rec.Code)
}
}
+61
View File
@@ -0,0 +1,61 @@
// 会话身份的解析与角色守卫。token 只带用户 ID(防篡改靠 HMAC),角色与
// 停用状态每请求从 users 表现读——后台改角色 / 停用账号即时生效,
// 不用等 7 天会话过期。
package admin
import (
"context"
"net/http"
"oneblog/internal/httpx"
"oneblog/internal/model"
)
type actor struct {
ID int64
Handle string
Role string
}
type actorKey struct{}
func (a *API) guard(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token := bearer(r)
if token == "" {
if c, err := r.Cookie(cookieName); err == nil {
token = c.Value
}
}
id, err := a.Sessions.Verify(token)
if err != nil {
httpx.Unauthorized(w)
return
}
u, err := a.Store.GetUserByID(id)
// 只认后台账号行:token 是我们签发的,理论上不会指到 reader,
// 但防御式校验一层;停用的账号立即失效。
if err != nil || u.Provider != "admin" || u.Banned {
httpx.Unauthorized(w)
return
}
act := actor{ID: u.ID, Handle: u.Handle, Role: u.Role}
next(w, r.WithContext(context.WithValue(r.Context(), actorKey{}, act)))
}
}
// guardOwner 在 guard 之上加角色门槛:系统设置、用户管理只属于站主。
func (a *API) guardOwner(next http.HandlerFunc) http.HandlerFunc {
return a.guard(func(w http.ResponseWriter, r *http.Request) {
if actorFrom(r).Role != model.RoleOwner {
httpx.Error(w, http.StatusForbidden, "需要站主权限")
return
}
next(w, r)
})
}
func actorFrom(r *http.Request) actor {
act, _ := r.Context().Value(actorKey{}).(actor)
return act
}
+628 -51
View File
@@ -13,27 +13,45 @@ import (
"io" "io"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"net/url"
"os" "os"
"path"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
"golang.org/x/crypto/bcrypt"
"oneblog/internal/auth"
"oneblog/internal/config" "oneblog/internal/config"
"oneblog/internal/httpx" "oneblog/internal/httpx"
"oneblog/internal/hub"
"oneblog/internal/linkmeta"
"oneblog/internal/model" "oneblog/internal/model"
"oneblog/internal/storage" "oneblog/internal/storage"
"oneblog/internal/store" "oneblog/internal/store"
"oneblog/internal/thumbs"
) )
type API struct { type API struct {
Store *store.Store Store *store.Store
// Cfg 是环境变量配置(兜底值)。设置了 Res 时一律以 Res.Config()
// 的有效配置为准(DB 里的系统设置优先);Res 为 nil(部分测试)才直接用它。
Cfg *config.Config Cfg *config.Config
Res *config.Resolver
Sessions *Sessions Sessions *Sessions
// 文件上传的存储后端与公开域名(main.go 装配,两个 API 共享同一实例) // Hub 是评论变更广播(与公开 API 共享同一实例,main.go 装配);
Blobs storage.BlobStore // 审核通过 / 后台删除评论时让前台打开着的页面即时刷新。
PublicBase string Hub *hub.Hub
// 文件上传的存储后端兜底(Res 未设置时使用;设置了走 Res.Blobs() 热重建)
Blobs storage.BlobStore
// Thumbs 是缩略图磁盘缓存(main.go 装配)。删上传文件时顺手清掉它的
// 缩略图产物,否则已删图片会一直占着缓存。
Thumbs *thumbs.Store
// Passkeys 是 WebAuthn 服务(main.go 装配;未配置时为 nil,相关端点直接 503)
Passkeys *auth.Passkeys
loginOnce sync.Once loginOnce sync.Once
logins *loginLimiter logins *loginLimiter
@@ -71,8 +89,29 @@ func (a *API) Routes() http.Handler {
mux.HandleFunc("/api/admin/projects", a.guard(a.listProjects)) mux.HandleFunc("/api/admin/projects", a.guard(a.listProjects))
mux.HandleFunc("/api/admin/projects/", a.guard(a.projectByID)) mux.HandleFunc("/api/admin/projects/", a.guard(a.projectByID))
mux.HandleFunc("/api/admin/files", a.guard(a.files)) 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/files/", a.guard(a.fileByID))
mux.HandleFunc("/api/admin/settings", a.guard(a.settings)) mux.HandleFunc("/api/admin/settings", a.guard(a.settings))
// 用户管理:站主专属(多用户与角色)
mux.HandleFunc("/api/admin/users", a.guardOwner(a.users))
mux.HandleFunc("/api/admin/users/", a.guardOwner(a.userByID))
mux.HandleFunc("/api/admin/account/password", a.guard(a.changePassword))
// 账户页:资料 + 身份绑定 + 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.adminComments))
mux.HandleFunc("/api/admin/comments/", a.guard(a.adminCommentByID)) 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(a.adminReaders))
@@ -94,23 +133,7 @@ func (a *API) Routes() http.Handler {
return mux return mux
} }
// guard requires a valid session; the token may arrive as a cookie (browser) // guard / actor 解析在 actor.go:token 只带用户 ID,角色每请求从库现读。
// or as a Bearer token (CLI / API client).
func (a *API) guard(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
token := bearer(r)
if token == "" {
if c, err := r.Cookie(cookieName); err == nil {
token = c.Value
}
}
if token == "" || !a.valid(token) {
httpx.Unauthorized(w)
return
}
next(w, r)
}
}
func bearer(r *http.Request) string { func bearer(r *http.Request) string {
h := r.Header.Get("Authorization") h := r.Header.Get("Authorization")
@@ -120,11 +143,6 @@ func bearer(r *http.Request) string {
return "" return ""
} }
func (a *API) valid(token string) bool {
_, err := a.Sessions.Verify(token)
return err == nil
}
// ---------- auth ---------- // ---------- auth ----------
type loginRequest struct { type loginRequest struct {
@@ -132,13 +150,74 @@ type loginRequest struct {
Password string `json:"password"` Password string `json:"password"`
} }
// verifyAdmin 校验登录凭据,返回应发会话的后台账号行。
//
// 第一优先:users 表里的后台账号(owner / admin,行内 bcrypt 哈希)——
// 多用户体系的主路径,站主在后台改过密码后哈希就在自己那行上。
// 兜底:引导链。settings 里的哈希(旧版「管理员账号」写入的)或
// ONE_ADMIN_PASSWORD(显式设置时保留作解锁后路——env 和库都在同一台机器上,
// 能读 env 的人本来就能直接改库);首次登录成功时把引导凭据自迁移成
// owner 行的哈希,此后引导键不再参与。没显式设 env 密码时是 InsecureDev
// (admin/admin),只在 owner 行还没有哈希时有效——后台一旦设过密码即失效。
func (a *API) verifyAdmin(username, password string) (model.Reader, bool) {
// 1) 库里后台账号
if u, err := a.Store.GetStaffByHandle(username); err == nil && !u.Banned && u.PasswordHash != "" {
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) == nil {
return u, true
}
}
// 2) 引导链:只认 owner 用户名(settings 覆盖值或 env 默认值)
c := a.cfg()
ownerName := c.AdminUser // Overlay 已把 settings 的 admin_username 叠进来
owner, oerr := a.Store.EnsureOwner(ownerName)
if oerr != nil {
return model.Reader{}, false
}
if username != owner.Handle {
return model.Reader{}, false
}
// env 显式密码:永久后路(无论行内是否有哈希)
if !c.InsecureDev && subtle.ConstantTimeCompare([]byte(password), []byte(c.AdminPass)) == 1 {
a.ensureOwnerHash(owner, password)
return owner, true
}
// 行内还没有哈希 → 首次登录引导:收 settings 哈希或 dev 密码,写行。
// settings 哈希存在时 dev 密码(admin/admin)不再生效——那是真的库内密码。
if owner.PasswordHash == "" {
st, serr := a.Store.GetSettings()
hasLegacyHash := serr == nil && st.AdminPasswordHash != ""
if hasLegacyHash &&
bcrypt.CompareHashAndPassword([]byte(st.AdminPasswordHash), []byte(password)) == nil {
a.ensureOwnerHash(owner, password)
return owner, true
}
if !hasLegacyHash && c.InsecureDev && password == c.AdminPass {
a.ensureOwnerHash(owner, password)
return owner, true
}
}
return model.Reader{}, false
}
// ensureOwnerHash 把引导凭据落成 owner 行的 bcrypt 哈希(自迁移)。
// 走 EnsureStaffHash:只在行内为空时写,不覆盖后台改过的密码。
func (a *API) ensureOwnerHash(owner model.Reader, password string) {
h, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return
}
_ = a.Store.EnsureStaffHash(owner.ID, string(h))
}
func (a *API) login(w http.ResponseWriter, r *http.Request) { func (a *API) login(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost { if r.Method != http.MethodPost {
httpx.Error(w, http.StatusMethodNotAllowed, "POST required") httpx.Error(w, http.StatusMethodNotAllowed, "POST required")
return return
} }
key := sourceKey(r) key := sourceKey(r)
if a.limiter().blocked(key) { if a.limiter().Blocked(key) {
httpx.Error(w, http.StatusTooManyRequests, "失败次数过多,请 10 分钟后再试") httpx.Error(w, http.StatusTooManyRequests, "失败次数过多,请 10 分钟后再试")
return return
} }
@@ -147,15 +226,14 @@ func (a *API) login(w http.ResponseWriter, r *http.Request) {
httpx.BadRequest(w, "invalid body") httpx.BadRequest(w, "invalid body")
return return
} }
userOK := subtle.ConstantTimeCompare([]byte(in.Username), []byte(a.Cfg.AdminUser)) == 1 user, ok := a.verifyAdmin(in.Username, in.Password)
passOK := subtle.ConstantTimeCompare([]byte(in.Password), []byte(a.Cfg.AdminPass)) == 1 if !ok {
if !userOK || !passOK { a.limiter().Add(key)
a.limiter().fail(key)
httpx.Unauthorized(w) httpx.Unauthorized(w)
return return
} }
a.limiter().reset(key) a.limiter().Reset(key)
token, exp := a.Sessions.Issue(a.Cfg.AdminUser) token, exp := a.Sessions.Issue(user.ID)
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: cookieName, Name: cookieName,
Value: token, Value: token,
@@ -166,7 +244,7 @@ func (a *API) login(w http.ResponseWriter, r *http.Request) {
Expires: exp, Expires: exp,
MaxAge: a.Sessions.TTL(), MaxAge: a.Sessions.TTL(),
}) })
httpx.OK(w, map[string]any{"token": token, "expires_at": exp.UTC().Format(rfc3339)}) httpx.OK(w, map[string]any{"token": token, "expires_at": exp.UTC().Format(rfc3339), "role": user.Role, "user": user.Handle})
} }
const rfc3339 = "2006-01-02T15:04:05Z07:00" const rfc3339 = "2006-01-02T15:04:05Z07:00"
@@ -185,7 +263,197 @@ func (a *API) logout(w http.ResponseWriter, r *http.Request) {
} }
func (a *API) me(w http.ResponseWriter, r *http.Request) { func (a *API) me(w http.ResponseWriter, r *http.Request) {
httpx.OK(w, map[string]any{"user": a.Cfg.AdminUser}) // guard 已解析过 actor;这里直接回带角色,前端据此显隐「用户管理」等入口
act := actorFrom(r)
httpx.OK(w, map[string]any{"user": act.Handle, "role": act.Role})
}
// ---------- users(多用户管理,站主专属) ----------
// users:GET 列表 / POST 创建内容管理员。
func (a *API) users(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
list, err := a.Store.ListStaff()
if err != nil {
httpx.ServerError(w, err)
return
}
httpx.OK(w, map[string]any{"users": list})
case http.MethodPost:
var in struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := httpx.Decode(r, &in); err != nil {
httpx.BadRequest(w, "invalid body")
return
}
name := strings.TrimSpace(in.Username)
if !validStaffName(name) {
httpx.BadRequest(w, "用户名限 2-32 位,字母 / 数字 / _ - . @")
return
}
if len(in.Password) < 6 || len(in.Password) > 72 {
httpx.BadRequest(w, "密码长度需在 6-72 位之间")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
if err != nil {
httpx.ServerError(w, err)
return
}
u, err := a.Store.CreateStaff(name, string(hash))
if errors.Is(err, store.ErrConflict) {
httpx.Error(w, http.StatusConflict, "用户名已被占用")
return
}
if err != nil {
httpx.ServerError(w, err)
return
}
httpx.Created(w, u)
default:
httpx.Error(w, http.StatusMethodNotAllowed, "GET/POST required")
}
}
// userByID:PATCH(重置密码 / 停用恢复)/ DELETE。owner 行不可动,自己
// 不能停用自己(会把自己锁在会话外面)。
func (a *API) userByID(w http.ResponseWriter, r *http.Request) {
id, err := parseInt(strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/admin/users/"), "/"))
if err != nil || id <= 0 {
httpx.BadRequest(w, "bad user id")
return
}
target, terr := a.Store.GetUserByID(id)
if terr != nil || target.Provider != "admin" {
httpx.NotFound(w)
return
}
if target.Role == model.RoleOwner {
httpx.BadRequest(w, "站主账号不可在此修改")
return
}
act := actorFrom(r)
switch r.Method {
case http.MethodPatch:
var in struct {
Password *string `json:"password"`
Banned *bool `json:"banned"`
}
if err := httpx.Decode(r, &in); err != nil {
httpx.BadRequest(w, "invalid body")
return
}
if in.Password != nil && *in.Password != "" {
if len(*in.Password) < 6 || len(*in.Password) > 72 {
httpx.BadRequest(w, "密码长度需在 6-72 位之间")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(*in.Password), bcrypt.DefaultCost)
if err != nil {
httpx.ServerError(w, err)
return
}
if err := a.Store.SetStaffPassword(id, string(hash)); err != nil {
httpx.ServerError(w, err)
return
}
}
if in.Banned != nil {
if *in.Banned && id == act.ID {
httpx.BadRequest(w, "不能停用自己")
return
}
if err := a.Store.SetStaffBanned(id, *in.Banned); err != nil {
httpx.ServerError(w, err)
return
}
}
u, err := a.Store.GetUserByID(id)
if err != nil {
httpx.NotFound(w)
return
}
httpx.OK(w, u)
case http.MethodDelete:
if id == act.ID {
httpx.BadRequest(w, "不能删除自己")
return
}
if err := a.Store.DeleteStaff(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})
default:
httpx.Error(w, http.StatusMethodNotAllowed, "PATCH/DELETE required")
}
}
// validStaffName 用户名白名单:2-32 位,字母数字与 _ - . @。
func validStaffName(s string) bool {
if len(s) < 2 || len(s) > 32 {
return false
}
for _, c := range s {
ok := c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' ||
c == '_' || c == '-' || c == '.' || c == '@'
if !ok {
return false
}
}
return true
}
// changePassword 自己改自己的密码(owner / admin 通用)。
// 旧密码只在行内已有哈希时校验——OAuth / Passkey 直接登录、从没设过密码的
// 账号第一次设密码不需要旧密码。
func (a *API) changePassword(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPatch && r.Method != http.MethodPut {
httpx.Error(w, http.StatusMethodNotAllowed, "PATCH required")
return
}
var in struct {
Old string `json:"old_password"`
New string `json:"new_password"`
}
if err := httpx.Decode(r, &in); err != nil {
httpx.BadRequest(w, "invalid body")
return
}
if len(in.New) < 6 || len(in.New) > 72 {
httpx.BadRequest(w, "新密码长度需在 6-72 位之间")
return
}
act := actorFrom(r)
u, err := a.Store.GetUserByID(act.ID)
if err != nil {
httpx.Unauthorized(w)
return
}
if u.PasswordHash != "" {
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(in.Old)) != nil {
httpx.Error(w, http.StatusForbidden, "旧密码不对")
return
}
}
hash, err := bcrypt.GenerateFromPassword([]byte(in.New), bcrypt.DefaultCost)
if err != nil {
httpx.ServerError(w, err)
return
}
if err := a.Store.SetStaffPassword(u.ID, string(hash)); err != nil {
httpx.ServerError(w, err)
return
}
httpx.OK(w, map[string]any{"ok": true})
} }
// ---------- posts ---------- // ---------- posts ----------
@@ -218,6 +486,8 @@ func (a *API) listPosts(w http.ResponseWriter, r *http.Request) {
return return
} }
in.Status = store.NormalizeStatus(in.Status) in.Status = store.NormalizeStatus(in.Status)
attachLinkCard(r.Context(), &in, nil)
autoMeta(&in, nil)
p, err := a.Store.Create(in) p, err := a.Store.Create(in)
if err != nil { if err != nil {
httpx.ServerError(w, err) httpx.ServerError(w, err)
@@ -257,6 +527,16 @@ func (a *API) postByID(w http.ResponseWriter, r *http.Request) {
if in.Status != "" { if in.Status != "" {
in.Status = store.NormalizeStatus(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) p, err := a.Store.Update(id, in)
writeOne(w, p, err) writeOne(w, p, err)
case http.MethodDelete: case http.MethodDelete:
@@ -479,6 +759,83 @@ func writeProject(w http.ResponseWriter, p model.Project, err error) {
// ---------- settings ---------- // ---------- settings ----------
// credKeys 是从环境变量迁进后台的系统设置项。
// secret 为 true 的项 GET 时永远不回值,只报「是否已配置、来自哪里」。
type credKey struct {
key string // settings 表的键
kind string // 前端字段名(= key)
secret bool
envOf func(c *config.Config) string
}
var credKeys = []credKey{
{key: "site_url", envOf: func(c *config.Config) string { return c.SiteURL }},
{key: "github_client_id", envOf: func(c *config.Config) string { return c.GitHubClientID }},
{key: "github_client_secret", secret: true, envOf: func(c *config.Config) string { return c.GitHubClientSecret }},
{key: "google_client_id", envOf: func(c *config.Config) string { return c.GoogleClientID }},
{key: "google_client_secret", secret: true, envOf: func(c *config.Config) string { return c.GoogleClientSecret }},
{key: "telegram_bot", envOf: func(c *config.Config) string { return c.TelegramBot }},
{key: "telegram_bot_token", secret: true, envOf: func(c *config.Config) string { return c.TelegramToken }},
{key: "s3_endpoint", envOf: func(c *config.Config) string { return c.S3Endpoint }},
{key: "r2_bucket", envOf: func(c *config.Config) string { return c.R2Bucket }},
{key: "r2_access_key", secret: true, envOf: func(c *config.Config) string { return c.R2AccessKey }},
{key: "r2_secret_key", secret: true, envOf: func(c *config.Config) string { return c.R2SecretKey }},
{key: "uploads_public_base", envOf: func(c *config.Config) string { return c.UploadsPublicBase }},
{key: "admin_username", envOf: func(c *config.Config) string { return c.AdminUser }},
{key: "admin_password_hash", secret: true, envOf: func(*config.Config) string { return "" }},
}
// credentialMeta 告诉前端每个迁移项的配置状态:
// set=是否已有生效值,source=db(后台配的)/ env(环境变量兜底)/ unset。
func (a *API) credentialMeta() (map[string]any, error) {
m, err := a.Store.GetSettingsMap()
if err != nil {
return nil, err
}
c := a.cfg()
out := map[string]any{}
for _, ck := range credKeys {
src := "unset"
if ck.key == "admin_password_hash" {
// 多用户后密码在 owner 行上;settings 键只是旧版遗留引导,
// 行上有哈希就以此为准
if owner, err := a.Store.EnsureOwner(c.AdminUser); err == nil && owner.PasswordHash != "" {
src = "db"
} else if v, ok := m[ck.key]; ok && strings.TrimSpace(v) != "" {
src = "db"
} else {
src = "env" // env 显式密码或 dev 默认,都算「有生效值」
}
out[ck.key] = map[string]any{"set": src != "unset", "source": src}
continue
}
if v, ok := m[ck.key]; ok && strings.TrimSpace(v) != "" {
src = "db"
} else if ck.envOf(c) != "" {
src = "env"
}
out[ck.key] = map[string]any{"set": src != "unset", "source": src}
}
return out, nil
}
// settingsPut 是设置页的保存载荷:常规设置全量替换(与旧行为一致),
// secrets 只写传了非空值的键(秘密项留空 = 保持现值,永远不可能被顺手清掉)。
type settingsPut struct {
model.Settings
Secrets map[string]string `json:"secrets"`
}
// writableSecrets 是允许通过 PUT 写入的秘密键;admin_password 单独处理
// (入库前要 bcrypt 哈希)。
var writableSecrets = map[string]bool{
"github_client_secret": true,
"google_client_secret": true,
"telegram_bot_token": true,
"r2_access_key": true,
"r2_secret_key": true,
}
func (a *API) settings(w http.ResponseWriter, r *http.Request) { func (a *API) settings(w http.ResponseWriter, r *http.Request) {
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
@@ -487,28 +844,67 @@ func (a *API) settings(w http.ResponseWriter, r *http.Request) {
httpx.ServerError(w, err) httpx.ServerError(w, err)
return return
} }
httpx.OK(w, st) meta, err := a.credentialMeta()
if err != nil {
httpx.ServerError(w, err)
return
}
httpx.OK(w, map[string]any{"settings": st, "credential_meta": meta})
case http.MethodPut, http.MethodPost: case http.MethodPut, http.MethodPost:
var in model.Settings // 系统设置(含凭据与管理员用户名)是站主的权限;内容管理员只读
if act := actorFrom(r); act.Role != model.RoleOwner {
httpx.Error(w, http.StatusForbidden, "需要站主权限")
return
}
var in settingsPut
if err := httpx.Decode(r, &in); err != nil { if err := httpx.Decode(r, &in); err != nil {
httpx.BadRequest(w, "invalid body") httpx.BadRequest(w, "invalid body")
return return
} }
if err := a.Store.UpdateSettings(in); err != nil { // 密码变更不走这里:多用户后密码在 users 行上,
// 自己改走 /api/admin/account/password,重置别人走 /api/admin/users/{id}
for k, v := range in.Secrets {
v = strings.TrimSpace(v)
if v == "" {
continue // 留空 = 不改
}
if !writableSecrets[k] {
httpx.BadRequest(w, "unknown secret key: "+k)
return
}
if err := a.Store.SetSetting(k, v); err != nil {
httpx.ServerError(w, err)
return
}
}
if err := a.Store.UpdateSettings(in.Settings); err != nil {
httpx.ServerError(w, err) httpx.ServerError(w, err)
return return
} }
a.invalidate()
st, err := a.Store.GetSettings() st, err := a.Store.GetSettings()
if err != nil { if err != nil {
httpx.ServerError(w, err) httpx.ServerError(w, err)
return return
} }
httpx.OK(w, st) meta, err := a.credentialMeta()
if err != nil {
httpx.ServerError(w, err)
return
}
httpx.OK(w, map[string]any{"settings": st, "credential_meta": meta})
default: default:
httpx.Error(w, http.StatusMethodNotAllowed, "GET/PUT required") httpx.Error(w, http.StatusMethodNotAllowed, "GET/PUT required")
} }
} }
// invalidate 让有效配置缓存立即失效(Res 未装配时没有缓存,跳过)。
func (a *API) invalidate() {
if a.Res != nil {
a.Res.Invalidate()
}
}
func parseInt(s string) (int64, error) { func parseInt(s string) (int64, error) {
if s == "" { if s == "" {
return 0, errors.New("empty") return 0, errors.New("empty")
@@ -608,7 +1004,7 @@ func (a *API) files(w http.ResponseWriter, r *http.Request) {
return return
} }
for i := range fp.Items { for i := range fp.Items {
fp.Items[i].URL = storage.FileURL(fp.Items[i].Store, fp.Items[i].Key, a.Cfg.UploadsPublicBase) fp.Items[i].URL = storage.FileURL(fp.Items[i].Store, fp.Items[i].Key, a.cfg().UploadsPublicBase)
} }
httpx.OK(w, fp) httpx.OK(w, fp)
case http.MethodPost: case http.MethodPost:
@@ -648,7 +1044,7 @@ func (a *API) uploadFiles(w http.ResponseWriter, r *http.Request) {
} }
// URL 统一在这里解析:storeOne 的「去重复用已有行」路径也不例外 // URL 统一在这里解析:storeOne 的「去重复用已有行」路径也不例外
for i := range out { for i := range out {
out[i].URL = storage.FileURL(out[i].Store, out[i].Key, a.Cfg.UploadsPublicBase) out[i].URL = storage.FileURL(out[i].Store, out[i].Key, a.cfg().UploadsPublicBase)
} }
httpx.Created(w, out) httpx.Created(w, out)
} }
@@ -712,50 +1108,166 @@ func (a *API) storeOne(ctx context.Context, fh *multipart.FileHeader) (model.Fil
} }
sum := hex.EncodeToString(hasher.Sum(nil)) 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) key := fmt.Sprintf("%s/%s%s", time.Now().UTC().Format("2006/01"), sum[:12], ext)
// S3Api 端点带路径段时(如 .../oss),该段会折进对象 key—— // S3Api 端点带路径段时(如 .../oss),该段会折进对象 key——
// 数据库必须记录同样的完整 key,直链才不会 404 // 数据库必须记录同样的完整 key,直链才不会 404
if p := storage.EndpointKeyPrefix(a.Cfg.S3Endpoint); p != "" { if p := storage.EndpointKeyPrefix(a.cfg().S3Endpoint); p != "" {
key = p + "/" + key key = p + "/" + key
} }
// 内容去重:同一份内容只存一份,复用已有行 // 内容去重:同一份内容只存一份,复用已有行。
// URL 必须按当前存储配置重新解析——去重路径不走下面的 created 赋值,
// 漏了它转存替换会拿到空 URL(真实事故:正文图片链接被清空)。
if exist, err := a.Store.GetFileByKey(key); err == nil { if exist, err := a.Store.GetFileByKey(key); err == nil {
exist.URL = storage.FileURL(exist.Store, exist.Key, a.cfg().UploadsPublicBase)
return exist, nil return exist, nil
} }
f, err := os.Open(tmp.Name()) f, err := os.Open(tmpPath)
if err != nil { if err != nil {
return model.File{}, err return model.File{}, err
} }
defer f.Close() defer f.Close()
if err := a.Blobs.Put(ctx, key, f, size, mime); err != nil { if err := a.blobs().Put(ctx, key, f, size, mime); err != nil {
return model.File{}, err return model.File{}, err
} }
created, err := a.Store.CreateFile(model.File{ created, err := a.Store.CreateFile(model.File{
Key: key, Key: key,
Name: fh.Filename, Name: name,
Mime: mime, Mime: mime,
Size: size, Size: size,
SHA256: sum, SHA256: sum,
Store: a.Cfg.StorageDriver, Store: a.cfg().StorageDriver,
}) })
if err != nil { if err != nil {
return model.File{}, err return model.File{}, err
} }
created.URL = storage.FileURL(created.Store, created.Key, a.Cfg.UploadsPublicBase) created.URL = storage.FileURL(created.Store, created.Key, a.cfg().UploadsPublicBase)
return created, nil 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) { func (a *API) fileByID(w http.ResponseWriter, r *http.Request) {
id, err := parseInt(strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/admin/files/"), "/")) 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 { if err != nil {
httpx.BadRequest(w, "bad file id") httpx.BadRequest(w, "bad file id")
return return
} }
switch r.Method { switch r.Method {
case http.MethodGet: case http.MethodGet:
if sub == "refs" {
a.fileRefs(w, r, id)
return
}
f, err := a.Store.GetFile(id) f, err := a.Store.GetFile(id)
if errors.Is(err, store.ErrNotFound) { if errors.Is(err, store.ErrNotFound) {
httpx.NotFound(w) httpx.NotFound(w)
@@ -765,7 +1277,7 @@ func (a *API) fileByID(w http.ResponseWriter, r *http.Request) {
httpx.ServerError(w, err) httpx.ServerError(w, err)
return return
} }
f.URL = storage.FileURL(f.Store, f.Key, a.Cfg.UploadsPublicBase) f.URL = storage.FileURL(f.Store, f.Key, a.cfg().UploadsPublicBase)
httpx.OK(w, f) httpx.OK(w, f)
case http.MethodDelete: case http.MethodDelete:
f, err := a.Store.GetFile(id) f, err := a.Store.GetFile(id)
@@ -777,8 +1289,17 @@ func (a *API) fileByID(w http.ResponseWriter, r *http.Request) {
httpx.ServerError(w, err) httpx.ServerError(w, err)
return 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 { if err := a.blobs().Delete(r.Context(), f.Key); err != nil {
httpx.ServerError(w, err) httpx.ServerError(w, err)
return return
} }
@@ -786,12 +1307,58 @@ func (a *API) fileByID(w http.ResponseWriter, r *http.Request) {
httpx.ServerError(w, err) httpx.ServerError(w, err)
return return
} }
if a.Thumbs != nil {
a.Thumbs.Purge(f.SHA256)
}
httpx.OK(w, map[string]any{"ok": true}) httpx.OK(w, map[string]any{"ok": true})
default: default:
httpx.Error(w, http.StatusMethodNotAllowed, "GET/DELETE required") 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(评论审核与管理) ---------- // ---------- comments(评论审核与管理) ----------
func (a *API) adminComments(w http.ResponseWriter, r *http.Request) { func (a *API) adminComments(w http.ResponseWriter, r *http.Request) {
@@ -835,6 +1402,11 @@ func (a *API) adminCommentByID(w http.ResponseWriter, r *http.Request) {
httpx.ServerError(w, err) httpx.ServerError(w, err)
return 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}) httpx.OK(w, map[string]any{"ok": true})
case http.MethodDelete: case http.MethodDelete:
// 后台删除同样走软删(墓碑保楼层) // 后台删除同样走软删(墓碑保楼层)
@@ -846,6 +1418,11 @@ func (a *API) adminCommentByID(w http.ResponseWriter, r *http.Request) {
httpx.ServerError(w, err) httpx.ServerError(w, err)
return 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}) httpx.OK(w, map[string]any{"ok": true})
default: default:
httpx.Error(w, http.StatusMethodNotAllowed, "PUT/DELETE required") httpx.Error(w, http.StatusMethodNotAllowed, "PUT/DELETE required")
+251 -3
View File
@@ -8,6 +8,8 @@ import (
"testing" "testing"
"time" "time"
"golang.org/x/crypto/bcrypt"
"oneblog/internal/config" "oneblog/internal/config"
"oneblog/internal/db" "oneblog/internal/db"
"oneblog/internal/model" "oneblog/internal/model"
@@ -176,10 +178,15 @@ func TestSettingsUIRoundTrip(t *testing.T) {
if rec.Code != http.StatusOK { if rec.Code != http.StatusOK {
t.Fatalf("get settings: got %d", rec.Code) t.Fatalf("get settings: got %d", rec.Code)
} }
var got model.Settings var wrap struct {
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil { Settings model.Settings `json:"settings"`
CredentialMeta map[string]any `json:"credential_meta"`
Secrets map[string]string `json:"-"`
}
if err := json.NewDecoder(rec.Body).Decode(&wrap); err != nil {
t.Fatalf("decode: %v", err) t.Fatalf("decode: %v", err)
} }
got := wrap.Settings
if got.UIID != "vivid" { if got.UIID != "vivid" {
t.Errorf("ui_id = %q, want vivid", got.UIID) t.Errorf("ui_id = %q, want vivid", got.UIID)
} }
@@ -189,6 +196,11 @@ func TestSettingsUIRoundTrip(t *testing.T) {
if _, ok := got.CustomCSS["bogus"]; ok { if _, ok := got.CustomCSS["bogus"]; ok {
t.Error("unknown section should not be persisted") t.Error("unknown section should not be persisted")
} }
// 秘密项在任何响应里都不该出现明文
if strings.Contains(rec.Body.String(), "client_secret\":") &&
!strings.Contains(rec.Body.String(), "credential_meta") {
t.Error("settings response should not carry raw secrets")
}
// An invalid ui_id falls back rather than being stored verbatim. // An invalid ui_id falls back rather than being stored verbatim.
rec = put(`{"site_title":"ONE","posts_per_page":10,"light_skin_id":"paper","ui_id":"neon","custom_css":{}}`) rec = put(`{"site_title":"ONE","posts_per_page":10,"light_skin_id":"paper","ui_id":"neon","custom_css":{}}`)
@@ -197,7 +209,8 @@ func TestSettingsUIRoundTrip(t *testing.T) {
} }
rec = httptest.NewRecorder() rec = httptest.NewRecorder()
h.ServeHTTP(rec, get) h.ServeHTTP(rec, get)
_ = json.NewDecoder(rec.Body).Decode(&got) _ = json.NewDecoder(rec.Body).Decode(&wrap)
got = wrap.Settings
if got.UIID != "classic" { if got.UIID != "classic" {
t.Errorf("invalid ui_id should fall back to classic, got %q", got.UIID) t.Errorf("invalid ui_id should fall back to classic, got %q", got.UIID)
} }
@@ -205,3 +218,238 @@ func TestSettingsUIRoundTrip(t *testing.T) {
t.Error("custom_css should be non-nil") t.Error("custom_css should be non-nil")
} }
} }
// 系统设置迁移项:secrets 走单独通道写入且不可读回;DB 值在有效配置里
// 覆盖环境变量;登录改用 DB 密码后 env 密码是否还作数取决于是否显式配置。
func TestSettingsCredentialsRoundTrip(t *testing.T) {
a, h := newTestAPI(t)
a.Cfg = &config.Config{SiteURL: "http://env.example", AdminUser: "admin",
AdminPass: "s3cret", GitHubClientID: "env-id", GitHubClientSecret: "env-sec",
GoogleClientID: "env-google"}
a.Res = config.NewResolver(a.Cfg, a.Store)
token := login(t, h, "admin", "s3cret")
var sess struct {
Token string `json:"token"`
}
_ = json.NewDecoder(token.Body).Decode(&sess)
put := func(body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPut, "/api/admin/settings", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+sess.Token)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
// 写入 DB 值(含一个 secret);响应与 GET 都不能回显 secret 明文。
// 密码不再走 settings(多用户后在 users 行上),改走 account/password 端点。
rec := put(`{"site_url":"https://db.example","github_client_id":"db-id",
"secrets":{"github_client_secret":"db-sec"}}`)
if rec.Code != http.StatusOK {
t.Fatalf("put: got %d body=%s", rec.Code, rec.Body.String())
}
if strings.Contains(rec.Body.String(), "db-sec") {
t.Fatal("secret echoed back in plaintext")
}
// admin_password 已退役:出现即 400
if rec := put(`{"secrets":{"admin_password":"newpass1"}}`); rec.Code != http.StatusBadRequest {
t.Errorf("legacy admin_password: got %d, want 400", rec.Code)
}
// 来源标记:site_url 来自 db,client_id 来自 db,secret 来自 db;
// 未写的项回落 env;密码此时还在 env 引导链上(首次登录已自迁移成行哈希,
// 来源也是 db)
if meta, err := a.credentialMeta(); err != nil {
t.Fatal(err)
} else {
want := map[string]string{
"site_url": "db", "github_client_id": "db",
"github_client_secret": "db", "google_client_id": "env",
"admin_password_hash": "db",
}
for k, src := range want {
m, _ := meta[k].(map[string]any)
if m == nil || m["source"] != src {
t.Errorf("meta[%s] = %+v, want source=%s", k, m, src)
}
}
}
// 有效配置:DB 值覆盖 env;没配的项回落 env
c := a.cfg()
if c.SiteURL != "https://db.example" || c.GitHubClientID != "db-id" {
t.Errorf("overlay failed: site_url=%q client_id=%q", c.SiteURL, c.GitHubClientID)
}
if c.GitHubClientSecret != "db-sec" {
t.Errorf("secret overlay failed: %q", c.GitHubClientSecret)
}
// 自己改密码:旧密码错被拒,对了立即生效;显式 env 密码仍作后路
patch := func(body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPatch, "/api/admin/account/password", strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+sess.Token)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
if rec := patch(`{"old_password":"wrong","new_password":"newpass1"}`); rec.Code != http.StatusForbidden {
t.Errorf("wrong old password: got %d, want 403", rec.Code)
}
if rec := patch(`{"old_password":"s3cret","new_password":"newpass1"}`); rec.Code != http.StatusOK {
t.Errorf("change password: got %d body=%s", rec.Code, rec.Body.String())
}
if _, ok := a.verifyAdmin("admin", "newpass1"); !ok {
t.Error("row password should work right after change")
}
if _, ok := a.verifyAdmin("admin", "s3cret"); !ok {
t.Error("explicit env password should stay as backstop")
}
if _, ok := a.verifyAdmin("admin", "wrong"); ok {
t.Error("wrong password must fail")
}
}
// InsecureDev(env 未显式设密码)时 admin/admin 有效,但后台一旦改了密码
// admin/admin 必须立刻失效。
// InsecureDev(env 未显式设密码)时 admin/admin 有效;首次登录会把引导凭据
// 自迁移成 owner 行哈希——之后站主改了密码(行哈希更新),admin/admin 即失效。
func TestInsecureDevDisabledByDBPassword(t *testing.T) {
a, _ := newTestAPI(t)
a.Cfg = &config.Config{AdminUser: "admin", AdminPass: "admin", InsecureDev: true}
if _, ok := a.verifyAdmin("admin", "admin"); !ok {
t.Fatal("insecure default should work before any password is set")
}
owner, err := a.Store.EnsureOwner("admin")
if err != nil {
t.Fatal(err)
}
if owner.PasswordHash == "" {
t.Fatal("first login should migrate bootstrap credentials onto the owner row")
}
if err := a.Store.SetStaffPassword(owner.ID, bcryptHash(t, "newpass1")); err != nil {
t.Fatal(err)
}
if _, ok := a.verifyAdmin("admin", "admin"); ok {
t.Error("admin/admin must stop working once a row password exists")
}
if _, ok := a.verifyAdmin("admin", "newpass1"); !ok {
t.Error("row password should be accepted")
}
}
func bcryptHash(t *testing.T, pw string) string {
t.Helper()
b, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.MinCost)
if err != nil {
t.Fatal(err)
}
return string(b)
}
// 多用户与角色:owner 创建 admin 账号;admin 能进内容接口,
// 动不了系统设置与用户管理;被停用后存量会话立即失效。
func TestMultiUserLifecycle(t *testing.T) {
_, h := newTestAPI(t)
tokOf := func(user, pass string) string {
rec := login(t, h, user, pass)
var out struct {
Token string `json:"token"`
Role string `json:"role"`
}
if rec.Code != http.StatusOK {
t.Fatalf("login %s: got %d body=%s", user, rec.Code, rec.Body.String())
}
if err := json.NewDecoder(rec.Body).Decode(&out); err != nil || out.Token == "" {
t.Fatalf("login %s: no token: %v", user, err)
}
if user == "admin" && out.Role != model.RoleOwner {
t.Errorf("owner role = %q", out.Role)
}
return out.Token
}
reqAs := func(tok, method, path, body string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Authorization", "Bearer "+tok)
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
owner := tokOf("admin", "s3cret")
// owner 创建内容管理员
rec := reqAs(owner, http.MethodPost, "/api/admin/users", `{"username":"editor","password":"editor1"}`)
if rec.Code != http.StatusCreated {
t.Fatalf("create staff: got %d body=%s", rec.Code, rec.Body.String())
}
// 用户名占用
if rec := reqAs(owner, http.MethodPost, "/api/admin/users", `{"username":"editor","password":"editor1"}`); rec.Code != http.StatusConflict {
t.Errorf("duplicate username: got %d, want 409", rec.Code)
}
// staff 登录,角色是 admin
staff := tokOf("editor", "editor1")
// 内容接口可进
if rec := reqAs(staff, http.MethodGet, "/api/admin/posts", ""); rec.Code != http.StatusOK {
t.Errorf("staff read posts: got %d", rec.Code)
}
// 系统设置写不了、用户管理进不去
if rec := reqAs(staff, http.MethodPut, "/api/admin/settings", `{"site_title":"x"}`); rec.Code != http.StatusForbidden {
t.Errorf("staff put settings: got %d, want 403", rec.Code)
}
if rec := reqAs(staff, http.MethodGet, "/api/admin/users", ""); rec.Code != http.StatusForbidden {
t.Errorf("staff list users: got %d, want 403", rec.Code)
}
if rec := reqAs(staff, http.MethodPost, "/api/admin/users", `{"username":"x2","password":"xxxxxx"}`); rec.Code != http.StatusForbidden {
t.Errorf("staff create user: got %d, want 403", rec.Code)
}
// owner 重置 staff 密码后,新密码立即生效
staffID := 0
list := reqAs(owner, http.MethodGet, "/api/admin/users", "")
var lu struct {
Users []model.Reader `json:"users"`
}
_ = json.NewDecoder(list.Body).Decode(&lu)
for _, u := range lu.Users {
if u.Handle == "editor" {
staffID = int(u.ID)
if u.Role != model.RoleAdmin {
t.Errorf("staff role = %q, want admin", u.Role)
}
}
}
if staffID == 0 {
t.Fatal("editor not in staff list")
}
if rec := reqAs(owner, http.MethodPatch, "/api/admin/users/"+itoa(int64(staffID)),
`{"password":"reset99"}`); rec.Code != http.StatusOK {
t.Errorf("reset password: got %d", rec.Code)
}
if rec := login(t, h, "editor", "reset99"); rec.Code != http.StatusOK {
t.Errorf("login with reset password: got %d", rec.Code)
}
// 停用后存量会话立即 401;owner 行与自身不可停用 / 不可删
if rec := reqAs(owner, http.MethodPatch, "/api/admin/users/"+itoa(int64(staffID)), `{"banned":true}`); rec.Code != http.StatusOK {
t.Fatalf("ban staff: got %d", rec.Code)
}
if rec := reqAs(staff, http.MethodGet, "/api/admin/posts", ""); rec.Code != http.StatusUnauthorized {
t.Errorf("banned staff session: got %d, want 401", rec.Code)
}
if rec := login(t, h, "editor", "reset99"); rec.Code != http.StatusUnauthorized {
t.Errorf("banned staff login: got %d, want 401", rec.Code)
}
if rec := reqAs(owner, http.MethodDelete, "/api/admin/users/"+itoa(int64(staffID)), ""); rec.Code != http.StatusOK {
t.Errorf("delete staff: got %d", rec.Code)
}
// owner 行自我保护
if rec := reqAs(owner, http.MethodDelete, "/api/admin/users/1", ""); rec.Code == http.StatusOK {
t.Error("owner row must not be deletable")
}
}
+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, _ := 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, a, 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, a, http.MethodGet, "/api/admin/files/9999/refs", "")
if rec.Code != http.StatusNotFound {
t.Fatalf("want 404, got %d", rec.Code)
}
// 乱七八糟的子路径不收
rec = doAs(t, a, 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, _ := 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, a, 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, _ := 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, a, 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, _ := newTestAPI(t)
a.Blobs = storage.NewLocal(t.TempDir())
f := seed(t, a, "2026/09/ddd.png", "")
rec := doAs(t, a, 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 package admin
import ( import (
"net"
"net/http" "net/http"
"sync"
"time" "time"
"oneblog/internal/ratelimit"
) )
// loginLimiter 限制每个来源 IP 的登录失败次数(滑动窗口), // loginLimiter 限制每个来源 IP 的登录失败次数(滑动窗口),
// 防止默认/弱口令被在线暴力破解。成功登录后计数清零。 // 防止默认/弱口令被在线暴力破解。成功登录后计数清零。
type loginLimiter struct { // 实现挪到了 internal/ratelimit(读者登录/评论写入共用),这里保留
mu sync.Mutex // 原有调用面与常量。
hits map[string][]time.Time type loginLimiter = ratelimit.Window
}
const ( const (
maxLoginFails = 10 maxLoginFails = 10
@@ -20,55 +19,10 @@ const (
) )
func newLoginLimiter() *loginLimiter { func newLoginLimiter() *loginLimiter {
return &loginLimiter{hits: make(map[string][]time.Time)} return ratelimit.New(maxLoginFails, loginWindow)
} }
func (l *loginLimiter) blocked(key string) bool { func sourceKey(r *http.Request) string { return ratelimit.SourceKey(r) }
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
}
// isTLS 判断最终用户看到的是不是 HTTPS(含反代 X-Forwarded-Proto)。 // isTLS 判断最终用户看到的是不是 HTTPS(含反代 X-Forwarded-Proto)。
func isTLS(r *http.Request) bool { 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})
}
+27
View File
@@ -0,0 +1,27 @@
// 有效配置与存储后端的取用口。系统设置迁进后台后,凭据 / 站点地址不再是
// 启动期常量:按「DB 设置叠加 env」现算(Res.Config 自带短缓存),
// 后台保存设置后立即生效。
package admin
import (
"oneblog/internal/config"
"oneblog/internal/storage"
)
// cfg 返回当前请求应使用的有效配置。
func (a *API) cfg() *config.Config {
if a.Res != nil {
return a.Res.Config()
}
return a.Cfg
}
// blobs 返回当前存储后端(Res 存在时支持后台改配置热重建)。
func (a *API) blobs() storage.BlobStore {
if a.Res != nil {
if b := a.Res.Blobs(); b != nil {
return b
}
}
return a.Blobs
}
+22 -16
View File
@@ -11,8 +11,10 @@ import (
"time" "time"
) )
// Sessions are stateless: base64("user:expiryUnix") + "." + HMAC-SHA256. // Sessions are stateless: base64("user:<id>:expiryUnix") + "." + HMAC-SHA256.
// They survive restarts as long as ONE_SECRET stays the same. // They survive restarts as long as ONE_SECRET stays the same. The token only
// carries the user's DB id — role / ban state is read fresh from the database
// on every request, so demotions and bans take effect immediately.
type Sessions struct { type Sessions struct {
secret []byte secret []byte
ttl time.Duration ttl time.Duration
@@ -27,37 +29,41 @@ func NewSessions(secret string, ttl time.Duration) *Sessions {
var ErrBadSession = errors.New("invalid session") var ErrBadSession = errors.New("invalid session")
func (s *Sessions) Issue(user string) (string, time.Time) { func (s *Sessions) Issue(userID int64) (string, time.Time) {
exp := time.Now().Add(s.ttl) exp := time.Now().Add(s.ttl)
payload := base64.RawURLEncoding.EncodeToString([]byte(user + ":" + strconv.FormatInt(exp.Unix(), 10))) payload := base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf("user:%d:%d", userID, exp.Unix())))
return payload + "." + s.sign(payload), exp return payload + "." + s.sign(payload), exp
} }
func (s *Sessions) Verify(token string) (string, error) { func (s *Sessions) Verify(token string) (int64, error) {
parts := strings.Split(token, ".") parts := strings.Split(token, ".")
if len(parts) != 2 { if len(parts) != 2 {
return "", ErrBadSession return 0, ErrBadSession
} }
if !hmac.Equal([]byte(s.sign(parts[0])), []byte(parts[1])) { if !hmac.Equal([]byte(s.sign(parts[0])), []byte(parts[1])) {
return "", ErrBadSession return 0, ErrBadSession
} }
raw, err := base64.RawURLEncoding.DecodeString(parts[0]) raw, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil { if err != nil {
return "", ErrBadSession return 0, ErrBadSession
} }
i := strings.LastIndex(string(raw), ":") // user:<id>:<exp>
if i <= 0 { f := strings.Split(string(raw), ":")
return "", ErrBadSession if len(f) != 3 || f[0] != "user" {
return 0, ErrBadSession
} }
user := string(raw)[:i] id, err := strconv.ParseInt(f[1], 10, 64)
expUnix, err := strconv.ParseInt(string(raw)[i+1:], 10, 64) if err != nil || id <= 0 {
return 0, ErrBadSession
}
expUnix, err := strconv.ParseInt(f[2], 10, 64)
if err != nil { if err != nil {
return "", ErrBadSession return 0, ErrBadSession
} }
if time.Now().After(time.Unix(expUnix, 0)) { if time.Now().After(time.Unix(expUnix, 0)) {
return "", ErrBadSession return 0, ErrBadSession
} }
return user, nil return id, nil
} }
func (s *Sessions) sign(payload string) string { func (s *Sessions) sign(payload string) string {
+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.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.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.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, u.ID)
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.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, userID int64) {
token, exp := a.AdminSessions.Issue(userID)
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, u.ID)
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) (int64, error) {
if f.valid[token] {
return 1, nil
}
return 0, errors.New("bad session")
}
func (f fakeAdmin) Issue(int64) (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",
GitHubClientID: "id", GitHubClientSecret: "sec"}
a.AdminSessions = fakeAdmin{valid: map[string]bool{"good-session": true}}
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.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)
}
}
}
+96 -14
View File
@@ -15,26 +15,67 @@ import (
"oneblog/internal/auth" "oneblog/internal/auth"
"oneblog/internal/config" "oneblog/internal/config"
"oneblog/internal/httpx" "oneblog/internal/httpx"
"oneblog/internal/hub"
"oneblog/internal/model" "oneblog/internal/model"
"oneblog/internal/ratelimit"
"oneblog/internal/render"
"oneblog/internal/storage" "oneblog/internal/storage"
"oneblog/internal/store" "oneblog/internal/store"
"oneblog/internal/thumbs"
) )
type API struct { type API struct {
Store *store.Store Store *store.Store
Cfg *config.Config // Cfg 是环境变量配置(兜底值)。设置了 Res 时一律以 Res.Config()
Blobs storage.BlobStore // 文件上传的存储后端(main.go 装配,与 admin 共享) // 的有效配置为准(DB 里的系统设置优先);Res 为 nil(部分测试)才直接用它。
Cfg *config.Config
// Res 解析后台「系统设置」(DB 叠加 env),main.go 装配;可为 nil。
Res *config.Resolver
Blobs storage.BlobStore // 文件上传的存储后端(Res 设置时的兜底,二者取其一)
// Thumbs 是缩略图磁盘缓存(main.go 装配,DataDir/.thumbnail_cache)。
// 为 nil 时 /uploads/thumb/ 路由直接回原图。
Thumbs *thumbs.Store
// 评论区读者会话与 GitHub OAuth(main.go 装配) // 评论区读者会话与 GitHub OAuth(main.go 装配)
ReaderSessions *auth.ReaderSessions ReaderSessions *auth.ReaderSessions
GH auth.GitHub // AdminSessions 是后台管理员会话(admin.Sessions 满足它)。
// AdminSessions 是后台管理员会话验证器(admin.Sessions 满足它)。 // 前台访客登录时命中「已绑定给站主」的身份就靠它发后台会话。
// 管理员登录后台后无需再走读者登录即可用站主身份评论。 // token 只带用户 ID(多用户后角色每请求查库,改角色 / 停用即时生效)。
AdminSessions interface { AdminSessions interface {
Verify(token string) (string, error) Verify(token string) (int64, error)
Issue(userID int64) (string, time.Time)
TTL() int
} }
// 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 { 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 := http.NewServeMux()
mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
if err := a.Store.Ping(r.Context()); err != nil { if err := a.Store.Ping(r.Context()); err != nil {
@@ -49,7 +90,16 @@ func (a *API) Routes() http.Handler {
mux.HandleFunc("/api/auth/logout", a.authLogout) mux.HandleFunc("/api/auth/logout", a.authLogout)
mux.HandleFunc("/api/auth/github/login", a.githubLogin) mux.HandleFunc("/api/auth/github/login", a.githubLogin)
mux.HandleFunc("/api/auth/callback/github", a.githubCallback) 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", a.comments)
mux.HandleFunc("/api/comments/stream", a.commentsStream)
mux.HandleFunc("/api/comments/", a.commentSub) mux.HandleFunc("/api/comments/", a.commentSub)
mux.HandleFunc("/api/site", a.site) mux.HandleFunc("/api/site", a.site)
mux.HandleFunc("/api/posts", a.listPosts) mux.HandleFunc("/api/posts", a.listPosts)
@@ -75,7 +125,25 @@ func (a *API) site(w http.ResponseWriter, r *http.Request) {
httpx.ServerError(w, err) httpx.ServerError(w, err)
return return
} }
httpx.OK(w, st) // /api/site 是公开端点:管理员用户名只服务后台设置页,不外泄
st.AdminUsername = ""
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 { func listOptions(r *http.Request, defSize int) store.ListOptions {
@@ -95,6 +163,12 @@ func (a *API) listPosts(w http.ResponseWriter, r *http.Request) {
httpx.ServerError(w, err) httpx.ServerError(w, err)
return 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) httpx.OK(w, page)
} }
@@ -123,6 +197,8 @@ func (a *API) getPost(w http.ResponseWriter, r *http.Request) {
if err != nil { if err != nil {
nb = nil nb = nil
} }
// 详情正文出口做盘古之白(存库不动,改的是渲染层)
p.ContentHTML = render.PanguHTML(p.ContentHTML)
httpx.OK(w, struct { httpx.OK(w, struct {
model.Post model.Post
Neighbors []model.Post `json:"neighbors"` Neighbors []model.Post `json:"neighbors"`
@@ -202,7 +278,7 @@ func (a *API) RSS(w http.ResponseWriter, r *http.Request) {
httpx.ServerError(w, err) httpx.ServerError(w, err)
return return
} }
base := strings.TrimSuffix(a.Cfg.SiteURL, "/") base := strings.TrimSuffix(a.siteURL(), "/")
feed := rssFeed{ feed := rssFeed{
Version: "2.0", Version: "2.0",
@@ -222,7 +298,7 @@ func (a *API) RSS(w http.ResponseWriter, r *http.Request) {
} }
desc := p.Summary desc := p.Summary
if desc == "" { if desc == "" {
desc = trimRunes(stripTags(p.ContentHTML), 160) desc = trimRunes(stripTags(render.PanguHTML(p.ContentHTML)), 160)
} }
item := rssItem{ item := rssItem{
Title: title, Title: title,
@@ -323,23 +399,29 @@ func (a *API) uploads(w http.ResponseWriter, r *http.Request) {
} }
// R2 且配了公开域名:302 到直链,后端不出流量 // R2 且配了公开域名:302 到直链,后端不出流量
if f.Store == "r2" && a.Cfg.UploadsPublicBase != "" { if f.Store == "r2" && a.cfg().UploadsPublicBase != "" {
http.Redirect(w, r, storage.FileURL(f.Store, f.Key, a.Cfg.UploadsPublicBase), http.StatusFound) http.Redirect(w, r, storage.FileURL(f.Store, f.Key, a.cfg().UploadsPublicBase), http.StatusFound)
return return
} }
rc, size, err := a.Blobs.Open(r.Context(), f.Key) rc, size, err := a.blobs().Open(r.Context(), f.Key)
if err != nil { if err != nil {
httpx.NotFound(w) httpx.NotFound(w)
return return
} }
defer rc.Close() defer rc.Close()
// ServeContent 自带 Range(视频/音频拖动进度条必需)、ETag 比对与 304。
// key 含内容哈希:内容不变则 URL 不变,可永久缓存。
w.Header().Set("Content-Type", f.Mime) w.Header().Set("Content-Type", f.Mime)
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
w.Header().Set("ETag", `"`+f.SHA256+`"`) w.Header().Set("ETag", `"`+f.SHA256+`"`)
// key 含内容哈希:内容不变则 URL 不变,可永久缓存
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") 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+`"` { if match := r.Header.Get("If-None-Match"); match != "" && match == `"`+f.SHA256+`"` {
w.WriteHeader(http.StatusNotModified) w.WriteHeader(http.StatusNotModified)
return return
+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",
TelegramBot: "testbot", TelegramToken: "123:abc"},
ReaderSessions: auth.NewReaderSessions("test-secret", time.Hour),
}
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)
}
}
+61 -52
View File
@@ -10,7 +10,6 @@ import (
"encoding/hex" "encoding/hex"
"errors" "errors"
"net/http" "net/http"
"net/url"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -18,6 +17,7 @@ import (
"oneblog/internal/auth" "oneblog/internal/auth"
"oneblog/internal/httpx" "oneblog/internal/httpx"
"oneblog/internal/model" "oneblog/internal/model"
"oneblog/internal/ratelimit"
"oneblog/internal/render" "oneblog/internal/render"
"oneblog/internal/store" "oneblog/internal/store"
) )
@@ -42,7 +42,8 @@ func (a *API) readerID(r *http.Request) (int64, bool) {
} }
// resolveReader 解出当前访客的读者身份:读者会话优先, // resolveReader 解出当前访客的读者身份:读者会话优先,
// 其次是后台管理员会话(自动 upsert 一个 provider=admin 的站主读者)。 // 其次是后台管理员会话——管理员用自己 users 行上的身份发言
// (多用户后 owner / admin 各自署名,不再都挂在站主名下)。
func (a *API) resolveReader(r *http.Request) (model.Reader, bool, error) { func (a *API) resolveReader(r *http.Request) (model.Reader, bool, error) {
if ck, err := r.Cookie(auth.ReaderCookie); err == nil && ck.Value != "" { if ck, err := r.Cookie(auth.ReaderCookie); err == nil && ck.Value != "" {
if id, verr := a.ReaderSessions.Verify(ck.Value); verr == nil { if id, verr := a.ReaderSessions.Verify(ck.Value); verr == nil {
@@ -54,9 +55,17 @@ func (a *API) resolveReader(r *http.Request) (model.Reader, bool, error) {
} }
if a.AdminSessions != nil { if a.AdminSessions != nil {
if ck, err := r.Cookie("one_session"); err == nil && ck.Value != "" { if ck, err := r.Cookie("one_session"); err == nil && ck.Value != "" {
if _, verr := a.AdminSessions.Verify(ck.Value); verr == nil { if id, verr := a.AdminSessions.Verify(ck.Value); verr == nil {
rd, oerr := a.ownerReader() rd, gerr := a.Store.GetReader(id)
if oerr == nil { // 行被删 / 被停用的后台账号:会话当作不存在,
// 不能落到站主身份上顶名发言
if gerr == nil && rd.Provider == "admin" && !rd.Banned {
if rd.Name == "" {
// 首次发言补一次署名(站点作者名)
if filled, ferr := a.ownerReader(); ferr == nil {
return filled, true, nil
}
}
return rd, true, nil return rd, true, nil
} }
} }
@@ -72,20 +81,10 @@ func (a *API) ownerReader() (model.Reader, error) {
name = st.AuthorName name = st.AuthorName
} }
return a.Store.UpsertReader(model.Reader{ return a.Store.UpsertReader(model.Reader{
Provider: "admin", Handle: a.Cfg.AdminUser, Name: name, Provider: "admin", Handle: a.cfg().AdminUser, Name: name,
}) })
} }
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",
})
}
httpx.OK(w, map[string]any{"providers": providers})
}
func (a *API) authMe(w http.ResponseWriter, r *http.Request) { func (a *API) authMe(w http.ResponseWriter, r *http.Request) {
var user any // 匿名时 {user: null},前端判空即「未登录」 var user any // 匿名时 {user: null},前端判空即「未登录」
if reader, ok, err := a.resolveReader(r); err == nil && ok { if reader, ok, err := a.resolveReader(r); err == nil && ok {
@@ -103,73 +102,68 @@ func (a *API) authLogout(w http.ResponseWriter, r *http.Request) {
httpx.OK(w, map[string]any{"ok": true}) httpx.OK(w, map[string]any{"ok": true})
} }
// githubLogin 跳转 GitHub 授权页。state 防 CSRF 存短命 cookie; // githubLogin 跳转 GitHub 授权页。state / 回跳地址的处理抽到 startOAuth,
// 授权完成回到 callback 后必须带上同一个值。 // 与「绑定」入口共用同一套跳转(bind 只是多打一个一次性 cookie)。
// 同时把发起登录的前台 origin 记下来(one_oauth_back),
// callback 用它跳回去——开发时前端 3000 / 后端 8080 分离才不会落错站。
func (a *API) githubLogin(w http.ResponseWriter, r *http.Request) { func (a *API) githubLogin(w http.ResponseWriter, r *http.Request) {
if !a.GH.Enabled() { if !a.gh().Enabled() {
httpx.NotFound(w) httpx.NotFound(w)
return return
} }
state := randHex(16) a.startOAuth(w, r, func(state string) string {
http.SetCookie(w, &http.Cookie{Name: oauthStateCook, Value: state, Path: "/", return a.gh().LoginURL(a.siteURL()+"/api/auth/callback/github", state)
HttpOnly: true, MaxAge: 600}) })
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, a.GH.LoginURL(a.Cfg.SiteURL+"/api/auth/callback/github", state), http.StatusFound)
} }
// githubCallback 用 code 换身份:GitHub 用户 → upsert 读者 → 发会话 → // githubCallback 用 code 换身份,交给统一分流(绑定 / 已绑账号 / 新读者)。
// 回到首页。
func (a *API) githubCallback(w http.ResponseWriter, r *http.Request) { func (a *API) githubCallback(w http.ResponseWriter, r *http.Request) {
if !a.GH.Enabled() { if !a.gh().Enabled() {
httpx.NotFound(w) httpx.NotFound(w)
return return
} }
ip := ratelimit.SourceKey(r)
if a.authFails.Blocked(ip) {
httpx.Error(w, http.StatusTooManyRequests, "登录失败次数过多,请稍后再试")
return
}
ck, err := r.Cookie(oauthStateCook) ck, err := r.Cookie(oauthStateCook)
if err != nil || ck.Value == "" || ck.Value != r.FormValue("state") { if err != nil || ck.Value == "" || ck.Value != r.FormValue("state") {
a.authFails.Add(ip)
httpx.BadRequest(w, "state 不匹配,请重新登录") httpx.BadRequest(w, "state 不匹配,请重新登录")
return return
} }
gh, err := a.GH.Exchange(r.Context(), r.FormValue("code"), a.Cfg.SiteURL+"/api/auth/callback/github") gh, err := a.gh().Exchange(r.Context(), r.FormValue("code"), a.siteURL()+"/api/auth/callback/github")
if err != nil { if err != nil {
a.authFails.Add(ip)
httpx.ServerError(w, err) httpx.ServerError(w, err)
return return
} }
u, err := a.GH.FetchUser(r.Context(), gh) u, err := a.gh().FetchUser(r.Context(), gh)
if err != nil { if err != nil {
a.authFails.Add(ip)
httpx.ServerError(w, err) httpx.ServerError(w, err)
return return
} }
if u.Login == "" {
a.authFails.Add(ip)
httpx.ServerError(w, errors.New("github 未返回用户名"))
return
}
name := u.Name name := u.Name
if name == "" { if name == "" {
name = u.Login name = u.Login
} }
reader, err := a.Store.UpsertReader(model.Reader{ persona := model.Reader{
Provider: "github", Handle: u.Login, Name: name, Provider: "github", Handle: u.Login, Name: name,
AvatarURL: u.AvatarURL, URL: u.HTMLURL, AvatarURL: u.AvatarURL, URL: u.HTMLURL,
})
if err != nil {
httpx.ServerError(w, err)
return
} }
token, _ := a.ReaderSessions.Issue(reader.ID) // 稳定 id 才是绑定键;老接口没返回 id 时退化为按 handle 认人
http.SetCookie(w, &http.Cookie{Name: auth.ReaderCookie, Value: token, Path: "/", extern := strconv.FormatInt(u.ID, 10)
HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: int((30 * 24 * time.Hour).Seconds())}) if u.ID == 0 {
// 回到发起登录的前台;没有记录(直接敲 URL 进来的)就回站点根 extern = ""
back := a.Cfg.SiteURL }
if ck, err := r.Cookie(oauthBackCook); err == nil && ck.Value != "" { if back := a.afterIdentity(w, r, "github", extern, u.Login, persona); back != "" {
if u, err := url.Parse(ck.Value); err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" && u.Path == "" { http.Redirect(w, r, back, http.StatusFound)
back = u.Scheme + "://" + u.Host
}
} }
http.SetCookie(w, &http.Cookie{Name: oauthBackCook, Value: "", Path: "/", MaxAge: -1})
http.Redirect(w, r, back, http.StatusFound)
} }
func randHex(n int) string { func randHex(n int) string {
@@ -227,6 +221,15 @@ func (a *API) createComment(w http.ResponseWriter, r *http.Request) {
httpx.Error(w, http.StatusForbidden, "你已被禁言,暂时无法评论") httpx.Error(w, http.StatusForbidden, "你已被禁言,暂时无法评论")
return 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() st, err := a.Store.GetSettings()
if err != nil { if err != nil {
httpx.ServerError(w, err) httpx.ServerError(w, err)
@@ -288,6 +291,10 @@ func (a *API) createComment(w http.ResponseWriter, r *http.Request) {
httpx.ServerError(w, err) httpx.ServerError(w, err)
return return
} }
a.Hub.Broadcast(in.PostID)
if rateKey != "" {
a.commentNew.Add(rateKey) // 只计成功写入:空正文这类手滑不扣配额
}
httpx.Created(w, c) httpx.Created(w, c)
} }
@@ -390,6 +397,7 @@ func (a *API) editComment(w http.ResponseWriter, r *http.Request, id int64) {
httpx.ServerError(w, err) httpx.ServerError(w, err)
return return
} }
a.Hub.Broadcast(updated.PostID)
httpx.OK(w, updated) httpx.OK(w, updated)
} }
@@ -417,6 +425,7 @@ func (a *API) deleteComment(w http.ResponseWriter, r *http.Request, id int64) {
httpx.ServerError(w, err) httpx.ServerError(w, err)
return return
} }
a.Hub.Broadcast(c.PostID)
httpx.OK(w, map[string]any{"ok": true}) httpx.OK(w, map[string]any{"ok": true})
} }
+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.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.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, u.ID)
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
}
+49
View File
@@ -0,0 +1,49 @@
// 有效配置与登录方式的取用口。OAuth / Telegram 凭据迁移进后台系统设置后
// 不再是启动期常量:每次请求按「DB 设置叠加 env」现算(Res.Config 自带
// 短缓存),后台改完凭据不用重启就生效。
package api
import (
"oneblog/internal/auth"
"oneblog/internal/config"
"oneblog/internal/storage"
)
// cfg 返回当前请求应使用的有效配置。
func (a *API) cfg() *config.Config {
if a.Res != nil {
return a.Res.Config()
}
return a.Cfg
}
// blobs 返回当前存储后端(Res 存在时支持后台改配置热重建)。
func (a *API) blobs() storage.BlobStore {
if a.Res != nil {
if b := a.Res.Blobs(); b != nil {
return b
}
}
return a.blobs()
}
// 三个读者登录方式。凭据为空时 Enabled() 为 false,相关端点自动 404。
func (a *API) gh() auth.GitHub {
c := a.cfg()
return auth.GitHub{ClientID: c.GitHubClientID, ClientSecret: c.GitHubClientSecret}
}
func (a *API) gg() auth.Google {
c := a.cfg()
return auth.Google{ClientID: c.GoogleClientID, ClientSecret: c.GoogleClientSecret}
}
func (a *API) tg() auth.Telegram {
c := a.cfg()
return auth.Telegram{Bot: c.TelegramBot, Token: c.TelegramToken}
}
// siteURL 去掉尾部斜杠的站点地址(OAuth 回调、RSS 链接的拼装基座)。
func (a *API) siteURL() string {
return a.cfg().SiteURL
}
+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)
}
+7
View File
@@ -43,6 +43,10 @@ func (s *ReaderSessions) Issue(readerID int64) (string, time.Time) {
return enc + "." + s.sign(payload), exp 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) { func (s *ReaderSessions) Verify(token string) (int64, error) {
parts := strings.Split(token, ".") parts := strings.Split(token, ".")
if len(parts) != 2 { if len(parts) != 2 {
@@ -119,6 +123,9 @@ func (g GitHub) LoginURL(redirectURI, state string) string {
// GitHubUser 是 GitHub 用户接口里我们关心的字段 // GitHubUser 是 GitHub 用户接口里我们关心的字段
type GitHubUser struct { type GitHubUser struct {
// ID 是 GitHub 的数字主键:永不复用、改名不变。身份绑定必须用它,
// 用 login 的话对方一改用户名,绑定就指向了另一个人。
ID int64 `json:"id"`
Login string `json:"login"` Login string `json:"login"`
Name string `json:"name"` Name string `json:"name"`
AvatarURL string `json:"avatar_url"` AvatarURL string `json:"avatar_url"`
+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
}
+63
View File
@@ -4,6 +4,7 @@ import (
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"log" "log"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -25,6 +26,23 @@ type Config struct {
GitHubClientID string GitHubClientID string
GitHubClientSecret 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 里的写法一致(站主已整理): // 对象存储(文件上传)。变量名与站主 .env 里的写法一致(站主已整理):
// S3Api = R2 的 S3 API 端点(https://<账户ID>.r2.cloudflarestorage.com, // S3Api = R2 的 S3 API 端点(https://<账户ID>.r2.cloudflarestorage.com,
// 控制台 R2 概览可复制),上传走它 —— 公开域名收不了上传请求 // 控制台 R2 概览可复制),上传走它 —— 公开域名收不了上传请求
@@ -129,10 +147,55 @@ func Load() (*Config, error) {
// 评论区 GitHub 登录(OAuth App 凭据,站主在 GitHub 上创建后填入) // 评论区 GitHub 登录(OAuth App 凭据,站主在 GitHub 上创建后填入)
c.GitHubClientID = getenv("ONE_GITHUB_CLIENT_ID", "") c.GitHubClientID = getenv("ONE_GITHUB_CLIENT_ID", "")
c.GitHubClientSecret = getenv("ONE_GITHUB_CLIENT_SECRET", "") 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 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 } type badDriver struct{ d string }
func (e *badDriver) Error() string { func (e *badDriver) Error() string {
+143
View File
@@ -0,0 +1,143 @@
// 系统设置的运行时解析层。
//
// 迁移自环境变量的设置(站点地址、OAuth 凭据、R2 存储、管理员账号)存进
// settings KV 表后,生效规则统一为「DB 有值用 DB,空值回落环境变量」:
// 老部署不改 env 也能照常跑,后台改过的值随时可以赢回来。
//
// Resolver 在 env Config 之上叠加 DB 值,给两个 API 提供按需解析的有效配置;
// 配置带短缓存(设置保存时主动失效),存储后端按配置签名惰性重建——
// 后台改完 R2 凭据不用重启,下一次上传就走新桶。
package config
import (
"strings"
"sync"
"time"
"oneblog/internal/model"
"oneblog/internal/storage"
"oneblog/internal/store"
)
// Overlay 返回 c 的副本:model.Settings 里非空的系统设置项覆盖 env 值。
// 每个字段独立回落——比如 client_id 在后台改了、secret 还是 env 的,
// 两个拼起来照样能用,不必逼站主一次填齐。
func (c *Config) Overlay(st model.Settings) *Config {
e := *c
pick := func(dst *string, v string) {
if t := strings.TrimSpace(v); t != "" {
*dst = t
}
}
pick(&e.SiteURL, st.SiteURL)
pick(&e.GitHubClientID, st.GitHubClientID)
pick(&e.GitHubClientSecret, st.GitHubClientSecret)
pick(&e.GoogleClientID, st.GoogleClientID)
pick(&e.GoogleClientSecret, st.GoogleClientSecret)
pick(&e.TelegramBot, st.TelegramBot)
pick(&e.TelegramToken, st.TelegramToken)
pick(&e.AdminUser, st.AdminUsername)
pick(&e.UploadsPublicBase, st.UploadsPublicBase)
pick(&e.S3Endpoint, st.S3Endpoint)
pick(&e.R2Bucket, st.R2Bucket)
pick(&e.R2AccessKey, st.R2AccessKey)
pick(&e.R2SecretKey, st.R2SecretKey)
// 存储端按「凭据是否齐全」重新判定,与 config.Load 的规则一致
if e.S3Endpoint != "" && e.R2Bucket != "" && e.R2AccessKey != "" && e.R2SecretKey != "" {
e.StorageDriver = "r2"
} else {
e.StorageDriver = "local"
}
return &e
}
// SettingsSource 是 Resolver 对 store 的最小依赖(测试可注入假实现)。
type SettingsSource interface {
GetSettings() (model.Settings, error)
}
// Resolver 提供带缓存的有效配置与按需重建的存储后端。零值不可用,
// 用 NewResolver 构造;API 拿不到 Resolver 时(旧测试)直接用 env 值。
type Resolver struct {
env *Config
st SettingsSource
mu sync.Mutex
cfg *Config
cfgAt time.Time
blobs storage.BlobStore
blobsKV string
}
// cacheTTL 只兜「绕过后台直接改库」这种少数派路径;正常保存走
// Invalidate() 立即生效,所以 TTL 可以放宽到不影响体验的量级。
const cacheTTL = 10 * time.Second
func NewResolver(env *Config, st SettingsSource) *Resolver {
return &Resolver{env: env, st: st}
}
// Config 返回叠加了 DB 设置的有效配置。读库失败时回落 env——
// 设置页不能因为 DB 抖动把整站带挂。
func (r *Resolver) Config() *Config {
if r == nil {
return r.env
}
r.mu.Lock()
defer r.mu.Unlock()
if r.cfg != nil && time.Since(r.cfgAt) < cacheTTL {
return r.cfg
}
st, err := r.st.GetSettings()
if err != nil {
if r.cfg == nil {
return r.env
}
return r.cfg // 沿用上次缓存,别让一次查询失败打断请求
}
r.cfg = r.env.Overlay(st)
r.cfgAt = time.Now()
return r.cfg
}
// Invalidate 丢弃缓存:后台保存设置后调用,改完立即生效。
func (r *Resolver) Invalidate() {
if r == nil {
return
}
r.mu.Lock()
r.cfg, r.cfgAt = nil, time.Time{}
r.mu.Unlock()
}
// Blobs 返回当前有效配置对应的存储后端,配置变了自动重建。
// 构造失败(R2 凭据不对等)返回 nil,调用方按「无存储」处理。
func (r *Resolver) Blobs() storage.BlobStore {
if r == nil {
return nil
}
c := r.Config()
kv := c.StorageDriver + "|" + c.S3Endpoint + "|" + c.R2Bucket + "|" +
c.R2AccessKey + "|" + c.R2SecretKey
r.mu.Lock()
defer r.mu.Unlock()
if r.blobs != nil && kv == r.blobsKV {
return r.blobs
}
var b storage.BlobStore
switch c.StorageDriver {
case "r2":
s, err := storage.NewR2(c.S3Endpoint, c.R2Bucket, c.R2AccessKey, c.R2SecretKey)
if err != nil {
return r.blobs // 重建失败沿用旧后端,至少不把上传/读取打断
}
b = s
default:
b = storage.NewLocal(c.DataDir + "/uploads")
}
r.blobs, r.blobsKV = b, kv
return b
}
// 静态断言:*store.Store 满足 SettingsSource(GetSettings 在 store 包里)。
var _ SettingsSource = (*store.Store)(nil)
+53
View File
@@ -0,0 +1,53 @@
package config
import (
"testing"
"oneblog/internal/model"
)
func TestOverlayDBWinsEnvFallsBack(t *testing.T) {
env := &Config{
SiteURL: "https://env.example",
GitHubClientID: "env-id",
GitHubClientSecret: "env-sec",
UploadsPublicBase: "https://cdn.example/",
}
st := model.Settings{
SiteURL: "https://db.example",
GitHubClientSecret: "db-sec",
}
got := env.Overlay(st)
if got.SiteURL != "https://db.example" {
t.Errorf("SiteURL = %q, want db value", got.SiteURL)
}
if got.GitHubClientID != "env-id" {
t.Errorf("GitHubClientID = %q, want env fallback", got.GitHubClientID)
}
if got.GitHubClientSecret != "db-sec" {
t.Errorf("GitHubClientSecret = %q, want db value", got.GitHubClientSecret)
}
// env 原件不能被改掉
if env.SiteURL != "https://env.example" {
t.Error("Overlay must not mutate the env config")
}
}
// 存储端按凭据齐全与否重新判定,与 config.Load 的规则一致。
func TestOverlayStorageCompleteness(t *testing.T) {
env := &Config{DataDir: "/tmp/one"}
full := model.Settings{
S3Endpoint: "https://acct.r2.cloudflarestorage.com",
R2Bucket: "b",
R2AccessKey: "ak",
R2SecretKey: "sk",
}
if c := env.Overlay(full); c.StorageDriver != "r2" {
t.Errorf("complete R2 set: driver = %q, want r2", c.StorageDriver)
}
partial := full
partial.R2SecretKey = ""
if c := env.Overlay(partial); c.StorageDriver != "local" {
t.Errorf("incomplete R2 set: driver = %q, want local", c.StorageDriver)
}
}
+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 ""
}
+103 -1
View File
@@ -32,6 +32,23 @@ type Post struct {
// Images 是短文配图(快发盒上传,≤4 张,Twitter 式网格展示), // Images 是短文配图(快发盒上传,≤4 张,Twitter 式网格展示),
// 以 JSON 数组存在 posts.images 列里,正文 content_md 不含它们。 // 以 JSON 数组存在 posts.images 列里,正文 content_md 不含它们。
Images []string `json:"images"` 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 { type PostInput struct {
@@ -47,6 +64,8 @@ type PostInput struct {
ReadingMinutes *int `json:"reading_minutes"` ReadingMinutes *int `json:"reading_minutes"`
// Images 是短文配图 URL(快发盒上传),nil = 不修改、空数组 = 清空 // Images 是短文配图 URL(快发盒上传),nil = 不修改、空数组 = 清空
Images []string `json:"images"` Images []string `json:"images"`
// LinkCard 由服务端在保存时按正文内容重算(站主不必发这个字段)
LinkCard *LinkCard `json:"link_card,omitempty"`
} }
type Tag struct { type Tag struct {
@@ -128,6 +147,16 @@ type FilePage struct {
Size int `json:"size"` 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 是后台评论管理的分页容器(含文章标题与发表者)。 // CommentPage 是后台评论管理的分页容器(含文章标题与发表者)。
type CommentPage struct { type CommentPage struct {
Items []Comment `json:"items"` Items []Comment `json:"items"`
@@ -202,9 +231,50 @@ type Settings struct {
// of either UI, never on /admin. Stored raw — it's the owner's own code, // of either UI, never on /admin. Stored raw — it's the owner's own code,
// sanitizing it would only break the snippet. // sanitizing it would only break the snippet.
CustomJS string `json:"custom_js"` 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"`
// —— 以下是从环境变量迁进后台的「系统设置」。值存在 settings KV 表里,
// 生效规则统一是「DB 有值用 DB,空值回落环境变量」(见 config.Overlay)。
// 非秘密项直接进公开 JSON(client_id 本来就会出现在登录跳转 URL 里);
// 秘密项一律 json:"-",只在后台 API 里以「是否已配置 + 来源」的形式露出,
// 修改走 settings PUT 的 secrets 字段(留空 = 保持现值)。
SiteURL string `json:"site_url"`
GitHubClientID string `json:"github_client_id,omitempty"`
GoogleClientID string `json:"google_client_id,omitempty"`
TelegramBot string `json:"telegram_bot,omitempty"`
// AdminUsername 不算秘密(登录页本来就要输入它),但也不进公开 JSON。
AdminUsername string `json:"admin_username,omitempty"`
UploadsPublicBase string `json:"-"`
S3Endpoint string `json:"-"`
R2Bucket string `json:"-"`
R2AccessKey string `json:"-"`
R2SecretKey string `json:"-"`
GitHubClientSecret string `json:"-"`
GoogleClientSecret string `json:"-"`
TelegramToken string `json:"-"`
// AdminPasswordHash 只存 bcrypt 哈希,从不离开服务端。
AdminPasswordHash string `json:"-"`
} }
// Reader 是评论区的登录用户(GitHub OAuth)。Banned = 禁言中。 // 账号角色。owner 全库唯一(站主),admin 是站主在后台创建的内容管理员
// (可管内容、不可动系统设置与用户),reader 是评论区登录进来的访客。
const (
RoleOwner = "owner"
RoleAdmin = "admin"
RoleReader = "reader"
)
// Reader 是一条评论区身份:既包括站主(role=owner,provider=admin),
// 也包括通过 GitHub / Google / Telegram 登录进来的访客(role=reader)。
// Banned = 禁言中。
type Reader struct { type Reader struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Provider string `json:"provider"` Provider string `json:"provider"`
@@ -213,11 +283,43 @@ type Reader struct {
AvatarURL string `json:"avatar_url"` AvatarURL string `json:"avatar_url"`
URL string `json:"url"` URL string `json:"url"`
Banned bool `json:"banned"` Banned bool `json:"banned"`
// Role 区分站主、内容管理员与访客:绑定到 owner 的第三方身份登录时
// 会话升级为管理员。
Role string `json:"role"`
// PasswordHash 只属于后台账号(provider=admin,owner/admin 两级),
// bcrypt 哈希从不离开服务端;reader 恒为空串。
PasswordHash string `json:"-"`
// CommentCount 是累计评论数(后台用户列表展示用) // CommentCount 是累计评论数(后台用户列表展示用)
CommentCount int64 `json:"comment_count"` CommentCount int64 `json:"comment_count"`
CreatedAt string `json:"created_at"` 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。 // Comment 是一条评论。回复扁平存储(parent_id/root_id),渲染时挂到 replies。
// User 是发表者快照;is_deleted = 软删(留壳显示「已删除」,保住楼层上下文)。 // User 是发表者快照;is_deleted = 软删(留壳显示「已删除」,保住楼层上下文)。
type Comment struct { type Comment struct {
+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)
}
}
}
+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)
}
}
+515 -22
View File
@@ -23,6 +23,9 @@ func readingMinutes(md string) int { return render.ReadingMinutes(md) }
var ErrNotFound = errors.New("not found") var ErrNotFound = errors.New("not found")
// ErrConflict 表示要建的唯一键已被占(例如某个外部身份已绑到别的账号)。
var ErrConflict = errors.New("conflict")
type Store struct { type Store struct {
db *db.DB db *db.DB
} }
@@ -120,6 +123,30 @@ func (s *Store) migrate() error {
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
edited_at TEXT NOT NULL DEFAULT '' edited_at TEXT NOT NULL DEFAULT ''
)`, ai), )`, 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 { for _, q := range stmts {
if _, err := s.db.Exec(s.db.Q(q)); err != nil { if _, err := s.db.Exec(s.db.Q(q)); err != nil {
@@ -135,6 +162,13 @@ func (s *Store) migrate() error {
`ALTER TABLE tags ADD COLUMN color TEXT NOT NULL DEFAULT ''`, `ALTER TABLE tags ADD COLUMN color TEXT NOT NULL DEFAULT ''`,
// 短文配图(快发盒上传,Twitter 式网格展示),JSON 数组 // 短文配图(快发盒上传,Twitter 式网格展示),JSON 数组
`ALTER TABLE posts ADD COLUMN images TEXT NOT NULL DEFAULT '[]'`, `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'`,
// 后台账号密码(provider=admin 的行才有值):多用户改造后密码上库,
// env / settings 只作首次引导
`ALTER TABLE users ADD COLUMN password_hash TEXT NOT NULL DEFAULT ''`,
} }
for _, q := range columnAdds { for _, q := range columnAdds {
if _, err := s.db.Exec(s.db.Q(q)); err != nil && !strings.Contains(err.Error(), "already exists") && if _, err := s.db.Exec(s.db.Q(q)); err != nil && !strings.Contains(err.Error(), "already exists") &&
@@ -155,6 +189,8 @@ func (s *Store) migrate() error {
{"idx_comments_post", `CREATE INDEX IF NOT EXISTS idx_comments_post ON comments(post_id, created_at)`}, {"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_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_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 { for _, ix := range indexes {
if _, err := s.db.Exec(s.db.Q(ix.ddl)); err != nil && !strings.Contains(err.Error(), "already exists") { if _, err := s.db.Exec(s.db.Q(ix.ddl)); err != nil && !strings.Contains(err.Error(), "already exists") {
@@ -193,6 +229,26 @@ func (s *Store) seedSettings() error {
return nil return nil
} }
// GetSettingsMap 返回 settings 表的原始 KV。后台设置页用它判断每个
// 配置项的来源(DB 有值 / 回落环境变量)——这个信息在解析后的
// model.Settings 里已经丢了(空串既可能是「没配」也可能是「回落 env」)。
func (s *Store) GetSettingsMap() (map[string]string, error) {
rows, err := s.db.Query(s.db.Q(`SELECT key, value FROM settings`))
if err != nil {
return nil, err
}
defer rows.Close()
m := map[string]string{}
for rows.Next() {
var k, v string
if err := rows.Scan(&k, &v); err != nil {
return nil, err
}
m[k] = v
}
return m, rows.Err()
}
// ---------- settings ---------- // ---------- settings ----------
func (s *Store) GetSettings() (model.Settings, error) { func (s *Store) GetSettings() (model.Settings, error) {
@@ -238,6 +294,22 @@ func settingsFromMap(m map[string]string) model.Settings {
st.CustomCSS = decodeCSSMap(m["custom_css"]) st.CustomCSS = decodeCSSMap(m["custom_css"])
st.CustomJS = m["custom_js"] st.CustomJS = m["custom_js"]
st.SocialLinks = decodeSocialLinks(m["social_links"]) st.SocialLinks = decodeSocialLinks(m["social_links"])
st.AuthorAvatarKey = m["owner_avatar_key"]
// 从环境变量迁进后台的系统设置(DB 值优先,空值由 config.Overlay 回落 env)
st.SiteURL = strings.TrimSpace(m["site_url"])
st.GitHubClientID = strings.TrimSpace(m["github_client_id"])
st.GoogleClientID = strings.TrimSpace(m["google_client_id"])
st.TelegramBot = strings.TrimSpace(m["telegram_bot"])
st.AdminUsername = strings.TrimSpace(m["admin_username"])
st.UploadsPublicBase = strings.TrimRight(strings.TrimSpace(m["uploads_public_base"]), "/")
st.S3Endpoint = strings.TrimSpace(m["s3_endpoint"])
st.R2Bucket = strings.TrimSpace(m["r2_bucket"])
st.R2AccessKey = m["r2_access_key"]
st.R2SecretKey = m["r2_secret_key"]
st.GitHubClientSecret = m["github_client_secret"]
st.GoogleClientSecret = m["google_client_secret"]
st.TelegramToken = m["telegram_bot_token"]
st.AdminPasswordHash = m["admin_password_hash"]
// 开关类:'1' / 'true' 都算开,其余(含空)算关 // 开关类:'1' / 'true' 都算开,其余(含空)算关
st.CommentsEnabled = m["comments_enabled"] == "1" || strings.EqualFold(m["comments_enabled"], "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") st.CommentsReview = m["comments_review"] == "1" || strings.EqualFold(m["comments_review"], "true")
@@ -428,6 +500,17 @@ func (s *Store) UpdateSettings(st model.Settings) error {
// 开关统一存 '1' / '0'。 // 开关统一存 '1' / '0'。
"comments_enabled": b2s(st.CommentsEnabled), "comments_enabled": b2s(st.CommentsEnabled),
"comments_review": b2s(st.CommentsReview), "comments_review": b2s(st.CommentsReview),
// 从环境变量迁进后台的系统设置(非秘密项)。秘密项不在这里写——
// 它们经过 model.Settings 的 json:"-" 反序列化永远是空串,
// 全量替换会把配置清掉;改秘密走 admin settings PUT 的 secrets 字段。
"site_url": strings.TrimSpace(st.SiteURL),
"github_client_id": strings.TrimSpace(st.GitHubClientID),
"google_client_id": strings.TrimSpace(st.GoogleClientID),
"telegram_bot": strings.TrimSpace(st.TelegramBot),
"admin_username": strings.TrimSpace(st.AdminUsername),
"uploads_public_base": strings.TrimRight(strings.TrimSpace(st.UploadsPublicBase), "/"),
"s3_endpoint": strings.TrimSpace(st.S3Endpoint),
"r2_bucket": strings.TrimSpace(st.R2Bucket),
} }
for k, v := range sets { for k, v := range sets {
if s.db.Dialect == db.Postgres { if s.db.Dialect == db.Postgres {
@@ -445,6 +528,19 @@ func (s *Store) UpdateSettings(st model.Settings) error {
return nil 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 ---------- // ---------- posts ----------
type ListOptions struct { type ListOptions struct {
@@ -477,14 +573,14 @@ func sanitizeOrder(o string) string {
} }
const postCols = `id, kind, title, slug, summary, cover_url, content_md, content_html, status, 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), images` published_at, created_at, updated_at, reading_minutes, LENGTH(content_md), images, link_card`
// listCols 用于列表/时间线:不传 content_md(前端不用), // listCols 用于列表/时间线:不传 content_md(前端不用),
// 长文 content_html 只截 600 字符供无摘要时提取纯文本,短文保留全文渲染。 // 长文 content_html 只截 600 字符供无摘要时提取纯文本,短文保留全文渲染。
const listCols = `id, kind, title, slug, summary, cover_url, const listCols = `id, kind, title, slug, summary, cover_url,
'' AS content_md, '' AS content_md,
CASE WHEN kind = 'short' THEN content_html ELSE substr(content_html, 1, 600) END AS content_html, 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), images` status, published_at, created_at, updated_at, reading_minutes, LENGTH(content_md), images, link_card`
// images 列的 JSON 编解码(列存 '[]',Go 侧 []string;坏数据静默为空)。 // images 列的 JSON 编解码(列存 '[]',Go 侧 []string;坏数据静默为空)。
func decodeImages(s string) []string { func decodeImages(s string) []string {
@@ -509,14 +605,39 @@ func encodeImages(imgs []string) string {
return string(b) 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) { func scanPost(rows interface{ Scan(...any) error }) (model.Post, error) {
var p model.Post var p model.Post
var imgs string var imgs, card string
err := rows.Scan(&p.ID, &p.Kind, &p.Title, &p.Slug, &p.Summary, &p.CoverURL, 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.ContentMd, &p.ContentHTML, &p.Status, &p.PublishedAt, &p.CreatedAt, &p.UpdatedAt,
&p.ReadingMinutes, &p.ContentLen, &imgs) &p.ReadingMinutes, &p.ContentLen, &imgs, &card)
p.Tags = []string{} p.Tags = []string{}
p.Images = decodeImages(imgs) p.Images = decodeImages(imgs)
p.LinkCard = decodeLinkCard(card)
return p, err return p, err
} }
@@ -689,10 +810,10 @@ func (s *Store) GetBySlug(slug string) (model.Post, error) {
} }
func scanPostInto(row *sql.Row, p *model.Post) error { func scanPostInto(row *sql.Row, p *model.Post) error {
var imgs string var imgs, card string
err := row.Scan(&p.ID, &p.Kind, &p.Title, &p.Slug, &p.Summary, &p.CoverURL, 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.ContentMd, &p.ContentHTML, &p.Status, &p.PublishedAt, &p.CreatedAt, &p.UpdatedAt,
&p.ReadingMinutes, &p.ContentLen, &imgs) &p.ReadingMinutes, &p.ContentLen, &imgs, &card)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return ErrNotFound return ErrNotFound
} }
@@ -701,6 +822,7 @@ func scanPostInto(row *sql.Row, p *model.Post) error {
} }
p.Tags = []string{} p.Tags = []string{}
p.Images = decodeImages(imgs) p.Images = decodeImages(imgs)
p.LinkCard = decodeLinkCard(card)
return nil return nil
} }
@@ -945,17 +1067,19 @@ func (s *Store) Create(in model.PostInput) (model.Post, error) {
var id int64 var id int64
q := s.db.Q(`INSERT INTO posts (kind,title,slug,summary,cover_url,content_md,content_html,status, 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,images) published_at,created_at,updated_at,reading_minutes,images,link_card)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
if s.db.Dialect == db.Postgres { 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, 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, encodeImages(in.Images)).Scan(&id) p.Status, p.PublishedAt, p.CreatedAt, p.UpdatedAt, p.ReadingMinutes, encodeImages(in.Images),
encodeLinkCard(in.LinkCard)).Scan(&id)
if err != nil { if err != nil {
return p, err return p, err
} }
} else { } else {
res, err := s.db.Exec(q, p.Kind, p.Title, p.Slug, p.Summary, p.CoverURL, p.ContentMd, p.ContentHTML, 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, encodeImages(in.Images)) p.Status, p.PublishedAt, p.CreatedAt, p.UpdatedAt, p.ReadingMinutes, encodeImages(in.Images),
encodeLinkCard(in.LinkCard))
if err != nil { if err != nil {
return p, err return p, err
} }
@@ -965,7 +1089,12 @@ func (s *Store) Create(in model.PostInput) (model.Post, error) {
} }
} }
p.ID = id 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 p, err
} }
return s.Get(id) return s.Get(id)
@@ -1007,6 +1136,10 @@ func (s *Store) Update(id int64, in model.PostInput) (model.Post, error) {
if in.Images != nil { if in.Images != nil {
p.Images = in.Images p.Images = in.Images
} }
// LinkCard 由 admin 层按正文重算后传入;空卡片(含只有 URL)= 清空
if in.LinkCard != nil {
p.LinkCard = in.LinkCard
}
p.UpdatedAt = now() p.UpdatedAt = now()
if in.ReadingMinutes != nil && *in.ReadingMinutes > 0 { if in.ReadingMinutes != nil && *in.ReadingMinutes > 0 {
p.ReadingMinutes = *in.ReadingMinutes p.ReadingMinutes = *in.ReadingMinutes
@@ -1015,13 +1148,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=?, 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=?,images=? 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.Kind, p.Title, p.Slug, p.Summary, p.CoverURL, p.ContentMd, p.ContentHTML, p.Status,
p.PublishedAt, p.UpdatedAt, p.ReadingMinutes, encodeImages(p.Images), id); err != nil { p.PublishedAt, p.UpdatedAt, p.ReadingMinutes, encodeImages(p.Images), encodeLinkCard(p.LinkCard), id); err != nil {
return p, err 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 return p, err
} }
} }
@@ -1561,6 +1699,68 @@ func (s *Store) GetFileByKey(key string) (model.File, error) {
return f, nil 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 删索引行并返回被删的行(调用方负责先删对象存储里的本体)。 // DeleteFile 删索引行并返回被删的行(调用方负责先删对象存储里的本体)。
func (s *Store) DeleteFile(id int64) (model.File, error) { func (s *Store) DeleteFile(id int64) (model.File, error) {
f, err := s.GetFile(id) f, err := s.GetFile(id)
@@ -1611,8 +1811,16 @@ func (s *Store) UpsertReader(r model.Reader) (model.Reader, error) {
return s.GetReaderByProviderHandle(r.Provider, r.Handle) return s.GetReaderByProviderHandle(r.Provider, r.Handle)
} }
// userCols 是 users 表的读取列清单。列在多处 SELECT 复用,抽出来免得加一列
// 就要同步改一遍(漏一处就是扫错位置)。password_hash 只在后台账号路径用,
// reader 恒为空串,带上无害。
const userCols = `id,provider,handle,name,avatar_url,url,banned,role,password_hash,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.password_hash,u.created_at`
func (s *Store) GetReader(id int64) (model.Reader, error) { func (s *Store) GetReader(id int64) (model.Reader, error) {
r, err := scanReader(s.db.QueryRow(s.db.Q(`SELECT id,provider,handle,name,avatar_url,url,banned,created_at FROM users WHERE id = ?`), id)) r, err := scanReader(s.db.QueryRow(s.db.Q(`SELECT `+userCols+` FROM users WHERE id = ?`), id))
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return model.Reader{}, ErrNotFound return model.Reader{}, ErrNotFound
@@ -1623,7 +1831,7 @@ func (s *Store) GetReader(id int64) (model.Reader, error) {
} }
func (s *Store) GetReaderByProviderHandle(provider, handle string) (model.Reader, error) { func (s *Store) GetReaderByProviderHandle(provider, handle string) (model.Reader, error) {
r, err := scanReader(s.db.QueryRow(s.db.Q(`SELECT id,provider,handle,name,avatar_url,url,banned,created_at FROM users WHERE provider = ? AND handle = ?`), provider, handle)) r, err := scanReader(s.db.QueryRow(s.db.Q(`SELECT `+userCols+` FROM users WHERE provider = ? AND handle = ?`), provider, handle))
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return model.Reader{}, ErrNotFound return model.Reader{}, ErrNotFound
@@ -1644,11 +1852,11 @@ func (s *Store) SetReaderBanned(id int64, banned bool) error {
return nil return nil
} }
// ListReaders 后台的用户列表:带评论数,禁言中的排前面 // ListReaders 后台的用户列表:带评论数,站主最前、禁言中的排前面
func (s *Store) ListReaders() ([]model.Reader, error) { 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.created_at, 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 (SELECT COUNT(*) FROM comments c WHERE c.user_id = u.id AND c.is_deleted = 0) AS cnt
FROM users u ORDER BY u.banned DESC, u.created_at DESC`)) 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 { if err != nil {
return nil, err return nil, err
} }
@@ -1657,7 +1865,7 @@ func (s *Store) ListReaders() ([]model.Reader, error) {
for rows.Next() { for rows.Next() {
var r model.Reader var r model.Reader
var cnt int64 var cnt int64
if err := rows.Scan(&r.ID, &r.Provider, &r.Handle, &r.Name, &r.AvatarURL, &r.URL, &r.Banned, &r.CreatedAt, &cnt); err != nil { 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 return nil, err
} }
r.CommentCount = cnt r.CommentCount = cnt
@@ -1666,6 +1874,291 @@ func (s *Store) ListReaders() ([]model.Reader, error) {
return out, rows.Err() 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
}
// 老库升级路径:站主在评论区的历史身份行 role 可能是 reader
// (多用户之前用 admin 会话评论时自动建的)。登录即纠正成 owner,
// 否则升级后站主拿到的会话是 reader 角色,用户管理直接 403。
if cur.Role != model.RoleOwner {
if _, uerr := s.db.Exec(s.db.Q(`UPDATE users SET role = 'owner' WHERE id = ?`), cur.ID); uerr != nil {
return model.Reader{}, uerr
}
cur.Role = model.RoleOwner
}
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 CASE WHEN role = 'owner' THEN 0 ELSE 1 END, id LIMIT 1`)))
if errors.Is(err, sql.ErrNoRows) {
return model.Reader{}, ErrNotFound
}
return r, err
}
// ---------- 后台账号(多用户):provider=admin 的行,owner / admin 两级 ----------
const staffCols = `id,provider,handle,name,avatar_url,url,banned,role,password_hash,created_at`
func scanStaff(sc interface{ Scan(...any) error }) (model.Reader, error) {
var u model.Reader
if err := sc.Scan(&u.ID, &u.Provider, &u.Handle, &u.Name, &u.AvatarURL, &u.URL,
&u.Banned, &u.Role, &u.PasswordHash, &u.CreatedAt); err != nil {
return model.Reader{}, err
}
return u, nil
}
// ListStaff 后台账号列表:站主在前、其余按创建时间。
func (s *Store) ListStaff() ([]model.Reader, error) {
rows, err := s.db.Query(s.db.Q(`SELECT ` + staffCols + ` FROM users
WHERE provider = 'admin' ORDER BY CASE WHEN role = 'owner' THEN 0 ELSE 1 END, created_at`))
if err != nil {
return nil, err
}
defer rows.Close()
out := []model.Reader{}
for rows.Next() {
u, err := scanStaff(rows)
if err != nil {
return nil, err
}
out = append(out, u)
}
return out, rows.Err()
}
// GetStaffByHandle 按用户名取后台账号(登录用)。只在带密码哈希的行上有意义。
func (s *Store) GetStaffByHandle(handle string) (model.Reader, error) {
u, err := scanStaff(s.db.QueryRow(s.db.Q(`SELECT `+staffCols+` FROM users
WHERE provider = 'admin' AND handle = ?`), handle))
if errors.Is(err, sql.ErrNoRows) {
return model.Reader{}, ErrNotFound
}
return u, err
}
// GetUserByID 按主键取任意用户(含 reader)——会话校验用:token 只带 ID,
// 角色每请求从库里现读,改角色 / 禁用即时生效。
func (s *Store) GetUserByID(id int64) (model.Reader, error) {
r, err := scanReader(s.db.QueryRow(s.db.Q(`SELECT `+userCols+` FROM users WHERE id = ?`), id))
if errors.Is(err, sql.ErrNoRows) {
return model.Reader{}, ErrNotFound
}
return r, err
}
// CreateStaff 站主创建内容管理员:provider=admin、role=admin。
// 用户名占用返回 ErrConflict。
func (s *Store) CreateStaff(handle, passwordHash string) (model.Reader, error) {
if _, err := s.db.Exec(s.db.Q(`INSERT INTO users (provider,handle,name,avatar_url,url,banned,role,password_hash,created_at)
VALUES ('admin',?,'','','',0,'admin',?,?)`), handle, passwordHash, now()); err != nil {
if strings.Contains(err.Error(), "UNIQUE") || strings.Contains(err.Error(), "duplicate key") {
return model.Reader{}, ErrConflict
}
return model.Reader{}, err
}
return s.GetStaffByHandle(handle)
}
// SetStaffPassword 改后台账号密码(用户管理重置 / 自己修改)。
func (s *Store) SetStaffPassword(id int64, hash string) error {
_, err := s.db.Exec(s.db.Q(`UPDATE users SET password_hash = ? WHERE id = ? AND provider = 'admin'`), hash, id)
return err
}
// EnsureStaffHash 只在行内还没有哈希时写入:首次登录把 env / settings 的
// 引导凭据自迁移成行内哈希。已有哈希(站主后台改过密码)绝不能被
// env 后路登录覆盖——这正是它和 SetStaffPassword 的区别。
func (s *Store) EnsureStaffHash(id int64, hash string) error {
_, err := s.db.Exec(s.db.Q(`UPDATE users SET password_hash = ?
WHERE id = ? AND provider = 'admin' AND (password_hash = '' OR password_hash IS NULL)`), hash, id)
return err
}
// SetStaffBanned 停用 / 恢复后台账号(禁用后既不能登录,存量会话也会在
// guard 查库时被拒)。
func (s *Store) SetStaffBanned(id int64, banned bool) error {
_, err := s.db.Exec(s.db.Q(`UPDATE users SET banned = ? WHERE id = ? AND provider = 'admin' AND role != 'owner'`), b2i(banned), id)
return err
}
// DeleteStaff 删除后台账号。owner 行不可删(全库唯一锚点)。
func (s *Store) DeleteStaff(id int64) error {
res, err := s.db.Exec(s.db.Q(`DELETE FROM users WHERE id = ? AND provider = 'admin' AND role != 'owner'`), id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// 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 { func b2i(b bool) int64 {
if b { if b {
return 1 return 1
@@ -1676,7 +2169,7 @@ func b2i(b bool) int64 {
func scanReader(sc interface{ Scan(...any) error }) (model.Reader, error) { func scanReader(sc interface{ Scan(...any) error }) (model.Reader, error) {
var r model.Reader var r model.Reader
var banned int64 var banned int64
err := sc.Scan(&r.ID, &r.Provider, &r.Handle, &r.Name, &r.AvatarURL, &r.URL, &banned, &r.CreatedAt) err := sc.Scan(&r.ID, &r.Provider, &r.Handle, &r.Name, &r.AvatarURL, &r.URL, &banned, &r.Role, &r.PasswordHash, &r.CreatedAt)
r.Banned = banned == 1 r.Banned = banned == 1
return r, err return r, err
} }
+39
View File
@@ -490,3 +490,42 @@ func TestProjectCRUD(t *testing.T) {
t.Errorf("after delete: got %v, want ErrNotFound", err) t.Errorf("after delete: got %v, want ErrNotFound", err)
} }
} }
// 老库升级:多用户之前以 admin 会话评论会留下 provider=admin、role=reader
// 的历史身份行。升级后 EnsureOwner 必须把它纠正成 owner——否则站主登录
// 拿到的是 reader 角色,用户管理直接 403(真实事故)。
func TestEnsureOwnerPromotesLegacyReaderRow(t *testing.T) {
d, err := db.Open("sqlite", ":memory:")
if err != nil {
t.Fatal(err)
}
defer d.Close()
st, err := New(d)
if err != nil {
t.Fatal(err)
}
// 模拟老库:站主发过评论,留下 role=reader 的 admin 身份行
if _, err := st.UpsertReader(model.Reader{Provider: "admin", Handle: "admin", Name: "站主"}); err != nil {
t.Fatal(err)
}
legacy, err := st.GetStaffByHandle("admin")
if err != nil {
t.Fatal(err)
}
if legacy.Role != model.RoleReader {
t.Fatalf("setup: legacy role = %q, want reader", legacy.Role)
}
owner, err := st.EnsureOwner("admin")
if err != nil {
t.Fatal(err)
}
if owner.Role != model.RoleOwner {
t.Fatalf("after EnsureOwner: role = %q, want owner", owner.Role)
}
// 行上是真的改了,不是只改返回值
again, _ := st.GetStaffByHandle("admin")
if again.Role != model.RoleOwner {
t.Fatalf("persisted role = %q, want owner", again.Role)
}
}
+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("误删了别的文件的缓存")
}
}
+54 -20
View File
@@ -21,8 +21,9 @@ import (
"oneblog/internal/auth" "oneblog/internal/auth"
"oneblog/internal/config" "oneblog/internal/config"
"oneblog/internal/db" "oneblog/internal/db"
"oneblog/internal/storage" "oneblog/internal/hub"
"oneblog/internal/store" "oneblog/internal/store"
"oneblog/internal/thumbs"
) )
func main() { func main() {
@@ -48,39 +49,53 @@ func main() {
log.Fatalf("store: %v", err) log.Fatalf("store: %v", err)
} }
// 文件上传的存储后端:R2 配置齐全用 R2,否则本地磁盘兜底。 // 有效配置 = DB 里的系统设置叠加环境变量(DB 优先)。站点地址、
// 两个 API 共享同一实例;公开访问路由 /uploads/ 也走它。 // OAuth 凭据、R2 存储、管理员账号都已迁进后台「设置」页,
var blobs storage.BlobStore // 这里只保留 env 兜底值,改设置不再需要重启。
switch cfg.StorageDriver { res := config.NewResolver(cfg, st)
case "r2": cfgEff := res.Config()
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 天有效 // 评论区的读者会话(cookie one_reader),30 天有效
readerSessions := auth.NewReaderSessions(cfg.SessionSec, 30*24*time.Hour) readerSessions := auth.NewReaderSessions(cfg.SessionSec, 30*24*time.Hour)
// 后台管理员会话(cookie one_session),前台评论也用它识别站主身份 // 后台管理员会话(cookie one_session),前台评论也用它识别站主身份
adminSessions := admin.NewSessions(cfg.SessionSec, 7*24*time.Hour) 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{ public := &api.API{
Store: st, Store: st,
Cfg: cfg, Cfg: cfg,
Blobs: blobs, Res: res,
Thumbs: thumbCache,
ReaderSessions: readerSessions, ReaderSessions: readerSessions,
GH: auth.GitHub{ClientID: cfg.GitHubClientID, ClientSecret: cfg.GitHubClientSecret},
AdminSessions: adminSessions, AdminSessions: adminSessions,
Passkeys: passkeys,
Hub: commentHub,
} }
adminAPI := admin.NewAPI(st, cfg, adminSessions) adminAPI := admin.NewAPI(st, cfg, adminSessions)
adminAPI.Blobs = blobs adminAPI.Res = res
adminAPI.Blobs = blobs adminAPI.Hub = commentHub
adminAPI.Thumbs = thumbCache // 删文件时连带清掉它的缩略图
adminAPI.Passkeys = passkeys
root := http.NewServeMux() root := http.NewServeMux()
root.Handle("/api/admin/", adminAPI.Routes()) root.Handle("/api/admin/", adminAPI.Routes())
public.Mount(root) public.Mount(root)
// 更长的前缀优先匹配:/uploads/thumb/ 走缩略图,其余 /uploads/ 走原图流式
root.Handle("/uploads/thumb/", public.ThumbHandler())
root.Handle("/uploads/", public.UploadsHandler()) root.Handle("/uploads/", public.UploadsHandler())
root.Handle("/", spaHandler(cfg.WebDist)) root.Handle("/", spaHandler(cfg.WebDist))
@@ -94,10 +109,21 @@ func main() {
go func() { go func() {
log.Printf("ONE server listening on %s (db=%s)", cfg.Addr, cfg.Driver) log.Printf("ONE server listening on %s (db=%s)", cfg.Addr, cfg.Driver)
if cfg.InsecureDev { // 登录提示按有效配置报:后台改过密码/用户名后,这里说的是 DB 里的那套
log.Printf("admin login: %s / %s (set ONE_ADMIN_PASSWORD to change)", cfg.AdminUser, cfg.AdminPass) sysSt, err := st.GetSettings()
passNote := "(set ONE_ADMIN_PASSWORD to change)"
if err == nil && sysSt.AdminPasswordHash != "" {
passNote = "(password set in admin settings)"
}
if cfgEff.InsecureDev && (err != nil || sysSt.AdminPasswordHash == "") {
log.Printf("admin login: %s / %s (set ONE_ADMIN_PASSWORD to change)", cfgEff.AdminUser, cfg.AdminPass)
} else { } else {
log.Printf("admin login: %s / (from ONE_ADMIN_PASSWORD)", cfg.AdminUser) log.Printf("admin login: %s / %s", cfgEff.AdminUser, passNote)
}
if cfgEff.StorageDriver == "r2" {
log.Printf("storage: R2 (%s)", cfgEff.R2Bucket)
} else {
log.Print("storage: local disk (在后台「设置 → 对象存储」配置 R2 后无需重启即生效)")
} }
if !dirExists(cfg.WebDist) { if !dirExists(cfg.WebDist) {
log.Printf("frontend build not found at %s · run `make web` or `make dev` (API still available)", cfg.WebDist) log.Printf("frontend build not found at %s · run `make web` or `make dev` (API still available)", cfg.WebDist)
@@ -179,6 +205,14 @@ type statusRecorder struct {
func (s *statusRecorder) WriteHeader(code int) { s.status = code; s.ResponseWriter.WriteHeader(code) } 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 { func placeholderPage() string {
return `<!doctype html><meta charset="utf-8"><title>ONE</title> return `<!doctype html><meta charset="utf-8"><title>ONE</title>
<style>body{font-family:-apple-system,"PingFang SC",sans-serif;background:#faf7f1;color:#33302b; <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)" /> <meta name="theme-color" content="#1f1d1a" media="(prefers-color-scheme: dark)" />
<title>ONE · 一个博客</title> <title>ONE · 一个博客</title>
<meta name="description" content="长文与短文,同一种节奏。" /> <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/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" /> <link rel="alternate" type="application/rss+xml" title="RSS" href="/rss.xml" />
<!-- <!--
UI 版本在 <html> 上以 data-ui 表达,服务端设置要等 /api/site 才知道。 UI 版本在 <html> 上以 data-ui 表达,服务端设置要等 /api/site 才知道。
+6
View File
@@ -10,7 +10,13 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "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", "@milkdown/crepe": "^7.22.1",
"@phosphor-icons/vue": "^2.2.1",
"dompurify": "^3.1.6", "dompurify": "^3.1.6",
"vue": "^3.4.0", "vue": "^3.4.0",
"vue-router": "^4.4.0" "vue-router": "^4.4.0"
+76 -48
View File
@@ -8,9 +8,27 @@ importers:
.: .:
dependencies: 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': '@milkdown/crepe':
specifier: ^7.22.1 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) version: 7.22.1(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.4)(typescript@5.9.3)
'@phosphor-icons/vue':
specifier: ^2.2.1
version: 2.2.1(vue@3.5.40(typescript@5.9.3))
dompurify: dompurify:
specifier: ^3.1.6 specifier: ^3.1.6
version: 3.4.12 version: 3.4.12
@@ -140,14 +158,14 @@ packages:
'@codemirror/search@6.7.2': '@codemirror/search@6.7.2':
resolution: {integrity: sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==} resolution: {integrity: sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==}
'@codemirror/state@6.7.5': '@codemirror/state@6.7.6':
resolution: {integrity: sha512-QjLbZmY1Au3JiRrDVYFLRD0BZ3SOKS9pR3yjIkd7u27YY8TFD9/Q9fhPnLV5l1mHFSo3hHU/N31vpwEJOx4owQ==} resolution: {integrity: sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==}
'@codemirror/theme-one-dark@6.1.3': '@codemirror/theme-one-dark@6.1.3':
resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==} resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==}
'@codemirror/view@6.43.12': '@codemirror/view@6.43.13':
resolution: {integrity: sha512-Nv0vxQ19NAqvB/c2pFzjIzFlzzJl7jmdtNkwOwGbn0Ks9mFAzibvumz7cQem5cRsFA2cEw2fg+uHZGbcHupLQQ==} resolution: {integrity: sha512-sihaFrUzAsYBQsL9J2t69y8nfMQGwcYmggAZsk+kjPbjYZMyuf2hU8tUNTZ+P+isb6XRr8JE22TZlJxBoVdH1A==}
'@esbuild/aix-ppc64@0.21.5': '@esbuild/aix-ppc64@0.21.5':
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
@@ -429,6 +447,12 @@ packages:
'@ocavue/utils@1.8.0': '@ocavue/utils@1.8.0':
resolution: {integrity: sha512-BD1ro+6Kllkmr/at+o32kvnLrVooUbk9gw3JAPaoAZXx+A5GhZ6Laa+UA38ifhG3vaamx+jcgEwX22BmTSMUVA==} resolution: {integrity: sha512-BD1ro+6Kllkmr/at+o32kvnLrVooUbk9gw3JAPaoAZXx+A5GhZ6Laa+UA38ifhG3vaamx+jcgEwX22BmTSMUVA==}
'@phosphor-icons/vue@2.2.1':
resolution: {integrity: sha512-3RNg1utc2Z5RwPKWFkW3eXI/0BfQAwXgtFxPUPeSzi55jGYUq16b+UqcgbKLazWFlwg5R92OCLKjDiJjeiJcnA==}
engines: {node: '>=14'}
peerDependencies:
vue: '>=3.2.39'
'@rollup/rollup-android-arm-eabi@4.62.3': '@rollup/rollup-android-arm-eabi@4.62.3':
resolution: {integrity: sha512-c0wdcekXtQvvn5Tsrk/+op/gUArrbWaFduBnTLP2l1cKLSQs4diMWjJw3m6A0DdzT8dAAX95KpkJ3qynCePbmw==} resolution: {integrity: sha512-c0wdcekXtQvvn5Tsrk/+op/gUArrbWaFduBnTLP2l1cKLSQs4diMWjJw3m6A0DdzT8dAAX95KpkJ3qynCePbmw==}
cpu: [arm] cpu: [arm]
@@ -1118,15 +1142,15 @@ snapshots:
'@codemirror/autocomplete@6.20.3': '@codemirror/autocomplete@6.20.3':
dependencies: dependencies:
'@codemirror/language': 6.12.4 '@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5 '@codemirror/state': 6.7.6
'@codemirror/view': 6.43.12 '@codemirror/view': 6.43.13
'@lezer/common': 1.5.2 '@lezer/common': 1.5.2
'@codemirror/commands@6.11.1': '@codemirror/commands@6.11.1':
dependencies: dependencies:
'@codemirror/language': 6.12.4 '@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5 '@codemirror/state': 6.7.6
'@codemirror/view': 6.43.12 '@codemirror/view': 6.43.13
'@lezer/common': 1.5.2 '@lezer/common': 1.5.2
'@codemirror/lang-angular@0.1.4': '@codemirror/lang-angular@0.1.4':
@@ -1147,7 +1171,7 @@ snapshots:
dependencies: dependencies:
'@codemirror/autocomplete': 6.20.3 '@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4 '@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5 '@codemirror/state': 6.7.6
'@lezer/common': 1.5.2 '@lezer/common': 1.5.2
'@lezer/css': 1.3.6 '@lezer/css': 1.3.6
@@ -1155,7 +1179,7 @@ snapshots:
dependencies: dependencies:
'@codemirror/autocomplete': 6.20.3 '@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4 '@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5 '@codemirror/state': 6.7.6
'@lezer/common': 1.5.2 '@lezer/common': 1.5.2
'@lezer/go': 1.0.1 '@lezer/go': 1.0.1
@@ -1165,8 +1189,8 @@ snapshots:
'@codemirror/lang-css': 6.3.1 '@codemirror/lang-css': 6.3.1
'@codemirror/lang-javascript': 6.2.5 '@codemirror/lang-javascript': 6.2.5
'@codemirror/language': 6.12.4 '@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5 '@codemirror/state': 6.7.6
'@codemirror/view': 6.43.12 '@codemirror/view': 6.43.13
'@lezer/common': 1.5.2 '@lezer/common': 1.5.2
'@lezer/css': 1.3.6 '@lezer/css': 1.3.6
'@lezer/html': 1.3.13 '@lezer/html': 1.3.13
@@ -1181,8 +1205,8 @@ snapshots:
'@codemirror/autocomplete': 6.20.3 '@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4 '@codemirror/language': 6.12.4
'@codemirror/lint': 6.9.7 '@codemirror/lint': 6.9.7
'@codemirror/state': 6.7.5 '@codemirror/state': 6.7.6
'@codemirror/view': 6.43.12 '@codemirror/view': 6.43.13
'@lezer/common': 1.5.2 '@lezer/common': 1.5.2
'@lezer/javascript': 1.5.5 '@lezer/javascript': 1.5.5
@@ -1191,8 +1215,8 @@ snapshots:
'@codemirror/autocomplete': 6.20.3 '@codemirror/autocomplete': 6.20.3
'@codemirror/lang-html': 6.4.12 '@codemirror/lang-html': 6.4.12
'@codemirror/language': 6.12.4 '@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5 '@codemirror/state': 6.7.6
'@codemirror/view': 6.43.12 '@codemirror/view': 6.43.13
'@lezer/common': 1.5.2 '@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3 '@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10 '@lezer/lr': 1.4.10
@@ -1215,8 +1239,8 @@ snapshots:
'@codemirror/autocomplete': 6.20.3 '@codemirror/autocomplete': 6.20.3
'@codemirror/lang-html': 6.4.12 '@codemirror/lang-html': 6.4.12
'@codemirror/language': 6.12.4 '@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5 '@codemirror/state': 6.7.6
'@codemirror/view': 6.43.12 '@codemirror/view': 6.43.13
'@lezer/common': 1.5.2 '@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3 '@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10 '@lezer/lr': 1.4.10
@@ -1226,8 +1250,8 @@ snapshots:
'@codemirror/autocomplete': 6.20.3 '@codemirror/autocomplete': 6.20.3
'@codemirror/lang-html': 6.4.12 '@codemirror/lang-html': 6.4.12
'@codemirror/language': 6.12.4 '@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5 '@codemirror/state': 6.7.6
'@codemirror/view': 6.43.12 '@codemirror/view': 6.43.13
'@lezer/common': 1.5.2 '@lezer/common': 1.5.2
'@lezer/markdown': 1.7.2 '@lezer/markdown': 1.7.2
@@ -1235,7 +1259,7 @@ snapshots:
dependencies: dependencies:
'@codemirror/lang-html': 6.4.12 '@codemirror/lang-html': 6.4.12
'@codemirror/language': 6.12.4 '@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5 '@codemirror/state': 6.7.6
'@lezer/common': 1.5.2 '@lezer/common': 1.5.2
'@lezer/php': 1.0.6 '@lezer/php': 1.0.6
@@ -1243,7 +1267,7 @@ snapshots:
dependencies: dependencies:
'@codemirror/autocomplete': 6.20.3 '@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4 '@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5 '@codemirror/state': 6.7.6
'@lezer/common': 1.5.2 '@lezer/common': 1.5.2
'@lezer/python': 1.1.19 '@lezer/python': 1.1.19
@@ -1256,7 +1280,7 @@ snapshots:
dependencies: dependencies:
'@codemirror/lang-css': 6.3.1 '@codemirror/lang-css': 6.3.1
'@codemirror/language': 6.12.4 '@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5 '@codemirror/state': 6.7.6
'@lezer/common': 1.5.2 '@lezer/common': 1.5.2
'@lezer/sass': 1.1.0 '@lezer/sass': 1.1.0
@@ -1264,7 +1288,7 @@ snapshots:
dependencies: dependencies:
'@codemirror/autocomplete': 6.20.3 '@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4 '@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5 '@codemirror/state': 6.7.6
'@lezer/common': 1.5.2 '@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3 '@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10 '@lezer/lr': 1.4.10
@@ -1289,8 +1313,8 @@ snapshots:
dependencies: dependencies:
'@codemirror/autocomplete': 6.20.3 '@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4 '@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5 '@codemirror/state': 6.7.6
'@codemirror/view': 6.43.12 '@codemirror/view': 6.43.13
'@lezer/common': 1.5.2 '@lezer/common': 1.5.2
'@lezer/xml': 1.0.6 '@lezer/xml': 1.0.6
@@ -1298,7 +1322,7 @@ snapshots:
dependencies: dependencies:
'@codemirror/autocomplete': 6.20.3 '@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4 '@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5 '@codemirror/state': 6.7.6
'@lezer/common': 1.5.2 '@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3 '@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10 '@lezer/lr': 1.4.10
@@ -1332,8 +1356,8 @@ snapshots:
'@codemirror/language@6.12.4': '@codemirror/language@6.12.4':
dependencies: dependencies:
'@codemirror/state': 6.7.5 '@codemirror/state': 6.7.6
'@codemirror/view': 6.43.12 '@codemirror/view': 6.43.13
'@lezer/common': 1.5.2 '@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3 '@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10 '@lezer/lr': 1.4.10
@@ -1345,30 +1369,30 @@ snapshots:
'@codemirror/lint@6.9.7': '@codemirror/lint@6.9.7':
dependencies: dependencies:
'@codemirror/state': 6.7.5 '@codemirror/state': 6.7.6
'@codemirror/view': 6.43.12 '@codemirror/view': 6.43.13
crelt: 1.0.7 crelt: 1.0.7
'@codemirror/search@6.7.2': '@codemirror/search@6.7.2':
dependencies: dependencies:
'@codemirror/state': 6.7.5 '@codemirror/state': 6.7.6
'@codemirror/view': 6.43.12 '@codemirror/view': 6.43.13
crelt: 1.0.7 crelt: 1.0.7
'@codemirror/state@6.7.5': '@codemirror/state@6.7.6':
dependencies: dependencies:
'@marijn/find-cluster-break': 1.0.4 '@marijn/find-cluster-break': 1.0.4
'@codemirror/theme-one-dark@6.1.3': '@codemirror/theme-one-dark@6.1.3':
dependencies: dependencies:
'@codemirror/language': 6.12.4 '@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5 '@codemirror/state': 6.7.6
'@codemirror/view': 6.43.12 '@codemirror/view': 6.43.13
'@lezer/highlight': 1.2.3 '@lezer/highlight': 1.2.3
'@codemirror/view@6.43.12': '@codemirror/view@6.43.13':
dependencies: dependencies:
'@codemirror/state': 6.7.5 '@codemirror/state': 6.7.6
crelt: 1.0.7 crelt: 1.0.7
style-mod: 4.1.4 style-mod: 4.1.4
w3c-keyname: 2.2.8 w3c-keyname: 2.2.8
@@ -1550,11 +1574,11 @@ snapshots:
'@marijn/find-cluster-break@1.0.4': {} '@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: dependencies:
'@codemirror/language': 6.12.4 '@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5 '@codemirror/state': 6.7.6
'@codemirror/view': 6.43.12 '@codemirror/view': 6.43.13
'@floating-ui/dom': 1.8.0 '@floating-ui/dom': 1.8.0
'@milkdown/core': 7.22.1 '@milkdown/core': 7.22.1
'@milkdown/ctx': 7.22.1 '@milkdown/ctx': 7.22.1
@@ -1594,10 +1618,10 @@ snapshots:
'@codemirror/commands': 6.11.1 '@codemirror/commands': 6.11.1
'@codemirror/language': 6.12.4 '@codemirror/language': 6.12.4
'@codemirror/language-data': 6.5.2 '@codemirror/language-data': 6.5.2
'@codemirror/state': 6.7.5 '@codemirror/state': 6.7.6
'@codemirror/theme-one-dark': 6.1.3 '@codemirror/theme-one-dark': 6.1.3
'@codemirror/view': 6.43.12 '@codemirror/view': 6.43.13
'@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)
'@types/lodash-es': 4.17.12 '@types/lodash-es': 4.17.12
clsx: 2.1.1 clsx: 2.1.1
codemirror: 6.0.2 codemirror: 6.0.2
@@ -1621,9 +1645,9 @@ snapshots:
'@milkdown/exception@7.22.1': {} '@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: 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/core': 7.22.1
'@milkdown/ctx': 7.22.1 '@milkdown/ctx': 7.22.1
'@milkdown/exception': 7.22.1 '@milkdown/exception': 7.22.1
@@ -1835,6 +1859,10 @@ snapshots:
'@ocavue/utils@1.8.0': {} '@ocavue/utils@1.8.0': {}
'@phosphor-icons/vue@2.2.1(vue@3.5.40(typescript@5.9.3))':
dependencies:
vue: 3.5.40(typescript@5.9.3)
'@rollup/rollup-android-arm-eabi@4.62.3': '@rollup/rollup-android-arm-eabi@4.62.3':
optional: true optional: true
@@ -2036,8 +2064,8 @@ snapshots:
'@codemirror/language': 6.12.4 '@codemirror/language': 6.12.4
'@codemirror/lint': 6.9.7 '@codemirror/lint': 6.9.7
'@codemirror/search': 6.7.2 '@codemirror/search': 6.7.2
'@codemirror/state': 6.7.5 '@codemirror/state': 6.7.6
'@codemirror/view': 6.43.12 '@codemirror/view': 6.43.13
commander@15.0.0: {} 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 xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="ONE">
<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"> <!-- 方案 A「一横」:墨圆 + 一道白横,既是字母 O 也是汉字「一」。
<defs> 横杠用 fill-rule="evenodd" 挖成真洞(不是叠一块纸色矩形),
<style> 所以它在任何底色上都是透明的——不需要为深浅皮肤各出一版。 -->
.cls-1 { <style>
fill: url(#gradient3); .m { fill: #33302b }
} @media (prefers-color-scheme: dark) { .m { fill: #e8e3d9 } }
</style>
.cls-2 { <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"/>
fill: #000; </svg>
}
@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>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 648 B

+29 -3
View File
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed, watch } from 'vue' import { computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import TopBar from './components/TopBar.vue' import TopBar from './components/TopBar.vue'
import ThemeSwitcher from './components/ThemeSwitcher.vue' import ThemeSwitcher from './components/ThemeSwitcher.vue'
@@ -9,6 +9,31 @@ import YohakuFoot from './ui/yohaku/YohakuFoot.vue'
import { site, setAdminScope } from './site' import { site, setAdminScope } from './site'
const route = useRoute() 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')) const isAdmin = computed(() => route.path.startsWith('/admin'))
// vivid 只在公开前台生效:后台永远走 classic 那套(--admin-* token + 原 data-ui/data-theme), // vivid 只在公开前台生效:后台永远走 classic 那套(--admin-* token + 原 data-ui/data-theme),
// 不受 ui_id 影响。vivid.css 会命中 .btn / .input 这类全局类,后台表单正在用, // 不受 ui_id 影响。vivid.css 会命中 .btn / .input 这类全局类,后台表单正在用,
@@ -40,8 +65,9 @@ watch(
<!-- 图片预览层:时间线 / 详情页的配图点开时全屏展示 --> <!-- 图片预览层:时间线 / 详情页的配图点开时全屏展示 -->
<ImgLightbox /> <ImgLightbox />
<!-- 桌面端:左栏底部放 inline 形态;窄屏:左栏收起,浮动按钮兜底。 <!-- 桌面端:左栏底部放 inline 形态;窄屏:左栏收起,浮动按钮兜底。
vivid 的顶部导航自带切换器,浮动那个就不重复出现了。 --> vivid 的顶部导航自带切换器,浮动那个就不重复出现了。
<ThemeSwitcher v-if="!isVivid" variant="floating" class="theme-floating-only" /> 后台不走浮动:手机端顶栏里有(AdminLayout),浮动会压住卡片右下角。 -->
<ThemeSwitcher v-if="!isVivid && !isAdmin" variant="floating" class="theme-floating-only" />
</template> </template>
<style scoped> <style scoped>
+408
View File
@@ -0,0 +1,408 @@
<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)
// 修改登录密码(owner / admin 都在这里改;密码存在自己的账号行上)
const pw = ref({ old: '', next: '', confirm: '' })
const pwBusy = ref(false)
async function changePassword() {
if (pw.value.next !== pw.value.confirm) {
toastErr('两次输入的新密码不一致')
return
}
if (pw.value.next.length < 6) {
toastErr('新密码至少 6 位')
return
}
pwBusy.value = true
try {
await adminApi.changePassword({ old_password: pw.value.old, new_password: pw.value.next })
toastOk('密码已更新,下次登录用新密码')
pw.value = { old: '', next: '', confirm: '' }
} catch (e) {
toastErr(e.message || '修改失败')
} finally {
pwBusy.value = false
}
}
const route = useRoute()
const router = useRouter()
async function load() {
console.warn('ACCT: load() start')
loading.value = true
try {
acct.value = await adminApi.account()
console.warn('ACCT: loaded', !!acct.value)
} 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">
后台账号的用户名即登录名:站主的在「设置 → 登录与存储」里改,
管理员账号由站主在「用户」页创建。
</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="field">
<label>旧密码</label>
<input v-model="pw.old" class="input" type="password" autocomplete="current-password" placeholder="从未设过密码可不填" />
</div>
<div class="field">
<label>新密码(6-72 位)</label>
<input v-model="pw.next" class="input" type="password" autocomplete="new-password" />
</div>
<div class="field">
<label>确认新密码</label>
<input v-model="pw.confirm" class="input" type="password" autocomplete="new-password" />
</div>
<button class="btn btn-primary" :disabled="pwBusy" @click="changePassword">
{{ pwBusy ? '提交中…' : '更新密码' }}
</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>
+41 -3
View File
@@ -1,5 +1,6 @@
<script setup> <script setup>
import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue' import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue'
import ToastStack from './ToastStack.vue'
import { useRoute, useRouter, RouterLink, RouterView } from 'vue-router' import { useRoute, useRouter, RouterLink, RouterView } from 'vue-router'
import { checkAuth } from './auth' import { checkAuth } from './auth'
import { adminApi, session } from '../api' import { adminApi, session } from '../api'
@@ -13,6 +14,7 @@ const route = useRoute()
const ready = ref(false) const ready = ref(false)
const paletteOpen = ref(false) const paletteOpen = ref(false)
const shortcutsOpen = ref(false) const shortcutsOpen = ref(false)
const sideOpen = ref(false)
const counts = ref({ published: 0, draft: 0, tags: 0 }) const counts = ref({ published: 0, draft: 0, tags: 0 })
async function loadCounts() { async function loadCounts() {
@@ -61,6 +63,7 @@ const crumb = computed(() => {
if (/^\/admin\/\d+/.test(p)) return '编辑文章' if (/^\/admin\/\d+/.test(p)) return '编辑文章'
if (p.endsWith('/files')) return '文件' if (p.endsWith('/files')) return '文件'
if (p.endsWith('/comments')) return '评论' if (p.endsWith('/comments')) return '评论'
if (p.endsWith('/users')) return '用户'
if (p.endsWith('/tags')) return '标签' if (p.endsWith('/tags')) return '标签'
if (p.endsWith('/settings')) return '设置' if (p.endsWith('/settings')) return '设置'
if (p === '/admin' || p === '/admin/') return '总览' if (p === '/admin' || p === '/admin/') return '总览'
@@ -74,6 +77,8 @@ function onKeydown(e) {
paletteOpen.value = true paletteOpen.value = true
return return
} }
// Esc 收起手机端抽屉
if (e.key === 'Escape') sideOpen.value = false
// ? // ?
if (e.key === '?' && !e.metaKey && !e.ctrlKey && !e.altKey) { if (e.key === '?' && !e.metaKey && !e.ctrlKey && !e.altKey) {
const t = e.target const t = e.target
@@ -90,13 +95,17 @@ function onKeydown(e) {
onMounted(() => window.addEventListener('keydown', onKeydown)) onMounted(() => window.addEventListener('keydown', onKeydown))
onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown)) onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
// refresh counts when route changes (posts / tags edited) // refresh counts when route changes (posts / tags edited); 顺手收起手机端抽屉
watch(() => route.path, () => loadCounts()) watch(() => route.path, () => {
sideOpen.value = false
loadCounts()
})
</script> </script>
<template> <template>
<div v-if="ready" class="admin-shell"> <div v-if="ready" class="admin-shell">
<aside class="admin-side"> <ToastStack />
<aside class="admin-side" :class="{ open: sideOpen }">
<div class="brand"> <div class="brand">
<RouterLink to="/admin" class="name">{{ site.site_title || 'ONE' }}</RouterLink> <RouterLink to="/admin" class="name">{{ site.site_title || 'ONE' }}</RouterLink>
<span class="label">· 后台</span> <span class="label">· 后台</span>
@@ -138,6 +147,15 @@ watch(() => route.path, () => loadCounts())
<span class="ic">⚙</span> <span class="ic">⚙</span>
<span>设置</span> <span>设置</span>
</RouterLink> </RouterLink>
<RouterLink to="/admin/account" class="item">
<span class="ic">◉</span>
<span>账户</span>
</RouterLink>
<!-- 用户管理是站主专属:内容管理员(admin)看不到这个入口 -->
<RouterLink v-if="session.isOwner" to="/admin/users" class="item">
<span class="ic">◍</span>
<span>用户</span>
</RouterLink>
<div class="group">前台</div> <div class="group">前台</div>
<a href="/" target="_blank" class="item"> <a href="/" target="_blank" class="item">
<span class="ic">↗</span> <span class="ic">↗</span>
@@ -156,12 +174,19 @@ watch(() => route.path, () => loadCounts())
<div class="admin-main"> <div class="admin-main">
<header class="admin-topbar"> <header class="admin-topbar">
<div class="crumbs"> <div class="crumbs">
<!-- 手机端唤出侧栏抽屉(≤860px 显示,桌面隐藏) -->
<button class="menu-btn" type="button" aria-label="后台菜单" @click="sideOpen = !sideOpen">☰</button>
<strong>{{ crumb }}</strong> <strong>{{ crumb }}</strong>
</div> </div>
<div class="right"> <div class="right">
<button class="cmd-hint" @click="paletteOpen = true"> <button class="cmd-hint" @click="paletteOpen = true">
搜索 / 命令 <kbd>⌘K</kbd> 搜索 / 命令 <kbd>⌘K</kbd>
</button> </button>
<!-- 手机端主题切换收进顶栏(浮动按钮在总览页会压住卡片右下角);
桌面隐藏,切换仍走侧栏 footer -->
<ThemeSwitcher variant="inline" class="switch-m" />
<!-- 手机端侧栏进抽屉后,退出固定放顶栏;桌面隐藏,退出仍走侧栏 footer -->
<button class="btn leave-m" @click="logout">退出</button>
</div> </div>
</header> </header>
<main class="admin-content"> <main class="admin-content">
@@ -169,6 +194,8 @@ watch(() => route.path, () => loadCounts())
<RouterView :key="route.name === 'admin-edit' ? route.params.id : undefined" /> <RouterView :key="route.name === 'admin-edit' ? route.params.id : undefined" />
</main> </main>
</div> </div>
<!-- 手机端抽屉遮罩:点空白收回 -->
<div class="side-scrim" :class="{ on: sideOpen }" @click="sideOpen = false"></div>
<Transition name="pop"> <Transition name="pop">
<CommandPalette v-if="paletteOpen" @close="paletteOpen = false" /> <CommandPalette v-if="paletteOpen" @close="paletteOpen = false" />
@@ -186,4 +213,15 @@ watch(() => route.path, () => loadCounts())
text-align: center; text-align: center;
color: var(--admin-muted); color: var(--admin-muted);
} }
/* 顶栏主题切换:手机专属(≤860px 与抽屉同 breakpoint),桌面隐藏。
34px 比 inline 默认 40px 略小,跟 36px 的 ☰ / 退出按钮同排不突兀;
选择器带 .admin-topbar 提特度,压过组件自身的 .theme-switcher--inline。 */
.admin-topbar .switch-m {display: none;}
@media (max-width: 860px) {
.admin-topbar .switch-m {display: inline-flex;
width: 34px;
height: 34px;}
.admin-topbar .switch-m :deep(.ic) {font-size: 16px;}
}
</style> </style>
+4
View File
@@ -1,5 +1,6 @@
<script setup> <script setup>
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { toastOk, toastErr } from './toast'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { adminApi } from '../api' import { adminApi } from '../api'
import { relativeDate, stripTags } from '../utils' import { relativeDate, stripTags } from '../utils'
@@ -46,12 +47,14 @@ function setStatus(v) {
async function approve(id) { async function approve(id) {
await adminApi.approveComment(id) await adminApi.approveComment(id)
toastOk('已通过,前台可见')
load() load()
} }
async function remove(id) { async function remove(id) {
if (!window.confirm('删除这条评论?正文清空,楼层保留为「已删除」。')) return if (!window.confirm('删除这条评论?正文清空,楼层保留为「已删除」。')) return
await adminApi.deleteComment(id) await adminApi.deleteComment(id)
toastOk('已删除')
load() load()
} }
@@ -59,6 +62,7 @@ async function banToggle(c) {
const banned = !c.user?.banned const banned = !c.user?.banned
if (!window.confirm(banned ? `禁言「${c.user?.name || '该用户'}」?禁言后其无法再发表评论。` : '解除禁言?')) return if (!window.confirm(banned ? `禁言「${c.user?.name || '该用户'}」?禁言后其无法再发表评论。` : '解除禁言?')) return
await adminApi.setReaderBanned(c.user?.id, banned) await adminApi.setReaderBanned(c.user?.id, banned)
toastOk(banned ? '已禁言' : '已解除禁言')
c.user = { ...(c.user || {}), banned } c.user = { ...(c.user || {}), banned }
} }
+20 -3
View File
@@ -59,7 +59,7 @@ const chartMax = computed(() => {
</div> </div>
</div> </div>
<div style="display: grid; grid-template-columns: 1.4fr 1fr; gap: 16px;"> <div class="dash-cols">
<div class="panel"> <div class="panel">
<div class="panel-title"> <div class="panel-title">
<h2>最近 12 个月发布</h2> <h2>最近 12 个月发布</h2>
@@ -107,7 +107,7 @@ const chartMax = computed(() => {
</div> </div>
</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">
<div class="panel-title"> <div class="panel-title">
<h2>最近发布</h2> <h2>最近发布</h2>
@@ -137,4 +137,21 @@ const chartMax = computed(() => {
</div> </div>
</div> </div>
</section> </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;}
/* grid 子项默认 min-width:auto:标题列表里 white-space:nowrap 的标题会把
面板的 min-content 顶到整行宽度,撑破屏幕(375 视口量到 docW 4181)。
min-width:0 让面板可收缩,.title 的 ellipsis 截断才有机会生效。 */
.dash-cols > .panel {min-width: 0;}
@media (max-width: 720px) {
.dash-cols,
.dash-cols.even {grid-template-columns: 1fr;}
}
</style>
+292 -45
View File
@@ -1,10 +1,16 @@
<script setup> <script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' 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 { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import { adminApi } from '../api' import { adminApi } from '../api'
import { site } from '../site'
import { Crepe, CrepeFeature } from '@milkdown/crepe' import { Crepe, CrepeFeature } from '@milkdown/crepe'
import '@milkdown/crepe/theme/common/style.css' import '@milkdown/crepe/theme/common/style.css'
import '@milkdown/crepe/theme/frame.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 route = useRoute()
const router = useRouter() const router = useRouter()
@@ -184,6 +190,10 @@ onBeforeUnmount(() => {
crepe.destroy() crepe.destroy()
crepe = null crepe = null
} }
if (cmView) {
cmView.destroy()
cmView = null
}
}) })
// ---------- mode switching ---------- // ---------- mode switching ----------
@@ -195,6 +205,10 @@ async function switchMode(next) {
crepe.destroy() crepe.destroy()
crepe = null crepe = null
} }
// CM 懒挂载;已挂载就把 wysiwyg 期间产生的 markdown 灌回来
await nextTick()
mountCM()
syncCM()
} else { } else {
// MD → WYSIWYG: spin up crepe with the current markdown // MD → WYSIWYG: spin up crepe with the current markdown
await nextTick() await nextTick()
@@ -267,6 +281,9 @@ async function autosave() {
function applySaved(saved) { function applySaved(saved) {
form.slug = saved.slug form.slug = saved.slug
form.status = saved.status 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) publishedLocal.value = isoToLocal(saved.published_at)
} }
@@ -303,7 +320,7 @@ function buildPayload() {
async function save(status) { async function save(status) {
if (status) form.status = status if (status) form.status = status
if (!isEdit.value && !form.content_md.trim() && !form.title.trim()) { if (!isEdit.value && !form.content_md.trim() && !form.title.trim()) {
error.value = '先写点什么再保存' toastErr('先写点什么再保存')
return return
} }
saving.value = true saving.value = true
@@ -317,12 +334,17 @@ async function save(status) {
const created = await adminApi.createPost(payload) const created = await adminApi.createPost(payload)
localStorage.removeItem(DRAFT_KEY) localStorage.removeItem(DRAFT_KEY)
dirty.value = false dirty.value = false
toastOk('已创建')
router.replace(`/admin/${created.id}`) router.replace(`/admin/${created.id}`)
return return
} }
dirty.value = false dirty.value = false
savedAt.value = new Date().toLocaleTimeString('zh-CN', { hour12: false }) savedAt.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
// 手动保存(按钮/⌘S/发布)给回音;自动保存静默,不打扰
const label = status === 'published' ? '已发布' : status === 'draft' ? '已转回草稿' : '已保存'
toastOk(label + ' · ' + savedAt.value)
} catch (e) { } catch (e) {
toastErr(e.message || '保存失败')
error.value = e.message || '保存失败' error.value = e.message || '保存失败'
} finally { } finally {
saving.value = false saving.value = false
@@ -365,6 +387,14 @@ onBeforeRouteLeave(() => {
// ---------- 标签 ---------- // ---------- 标签 ----------
// 标签是长文的组织方式;切到短文时把已选的清掉(后端落库时也会强制丢弃)
watch(
() => form.kind,
(k) => {
if (k === 'short') form.tags = []
}
)
function addTag() { function addTag() {
const v = tagInput.value.trim().replace(/[,,]$/, '') const v = tagInput.value.trim().replace(/[,,]$/, '')
if (!v) return if (!v) return
@@ -391,6 +421,82 @@ function clearCover() {
// ---------- 工具栏:Markdown 模式插入 + 通用动作 ---------- // ---------- 工具栏:Markdown 模式插入 + 通用动作 ----------
const mdPane = ref(null) 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 showLinkInput = ref(false)
const showImageInput = ref(false) const showImageInput = ref(false)
const linkText = ref('') const linkText = ref('')
@@ -399,22 +505,31 @@ const imageUrl = ref('')
const imageAlt = ref('') const imageAlt = ref('')
function insertAtCursor(text, selStart, selEnd) { 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 before = form.content_md.slice(0, selStart)
const after = form.content_md.slice(selEnd) const after = form.content_md.slice(selEnd)
form.content_md = before + text + after 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) { function withSelection(fn) {
const el = mdPane.value if (cmView) {
const start = el ? el.selectionStart : form.content_md.length const sel = cmView.state.selection.main
const end = el ? el.selectionEnd : form.content_md.length fn(sel.from, sel.to)
fn(start, end) return
}
fn(form.content_md.length, form.content_md.length)
} }
function tbBold() { function tbBold() {
@@ -499,6 +614,15 @@ function tbTask() {
function tbHr() { function tbHr() {
withSelection((s, e) => insertAtCursor('\n---\n', s, e)) 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() { function openLink() {
showLinkInput.value = true showLinkInput.value = true
@@ -526,23 +650,110 @@ function commitImage() {
} }
// ---------- 上传(封面 / 编辑器插图共用) ---------- // ---------- 上传(封面 / 编辑器插图共用) ----------
// toast 用共享的 ./toast(ToastStack 渲染在 AdminLayout)。
// 后台「高级 → 自定义 JS」无关;走 /api/admin/files(FormData)。 // 后台「高级 → 自定义 JS」无关;走 /api/admin/files(FormData)。
const uploadingImage = ref(false) const uploadingImage = ref(false)
async function uploadOne(file) { // 与后端 allowFileExt / 50MB 上限一致:上传前本地校验,省一趟白跑
const fd = new FormData() const ALLOW_UPLOAD_EXT = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'avif', 'pdf', 'zip', 'txt']
fd.append('file', file) 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 { try {
const data = await adminApi.uploadFiles(fd) const data = await adminApi.uploadFile(file, (p) =>
toastUpdate(tid, `上传 ${file.name} ${Math.round(p * 100)}%`)
)
const f = Array.isArray(data) ? data[0] : null const f = Array.isArray(data) ? data[0] : null
if (!f || !f.url) throw new Error('上传失败') 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 return f.url
} catch (e) { } catch (e) {
toastUpdate(tid, `${file.name} 上传失败:${e.message || '未知错误'}`, 'err')
error.value = e.message || '上传失败' error.value = e.message || '上传失败'
throw e 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) const coverInput = ref(null)
async function onCoverPick(e) { async function onCoverPick(e) {
const file = e.target.files && e.target.files[0] const file = e.target.files && e.target.files[0]
@@ -610,15 +821,29 @@ function insertDate() {
} }
// 工具栏按钮的统一定义(用作 v-for 渲染) // 工具栏按钮的统一定义(用作 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(() => [ const tbGroups = computed(() => [
{ {
label: '格式', label: '格式',
items: [ items: [
{ key: 'b', label: 'B', title: '加粗', run: tbBold, show: mode.value === 'md' }, { key: 'b', title: '加粗', shortcut: 'Mod-b', run: tbBold, show: mode.value === 'md',
{ key: 'i', label: 'I', title: '斜体', run: tbItalic, italic: true, show: mode.value === 'md' }, icon: 'M6 4h8a4 4 0 0 1 0 8H6zM6 12h9a4 4 0 0 1 0 8H6z', fill: true },
{ key: 's', label: 'S', title: '删除线', run: tbStrike, show: mode.value === 'md' }, { key: 'i', title: '斜体', shortcut: 'Mod-i', run: tbItalic, show: mode.value === 'md',
{ key: 'code', label: '<>', title: '行内代码', run: tbCode, mono: true, show: mode.value === 'md' }, icon: 'M19 4h-9M14 20H5M15 4L9 20' },
{ key: 'cb', label: '```', title: '代码块', run: tbCodeBlock, mono: true, show: mode.value === 'md' } { 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' }
] ]
}, },
{ {
@@ -626,19 +851,29 @@ const tbGroups = computed(() => [
items: [ items: [
{ key: 'h2', label: 'H2', title: '二级标题', run: tbH2, show: mode.value === 'md' }, { key: 'h2', label: 'H2', title: '二级标题', run: tbH2, show: mode.value === 'md' },
{ key: 'h3', label: 'H3', title: '三级标题', run: tbH3, 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: 'q', title: '引用', run: tbQuote, show: mode.value === 'md',
{ key: 'ul', label: '•', title: '无序列表', run: tbUl, 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: 'ol', label: '1.', title: '有序列表', run: tbOl, show: mode.value === 'md' }, { key: 'ul', title: '无序列表', run: tbUl, show: mode.value === 'md',
{ key: 'task', label: '☐', title: '任务列表', run: tbTask, show: mode.value === 'md' }, icon: 'M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01' },
{ key: 'hr', label: '—', title: '分隔线', run: tbHr, show: mode.value === 'md' } { 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: '插入', label: '插入',
items: [ items: [
{ key: 'link', label: '🔗', title: '链接', run: openLink, show: mode.value === 'md' }, { key: 'link', title: '链接', shortcut: 'Mod-k', run: openLink, show: mode.value === 'md',
{ key: 'img', label: '🖼', title: '图片', run: openImage, 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: 'date', label: '📅', title: '插入今天日期', run: insertDate, show: mode.value === 'md' } { 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' }
] ]
} }
]) ])
@@ -675,16 +910,24 @@ const tbGroups = computed(() => [
<div class="editor-grid"> <div class="editor-grid">
<div> <div>
<!-- kind switch + title --> <!-- 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 === 'long' }" @click="form.kind = 'long'">长文</button>
<button class="chip" :class="{ on: form.kind === 'short' }" @click="form.kind = 'short'">短文</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' ? '一两段话,时间线里不显示标题' : '有标题、有结构,适合讲完整一件事' }} {{ form.kind === 'short' ? '一两段话,时间线里不显示标题' : '有标题、有结构,适合讲完整一件事' }}
</span> </span>
<span style="flex: 1;"></span> <span class="spacer"></span>
<div class="editor-mode"> <div class="editor-mode">
<button :class="{ on: mode === 'wysiwyg' }" @click="switchMode('wysiwyg')">富文本</button> <button :class="{ on: mode === 'wysiwyg' }" @click="switchMode('wysiwyg')">富文本</button>
<button :class="{ on: mode === 'md' }" @click="switchMode('md')">Markdown</button> <button :class="{ on: mode === 'md' }" @click="switchMode('md')">Markdown</button>
<button
class="btn"
:disabled="importing"
title="把正文里的外链图片抓回自己的存储"
@click="importExternal"
>
{{ importing ? '转存中…' : '⇲ 转存外链图' }}
</button>
</div> </div>
</div> </div>
@@ -704,11 +947,18 @@ const tbGroups = computed(() => [
:key="it.key" :key="it.key"
type="button" type="button"
class="tb-btn" class="tb-btn"
:class="{ italic: it.italic, mono: it.mono }" :title="it.title + keyDisplay(it.shortcut)"
:title="it.title"
:aria-label="it.title" :aria-label="it.title"
@click="it.run()" @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>
</div> </div>
@@ -767,16 +1017,13 @@ const tbGroups = computed(() => [
<!-- editor area --> <!-- editor area -->
<div v-show="mode === 'wysiwyg'" ref="editorEl"></div> <div v-show="mode === 'wysiwyg'" ref="editorEl"></div>
<textarea <!-- md 页签:CodeMirror 6(语法高亮/历史/粘贴拖拽上传),markdown 唯一真相源 -->
v-show="mode === 'md'" <div v-show="mode === 'md'" ref="mdPane" class="md-pane" aria-label="Markdown 正文"></div>
ref="mdPane"
class="md-pane" <!-- 轻提示栈 -->
v-model="form.content_md" <div class="toast-stack" aria-live="polite">
placeholder="直接写 Markdown…" <div v-for="t in toasts" :key="t.id" class="toast" :class="t.type">{{ t.text }}</div>
aria-label="Markdown 正文" </div>
@paste="onMdPaste"
@drop="onMdDrop"
></textarea>
<div class="editor-stats"> <div class="editor-stats">
<span><strong>{{ cjkChars }}</strong> 汉字</span> <span><strong>{{ cjkChars }}</strong> 汉字</span>
@@ -848,7 +1095,7 @@ const tbGroups = computed(() => [
></textarea> ></textarea>
</div> </div>
<div class="field"> <div v-if="form.kind !== 'short'" class="field">
<label>标签</label> <label>标签</label>
<div v-if="form.tags.length" style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px;"> <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"> <span v-for="t in form.tags" :key="t" class="chip">
+30 -2
View File
@@ -1,5 +1,6 @@
<script setup> <script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { toastOk, toastErr } from './toast'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { adminApi } from '../api' import { adminApi } from '../api'
import { relativeDate } from '../utils' import { relativeDate } from '../utils'
@@ -55,8 +56,10 @@ async function upload(files) {
const fd = new FormData() const fd = new FormData()
for (const f of files) fd.append('file', f) for (const f of files) fd.append('file', f)
await adminApi.uploadFiles(fd) await adminApi.uploadFiles(fd)
toastOk(`已上传 ${files.length} 个文件`)
await load() await load()
} catch (e) { } catch (e) {
toastErr(e.message || '上传失败')
uploadError.value = e.message || '上传失败' uploadError.value = e.message || '上传失败'
} finally { } finally {
uploading.value = false uploading.value = false
@@ -75,6 +78,7 @@ function onDrop(e) {
async function copyUrl(f) { async function copyUrl(f) {
try { try {
await navigator.clipboard.writeText(f.url) await navigator.clipboard.writeText(f.url)
toastOk('链接已复制')
copiedId.value = f.id copiedId.value = f.id
setTimeout(() => { setTimeout(() => {
if (copiedId.value === f.id) copiedId.value = 0 if (copiedId.value === f.id) copiedId.value = 0
@@ -85,14 +89,38 @@ async function copyUrl(f) {
} }
} }
// 引用位置的展示文案:文章/项目带标题,头像没有标题可指。
// 草稿也列出来——现在没发布,删了图将来发出来就是裂的。
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) { async function remove(f) {
if (!window.confirm(`删除「${f.name}」?存储里的文件会一并删除,引用它的文章将失效。`)) return // 删除前查一次引用:后端默认会挡住被引用的文件(409),这里先把清单摊出来,
// 用户看完仍然要删才带 force=1 过去。
let refs = []
try { try {
await adminApi.deleteFile(f.id) 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 } }) if (items.value.length === 1 && page.value > 1) router.replace({ query: { ...route.query, page: page.value - 1 } })
else load() else load()
} catch (e) { } catch (e) {
toastErr(e.message || '删除失败')
error.value = e.message || '删除失败' error.value = e.message || '删除失败'
} }
} }
+8 -1
View File
@@ -1,5 +1,7 @@
<script setup> <script setup>
import { ref } from 'vue' import { ref } from 'vue'
import { toastOk, toastErr } from './toast'
import ToastStack from './ToastStack.vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { adminApi, session } from '../api' import { adminApi, session } from '../api'
import { site } from '../site' import { site } from '../site'
@@ -16,9 +18,12 @@ async function submit() {
try { try {
const data = await adminApi.login(username.value, password.value) const data = await adminApi.login(username.value, password.value)
session.user = username.value session.user = username.value
session.role = data.role || 'admin'
void data void data
toastOk('登录成功')
router.push('/admin') router.push('/admin')
} catch (e) { } catch (e) {
toastErr('用户名或密码不对')
error.value = '用户名或密码不对' error.value = '用户名或密码不对'
} finally { } finally {
busy.value = false busy.value = false
@@ -27,6 +32,7 @@ async function submit() {
</script> </script>
<template> <template>
<ToastStack />
<div class="page"> <div class="page">
<form class="card" @submit.prevent="submit"> <form class="card" @submit.prevent="submit">
<p class="eyebrow">后台</p> <p class="eyebrow">后台</p>
@@ -56,7 +62,8 @@ async function submit() {
</button> </button>
<p class="hint"> <p class="hint">
默认账号在服务端环境变量里:<code>ONE_ADMIN_USER</code> / <code>ONE_ADMIN_PASSWORD</code>。 初始账号在服务端环境变量里:<code>ONE_ADMIN_USER</code> / <code>ONE_ADMIN_PASSWORD</code>,
登录后可在「设置 → 登录与存储」里改成自己的用户名和密码。
</p> </p>
<RouterLink to="/" class="back">← 回到前台</RouterLink> <RouterLink to="/" class="back">← 回到前台</RouterLink>
+3 -2
View File
@@ -1,5 +1,6 @@
<script setup> <script setup>
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { toastOk, toastErr } from './toast'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { adminApi } from '../api' import { adminApi } from '../api'
import { formatDateShort, relativeDate } from '../utils' import { formatDateShort, relativeDate } from '../utils'
@@ -125,7 +126,7 @@ async function bulk(action) {
await adminApi.bulkPosts([...selected.value], action) await adminApi.bulkPosts([...selected.value], action)
await load() await load()
} catch (e) { } catch (e) {
alert(e.message || '操作失败') toastErr(e.message || '操作失败')
} }
} }
@@ -135,7 +136,7 @@ async function remove(id, title) {
await adminApi.deletePost(id) await adminApi.deletePost(id)
await load() await load()
} catch (e) { } catch (e) {
alert(e.message || '删除失败') toastErr(e.message || '删除失败')
} }
} }
+7 -6
View File
@@ -1,5 +1,6 @@
<script setup> <script setup>
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { toastOk, toastErr } from './toast'
import { adminApi } from '../api' import { adminApi } from '../api'
const projects = ref([]) const projects = ref([])
@@ -40,7 +41,7 @@ onMounted(load)
async function create() { async function create() {
if (!form.value.title.trim()) { if (!form.value.title.trim()) {
alert('请填写作品名称') toastErr('请填写作品名称')
return return
} }
try { try {
@@ -56,7 +57,7 @@ async function create() {
form.value = blank() form.value = blank()
await load() await load()
} catch (e) { } catch (e) {
alert(e.message || '创建失败') toastErr(e.message || '创建失败')
} }
} }
@@ -81,7 +82,7 @@ function cancelEdit() {
async function saveEdit() { async function saveEdit() {
if (!draft.value.title.trim()) { if (!draft.value.title.trim()) {
alert('请填写作品名称') toastErr('请填写作品名称')
return return
} }
try { try {
@@ -97,7 +98,7 @@ async function saveEdit() {
editing.value = null editing.value = null
await load() await load()
} catch (e) { } catch (e) {
alert(e.message || '保存失败') toastErr(e.message || '保存失败')
} }
} }
@@ -107,7 +108,7 @@ async function toggleStatus(p) {
await adminApi.updateProject(p.id, { status: next }) await adminApi.updateProject(p.id, { status: next })
await load() await load()
} catch (e) { } catch (e) {
alert(e.message || '更新失败') toastErr(e.message || '更新失败')
} }
} }
@@ -117,7 +118,7 @@ async function remove(p) {
await adminApi.deleteProject(p.id) await adminApi.deleteProject(p.id)
await load() await load()
} catch (e) { } catch (e) {
alert(e.message || '删除失败') toastErr(e.message || '删除失败')
} }
} }
</script> </script>
+164 -27
View File
@@ -1,6 +1,7 @@
<script setup> <script setup>
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { adminApi } from '../api' import { toastOk, toastErr } from './toast'
import { adminApi, session } from '../api'
import { SKIN_CONSTANTS, UI_CONSTANTS } from '../site' import { SKIN_CONSTANTS, UI_CONSTANTS } from '../site'
import { SECTIONS } from '../ui/sections' import { SECTIONS } from '../ui/sections'
@@ -11,6 +12,42 @@ const error = ref('')
const saving = ref(false) const saving = ref(false)
const savedAt = ref('') const savedAt = ref('')
// ---------- 系统设置(从环境变量迁进后台的部分) ----------
// 秘密项永不明文回显:输入框留空 = 保持现值。credential_meta 告诉每一项
// 现在是否生效、来自后台(db)还是环境变量(env)兜底。
const credMeta = ref({})
const secrets = ref({})
const secretFields = [
{ key: 'github_client_secret', label: 'GitHub Client Secret' },
{ key: 'google_client_secret', label: 'Google Client Secret' },
{ key: 'telegram_bot_token', label: 'Telegram Bot Token' },
{ key: 'r2_access_key', label: 'R2 Access Key' },
{ key: 'r2_secret_key', label: 'R2 Secret Key' }
]
function srcOf(key) {
const m = credMeta.value[key]
return m ? m.source : 'unset'
}
// 非秘密项的提示:值存哪、改了是否立即生效
function srcHint(key) {
const s = srcOf(key)
if (s === 'db') return '已在此配置'
if (s === 'env') return '当前来自环境变量,在此填写后将覆盖'
return '未配置'
}
// 秘密项输入框的 placeholder:不回显值,只报状态
function secretPlaceholder(key) {
const s = srcOf(key)
if (s === 'db') return '已配置(后台)· 留空保持不变'
if (s === 'env') return '已通过环境变量配置 · 填写后覆盖'
return '未配置'
}
// ---------- 社交链接(settings.social_links <-> 多行文本) ---------- // ---------- 社交链接(settings.social_links <-> 多行文本) ----------
// 文本框里每行一条「名称 | 链接」;数组存后端,文本框给人编辑。 // 文本框里每行一条「名称 | 链接」;数组存后端,文本框给人编辑。
const socialText = ref('') const socialText = ref('')
@@ -35,14 +72,11 @@ function textToSocial(text) {
.filter(Boolean) .filter(Boolean)
} }
// 这里只能选亮色皮肤(paper / sage / rose)。暗色端固定 ink,由访客在前台 // 皮肤已精简:亮端固定纸感,暗端固定墨色(2026 重设计)。下面只做展示。
// 右下角 ThemeSwitcher 切到「深色」或「自动 + 系统暗」时启用。
const lightSkins = [ const lightSkins = [
{ id: 'paper', label: '纸感(默认)', bg: '#faf7f1', fg: '#3d7f9c' }, { id: 'paper', label: '纸感(默认)', bg: '#faf7f1', fg: '#3d7f9c' }
{ id: 'sage', label: '鼠尾草', bg: '#eef0e6', fg: '#5f7b5c' },
{ id: 'rose', label: '玫瑰', bg: '#f7eee6', fg: '#a55a4a' }
] ]
const darkSkin = { id: 'ink', label: '墨色', bg: '#1f1d1a', fg: '#c3b58f' } const darkSkin = { id: 'ink', label: '墨色', bg: '#191817', fg: '#d9b97c' }
// ---------- 界面与自定义(第二套 UI) ---------- // ---------- 界面与自定义(第二套 UI) ----------
@@ -139,7 +173,10 @@ const currentSection = computed(() => SECTIONS.find((s) => s.id === cssSection.v
async function load() { async function load() {
loading.value = true loading.value = true
try { try {
settings.value = await adminApi.settings() const res = await adminApi.settings()
settings.value = res.settings
credMeta.value = res.credential_meta || {}
secrets.value = {}
if (!SKIN_CONSTANTS.LIGHT_SKINS.includes(settings.value.light_skin_id)) { if (!SKIN_CONSTANTS.LIGHT_SKINS.includes(settings.value.light_skin_id)) {
settings.value.light_skin_id = 'paper' settings.value.light_skin_id = 'paper'
} }
@@ -173,9 +210,16 @@ async function save() {
error.value = '' error.value = ''
try { try {
settings.value.social_links = textToSocial(socialText.value) settings.value.social_links = textToSocial(socialText.value)
const s = await adminApi.saveSettings(settings.value) // 秘密项走 secrets 单独通道(留空的键不会提交,服务端按「保持现值」处理)
settings.value = s const payload = { ...settings.value, secrets: {} }
socialText.value = socialToText(s.social_links) for (const f of secretFields) {
const v = (secrets.value[f.key] || '').trim()
if (v) payload.secrets[f.key] = v
}
const res = await adminApi.saveSettings(payload)
settings.value = res.settings
credMeta.value = res.credential_meta || {}
socialText.value = socialToText(settings.value.social_links)
// 保存后服务端会丢掉空的/超长的分区,重新补齐本地键,免得输入框失焦 // 保存后服务端会丢掉空的/超长的分区,重新补齐本地键,免得输入框失焦
if (!settings.value.custom_css || typeof settings.value.custom_css !== 'object') { if (!settings.value.custom_css || typeof settings.value.custom_css !== 'object') {
settings.value.custom_css = {} settings.value.custom_css = {}
@@ -185,8 +229,11 @@ async function save() {
settings.value.custom_css[sec.id] = '' settings.value.custom_css[sec.id] = ''
} }
} }
secrets.value = {}
savedAt.value = new Date().toLocaleTimeString('zh-CN', { hour12: false }) savedAt.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
toastOk('设置已保存')
} catch (e) { } catch (e) {
toastErr('保存失败:' + (e.message || ''))
error.value = e.message || '保存失败' error.value = e.message || '保存失败'
} finally { } finally {
saving.value = false saving.value = false
@@ -206,6 +253,7 @@ async function save() {
<button :class="{ on: tab === 'theme' }" @click="tab = 'theme'">主题外观</button> <button :class="{ on: tab === 'theme' }" @click="tab = 'theme'">主题外观</button>
<button :class="{ on: tab === 'ui' }" @click="tab = 'ui'">界面与自定义</button> <button :class="{ on: tab === 'ui' }" @click="tab = 'ui'">界面与自定义</button>
<button :class="{ on: tab === 'advanced' }" @click="tab = 'advanced'">高级</button> <button :class="{ on: tab === 'advanced' }" @click="tab = 'advanced'">高级</button>
<button v-if="session.isOwner" :class="{ on: tab === 'system' }" @click="tab = 'system'">登录与存储</button>
</div> </div>
<div> <div>
@@ -221,17 +269,25 @@ async function save() {
<input v-model="settings.site_desc" class="input" spellcheck="false" /> <input v-model="settings.site_desc" class="input" spellcheck="false" />
</div> </div>
<div class="field"> <div class="field">
<label>作者昵称</label> <label>站点地址</label>
<input v-model="settings.author_name" class="input" spellcheck="false" /> <input v-model="settings.site_url" class="input" spellcheck="false"
</div> :placeholder="srcOf('site_url') === 'env' ? '当前来自环境变量 ONE_SITE_URL' : 'https://your.domain'" />
<div class="field"> <p class="field-hint">
<label>作者简介</label> RSS 订阅链接、GitHub / Google 登录的回调地址都以它为基座。
<textarea v-model="settings.author_bio" class="textarea" rows="3" /> {{ srcHint('site_url') }},保存后立即生效,无需重启。
</p>
</div> </div>
<!-- 作者昵称/简介不在这里改:它们和头像、登录方式同属「账户」,
两处编辑同一个字段迟早漂移,所以收归账户页独占。 -->
<div class="field"> <div class="field">
<label>页脚备注</label> <label>页脚备注</label>
<input v-model="settings.footer_note" class="input" spellcheck="false" /> <input v-model="settings.footer_note" class="input" spellcheck="false" />
</div> </div>
<p class="field-hint">
作者昵称、简介与头像在
<RouterLink to="/admin/account">账户</RouterLink>
页设置(它们和登录方式属于同一件事,两处编辑同一个字段迟早漂移)。
</p>
</div> </div>
<!-- theme --> <!-- theme -->
@@ -241,19 +297,12 @@ async function save() {
<div class="field"> <div class="field">
<label>亮色皮肤</label> <label>亮色皮肤</label>
<div class="theme-swatches"> <div class="theme-swatches">
<div <div class="sw on" :style="{ background: lightSkins[0].bg, borderColor: lightSkins[0].fg }" :title="lightSkins[0].label + '(固定)'">
v-for="sk in lightSkins" <span :style="{ background: lightSkins[0].fg }">{{ lightSkins[0].label }}</span>
: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> </div>
</div> </div>
<p style="margin: 6px 0 0; font-size: 12px; color: var(--admin-muted);"> <p style="margin: 6px 0 0; font-size: 12px; color: var(--admin-muted);">
访客在前台选择「自动」且系统偏好浅色时使用。 亮端固定为纸感。访客在前台选择「自动」且系统偏好浅色时使用。
</p> </p>
</div> </div>
@@ -425,6 +474,94 @@ async function save() {
</div> </div>
</div> </div>
<!-- system:登录方式 / 对象存储 / 管理员账号(原环境变量配置项)。站主专属 -->
<div v-if="tab === 'system' && session.isOwner" class="panel">
<div class="panel-title"><h2>登录方式</h2></div>
<p class="field-hint" style="margin: 0 0 14px;">
评论区读者的第三方登录。凭据齐全的登录方式自动开放,清空即关闭。
GitHub / Google 的回调地址以「站点地址 + /api/auth/callback/平台名」为准,改完站点地址无需再去平台改回调。
</p>
<div class="field">
<label>GitHub OAuth App</label>
<input v-model="settings.github_client_id" class="input" spellcheck="false"
placeholder="Client ID" style="margin-bottom: 8px;" />
<input v-model="secrets.github_client_secret" class="input" type="password"
autocomplete="new-password" :placeholder="secretPlaceholder('github_client_secret')" />
<p class="field-hint">Client ID:{{ srcHint('github_client_id') }}</p>
</div>
<div class="field">
<label>Google OAuth 客户端</label>
<input v-model="settings.google_client_id" class="input" spellcheck="false"
placeholder="Client ID" style="margin-bottom: 8px;" />
<input v-model="secrets.google_client_secret" class="input" type="password"
autocomplete="new-password" :placeholder="secretPlaceholder('google_client_secret')" />
<p class="field-hint">Client ID:{{ srcHint('google_client_id') }}</p>
</div>
<div class="field">
<label>Telegram 登录 Widget</label>
<input v-model="settings.telegram_bot" class="input" spellcheck="false"
placeholder="Bot 用户名(不含 @)" style="margin-bottom: 8px;" />
<input v-model="secrets.telegram_bot_token" class="input" type="password"
autocomplete="new-password" :placeholder="secretPlaceholder('telegram_bot_token')" />
<p class="field-hint">Bot 用户名:{{ srcHint('telegram_bot') }};Token 在 @BotFather 里创建 Bot 后获得。</p>
</div>
<div class="field" style="border-top: 1px solid var(--admin-line); padding-top: 14px;">
<div class="panel-title"><h2>对象存储</h2></div>
<p class="field-hint" style="margin: 0 0 14px;">
文件上传的存储端。四项凭据齐全走 R2,缺任一项回落本地磁盘
(data/uploads),切换不影响已上传文件的链接。保存后立即生效,无需重启。
</p>
<div class="field" style="padding: 0; border: 0; margin-bottom: 8px;">
<label>S3 API 端点</label>
<input v-model="settings.s3_endpoint" class="input" spellcheck="false"
placeholder="https://&lt;账户ID&gt;.r2.cloudflarestorage.com" />
<p class="field-hint">{{ srcHint('s3_endpoint') }}</p>
</div>
<div class="field" style="padding: 0; border: 0; margin-bottom: 8px;">
<label>桶名(Bucket)</label>
<input v-model="settings.r2_bucket" class="input" spellcheck="false" />
<p class="field-hint">{{ srcHint('r2_bucket') }}</p>
</div>
<div class="field" style="padding: 0; border: 0; margin-bottom: 8px;">
<label>访问密钥</label>
<input v-model="secrets.r2_access_key" class="input" type="password"
autocomplete="new-password" :placeholder="secretPlaceholder('r2_access_key')" />
</div>
<div class="field" style="padding: 0; border: 0; margin-bottom: 8px;">
<label>私密密钥</label>
<input v-model="secrets.r2_secret_key" class="input" type="password"
autocomplete="new-password" :placeholder="secretPlaceholder('r2_secret_key')" />
</div>
<div class="field" style="padding: 0; border: 0;">
<label>公开访问域名</label>
<input v-model="settings.uploads_public_base" class="input" spellcheck="false"
placeholder="https://cdn.example.com(r2.dev 或绑定的自定义域名)" />
<p class="field-hint">
{{ srcHint('uploads_public_base') }}。配置后 R2 文件直链走 CDN;
不配则由本站 /uploads/ 路由代为输出。
</p>
</div>
</div>
<div class="field" style="border-top: 1px solid var(--admin-line); padding-top: 14px;">
<div class="panel-title"><h2>管理员账号</h2></div>
<p class="field-hint" style="margin: 0 0 14px;">
站主登录后台的用户名(改掉后环境变量 ONE_ADMIN_USER 不再生效)。
密码在「账户」页修改;给其他人开账号去「用户」页。
</p>
<div class="field" style="padding: 0; border: 0;">
<label>用户名</label>
<input v-model="settings.admin_username" class="input" spellcheck="false"
:placeholder="srcOf('admin_username') === 'env' ? '当前来自环境变量 ONE_ADMIN_USER' : ''" />
<p class="field-hint">{{ srcHint('admin_username') }}</p>
</div>
</div>
</div>
<div style="display: flex; align-items: center; gap: 12px; margin-top: 14px;"> <div style="display: flex; align-items: center; gap: 12px; margin-top: 14px;">
<button class="btn btn-primary" :disabled="saving" @click="save"> <button class="btn btn-primary" :disabled="saving" @click="save">
{{ saving ? '保存中…' : '保存设置' }} {{ saving ? '保存中…' : '保存设置' }}
+6 -5
View File
@@ -1,5 +1,6 @@
<script setup> <script setup>
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { toastOk, toastErr } from './toast'
import { adminApi } from '../api' import { adminApi } from '../api'
const tags = ref([]) const tags = ref([])
@@ -30,7 +31,7 @@ async function create() {
newColor.value = '' newColor.value = ''
await load() await load()
} catch (e) { } 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 adminApi.updateTag(t.id, { name: t.name, color: t.color })
await load() await load()
} catch (e) { } catch (e) {
alert(e.message || '更新失败') toastErr(e.message || '更新失败')
} }
} }
@@ -49,7 +50,7 @@ async function remove(t) {
await adminApi.deleteTag(t.id) await adminApi.deleteTag(t.id)
await load() await load()
} catch (e) { } catch (e) {
alert(e.message || '删除失败') toastErr(e.message || '删除失败')
} }
} }
@@ -69,7 +70,7 @@ function cancelMerge() {
async function doMerge(fromTag) { async function doMerge(fromTag) {
const toId = Number(mergeInto.value) const toId = Number(mergeInto.value)
if (!toId || toId === fromTag.id) { if (!toId || toId === fromTag.id) {
alert('选一个不同的目标标签') toastErr('选一个不同的目标标签')
return return
} }
if (!window.confirm(`把「${fromTag.name}」合并到选中的标签?此操作不可撤销。`)) return if (!window.confirm(`把「${fromTag.name}」合并到选中的标签?此操作不可撤销。`)) return
@@ -78,7 +79,7 @@ async function doMerge(fromTag) {
cancelMerge() cancelMerge()
await load() await load()
} catch (e) { } catch (e) {
alert(e.message || '合并失败') toastErr(e.message || '合并失败')
} }
} }
</script> </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>
+146
View File
@@ -0,0 +1,146 @@
<script setup>
import { onMounted, ref } from 'vue'
import { toastOk, toastErr } from './toast'
import { adminApi, session } from '../api'
import { formatDateShort } from '../utils'
// 用户管理(站主专属):创建内容管理员(admin 角色)、重置密码、停用 / 删除。
// owner 行(站主本人)只读展示——它是「谁登录算站主」的锚点,密码走账户页改。
const users = ref([])
const loading = ref(true)
const error = ref('')
const creating = ref(false)
const form = ref({ username: '', password: '' })
async function load() {
loading.value = true
error.value = ''
try {
const data = await adminApi.users()
users.value = data.users || []
} catch (e) {
error.value = e.message || '加载失败'
} finally {
loading.value = false
}
}
onMounted(load)
async function create() {
if (creating.value) return
creating.value = true
try {
await adminApi.createUser({ username: form.value.username.trim(), password: form.value.password })
toastOk(`已创建 ${form.value.username.trim()}`)
form.value = { username: '', password: '' }
await load()
} catch (e) {
toastErr(e.message || '创建失败')
} finally {
creating.value = false
}
}
async function resetPassword(u) {
const pw = window.prompt(`给「${u.handle}」设置新密码(6-72 位):`)
if (!pw) return
try {
await adminApi.patchUser(u.id, { password: pw })
toastOk('密码已重置')
} catch (e) {
toastErr(e.message || '重置失败')
}
}
async function toggleBan(u) {
if (u.banned) {
try {
await adminApi.patchUser(u.id, { banned: false })
toastOk(`已恢复「${u.handle}」`)
await load()
} catch (e) {
toastErr(e.message || '操作失败')
}
return
}
if (!window.confirm(`停用「${u.handle}」?停用后其立即无法登录,存量会话同步失效。`)) return
try {
await adminApi.patchUser(u.id, { banned: true })
toastOk('已停用')
await load()
} catch (e) {
toastErr(e.message || '操作失败')
}
}
async function remove(u) {
if (!window.confirm(`删除「${u.handle}」?其发布的评论保留,但无法再登录。`)) return
try {
await adminApi.deleteUser(u.id)
toastOk('已删除')
await load()
} catch (e) {
toastErr(e.message || '删除失败')
}
}
const roleLabel = (r) => (r === 'owner' ? '站主' : '管理员')
</script>
<template>
<section v-if="loading" class="loading">载入中…</section>
<section v-else-if="error" class="empty">{{ error }}</section>
<section v-else>
<h1 style="font-family: var(--serif); font-size: 22px; margin-bottom: 14px;">用户</h1>
<div class="panel">
<div class="panel-title"><h2>添加内容管理员</h2></div>
<form style="display: flex; gap: 10px; flex-wrap: wrap; align-items: flex-end;" @submit.prevent="create">
<div class="field" style="margin: 0; flex: 1; min-width: 160px;">
<label>用户名(2-32 位,字母 / 数字 / _ - . @)</label>
<input v-model="form.username" class="input" spellcheck="false" autocomplete="off" placeholder="editor" />
</div>
<div class="field" style="margin: 0; flex: 1; min-width: 160px;">
<label>初始密码(6-72 位)</label>
<input v-model="form.password" class="input" type="password" autocomplete="new-password" placeholder="至少 6 位" />
</div>
<button class="btn btn-primary" type="submit" :disabled="creating">添加</button>
</form>
<p class="field-hint" style="margin-top: 10px;">
管理员可以写文章、管理评论与文件;系统设置、用户管理与第三方凭据只有你能动。
他们自己的密码在「账户」页修改。
</p>
</div>
<div class="panel" style="margin-top: 18px;">
<div class="panel-title"><h2>后台账号</h2></div>
<div class="recent-list">
<div v-for="u in users" :key="u.id" class="recent-item" style="gap: 14px;">
<span class="title" style="font-weight: 700;">{{ u.handle }}</span>
<span class="meta" style="font-size: 12px;">{{ roleLabel(u.role) }}</span>
<span v-if="u.banned" class="meta" style="color: #b4553f;">已停用</span>
<span class="meta" style="margin-left: auto; white-space: normal; text-align: right;">
{{ formatDateShort(u.created_at) }}
</span>
<span v-if="u.role !== 'owner'" style="display: flex; gap: 4px; flex: none;">
<button class="btn" style="font-size: 12px; padding: 4px 10px;" type="button" @click="resetPassword(u)">重置密码</button>
<button class="btn" style="font-size: 12px; padding: 4px 10px;" type="button" @click="toggleBan(u)">
{{ u.banned ? '恢复' : '停用' }}
</button>
<button v-if="u.id !== 0" class="btn danger" style="font-size: 12px; padding: 4px 10px;" type="button" @click="remove(u)">删除</button>
</span>
<span v-else class="meta" style="flex: none;">站主本人 · 密码在账户页修改</span>
</div>
</div>
<p class="field-hint" style="margin-top: 12px;">
当前登录:{{ session.user }}({{ roleLabel(session.role) }})。
停用或删除后,对方的存量登录会话立即失效。
</p>
</div>
</section>
</template>
<style scoped>
.btn.danger {color: #b4553f;}
</style>
+5 -2
View File
@@ -1,9 +1,12 @@
import { adminApi, session } from '../api' import { adminApi, session } from '../api'
// 后台的登录态在服务端(httpOnly cookie);这里只是探测一次是否还有效 // 后台的登录态在服务端(httpOnly cookie);这里探测一次是否还有效,
// 顺带把用户名与角色(owner / admin)存下来,供导航显隐用
export async function checkAuth() { export async function checkAuth() {
try { try {
await adminApi.me() const me = await adminApi.me()
session.user = me.user || ''
session.role = me.role || 'admin'
return true return true
} catch (e) { } catch (e) {
session.clear() session.clear()
+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)
}
}
+63 -2
View File
@@ -83,6 +83,22 @@ export const adminApi = {
deleteProject: (id) => request('/api/admin/projects/' + id, { method: 'DELETE' }), deleteProject: (id) => request('/api/admin/projects/' + id, { method: 'DELETE' }),
settings: () => request('/api/admin/settings'), settings: () => request('/api/admin/settings'),
saveSettings: (body) => request('/api/admin/settings', { method: 'PUT', body }), saveSettings: (body) => request('/api/admin/settings', { method: 'PUT', body }),
users: () => request('/api/admin/users'),
createUser: (body) => request('/api/admin/users', { method: 'POST', body }),
patchUser: (id, body) => request('/api/admin/users/' + id, { method: 'PATCH', body }),
deleteUser: (id) => request('/api/admin/users/' + id, { method: 'DELETE' }),
changePassword: (body) => request('/api/admin/account/password', { method: 'PATCH', 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)), comments: (params = {}) => request('/api/admin/comments?' + new URLSearchParams(params)),
approveComment: (id) => approveComment: (id) =>
@@ -93,9 +109,41 @@ export const adminApi = {
request('/api/admin/readers/' + id + '/ban', { method: 'POST', body: { banned } }), request('/api/admin/readers/' + id + '/ban', { method: 'POST', body: { banned } }),
// ---------- 文件上传 ---------- // ---------- 文件上传 ----------
files: (params = {}) => request('/api/admin/files?' + new URLSearchParams(params)), files: (params = {}) => request('/api/admin/files?' + new URLSearchParams(params)),
deleteFile: (id) => request('/api/admin/files/' + id, { method: 'DELETE' }), // 删除前查引用(被哪些文章/项目/站主头像用着);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。 // 上传走 FormData:request() 是 JSON helper,这里单独 fetch。
// 401 同样广播 one:unauthorized,错误消息从 JSON body 里取(与 request 一致)。 // 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) => { uploadFiles: async (formData) => {
const res = await fetch(base + '/api/admin/files', { const res = await fetch(base + '/api/admin/files', {
method: 'POST', method: 'POST',
@@ -143,8 +191,10 @@ export const readerApi = {
remove: (id) => request(`/api/comments/${id}`, { method: 'DELETE' }) remove: (id) => request(`/api/comments/${id}`, { method: 'DELETE' })
} }
// 后台登录态:cookie 由服务端下发(httpOnly),这里只存一份展示用的用户名 // 后台登录态:cookie 由服务端下发(httpOnly),这里只存展示用的用户名与角色
// (owner / admin,来自 /api/admin/me,控制「用户管理」等入口的显隐)
const USER_KEY = 'one.admin.user' const USER_KEY = 'one.admin.user'
const ROLE_KEY = 'one.admin.role'
export const session = { export const session = {
get user() { get user() {
@@ -154,7 +204,18 @@ export const session = {
if (v) localStorage.setItem(USER_KEY, v) if (v) localStorage.setItem(USER_KEY, v)
else localStorage.removeItem(USER_KEY) else localStorage.removeItem(USER_KEY)
}, },
get role() {
return localStorage.getItem(ROLE_KEY) || 'admin'
},
set role(v) {
if (v) localStorage.setItem(ROLE_KEY, v)
else localStorage.removeItem(ROLE_KEY)
},
get isOwner() {
return this.role === 'owner'
},
clear() { clear() {
localStorage.removeItem(USER_KEY) localStorage.removeItem(USER_KEY)
localStorage.removeItem(ROLE_KEY)
} }
} }
+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>
+70 -23
View File
@@ -2,34 +2,70 @@
import { onBeforeUnmount, watch } from 'vue' import { onBeforeUnmount, watch } from 'vue'
import { lightboxState as st, closeLightbox, stepLightbox } from '../lightbox' import { lightboxState as st, closeLightbox, stepLightbox } from '../lightbox'
// 图片预览层:App.vue 全局挂一次。铺满全屏的暗底 + 居中大图, // 图片预览层(手机重做版):App.vue 全局挂一次。
// 多图时左右箭头 / 方向键切换,角标显示第几张;Esc / 点空白收起。 //
// 手机上出过两次事故,这次的实现原则:
// 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) { function onKey(e) {
if (e.key === 'Escape') closeLightbox() if (e.key === 'Escape') closeLightbox()
if (e.key === 'ArrowLeft') stepLightbox(-1) if (e.key === 'ArrowLeft') stepLightbox(-1)
if (e.key === 'ArrowRight') stepLightbox(1) if (e.key === 'ArrowRight') stepLightbox(1)
} }
// 吸顶栏带 backdrop-filter,真机上其合成层会盖在遮罩之上
// (z-index 更高也拦不住)——开预览时整个藏掉
watch( watch(
() => st.open, () => st.open,
(v) => { (v) => {
if (v) window.addEventListener('keydown', onKey) if (v) window.addEventListener('keydown', onKey)
else window.removeEventListener('keydown', onKey) else window.removeEventListener('keydown', onKey)
document.body.classList.toggle('lb-open', v)
} }
) )
onBeforeUnmount(() => window.removeEventListener('keydown', onKey)) 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> </script>
<template> <template>
<Teleport to="body"> <Teleport to="body">
<Transition name="lb"> <Transition name="lb" :duration="220">
<div v-if="st.open" class="lb-overlay" @click="closeLightbox"> <div
<img v-if="st.open"
class="lb-img" class="lb-overlay"
:src="st.list[st.index]" @click="closeLightbox"
alt="" @touchstart.passive="onTouchStart"
@click.stop @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 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 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> <button class="lb-close" type="button" aria-label="关闭预览" @click="closeLightbox">×</button>
@@ -44,19 +80,23 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
position: fixed; position: fixed;
inset: 0; inset: 0;
z-index: 130; z-index: 130;
background: rgba(12, 11, 10, 0.88); background: rgba(12, 11, 10, 0.92);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
/* 遮罩全权处理手势:杜绝双击缩放/长按菜单/下拉刷新来添乱 */
touch-action: none;
} }
/* 百分比尺寸:遮罩即视口,图片永远不会超出屏幕半像素 */
.lb-img { .lb-img {
max-width: min(1200px, 92vw); max-width: 100%;
max-height: 88vh; max-height: 100%;
object-fit: contain; object-fit: contain;
border-radius: 6px; border-radius: 6px;
box-shadow: 0 30px 80px -20px rgba(0, 0, 0, 0.6); box-shadow: 0 30px 80px -20px rgba(0, 0, 0, 0.6);
user-select: none; user-select: none;
-webkit-user-drag: none;
} }
.lb-nav { .lb-nav {
@@ -123,29 +163,36 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
} }
@media (max-width: 640px) { /* 手机:滑动翻页为主,箭头贴边缩小;位置全走安全区 */
@media (pointer: coarse) {
.lb-img { .lb-img {
max-width: 96vw; border-radius: 0;
max-height: 74vh;
} }
.lb-nav { .lb-nav {
width: 38px; width: 40px;
height: 38px; height: 40px;
font-size: 22px; font-size: 22px;
background: rgba(255, 255, 255, 0.12);
} }
.lb-nav.prev { .lb-nav.prev {
left: 10px; left: calc(6px + env(safe-area-inset-left));
} }
.lb-nav.next { .lb-nav.next {
right: 10px; right: calc(6px + env(safe-area-inset-right));
} }
.lb-close { .lb-close {
top: 10px; top: calc(10px + env(safe-area-inset-top));
right: 10px; right: calc(10px + env(safe-area-inset-right));
width: 42px;
height: 42px;
}
.lb-count {
bottom: calc(14px + env(safe-area-inset-bottom));
} }
} }
+8 -3
View File
@@ -3,6 +3,7 @@ import { computed } from 'vue'
import { site } from '../site' import { site } from '../site'
import { socialIcon } from '../socialIcons' import { socialIcon } from '../socialIcons'
import ThemeSwitcher from './ThemeSwitcher.vue' import ThemeSwitcher from './ThemeSwitcher.vue'
import LogoMark from './LogoMark.vue'
// vivid 用顶部导航(VividNav)代替左栏。这里直接不渲染。 // vivid 用顶部导航(VividNav)代替左栏。这里直接不渲染。
const isVivid = computed(() => site.ui_id === 'vivid') 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"> <aside v-if="!isVivid" class="rail-left">
<div class="inner"> <div class="inner">
<RouterLink to="/" class="brand"> <RouterLink to="/" class="brand">
<span class="mark">○</span> <span class="mark"><LogoMark /></span>
<span class="name">{{ site.site_title || 'ONE' }}</span> <span class="name">{{ site.site_title || 'ONE' }}</span>
</RouterLink> </RouterLink>
<p class="desc">{{ site.site_desc }}</p> <p class="desc">{{ site.site_desc }}</p>
@@ -86,9 +87,13 @@ const social = computed(() => (site.social_links || []).filter((s) => s && s.url
gap: 8px; gap: 8px;
} }
/* 原来这里是一个排版字符 ○ 充当标记,换成真站标。
flex:none:它是 .brand(flex)的子项,不锁住会被长站名压成椭圆 */
.mark { .mark {
color: var(--accent); width: 22px;
font-size: 18px; height: 22px;
flex: none;
color: var(--ink);
} }
.name { .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>
+38 -20
View File
@@ -3,7 +3,9 @@ import { computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { site } from '../site' import { site } from '../site'
import { openLightbox } from '../lightbox' import { openLightbox } from '../lightbox'
import { relativeDate, stripTags, minutesLabel, sanitizeHtml } from '../utils' import { relativeDate, stripTags, minutesLabel, sanitizeHtml, thumbURL, thumbifyHtml } from '../utils'
import LinkCard from './LinkCard.vue'
import AuthorAvatar from './AuthorAvatar.vue'
const router = useRouter() const router = useRouter()
@@ -20,10 +22,11 @@ function openPost() {
router.push(`/post/${props.post.slug}`) router.push(`/post/${props.post.slug}`)
} }
// 时间线里短文不铺全文,超过 5 行折叠;长文只显示摘要 // 时间线里短文不铺全文,超过 5 行折叠;长文只显示摘要。
// 正文里的 <img> 换缩略图并补懒加载(详情仍是原图)。
const body = computed(() => { const body = computed(() => {
if (!isShort.value) return props.post.summary || stripTags(props.post.content_html || '') 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))
}) })
// 长文没写摘要时,从正文里截一段纯文本 // 长文没写摘要时,从正文里截一段纯文本
@@ -32,17 +35,17 @@ const summaryText = computed(() => {
return text.length > 110 ? text.slice(0, 110) + '…' : text 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> </script>
<template> <template>
<article class="row-feed" :class="{ short: isShort }" @click="openPost"> <article class="row-feed" :class="{ short: isShort }" @click="openPost">
<div class="avatar" aria-hidden="true">{{ initial }}</div> <div class="avatar"><AuthorAvatar /></div>
<div class="body"> <div class="body">
<div class="byline"> <div class="byline">
<span class="author">{{ site.author_name || 'ONE' }}</span> <span class="author">{{ site.author_name || 'ONE' }}</span>
<span class="handle">@oneblog</span>
<span class="dot">·</span> <span class="dot">·</span>
<time :datetime="post.published_at">{{ relativeDate(post.published_at) }}</time> <time :datetime="post.published_at">{{ relativeDate(post.published_at) }}</time>
<span v-if="isShort" class="kind">短文</span> <span v-if="isShort" class="kind">短文</span>
@@ -53,9 +56,14 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
<h2 class="title">{{ post.title || '无题' }}</h2> <h2 class="title">{{ post.title || '无题' }}</h2>
</RouterLink> </RouterLink>
<!-- 长文:摘要;短文:正文直接铺开 --> <!-- 长文:摘要 + 封面媒体位;短文:正文直接铺开 -->
<div v-if="isShort" class="prose short-body" v-html="body"></div> <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 四宫格);点图开预览 --> <!-- 短文配图:Twitter 式网格(1 全宽 / 2 并排 / 3 左大右二 / 4 四宫格);点图开预览 -->
<div <div
@@ -66,7 +74,7 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
<img <img
v-for="(u, i) in post.images.slice(0, 4)" v-for="(u, i) in post.images.slice(0, 4)"
:key="i" :key="i"
:src="u" :src="thumbURL(u, gridW)"
alt="" alt=""
loading="lazy" loading="lazy"
decoding="async" decoding="async"
@@ -74,6 +82,9 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
/> />
</div> </div>
<!-- 外链预览卡片:正文里第一个链接,发布时后端已抓好 -->
<LinkCard v-if="isShort && post.link_card" :card="post.link_card" />
<div class="meta"> <div class="meta">
<RouterLink :to="`/post/${post.slug}`" class="read" :title="isShort ? '查看' : '阅读全文'"> <RouterLink :to="`/post/${post.slug}`" class="read" :title="isShort ? '查看' : '阅读全文'">
<time :datetime="post.published_at">{{ relativeDate(post.published_at) }}</time> <time :datetime="post.published_at">{{ relativeDate(post.published_at) }}</time>
@@ -113,18 +124,12 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
background: var(--paper-sunken); background: var(--paper-sunken);
} }
/* 站标本身就是个圆,所以原来那层 accent-soft 圆底 / 衬线首字母都不需要了,
只留尺寸与颜色。想换回降饱和蓝只需改 color 一行。 */
.avatar { .avatar {
width: 40px; width: 40px;
height: 40px; height: 40px;
border-radius: 50%; color: var(--ink);
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;
} }
.byline { .byline {
@@ -141,7 +146,6 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
color: var(--ink); color: var(--ink);
} }
.handle,
.dot { .dot {
color: var(--faint); color: var(--faint);
} }
@@ -229,6 +233,12 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
height: 100%; height: 100%;
} }
/* 长文封面:与短文配图同款圆角媒体位,但点击语义是进文章而非预览 */
.imgs.cover img {
max-height: 340px;
cursor: pointer;
}
.meta { .meta {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -255,11 +265,19 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
gap: 6px; 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) { @media (max-width: 520px) {
.row-feed { .row-feed {
grid-template-columns: 34px minmax(0, 1fr); grid-template-columns: 34px minmax(0, 1fr);
padding: 16px 12px; padding: 16px 12px;
margin: 0 -12px;
} }
.avatar { .avatar {
+4 -11
View File
@@ -1,6 +1,7 @@
<script setup> <script setup>
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { adminApi } from '../api' import { adminApi } from '../api'
import AuthorAvatar from './AuthorAvatar.vue'
// Twitter 式短文发布盒,挂在时间线顶部。 // Twitter 式短文发布盒,挂在时间线顶部。
// //
@@ -17,7 +18,6 @@ import { adminApi } from '../api'
const emit = defineEmits(['published']) const emit = defineEmits(['published'])
const shown = ref(false) const shown = ref(false)
const userName = ref('')
const draft = ref('') const draft = ref('')
const publishing = ref(false) const publishing = ref(false)
const error = ref('') const error = ref('')
@@ -53,7 +53,6 @@ function removeImage(i) {
images.value.splice(i, 1) images.value.splice(i, 1)
} }
const initial = computed(() => (userName.value || 'O').trim().slice(0, 1).toUpperCase())
const canPost = computed( const canPost = computed(
() => (draft.value.trim().length > 0 || images.value.length > 0) && !publishing.value () => (draft.value.trim().length > 0 || images.value.length > 0) && !publishing.value
) )
@@ -62,7 +61,6 @@ onMounted(async () => {
try { try {
const d = await adminApi.me() const d = await adminApi.me()
if (d.user) { if (d.user) {
userName.value = String(d.user)
shown.value = true shown.value = true
} }
} catch { } catch {
@@ -110,7 +108,7 @@ async function publish() {
<!-- 桌面:内联 --> <!-- 桌面:内联 -->
<div class="qs-inline"> <div class="qs-inline">
<div class="qs-card"> <div class="qs-card">
<span class="qs-av">{{ initial }}</span> <span class="qs-av"><AuthorAvatar /></span>
<div class="qs-body"> <div class="qs-body">
<textarea v-model="draft" class="qs-input" rows="2" placeholder="记点短的…" @keydown="onKey" /> <textarea v-model="draft" class="qs-input" rows="2" placeholder="记点短的…" @keydown="onKey" />
<div v-if="images.length" class="qs-thumbs"> <div v-if="images.length" class="qs-thumbs">
@@ -197,17 +195,12 @@ async function publish() {
background: var(--card); background: var(--card);
} }
/* 站标自身是圆,去掉原来的 accent-soft 圆底与首字母字号;flex:none 防被压扁 */
.qs-av { .qs-av {
flex: none; flex: none;
width: 34px; width: 34px;
height: 34px; height: 34px;
border-radius: 50%; color: var(--ink);
background: var(--accent-soft);
color: var(--accent);
display: grid;
place-items: center;
font-weight: 700;
font-size: 14px;
} }
.qs-body { .qs-body {
+125 -20
View File
@@ -1,13 +1,42 @@
<script setup> <script setup>
import { computed, onMounted, ref } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { publicApi } from '../api' import { publicApi } from '../api'
import { site } from '../site' import { site } from '../site'
import { socialIcon } from '../socialIcons' import { socialIcon } from '../socialIcons'
import { relativeDate } from '../utils' import { relativeDate, stripTags, thumbURL } from '../utils'
// vivid 是单栏布局,右栏整体不渲染(省掉 latest/tags 两次请求)。 // vivid 是单栏布局,右栏整体不渲染(省掉 latest/tags 两次请求)。
const isVivid = computed(() => site.ui_id === 'vivid') 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),不来自后台配置 —— 和版权一样 // 项目源码仓库是固定入口(本站 git remote),不来自后台配置 —— 和版权一样
// 固定渲染在自己的那排;后台填的社交账号单独一排,URL 撞车的去重。 // 固定渲染在自己的那排;后台填的社交账号单独一排,URL 撞车的去重。
const PROJECT_REPO = 'https://git.gopher.ink/mirrors2/ONE' const PROJECT_REPO = 'https://git.gopher.ink/mirrors2/ONE'
@@ -27,6 +56,26 @@ const links = [
const latest = ref([]) const latest = ref([])
const tags = 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 () => { onMounted(async () => {
if (isVivid.value) return if (isVivid.value) return
try { try {
@@ -42,6 +91,15 @@ onMounted(async () => {
<template> <template>
<aside v-if="!isVivid" class="rail-right"> <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"> <section class="card">
<p class="eyebrow">关于这里</p> <p class="eyebrow">关于这里</p>
<p class="bio">{{ site.author_bio || site.site_desc }}</p> <p class="bio">{{ site.author_bio || site.site_desc }}</p>
@@ -52,12 +110,22 @@ onMounted(async () => {
<p class="eyebrow">最近更新</p> <p class="eyebrow">最近更新</p>
<ul class="list"> <ul class="list">
<li v-for="p in latest" :key="p.id"> <li v-for="p in latest" :key="p.id">
<RouterLink :to="`/post/${p.slug}`" class="row"> <RouterLink :to="`/post/${p.slug}`" class="row" :class="{ withThumb: thumbOf(p) }">
<span class="title"> <span class="row-main">
{{ p.kind === 'short' ? '短文' : p.title || '无题' }} <span class="title">
<span v-if="p.kind === 'short'" class="badge">短</span> {{ excerpt(p) }}
<span v-if="p.kind === 'short'" class="badge">短</span>
</span>
<span class="date">{{ relativeDate(p.published_at) }}</span>
</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> </RouterLink>
</li> </li>
</ul> </ul>
@@ -78,15 +146,6 @@ onMounted(async () => {
已删:右栏在窄屏会堆到信息流下方,卡片之后正好接页脚小字, 已删:右栏在窄屏会堆到信息流下方,卡片之后正好接页脚小字,
所有宽度都不缺位。 --> 所有宽度都不缺位。 -->
<footer class="rail-foot"> <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"> <div v-if="social.length" class="social-row">
<a <a
v-for="s in social" v-for="s in social"
@@ -102,6 +161,14 @@ onMounted(async () => {
<span v-else>{{ s.label }}</span> <span v-else>{{ s.label }}</span>
</a> </a>
</div> </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"> <div class="foot-meta">
<a class="social-icon" :href="PROJECT_REPO" title="项目源码" aria-label="项目源码" target="_blank" rel="noopener"> <a class="social-icon" :href="PROJECT_REPO" title="项目源码" aria-label="项目源码" target="_blank" rel="noopener">
@@ -145,6 +212,24 @@ onMounted(async () => {
color: var(--accent); 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 {
list-style: none; list-style: none;
margin: 8px 0 0; margin: 8px 0 0;
@@ -152,7 +237,9 @@ onMounted(async () => {
} }
.row { .row {
display: block; display: flex;
align-items: center;
gap: 10px;
padding: 7px 0; padding: 7px 0;
border-bottom: 1px dashed var(--line-soft); border-bottom: 1px dashed var(--line-soft);
} }
@@ -161,10 +248,27 @@ onMounted(async () => {
border-bottom: 0; border-bottom: 0;
} }
/* 文字列吃满剩余宽度,缩略图固定在右 */
.row-main {
flex: 1;
min-width: 0;
}
.title { .title {
display: block; display: block;
font-size: 14px; font-size: 14px;
line-height: 1.7; 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 { .badge {
@@ -219,12 +323,13 @@ onMounted(async () => {
opacity: 0.6; opacity: 0.6;
} }
/* 图标 + 版权同排:源码等入口是单色小图标,hover 才亮成强调色 */ /* 社交按钮:页脚第一排居中 */
.social-row { .social-row {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 4px; justify-content: center;
margin-top: 2px; gap: 6px;
margin-bottom: 10px;
} }
.foot-meta { .foot-meta {
+15 -1
View File
@@ -3,6 +3,7 @@ import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router' import { RouterLink, useRoute } from 'vue-router'
import { site } from '../site' import { site } from '../site'
import NavBurger from './NavBurger.vue' import NavBurger from './NavBurger.vue'
import LogoMark from './LogoMark.vue'
// 手机端导航。原来把 5 个链接平铺在顶栏里:375px 挤成一排小字, // 手机端导航。原来把 5 个链接平铺在顶栏里:375px 挤成一排小字,
// 触点只有十几像素高,320px 的屏还会折行。改成 brand + 汉堡钮, // 触点只有十几像素高,320px 的屏还会折行。改成 brand + 汉堡钮,
@@ -105,7 +106,10 @@ onBeforeUnmount(() => {
<template> <template>
<!-- 窄屏时左栏收起,导航回到顶部;下滑隐藏、上滑唤回 --> <!-- 窄屏时左栏收起,导航回到顶部;下滑隐藏、上滑唤回 -->
<header class="topbar" :class="{ hidden }"> <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 <NavBurger
ref="burger" ref="burger"
class="burger" class="burger"
@@ -179,6 +183,16 @@ onBeforeUnmount(() => {
font-family: var(--serif); font-family: var(--serif);
font-size: 17px; font-size: 17px;
color: var(--ink); color: var(--ink);
display: flex;
align-items: center;
gap: 8px;
}
.brand .mk {
width: 20px;
height: 20px;
flex: none;
color: var(--ink);
} }
.burger { .burger {
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed, nextTick, onMounted, ref } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { site } from '../../site' import { site } from '../../site'
import { reader, loadReader, signIn, signOut } from '../../reader' import { reader, loadReader, signIn, signOut } from '../../reader'
import { readerApi } from '../../api' import { readerApi } from '../../api'
@@ -20,7 +20,19 @@ const props = defineProps({
}) })
const api = useComments(props.postId) 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 —— 在那之前整个 // 总开关在站点设置里。后端阶段二才会下发 comments_enabled —— 在那之前整个
// 区块不渲染,线上访客不会看到一个请求 404 的半成品;后端一加就自动出现。 // 区块不渲染,线上访客不会看到一个请求 404 的半成品;后端一加就自动出现。
+19
View File
@@ -59,6 +59,24 @@ export function useComments(postId) {
load(true) 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) { async function submit(bodyMd, parent = null) {
@@ -167,6 +185,7 @@ export function useComments(postId) {
s, s,
remaining, remaining,
load, load,
refresh,
setSort, setSort,
submit, submit,
saveEdit, saveEdit,
+5 -3
View File
@@ -3,7 +3,11 @@ import { reactive } from 'vue'
// 全站通用的图片预览(lightbox)。任何地方的配图都可以 // 全站通用的图片预览(lightbox)。任何地方的配图都可以
// openLightbox(list, index) 打开:list 是同组图片的 URL 数组,index 是 // openLightbox(list, index) 打开:list 是同组图片的 URL 数组,index 是
// 点击的那张。Esc / 点空白 / 关闭钮收起,左右键或箭头在同组图片间切换。 // 点击的那张。Esc / 点空白 / 关闭钮收起,左右键或箭头在同组图片间切换。
// 打开时锁 body 滚动(和菜单 sheet 同一套做法)。 //
// 注意:这里绝不能碰 body 的 inline overflow——样式表的 overflow-x: clip
// 会被整个 overflow: hidden 覆盖,横向从「裁剪」变「可滚动容器」,
// 手机上整页能被拖宽(真实事故)。背景滚动由 ImgLightbox 遮罩自己的
// @wheel/@touchmove.prevent 拦截,滚动位置天然保留。
const state = reactive({ const state = reactive({
list: [], list: [],
index: 0, index: 0,
@@ -17,12 +21,10 @@ export function openLightbox(list, index = 0) {
state.list = list state.list = list
state.index = Math.max(0, Math.min(index, list.length - 1)) state.index = Math.max(0, Math.min(index, list.length - 1))
state.open = true state.open = true
document.body.style.overflow = 'hidden'
} }
export function closeLightbox() { export function closeLightbox() {
state.open = false state.open = false
document.body.style.overflow = ''
} }
export function stepLightbox(delta) { export function stepLightbox(delta) {
+3 -1
View File
@@ -22,7 +22,9 @@ const routes = [
{ path: ':id', name: 'admin-edit', component: () => import('./admin/EditorView.vue') }, { path: ':id', name: 'admin-edit', component: () => import('./admin/EditorView.vue') },
{ path: 'tags', name: 'admin-tags', component: () => import('./admin/TagsView.vue') }, { path: 'tags', name: 'admin-tags', component: () => import('./admin/TagsView.vue') },
{ path: 'projects', name: 'admin-projects', component: () => import('./admin/ProjectsView.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') },
{ path: 'users', name: 'admin-users', component: () => import('./admin/UsersView.vue') }
] ]
}, },
+16 -6
View File
@@ -1,10 +1,11 @@
import { reactive, watch } from 'vue' import { reactive, watch } from 'vue'
import { publicApi } from './api' import { publicApi } from './api'
import { setThumbBase } from './utils'
// 暗色皮肤固定为 ink —— 是当前唯一支持的暗端皮肤。 // 暗色皮肤固定为 ink —— 是当前唯一支持的暗端皮肤。
const DARK_SKIN = 'ink' const DARK_SKIN = 'ink'
// 亮色皮肤的合法集合(后台 Settings 也只让站主从这里选) // 亮色皮肤的合法集合(后台 Settings 也只让站主从这里选)
const LIGHT_SKINS = ['paper', 'sage', 'rose'] const LIGHT_SKINS = ['paper']
// 客户端显示模式 —— 持久化在 localStorage // 客户端显示模式 —— 持久化在 localStorage
const MODE_KEY = 'one.themeMode' const MODE_KEY = 'one.themeMode'
@@ -52,7 +53,7 @@ function systemPrefersDark() {
// 模式 → 实际 data-theme 值 // 模式 → 实际 data-theme 值
// //
// light / auto 亮端 → site.light_skin_id(站主在后台选的"基础亮色皮肤") // light / auto 亮端 → 固定纸感(paper);皮肤选择已精简,暗端固定墨色
// dark → ink(强制暗色) // dark → ink(强制暗色)
// //
// 后台的"基础亮色皮肤"是访客在任何「浅色偏好」下都会看到的; // 后台的"基础亮色皮肤"是访客在任何「浅色偏好」下都会看到的;
@@ -96,11 +97,16 @@ export const site = reactive({
custom_css: {}, custom_css: {},
// 站主自定义 JS(统计脚本等),公开页注入一次,/admin 不注入 // 站主自定义 JS(统计脚本等),公开页注入一次,/admin 不注入
custom_js: '', custom_js: '',
// 上传文件的公开域名(R2 直链前缀)。前端靠它判断哪些图片可以转本站缩略图,
// 也用它解析头像 URL。
uploads_public_base: '',
// 站主头像的解析结果(后端按 owner_avatar_key + 存储端算出来;空=未设置)
author_avatar_url: '',
themeMode: readMode(), themeMode: readMode(),
loaded: false 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-admin-theme → 后台明暗(light / dark),跟 site.themeMode 同步
// data-ui → 整站 UI 版本(classic / vivid) // data-ui → 整站 UI 版本(classic / vivid)
// //
@@ -128,7 +134,7 @@ function applyTheme(animate = false) {
setTimeout(() => el.classList.remove('theme-anim'), 300) setTimeout(() => el.classList.remove('theme-anim'), 300)
} }
const isDark = site.themeMode === 'dark' || (site.themeMode === 'auto' && systemPrefersDark()) 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 vivid = !adminScope && site.ui_id === 'vivid'
const theme = vivid const theme = vivid
? (isDark ? 'vivid-dark' : 'vivid') ? (isDark ? 'vivid-dark' : 'vivid')
@@ -152,8 +158,12 @@ if (typeof window !== 'undefined' && window.matchMedia) {
export async function loadSite() { export async function loadSite() {
try { try {
const data = await publicApi.site() const resp = await publicApi.site()
Object.assign(site, data) // 响应结构:{ 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 // 防御性:服务端返回的 light_skin_id 必须是白名单之一,否则兜底 paper
if (!isValidLightSkin(site.light_skin_id)) site.light_skin_id = 'paper' if (!isValidLightSkin(site.light_skin_id)) site.light_skin_id = 'paper'
// ui_id 同理;同时回写本地缓存,让下一次首屏不用等接口就知道该用哪套 UI // ui_id 同理;同时回写本地缓存,让下一次首屏不用等接口就知道该用哪套 UI
+286 -103
View File
@@ -31,56 +31,34 @@
--col-main: 640px; --col-main: 640px;
--col-right: 264px; --col-right: 264px;
--gutter: 28px;} --gutter: 28px;}
:root[data-theme='ink'] {--paper: #1f1d1a; :root[data-theme='ink'] {/* 墨:暖炭底、暖白字、鎏金点缀。比旧版更中性(褪掉黄褐相),
--card: #25221e; 层次靠三档灰阶拉开;强调金提亮提纯,暗底上对比更足 */
--paper-sunken: #2b2722; --paper: #191817;
--ink: #ece5d2; --card: #201e1c;
--ink-soft: #c3b58f; --paper-sunken: #262420;
--muted: #8c8270; --ink: #e7e3da;
--faint: #6a6155; --ink-soft: #b8b2a6;
--line: #3a352e; --muted: #867f73;
--line-soft: #2f2b25; --faint: #5f594f;
--accent: #c3a972; --line: #34302b;
--accent-soft: rgba(195, 169, 114, 0.14); --line-soft: #292623;
--accent-line: rgba(195, 169, 114, 0.4);} --accent: #d9b97c;
:root[data-theme='sage'] {--paper: #eef0e6; --accent-soft: rgba(217, 185, 124, 0.12);
--card: #f6f7ef; --accent-line: rgba(217, 185, 124, 0.4);}
--paper-sunken: #e2e6d6; :root[data-admin-theme='dark'] {--admin-bg: #191817;
--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;
--admin-paper: var(--admin-bg); --admin-paper: var(--admin-bg);
--admin-card: #25221e; --admin-card: #201e1c;
--admin-paper-sunken: #2f2b25; --admin-paper-sunken: #262420;
--admin-ink: #ece5d2; --admin-ink: #e7e3da;
--admin-ink-soft: #c3b58f; --admin-ink-soft: #b8b2a6;
--admin-muted: #9a8f7e; --admin-muted: #948c7f;
--admin-faint: #6e6557; --admin-faint: #635c51;
--admin-line: #3a352e; --admin-line: #34302b;
--admin-line-soft: #2f2b25; --admin-line-soft: #292623;
--admin-accent: #c3a972; --admin-accent: #d9b97c;
--admin-accent-soft: rgba(195, 169, 114, 0.16); --admin-accent-soft: rgba(217, 185, 124, 0.16);
--admin-accent-line: rgba(195, 169, 114, 0.42); --admin-accent-line: rgba(217, 185, 124, 0.42);
--admin-on-accent: #1a1815; --admin-on-accent: #191817;
--admin-danger: #d68d75; --admin-danger: #d68d75;
--admin-shadow: 0 4px 14px rgba(0, 0, 0, 0.4); --admin-shadow: 0 4px 14px rgba(0, 0, 0, 0.4);
--admin-overlay: rgba(0, 0, 0, 0.6);} --admin-overlay: rgba(0, 0, 0, 0.6);}
@@ -230,15 +208,12 @@ html.topbar-hidden {--topbar-h: 0px;}
min-width: 0;} min-width: 0;}
.prose {font-size: 16.5px; .prose {font-size: 16.5px;
line-height: var(--lh); 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 > :first-child {margin-top: 22px;}
.prose p {margin: 0 0 1.1em;} .prose p {margin: 0 0 1.1em;}
.prose > p:first-of-type::first-letter {float: left;
font-family: var(--serif);
font-size: 52px;
line-height: 1;
margin: 6px 10px 0 0;
color: var(--accent);}
.prose h1, .prose h2, .prose h3, .prose h4 {font-family: var(--serif); .prose h1, .prose h2, .prose h3, .prose h4 {font-family: var(--serif);
color: var(--ink); color: var(--ink);
margin: 1.8em 0 0.6em;} margin: 1.8em 0 0.6em;}
@@ -254,6 +229,17 @@ html.topbar-hidden {--topbar-h: 0px;}
margin-top: 8px; margin-top: 8px;
background: var(--accent);} background: var(--accent);}
.prose h4 {font-size: 16.5px;} .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; .prose blockquote {margin: 1.4em 0;
padding: 2px 0 2px 18px; padding: 2px 0 2px 18px;
border-left: 2px solid var(--accent-line); border-left: 2px solid var(--accent-line);
@@ -273,12 +259,32 @@ html.topbar-hidden {--topbar-h: 0px;}
background: var(--paper-sunken); background: var(--paper-sunken);
padding: 1px 5px; padding: 1px 5px;
border-radius: 3px;} 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); .prose pre {background: var(--paper-sunken);
border: 1px solid var(--line-soft); border: 1px solid var(--line-soft);
border-radius: 4px; border-radius: 10px;
padding: 14px 16px; padding: 16px 18px;
overflow-x: auto; overflow: auto;
line-height: 1.7;} line-height: 1.7;
max-height: 30.5em;}
.prose pre code {background: none; .prose pre code {background: none;
padding: 0; padding: 0;
font-size: 13.5px;} font-size: 13.5px;}
@@ -289,29 +295,24 @@ html.topbar-hidden {--topbar-h: 0px;}
background: var(--line); background: var(--line);
margin: 2em 0;} margin: 2em 0;}
.prose table {width: 100%; .prose table {width: 100%;
border-collapse: collapse; border-collapse: separate;
margin: 1.4em 0; border-spacing: 0;
font-size: 15px;} margin: 1.6em 0;
.prose th, .prose td {border: 1px solid var(--line); font-size: 14.5px;
padding: 7px 10px; 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;} 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); .prose th {background: var(--paper-sunken);
font-weight: 600;} font-weight: 600;}
.prose tbody tr:nth-child(even) {background: color-mix(in srgb, var(--paper-sunken) 55%, transparent);}
.prose-short {font-size: 20px; .prose-short {font-size: 20px;
line-height: 1.9;} line-height: 1.9;}
.prose-short > p:first-of-type::first-letter {float: none;
font-size: inherit;
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 张;详情页用) */ /* 短文配图:Twitter 式网格(快发盒上传,≤4 张;详情页用) */
.post-imgs {display: grid; .post-imgs {display: grid;
gap: 3px; gap: 3px;
@@ -482,15 +483,12 @@ html.topbar-hidden {--topbar-h: 0px;}
width: 100%; width: 100%;
margin: 0 auto;} margin: 0 auto;}
@media (max-width: 860px) { @media (max-width: 860px) {
.admin-shell {grid-template-columns: 1fr;} /* minmax(0,1fr) 而非裸 1fr:裸 1fr 带 min-content 下限,内容宽的页面会把
.admin-side {position: static; 整条轨道连页面一起撑出横向滚动。align-content:start:shell 的
height: auto; min-height:100dvh 会把 auto 行拉伸填满屏幕,行高回到内容高度。
border-right: 0; 侧栏本身在 ≤860px 变成抽屉(规则在文件末尾「后台 · 手机端适配」段)。 */
border-bottom: 1px solid var(--admin-line);} .admin-shell {grid-template-columns: minmax(0, 1fr);
.admin-side .nav {flex-direction: row; align-content: start;}
flex-wrap: wrap;
padding: 8px;}
.admin-side .group {display: none;}
.admin-content {padding: 20px 16px 60px;} .admin-content {padding: 20px 16px 60px;}
} }
@media (max-width: 780px) { @media (max-width: 780px) {
@@ -717,12 +715,21 @@ html.topbar-hidden {--topbar-h: 0px;}
cursor: pointer;} cursor: pointer;}
.post-card.has-check {grid-template-columns: 24px 96px minmax(0, 1fr) 110px 90px;} .post-card.has-check {grid-template-columns: 24px 96px minmax(0, 1fr) 110px 90px;}
@media (max-width: 760px) { @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 {grid-template-columns: 64px minmax(0, 1fr);}
.post-card.has-check {grid-template-columns: 24px 64px minmax(0, 1fr);}
.post-card .cover {width: 64px; .post-card .cover {width: 64px;
height: 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; .bulk-bar {display: flex;
flex-wrap: wrap;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
padding: 10px 14px; padding: 10px 14px;
@@ -835,24 +842,44 @@ html.topbar-hidden {--topbar-h: 0px;}
0%, 100% {opacity: 1;} 0%, 100% {opacity: 1;}
50% {opacity: 0.3;} 50% {opacity: 0.3;}
} }
/* md 页签容器现在是 CodeMirror 挂载点(原为 textarea) */
.md-pane {width: 100%; .md-pane {width: 100%;
min-height: 460px; min-height: 460px;
border: 1px solid var(--admin-line); border: 1px solid var(--admin-line);
border-radius: 3px; border-radius: 3px;
background: var(--admin-card); background: var(--admin-card);
padding: 18px 22px; padding: 6px 14px;
font-family: var(--mono);
font-size: 14px;
line-height: 1.7;
color: var(--admin-ink); 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;} outline: none;}
.md-pane:focus {border-color: var(--admin-accent);} .md-pane:focus {border-color: var(--admin-accent);}
.toolbar {display: flex; .toolbar {display: flex;
flex-wrap: wrap; flex-wrap: wrap;
align-items: center; align-items: center;
gap: 10px; gap: 0 10px;
padding: 6px 8px; padding: 4px 6px;
border: 1px solid var(--admin-line); border: 1px solid var(--admin-line);
border-bottom: 0; border-bottom: 0;
border-radius: 3px 3px 0 0; border-radius: 3px 3px 0 0;
@@ -868,12 +895,13 @@ html.topbar-hidden {--topbar-h: 0px;}
color: var(--admin-muted); color: var(--admin-muted);
margin-right: 4px; margin-right: 4px;
text-transform: uppercase;} text-transform: uppercase;}
.tb-btn {width: 28px; /* xLog 式工具栏按钮:方形圆角、线性图标、悬停出底框 */
height: 28px; .tb-btn {width: 32px;
height: 32px;
border: 1px solid transparent; border: 1px solid transparent;
border-radius: 3px; border-radius: 6px;
background: transparent; background: transparent;
color: var(--admin-ink-soft); color: var(--admin-muted);
cursor: pointer; cursor: pointer;
font-size: 12.5px; font-size: 12.5px;
display: inline-flex; display: inline-flex;
@@ -886,9 +914,12 @@ html.topbar-hidden {--topbar-h: 0px;}
border-color: var(--admin-line); border-color: var(--admin-line);
color: var(--admin-ink);} color: var(--admin-ink);}
} }
.tb-btn.italic {font-style: italic;} .tb-btn svg {width: 17px;
.tb-btn.mono {font-family: var(--mono); height: 17px;}
font-size: 11.5px;} .tb-text {font-family: var(--mono);
font-size: 11.5px;
font-weight: 700;
color: inherit;}
.tb-popover {display: flex; .tb-popover {display: flex;
gap: 6px; gap: 6px;
padding: 8px; padding: 8px;
@@ -991,6 +1022,21 @@ html.topbar-hidden {--topbar-h: 0px;}
color: var(--admin-accent); color: var(--admin-accent);
border-left-color: var(--admin-accent); border-left-color: var(--admin-accent);
font-weight: 500;} font-weight: 500;}
/* 窄屏下 tab 列表收成可换行的横排:竖排 + sticky 是桌面左栏的写法,
单列布局里它会粘在屏幕上盖住下面的面板。必须放在基础规则之后,
否则同优先级会被上面的 column/sticky 盖掉。 */
@media (max-width: 760px) {
.settings-tabs {position: static;
flex-direction: row;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 10px;}
.settings-tabs button {padding: 6px 12px;
border-left: 0;
border-radius: 999px;
background: var(--admin-paper-sunken, #f3efe6);}
.settings-tabs button.on {background: var(--admin-accent-soft);}
}
.theme-swatches {display: flex; .theme-swatches {display: flex;
gap: 10px; gap: 10px;
flex-wrap: wrap;} flex-wrap: wrap;}
@@ -1383,4 +1429,141 @@ a.cm-name-a:hover {color: var(--accent);}
.cm-link {padding: 8px 2px;} .cm-link {padding: 8px 2px;}
.cm-more-r {padding: 10px 0;} .cm-more-r {padding: 10px 0;}
.cm-input {font-size: 16px;} .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;}
+6 -2
View File
@@ -1,8 +1,9 @@
<script setup> <script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue' import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { sanitizeHtml } from '../../utils' import { applyImageRatio, sanitizeHtml } from '../../utils'
import { openLightbox } from '../../lightbox' import { openLightbox } from '../../lightbox'
import CommentSection from '../../components/comments/CommentSection.vue' import CommentSection from '../../components/comments/CommentSection.vue'
import LinkCard from '../../components/LinkCard.vue'
// 余白文章页。对应设计稿 viewPost() 的三栏「纸」: // 余白文章页。对应设计稿 viewPost() 的三栏「纸」:
// 左 side-l : 时间线(当前篇前后各两篇,标签来自位置) // 左 side-l : 时间线(当前篇前后各两篇,标签来自位置)
@@ -167,7 +168,7 @@ function onTocClick(e) {
<!-- 短文不渲染标题:正文第一句就是全部,再给个截断标题是重复 --> <!-- 短文不渲染标题:正文第一句就是全部,再给个截断标题是重复 -->
<h1 v-if="isShort" class="sr-only">{{ title }}</h1> <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 式网格(快发盒上传);点图开预览 --> <!-- 短文配图:Twitter 式网格(快发盒上传);点图开预览 -->
<div <div
@@ -186,6 +187,9 @@ function onTocClick(e) {
/> />
</div> </div>
<!-- 外链预览卡片:正文里第一个链接,发布时后端已抓好 -->
<LinkCard v-if="isShort && props.post.link_card" :card="props.post.link_card" />
<div class="end"> <div class="end">
<RouterLink v-for="t in props.post.tags || []" :key="t" class="tg" to="/tags">{{ t }}</RouterLink> <RouterLink v-for="t in props.post.tags || []" :key="t" class="tg" to="/tags">{{ t }}</RouterLink>
</div> </div>
+86 -41
View File
@@ -1,16 +1,44 @@
<script setup> <script setup>
import { RouterLink } from 'vue-router' import { reactive } from 'vue'
import { RouterLink, useRouter } from 'vue-router'
import { openLightbox } from '../../lightbox' import { openLightbox } from '../../lightbox'
import { thumbURL, thumbifyHtml } from '../../utils'
import LinkCard from '../../components/LinkCard.vue'
import { PhChatCircle, PhHeart, PhEye } from '@phosphor-icons/vue'
// 余白列表。对应设计稿的 <div class="feed">:长文是卡片(封面 + 标题 + 摘要 + 元信息 + 标签), // 余白列表。对应设计稿的 <div class="feed">:长文是卡片(封面 + 标题 + 摘要 + 元信息 + 标签),
// 短文是纸条(直接铺正文,元信息在正文后)。两种密度混在同一条时间线里,这是设计稿的主张。 // 短文是纸条(直接铺正文,元信息在正文后)。两种密度混在同一条时间线里,这是设计稿的主张。
// 元信息(类型/日期/时长/字数)从标题上方挪到了正文之后、和标签同一块: // 元信息(类型/日期/时长/字数)从标题上方挪到了正文之后、和标签同一块:
// 卡片开头只留标题摘要,读完再看「什么时候、多长」。 // 卡片开头只留标题摘要,读完再看「什么时候、多长」。
//
// 卡片/纸条本体是整块可点的链接;操作行(回复 / 赞 / 查看)是链接外面的兄弟节点,
// 不能塞进 <a> 里——嵌套交互元素既不合 HTML 语义,点击冒泡也理不清。
//
// 赞是本机赞:站点没有登录体系,点赞状态存 localStorage(按设备计),
// 不上传服务端。以后要做全局计数,加一张 post_likes 表 + 一个 POST 端点即可替换。
const props = defineProps({ const props = defineProps({
items: { type: Array, default: () => [] } items: { type: Array, default: () => [] }
}) })
const router = useRouter()
const LIKES_KEY = 'one_feed_likes'
const likes = reactive(readLikes())
function readLikes() {
try { return JSON.parse(localStorage.getItem(LIKES_KEY) || '{}') } catch { return {} }
}
function keyOf(p) { return String(p.id || p.slug) }
function toggleLike(p) {
const k = keyOf(p)
if (likes[k]) delete likes[k]
else likes[k] = 1
try { localStorage.setItem(LIKES_KEY, JSON.stringify(likes)) } catch { /* 隐私模式等存不上就算了 */ }
}
function go(p, hash) {
router.push(`/post/${encodeURIComponent(p.slug)}${hash || ''}`)
}
const fmt = new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' }) const fmt = new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit' })
const fmtDate = (iso) => fmt.format(new Date(iso)).replace(/\//g, '-') const fmtDate = (iso) => fmt.format(new Date(iso)).replace(/\//g, '-')
const fmtDateTime = (iso) => { const fmtDateTime = (iso) => {
@@ -32,53 +60,70 @@ const shortTitle = (p) => plain(p.content_html).slice(0, 40) + '…'
<template> <template>
<div class="feed"> <div class="feed">
<template v-for="p in props.items" :key="p.id || p.slug"> <template v-for="p in props.items" :key="p.id || p.slug">
<!-- 长文:卡片 --> <!-- 长文:卡片 + 操作行 -->
<RouterLink v-if="p.kind === 'long'" class="card" :to="`/post/${encodeURIComponent(p.slug)}`"> <div v-if="p.kind === 'long'" class="feed-item">
<div v-if="p.cover_url" class="cov"> <RouterLink class="card" :to="`/post/${encodeURIComponent(p.slug)}`">
<img :src="p.cover_url" alt="" loading="lazy"> <div v-if="p.cover_url" class="cov">
<img :src="thumbURL(p.cover_url, 640)" alt="" loading="lazy">
</div>
<div class="body">
<h2>{{ p.title }}</h2>
<p class="sum">{{ p.summary }}</p>
<div class="meta">
<span class="badge long">LONG</span>
<time :datetime="p.published_at">{{ fmtDate(p.published_at) }}</time>
<span>· 约 {{ p.reading_minutes }} 分钟</span>
<span>· {{ nf.format(p.content_len || 0) }} 字</span>
</div>
<div class="tags">
<span v-for="t in p.tags || []" :key="t" class="tg">{{ t }}</span>
</div>
</div>
</RouterLink>
<div class="acts">
<button type="button" @click="go(p, '#comments')"><PhChatCircle class="ic" aria-hidden="true" />回复</button>
<button type="button" :class="{ liked: likes[keyOf(p)] }" @click="toggleLike(p)"><PhHeart class="ic" aria-hidden="true" />赞</button>
<button type="button" @click="go(p)"><PhEye class="ic" aria-hidden="true" />查看</button>
</div> </div>
<div class="body"> </div>
<h2>{{ p.title }}</h2>
<p class="sum">{{ p.summary }}</p> <!-- 短文:纸条 + 操作行。带图时桌面端左右分栏(.split,见 home.css) -->
<div v-else class="feed-item">
<RouterLink 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"> <div class="meta">
<span class="badge long">LONG</span> <span class="badge short">SHORT</span>
<time :datetime="p.published_at">{{ fmtDate(p.published_at) }}</time> <time :datetime="p.published_at">{{ fmtDateTime(p.published_at) }}</time>
<span>· 约 {{ p.reading_minutes }} 分钟</span>
<span>· {{ nf.format(p.content_len || 0) }} 字</span>
</div> </div>
<div class="tags"> <div class="tags">
<span v-for="t in p.tags || []" :key="t" class="tg">{{ t }}</span> <span v-for="t in p.tags || []" :key="t" class="tg">{{ t }}</span>
</div> </div>
</RouterLink>
<div class="acts note-acts">
<button type="button" @click="go(p, '#comments')"><PhChatCircle class="ic" aria-hidden="true" />回复</button>
<button type="button" :class="{ liked: likes[keyOf(p)] }" @click="toggleLike(p)"><PhHeart class="ic" aria-hidden="true" />赞</button>
<button type="button" @click="go(p)"><PhEye class="ic" aria-hidden="true" />查看</button>
</div> </div>
</RouterLink> </div>
<!-- 短文:纸条,正文直接铺开 -->
<RouterLink v-else class="note" :to="`/post/${encodeURIComponent(p.slug)}`">
<div class="body" v-html="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="u"
alt=""
loading="lazy"
decoding="async"
@click.prevent.stop="openLightbox(p.images, i)"
/>
</div>
<div class="meta">
<span class="badge short">SHORT</span>
<time :datetime="p.published_at">{{ fmtDateTime(p.published_at) }}</time>
</div>
<div class="tags">
<span v-for="t in p.tags || []" :key="t" class="tg">{{ t }}</span>
</div>
</RouterLink>
</template> </template>
</div> </div>
</template> </template>
+38 -1
View File
@@ -1,6 +1,13 @@
<script setup> <script setup>
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import { site } from '../../site' 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">。 // 余白页脚。对应设计稿的 <footer class="foot">。
// //
@@ -8,6 +15,13 @@ import { site } from '../../site'
// 按站主要求去掉了(它只标识当前主题名,本站没有多套主题,属于纯装饰, // 按站主要求去掉了(它只标识当前主题名,本站没有多套主题,属于纯装饰,
// 而且固定在底部会挡住内容)。对应的 .switch 样式也一并从 // 而且固定在底部会挡住内容)。对应的 .switch 样式也一并从
// chrome.css / pages.css 里删除,避免留下死规则。 // chrome.css / pages.css 里删除,避免留下死规则。
//
// 手机端顶栏退场后,明暗切换没有了常驻入口:页脚这枚 ◐ 补位
// (chrome.css 里 ≤720px 才显示,桌面仍走顶栏的主题键)。
function toggleTheme() {
const dark = document.documentElement.getAttribute('data-theme') === 'vivid-dark'
site.themeMode = dark ? 'light' : 'dark'
}
const links = [ const links = [
{ to: '/', label: '时间线' }, { to: '/', label: '时间线' },
@@ -21,7 +35,20 @@ const links = [
<template> <template>
<footer class="foot"> <footer class="foot">
<div class="quote">What&rsquo;s left unsaid holds the most weight.</div> <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"> <div class="foot-nav">
<template v-for="(l, i) in links" :key="l.to"> <template v-for="(l, i) in links" :key="l.to">
<span v-if="i" class="sep">·</span> <span v-if="i" class="sep">·</span>
@@ -29,6 +56,16 @@ const links = [
</template> </template>
<span class="sep">·</span> <span class="sep">·</span>
<a href="/rss.xml">RSS</a> <a href="/rss.xml">RSS</a>
<span class="sep theme-sep">·</span>
<button class="theme-toggle" type="button" title="切换明暗" aria-label="切换明暗" @click="toggleTheme">◐</button>
</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> </div>
</footer> </footer>
</template> </template>
+2 -1
View File
@@ -4,6 +4,7 @@ import YohakuHero from './YohakuHero.vue'
import YohakuToolbar from './YohakuToolbar.vue' import YohakuToolbar from './YohakuToolbar.vue'
import YohakuFeed from './YohakuFeed.vue' import YohakuFeed from './YohakuFeed.vue'
import YohakuPager from './YohakuPager.vue' import YohakuPager from './YohakuPager.vue'
import { thumbURL } from '../../utils'
// 首页装配。对应设计稿 viewHome() 的 DOM 顺序: // 首页装配。对应设计稿 viewHome() 的 DOM 顺序:
// hero → wrap(toolbar + feed + empty + pager) → showcase(作品前三件 + 查看所有)。 // hero → wrap(toolbar + feed + empty + pager) → showcase(作品前三件 + 查看所有)。
@@ -81,7 +82,7 @@ const emit = defineEmits(['update:kind', 'update:tag', 'update:query', 'go'])
to="/projects" to="/projects"
> >
<div v-if="p.cover_url" class="cov"> <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> <span v-if="p.status" class="st">{{ p.status }}</span>
</div> </div>
<div class="body"> <div class="body">
+61 -40
View File
@@ -3,31 +3,42 @@ import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router' import { RouterLink, useRoute } from 'vue-router'
import { site } from '../../site' import { site } from '../../site'
import NavBurger from '../../components/NavBurger.vue' import NavBurger from '../../components/NavBurger.vue'
import AuthorAvatar from '../../components/AuthorAvatar.vue'
import {
PhHouse,
PhArchiveBox,
PhTag,
PhSquaresFour,
PhUserCircle
} from '@phosphor-icons/vue'
// 余白顶栏。对应设计稿的 <header class="top">:品牌 / 导航 / 操作三块, // 余白顶栏。对应设计稿的 <header class="top">:品牌 / 导航 / 操作三块,
// 滚动过 8px 加 .scrolled(设计稿的「四态里的第一态」)。 // 滚动过 8px 加 .scrolled(设计稿的「四态里的第一态」)。
// 样式在 ui/yohaku/chrome.css,选择器已收敛到 html[data-ui="vivid"] 下。 // 样式在 ui/yohaku/chrome.css,选择器已收敛到 html[data-ui="vivid"] 下。
// //
// 窄屏例外:设计稿在窄屏把导航整个 display:none 掉了,手机上会没有任何导航入口。 // 窄屏例外:内联导航退场,换成一枚常驻的顶部玻璃条(品牌 + 汉堡)+
// 这里补一个汉堡钮 + 全屏菜单(chrome.css 的 .menu)。菜单 z-index 压在顶栏 // 右侧抽屉(.drawer,chrome.css)。抽屉是 aria-modal 对话框:开着时锁背景
// 之下:顶栏(品牌 + 汉堡)浮在菜单上不消失,汉堡原位并成 ✕,不再需要一个 // 滚动、Esc 收起、焦点交给面板,路由一变自动收。
// 重复的菜单头。 // 明暗切换在抽屉 footer 和页脚各有一枚(≤720px 才显示),桌面走顶栏 ◐。
const route = useRoute() const route = useRoute()
const scrolled = ref(false) const scrolled = ref(false)
const menuOpen = ref(false) const menuOpen = ref(false)
const burger = ref(null) const burger = ref(null)
const menuEl = ref(null) const drawerEl = ref(null)
// 手机端顶栏:向下滑动藏起来给内容让路,向上一滑就回来。
// 抽屉开着时不藏——汉堡就是 ✕,藏了就没法关菜单。
const barHidden = ref(false)
let lastY = 0
const links = [ const links = [
{ to: '/', label: '时间线', en: 'Timeline' }, { to: '/', label: '时间线', icon: PhHouse },
{ to: '/archive', label: '归档', en: 'Archive' }, { to: '/archive', label: '归档', icon: PhArchiveBox },
{ to: '/tags', label: '标签', en: 'Tags' }, { to: '/tags', label: '标签', icon: PhTag },
{ to: '/projects', label: '作品', en: 'Projects' }, { to: '/projects', label: '作品', icon: PhSquaresFour },
{ to: '/about', label: '关于', en: 'About' } { to: '/about', label: '关于', icon: PhUserCircle }
] ]
const initial = () => (site.author_name || site.site_title || 'O').trim().slice(0, 1).toUpperCase()
const title = () => site.site_title?.split(/[·|]/)[0]?.trim() || 'ONE' const title = () => site.site_title?.split(/[·|]/)[0]?.trim() || 'ONE'
function isOn(l) { function isOn(l) {
@@ -35,7 +46,17 @@ function isOn(l) {
} }
function onScroll() { function onScroll() {
scrolled.value = (window.scrollY || 0) > 8 const y = window.scrollY || 0
scrolled.value = y > 8
// 页首附近不藏(没什么可让的路),滚过一屏的 1/8 才启用;
// 方向反转立即回来,误差抖动(|Δ|<2px)不触发
if (menuOpen.value) {
barHidden.value = false
} else if (Math.abs(y - lastY) >= 2) {
if (y > lastY && y > 80) barHidden.value = true
else barHidden.value = false
}
lastY = y
} }
function toggleMenu() { function toggleMenu() {
@@ -44,9 +65,9 @@ function toggleMenu() {
function openMenu() { function openMenu() {
menuOpen.value = true menuOpen.value = true
// 焦点交给对话框本身(aria-modal 承诺过),Tab 第一下才落到「时间线」。 // 焦点交给对话框本身(aria-modal 承诺过),Tab 第一下才落到「时间线」。
// 不直接 focus 第一个链接:即使 touch 上不长出焦点环,也会让首项看起来「被选中」, // 不直接 focus 第一个链接:即使 touch 上不长出焦点环,也会让首项看起来
// 和 .on 的高亮抢意思。preventScroll 防止 iOS 顺手滚背景。 // 「被选中」,和 .on 的高亮抢意思。preventScroll 防止 iOS 顺手滚背景。
nextTick(() => menuEl.value?.focus({ preventScroll: true })) nextTick(() => drawerEl.value?.focus({ preventScroll: true }))
} }
function closeMenu() { function closeMenu() {
menuOpen.value = false menuOpen.value = false
@@ -54,9 +75,9 @@ function closeMenu() {
nextTick(() => b?.focus?.({ preventScroll: true })) nextTick(() => b?.focus?.({ preventScroll: true }))
} }
// 菜单铺满全屏时锁住底层滚动(iOS 上不锁背景会跟着手指滑) // 抽屉铺满全屏时锁住底层滚动(iOS 上不锁背景会跟着手指滑)
watch(menuOpen, (v) => { document.body.style.overflow = v ? 'hidden' : '' }) watch(menuOpen, (v) => { document.body.style.overflow = v ? 'hidden' : '' })
// 点了链接 → 路由变了 → 菜单收起 // 点了链接 → 路由变了 → 抽屉收起
watch(() => route.fullPath, () => { menuOpen.value = false }) watch(() => route.fullPath, () => { menuOpen.value = false })
function onKey(e) { function onKey(e) {
@@ -81,7 +102,7 @@ onBeforeUnmount(() => {
menuOpen.value = false menuOpen.value = false
}) })
// 设计稿里主题键是 ◐ / ◑:亮色时显示 ◐(点它去暗),暗色时显示 ◑。 // 设计稿里主题键是 ◐:亮色时显示(点它去暗),暗色时切回亮色。
function toggleTheme() { function toggleTheme() {
// site.themeMode 是站点级偏好(auto / light / dark),site.js 里有 watcher 负责落盘与写 data-theme。 // site.themeMode 是站点级偏好(auto / light / dark),site.js 里有 watcher 负责落盘与写 data-theme。
// 这里直接改它,不自己碰 document : 单一数据源。 // 这里直接改它,不自己碰 document : 单一数据源。
@@ -94,7 +115,7 @@ function toggleTheme() {
<header class="top" :class="{ scrolled }"> <header class="top" :class="{ scrolled }">
<div class="top-in"> <div class="top-in">
<RouterLink class="brand" to="/"> <RouterLink class="brand" to="/">
<span class="av">{{ initial() }}</span> <span class="av"><AuthorAvatar /></span>
<span>{{ title() }}</span> <span>{{ title() }}</span>
</RouterLink> </RouterLink>
<nav class="nav" aria-label="站点导航"> <nav class="nav" aria-label="站点导航">
@@ -111,54 +132,54 @@ function toggleTheme() {
</div> </div>
</header> </header>
<!-- 窄屏:底部悬浮 dock(品牌 + 汉堡),常驻拇指区。桌面 display:none。 <!-- 窄屏:顶部玻璃条(品牌 + 汉堡)。桌面 display:none。
必须待在 .top 外面:.top 有 backdrop-filter,会给 fixed 后代当包含块。 --> 必须待在 .top 外面:.top 有 backdrop-filter,会给 fixed 后代当包含块。
<div class="dock"> 向下滑动自动藏起(.bar-hide),给内容让路;向上一滑立刻回来。 -->
<RouterLink class="dock-brand" to="/"> <header class="top-m" :class="{ 'bar-hide': barHidden }">
<span class="av">{{ initial() }}</span> <RouterLink class="top-m-brand" to="/">
<span class="av"><AuthorAvatar /></span>
<span>{{ title() }}</span> <span>{{ title() }}</span>
</RouterLink> </RouterLink>
<NavBurger <NavBurger
ref="burger" ref="burger"
:open="menuOpen" :open="menuOpen"
aria-controls="v-menu" aria-controls="v-drawer"
:aria-label="menuOpen ? '关闭菜单' : '打开菜单'" :aria-label="menuOpen ? '关闭菜单' : '打开菜单'"
@toggle="toggleMenu" @toggle="toggleMenu"
/> />
</div> </header>
<!-- 窄屏上拉菜单:scrim + 圆角毛玻璃 sheet,从 dock 上方长出来 --> <!-- 窄屏抽屉:scrim + 右侧滑入的面板。品牌条压在 scrim 之上不消失,
<Transition name="v-menu"> 汉堡原位并成 ✕,不需要重复的菜单头。 -->
<Transition name="v-drawer">
<div <div
v-if="menuOpen" v-if="menuOpen"
id="v-menu" id="v-drawer"
ref="menuEl" ref="drawerEl"
class="menu-root" class="drawer-root"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label="站点导航" aria-label="站点导航"
tabindex="-1" tabindex="-1"
> >
<div class="scrim" @click="closeMenu" /> <div class="scrim" @click="closeMenu" />
<div class="menu"> <aside class="drawer">
<nav class="menu-in" aria-label="站点导航"> <nav class="drawer-nav" aria-label="站点导航">
<RouterLink <RouterLink
v-for="(l, i) in links" v-for="l in links"
:key="l.to" :key="l.to"
:to="l.to" :to="l.to"
:class="{ on: isOn(l) }" :class="{ on: isOn(l) }"
:style="{ '--i': i }"
> >
<span class="idx">{{ String(i + 1).padStart(2, '0') }}</span> <component :is="l.icon" class="ic" aria-hidden="true" />
<span class="zh">{{ l.label }}</span> <span>{{ l.label }}</span>
<em>{{ l.en }}</em>
</RouterLink> </RouterLink>
</nav> </nav>
<div class="menu-foot"> <div class="drawer-foot">
<button class="chip" type="button" @click="toggleTheme">◐ 切换明暗</button> <button class="chip" type="button" @click="toggleTheme">◐ 切换明暗</button>
<p v-if="site.site_desc" class="m-desc">{{ site.site_desc }}</p> <p v-if="site.site_desc" class="m-desc">{{ site.site_desc }}</p>
</div> </div>
</div> </aside>
</div> </div>
</Transition> </Transition>
</template> </template>
+20 -11
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;} padding:2px 7px;border-radius:6px;}
html[data-ui='vivid'] .prose pre {background:color-mix(in srgb,var(--ink) 92%,var(--accent)); html[data-ui='vivid'] .prose pre {background:color-mix(in srgb,var(--ink) 92%,var(--accent));
color:#e8e8f0;padding:18px 20px;border-radius:12px; color:#e8e8f0;padding:18px 20px;border-radius:12px;
overflow-x:auto;font-size:13.5px;line-height:1.7; overflow:auto;font-size:13.5px;line-height:1.7;
box-shadow:var(--shadow-lg);} 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」混出的是浅底,浅底配浅字没法读;暗端换深底微混强调色 */ /* 暗色下「92% ink」混出的是浅底,浅底配浅字没法读;暗端换深底微混强调色 */
html[data-ui='vivid'][data-theme='vivid-dark'] .prose pre { html[data-ui='vivid'][data-theme='vivid-dark'] .prose pre {
background:color-mix(in srgb,var(--paper) 86%,var(--accent)); background:color-mix(in srgb,var(--paper) 86%,var(--accent));
@@ -160,15 +164,6 @@ html[data-ui='vivid'] .prose blockquote p:last-child {margin-bottom:0}
html[data-ui='vivid'] .prose pre {border:0} html[data-ui='vivid'] .prose pre {border:0}
html[data-ui='vivid'] .prose h4 {margin:1.4em 0 .5em;font-size:1.05em;font-weight:700; html[data-ui='vivid'] .prose h4 {margin:1.4em 0 .5em;font-size:1.05em;font-weight:700;
letter-spacing:-.01em;color:var(--ink);} letter-spacing:-.01em;color:var(--ink);}
/* 首字下沉:设计稿里没有这条,classic 漏过来的是固定 52px。参考站(innei.in 余白模式)
也有首字下沉,那就认领成自己的,按 2.4em 走、跟着正文字号缩放。 */
html[data-ui='vivid'] .prose > p:first-of-type::first-letter {float:left;
font-family:var(--serif);font-size:2.4em;line-height:1;
margin:.06em .18em 0 0;color:var(--accent);}
/* 短文不做首字下沉:正文第一句就是全部,压一个大字进去反而像截断
(classic 也是这么分的,它用 .prose-short 关掉) */
html[data-ui='vivid'] .prose.prose-note > p:first-of-type::first-letter {float:none;
font-family:inherit;font-size:inherit;line-height:inherit;margin:0;color:inherit;}
html[data-ui='vivid'] .back {display:inline-flex;align-items:center;gap:6px; html[data-ui='vivid'] .back {display:inline-flex;align-items:center;gap:6px;
font-size:13px;font-weight:700;color:var(--accent); font-size:13px;font-weight:700;color:var(--accent);
margin-bottom:20px;padding:6px 0; margin-bottom:20px;padding:6px 0;
@@ -215,3 +210,17 @@ html[data-ui='vivid'] .sr-only {position: absolute;
white-space: nowrap; white-space: nowrap;
border: 0;} border: 0;}
html[data-ui='vivid'] .toc a:active { color: var(--accent); } 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 基础样式 */
+108 -82
View File
@@ -15,15 +15,8 @@ html[data-ui='vivid'] .brand {display:flex;align-items:center;gap:10px;
@media (hover: hover) and (pointer: fine) { @media (hover: hover) and (pointer: fine) {
html[data-ui='vivid'] .brand:hover {transform:scale(1.04)} html[data-ui='vivid'] .brand:hover {transform:scale(1.04)}
} }
/* .av 品牌标记:顶栏一枚,菜单里那枚随菜单头一起撤了(顶栏浮在菜单上) */ /* .av 品牌标记:原来这里是一条 .brand .av 规则,但它被下面 :is(.brand,.dock-brand)
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);}
html[data-ui='vivid'] .nav {display:flex;gap:2px;flex:1;justify-content:center} 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); html[data-ui='vivid'] .nav a {position:relative;font-size:13.5px;font-weight:600;color:var(--soft);
padding:8px 14px;border-radius:999px; padding:8px 14px;border-radius:999px;
@@ -57,98 +50,97 @@ html[data-ui='vivid'] .menu-btn {display:none}
html[data-ui='vivid'] .top {display:none} html[data-ui='vivid'] .top {display:none}
} }
/* ---- 窄屏:底部悬浮 dock + 上拉菜单 ---- /* ---- 窄屏:顶部玻璃条 + 右侧抽屉 ----
dock 常驻拇指区(品牌 + 汉堡),点开一片从底部长出来的圆角毛玻璃菜单。 */ 条上只留品牌和汉堡(fixed,body 顶部让位),导航收进右侧抽屉:
html[data-ui='vivid'] .dock {position:fixed;z-index:90; 大字栏目 + 当前项左缘短杠 + mono 英文脚注,底部是明暗切换。
left:50%;bottom:max(14px, env(safe-area-inset-bottom));transform:translateX(-50%); 条的 z-index 压在抽屉 scrim 之上:开着时汉堡原位并成 ✕,不消失。 */
display:none;align-items:center;gap:10px; html[data-ui='vivid'] .top-m {position:fixed;z-index:120;
padding:6px 8px 6px 14px;border-radius:999px; top:0;left:0;right:0;
border:1px solid var(--line); display:none;align-items:center;justify-content:space-between;
padding:max(8px, env(safe-area-inset-top)) 12px 8px;
border-bottom:1px solid var(--line);
background:color-mix(in srgb,var(--card) 82%,transparent); background:color-mix(in srgb,var(--card) 82%,transparent);
backdrop-filter:blur(20px) saturate(1.5); backdrop-filter:blur(20px) saturate(1.5);
-webkit-backdrop-filter:blur(20px) saturate(1.5); -webkit-backdrop-filter:blur(20px) saturate(1.5);
box-shadow:var(--shadow-lg);} transition:transform .28s var(--ease);}
html[data-ui='vivid'] .dock-brand {display:flex;align-items:center;gap:9px; /* 向下滑动自动藏起:transform 滑出屏幕,不占布局本来也不占,纯粹让开视线。
抽屉开着时 JS 保证不加这个类(汉堡是 ✕,不能消失) */
html[data-ui='vivid'] .top-m.bar-hide {transform:translateY(-105%)}
html[data-ui='vivid'] .top-m-brand {display:flex;align-items:center;gap:10px;
font-weight:700;font-size:14px;letter-spacing:.02em;color:var(--ink);} font-weight:700;font-size:14px;letter-spacing:.02em;color:var(--ink);}
/* 窄屏才把 dock 摆出来;这条必须压在基准规则(display:none)之后 */ html[data-ui='vivid'] .top-m-brand .av {width:28px;height:28px;flex:none;color:var(--ink);}
/* 窄屏才摆出来;这条必须压在基准规则(display:none)之后 */
@media (max-width:720px) { @media (max-width:720px) {
html[data-ui='vivid'] .dock {display:flex} html[data-ui='vivid'] .top-m {display:flex}
/* 内容区顶部让位:条高 = 44 触达 + 16 上下内边距 + 1 发丝线 ≈ 61px */
html[data-ui='vivid'] body {padding-top:calc(max(8px, env(safe-area-inset-top)) + 61px)}
} }
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);}
/* 上拉菜单:圆角毛玻璃贴着 dock 上方长出来,最高 70vh,内容多就内部滚 */ /* 抽屉:scrim 全屏淡入,面板从右侧滑入(spring),玻璃面板圆左角 */
html[data-ui='vivid'] .menu-root {position:fixed;inset:0;z-index:110;} html[data-ui='vivid'] .drawer-root {position:fixed;inset:0;z-index:110;}
html[data-ui='vivid'] .menu-root .scrim {position:absolute;inset:0; html[data-ui='vivid'] .drawer-root .scrim {position:absolute;inset:0;
background:color-mix(in srgb,var(--ink) 32%,transparent);} background:color-mix(in srgb,var(--ink) 32%,transparent);}
html[data-ui='vivid'] .menu {position:absolute;left:10px;right:10px; html[data-ui='vivid'] .drawer {position:absolute;top:0;right:0;bottom:0;
bottom:max(10px, env(safe-area-inset-bottom)); width:min(66vw, 250px);
border-radius:26px; border-left:1px solid var(--line);
border-radius:24px 0 0 24px;
background:color-mix(in srgb,var(--card) 92%,transparent); background:color-mix(in srgb,var(--card) 92%,transparent);
backdrop-filter:blur(28px) saturate(1.6); backdrop-filter:blur(28px) saturate(1.6);
-webkit-backdrop-filter:blur(28px) saturate(1.6); -webkit-backdrop-filter:blur(28px) saturate(1.6);
border:1px solid var(--line);
box-shadow:var(--shadow-lg); box-shadow:var(--shadow-lg);
display:flex;flex-direction:column; display:flex;flex-direction:column;
padding:10px 22px max(14px, env(safe-area-inset-bottom)); padding:calc(max(8px, env(safe-area-inset-top)) + 66px) 22px max(14px, env(safe-area-inset-bottom));
overscroll-behavior:contain; overscroll-behavior:contain;
max-height:min(70vh, 560px);} overflow-y:auto;
html[data-ui='vivid'] .menu:focus {outline:none}
html[data-ui='vivid'] .menu-in {flex:1;display:flex;flex-direction:column;
justify-content:flex-start;
padding-top:6px;overflow-y:auto;
scrollbar-width:none;} scrollbar-width:none;}
html[data-ui='vivid'] .menu-in a {position:relative;display:flex;align-items:baseline;gap:14px; html[data-ui='vivid'] .drawer:focus {outline:none}
padding:15px 2px;font-size:25px;font-weight:800;letter-spacing:-.02em; html[data-ui='vivid'] .drawer-nav {display:flex;flex-direction:column;}
line-height:1.25;color:var(--soft); html[data-ui='vivid'] .drawer-nav a {position:relative;display:flex;align-items:center;gap:12px;
border-bottom:1px solid var(--line); padding:12px 8px;font-size:16.5px;font-weight:500;letter-spacing:.01em;
transition:color .3s var(--ease); line-height:1.3;color:var(--soft);border-radius:10px;
/* 逐项入场:--i 由模板按序号写进 style,错峰 45ms */ transition:color .3s var(--ease),background-color .3s var(--ease);}
animation:mItem .55s var(--spring) both; html[data-ui='vivid'] .drawer-nav a .ic {width:21px;height:21px;flex:none;
animation-delay:calc(var(--i, 0) * 45ms);} color:var(--soft);
@keyframes mItem {from {opacity:0;transform:translateY(14px)}} opacity:.75;
html[data-ui='vivid'] .menu-in a:last-child {border-bottom:0} transition:color .3s var(--ease),opacity .3s var(--ease);}
html[data-ui='vivid'] .menu-in a .idx {font-family:var(--mono);font-size:11px;font-weight:700; html[data-ui='vivid'] .drawer-nav a.on {color:var(--accent);
color:var(--muted);letter-spacing:.06em; background:color-mix(in srgb,var(--accent) 8%,transparent);}
transition:color .3s var(--ease);} html[data-ui='vivid'] .drawer-nav a.on .ic {color:var(--accent);opacity:1}
html[data-ui='vivid'] .menu-in a em {font-style:normal;font-size:11px;font-weight:700; /* 当前项的左缘短杠:面板内边距 22px,杠落在离屏边 10px 处 */
letter-spacing:.14em;text-transform:uppercase;color:var(--muted);margin-left:auto; html[data-ui='vivid'] .drawer-nav a:active {color:var(--accent);
transition:color .3s var(--ease);} background:color-mix(in srgb,var(--accent) 8%,transparent);}
html[data-ui='vivid'] .menu-in a.on {color:var(--accent)} html[data-ui='vivid'] .drawer-foot {margin-top:auto;padding-top:16px;
html[data-ui='vivid'] .menu-in a.on .idx, display:flex;flex-direction:column;align-items:flex-start;gap:10px;}
html[data-ui='vivid'] .menu-in a.on em {color:var(--accent)} html[data-ui='vivid'] .drawer-foot .chip {padding:6px 14px;border-radius:999px;
/* 当前项的左缘短杠:菜单内边距 22px,杠落在离屏边 10px 处 */ border:1px solid var(--line);background:transparent;
html[data-ui='vivid'] .menu-in a.on::before {content:"";position:absolute;left:-14px;top:50%; font-size:12.5px;font-weight:600;color:var(--soft);cursor:pointer;
width:3px;height:18px;border-radius:2px;background:var(--accent); transition:color .25s var(--ease),border-color .25s var(--ease);}
transform:translateY(-50%);} html[data-ui='vivid'] .drawer-foot .chip:active {color:var(--accent);border-color:var(--accent)}
html[data-ui='vivid'] .menu-in a:active {color:var(--accent)} html[data-ui='vivid'] .drawer-foot .m-desc {margin:0;font-size:12px;line-height:1.7;
html[data-ui='vivid'] .menu-foot {flex:none;padding-top:16px; color:var(--muted);}
display:flex;align-items:center;justify-content:space-between;gap:12px;} /* 进:scrim 淡入 + 面板从右侧滑入;出:整块滑回去 */
html[data-ui='vivid'] .menu-foot .m-desc {margin:0;font-size:12px;line-height:1.7; html[data-ui='vivid'] .v-drawer-enter-active {transition:opacity .3s var(--ease)}
color:var(--muted);text-align:right;} html[data-ui='vivid'] .v-drawer-enter-active .drawer {transition:transform .55s var(--spring),opacity .35s var(--ease)}
/* 进:scrim 淡入 + 菜单从底部长出来 + 项逐项推上来;出:整块沉回去 */ html[data-ui='vivid'] .v-drawer-enter-from {opacity:0}
html[data-ui='vivid'] .v-menu-enter-active {transition:opacity .3s var(--ease)} html[data-ui='vivid'] .v-drawer-enter-from .drawer {transform:translateX(60px);opacity:0}
html[data-ui='vivid'] .v-menu-enter-active .menu {transition:transform .55s var(--spring),opacity .35s var(--ease)} html[data-ui='vivid'] .v-drawer-leave-active {transition:opacity .22s var(--ease)}
html[data-ui='vivid'] .v-menu-enter-from {opacity:0} html[data-ui='vivid'] .v-drawer-leave-active .drawer {transition:transform .22s var(--ease)}
html[data-ui='vivid'] .v-menu-enter-from .menu {transform:translateY(56px);opacity:0} html[data-ui='vivid'] .v-drawer-leave-to {opacity:0}
html[data-ui='vivid'] .v-menu-leave-active {transition:opacity .22s var(--ease)} html[data-ui='vivid'] .v-drawer-leave-to .drawer {transform:translateX(48px)}
html[data-ui='vivid'] .v-menu-leave-active .menu {transition:transform .22s var(--ease)}
html[data-ui='vivid'] .v-menu-leave-to {opacity:0}
html[data-ui='vivid'] .v-menu-leave-to .menu {transform:translateY(48px)}
/* 全局 reduce-motion 只把 duration 压扁,delay 会留下来 → 单独清掉 */ /* 全局 reduce-motion 只把 duration 压扁,delay 会留下来 → 单独清掉 */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
html[data-ui='vivid'] .v-menu-enter-active, html[data-ui='vivid'] .v-drawer-enter-active,
html[data-ui='vivid'] .v-menu-enter-active .menu {transition-delay:0s !important} html[data-ui='vivid'] .v-drawer-enter-active .drawer {transition-delay:0s !important}
html[data-ui='vivid'] .menu-in a {animation:none}
} }
/* 品牌位只管尺寸:头像本身已裁成圆(AuthorAvatar 的 img),外面不加光环、
右下角也不加在线点——顶栏要的就是一张干净的圆头像。color 供 LogoMark
回落(未设头像时)着色。只作用于桌面顶栏 .brand:手机标签栏里没有品牌位,
选择器不需要 :is() 兼顾两边。 */
html[data-ui='vivid'] .brand .av {width:30px;height:30px;flex:none;
color:var(--ink);}
/* 短文快发盒(QuickShort)的 vivid 差异:圆角跟自己的半径档走, /* 短文快发盒(QuickShort)的 vivid 差异:圆角跟自己的半径档走,
FAB 不用给经典版的主题浮钮让位(vivid 的主题键在菜单里),贴到安全区 */ FAB 贴到安全区(手机端导航在顶部,底部没有常驻栏要避让) */
html[data-ui='vivid'] .qs-card {border-radius:14px;} html[data-ui='vivid'] .qs-card {border-radius:14px;}
html[data-ui='vivid'] .qs-fab {bottom:max(20px, env(safe-area-inset-bottom));} html[data-ui='vivid'] .qs-fab {bottom:max(20px, env(safe-area-inset-bottom));}
@@ -169,6 +161,40 @@ html[data-ui='vivid'] .foot {max-width:1100px;margin:40px auto 0;padding:28px 28
border-top:1px solid var(--line); border-top:1px solid var(--line);
text-align:center;color:var(--muted);font-size:12.5px;line-height:2;} 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} 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-nav 文字行的末尾,做成和相邻链接同观感的小方块;
前面的 · 分隔符跟按钮一起在桌面隐藏,免得尾巴上挂个空点。 */
html[data-ui='vivid'] .foot .theme-toggle,
html[data-ui='vivid'] .foot .theme-sep {display:none}
html[data-ui='vivid'] .foot .theme-toggle {padding:0;width:24px;height:24px;
align-items:center;justify-content:center;
border:0;border-radius:6px;background:transparent;
color:var(--accent);font-size:14px;line-height:1;cursor:pointer;
transition:opacity .25s;}
html[data-ui='vivid'] .foot .theme-toggle:active {opacity:.6}
@media (max-width:720px) {
html[data-ui='vivid'] .foot .theme-toggle {display:inline-flex}
html[data-ui='vivid'] .foot .theme-sep {display:inline}
}
/* 项目信息行:源码入口 + 版权。刻意不并进 .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) { @media (hover: hover) and (pointer: fine) {
html[data-ui='vivid'] .foot a:hover {opacity:.75} html[data-ui='vivid'] .foot a:hover {opacity:.75}
} }
+73 -17
View File
@@ -85,7 +85,7 @@ html[data-ui='vivid'] .card .cov img {width:100%;height:100%;object-fit:cover;tr
@media (hover: hover) and (pointer: fine) { @media (hover: hover) and (pointer: fine) {
html[data-ui='vivid'] .card:hover .cov img {transform:scale(1.04)} html[data-ui='vivid'] .card:hover .cov img {transform:scale(1.04)}
} }
html[data-ui='vivid'] .card .body {padding:22px 26px 24px} html[data-ui='vivid'] .card .body {padding:22px 8px 24px}
/* 元信息挪到摘要之后(原来在标题上方):和标签一起收在卡片尾部 */ /* 元信息挪到摘要之后(原来在标题上方):和标签一起收在卡片尾部 */
html[data-ui='vivid'] .card .meta {display:flex;gap:10px;align-items:center;flex-wrap:wrap; html[data-ui='vivid'] .card .meta {display:flex;gap:10px;align-items:center;flex-wrap:wrap;
font-size:12px;color:var(--muted);font-weight:600;margin-top:16px;} font-size:12px;color:var(--muted);font-weight:600;margin-top:16px;}
@@ -107,41 +107,97 @@ html[data-ui='vivid'] .card .tags {display:flex;gap:14px;flex-wrap:wrap;margin-t
html[data-ui='vivid'] .tg {font-size:12.5px;font-weight:600;color:var(--muted); html[data-ui='vivid'] .tg {font-size:12.5px;font-weight:600;color:var(--muted);
transition:color .35s var(--ease);} transition:color .35s var(--ease);}
html[data-ui='vivid'] .tg::before {content:"#"} html[data-ui='vivid'] .tg::before {content:"#"}
html[data-ui='vivid'] .note {display:block;background:transparent; /* 短文纸条:不再用左线标记(线 + 内边距在手机上吃掉近 30px,且离内容有段
border-left:3px solid var(--accent); 间距)。区分改用两层信号:极淡的强调色底块(背景不占布局宽度)+ meta 里的
border-radius:0 var(--radius) var(--radius) 0; SHORT 徽标;正文与长文卡片左缘对齐,一行内容都不浪费。 */
padding:18px 24px; /* 内边距对齐全站 12px 节奏;meta 上方 16px 的旧留白收掉。
margin-left:12px; 纸条本体横向 padding 归零:文字类内容自己缩进 12px(字贴边线不好看),
图片网格全宽贴边、圆角归零;外链卡片是带边框的卡,同样缩进 12px。 */
html[data-ui='vivid'] .note {display:block;
background:color-mix(in srgb,var(--accent) 7%,transparent);
border-radius:var(--radius);
padding:0;
margin-left:0;
overflow:hidden;
position:relative; position:relative;
transition:background .35s var(--ease),transform .55s var(--spring),border-color .3s;} transition:background .35s var(--ease),transform .55s var(--spring),border-color .3s;}
html[data-ui='vivid'] .note .body,
html[data-ui='vivid'] .note .meta,
html[data-ui='vivid'] .note .tags {padding-left:8px;padding-right:8px}
html[data-ui='vivid'] .note .link-card {margin-left:8px;margin-right:8px}
html[data-ui='vivid'] .note .imgs {border-radius:0}
@media (hover: hover) and (pointer: fine) { @media (hover: hover) and (pointer: fine) {
html[data-ui='vivid'] .note:hover {background:var(--card); html[data-ui='vivid'] .note:hover {background:color-mix(in srgb,var(--accent) 12%,transparent);
transform:translateX(6px); transform:translateX(6px);
box-shadow:var(--shadow);} box-shadow:var(--shadow);}
} }
html[data-ui='vivid'] .note .meta {display:flex;gap:10px;align-items:center; 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;} font-size:12px;color:var(--muted);font-weight:600;margin-top:10px;}
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; 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); border-bottom:1px solid color-mix(in srgb,var(--accent) 40%,transparent);
transition:border-color .25s;} transition:border-color .25s;}
@media (hover: hover) and (pointer: fine) { @media (hover: hover) and (pointer: fine) {
html[data-ui='vivid'] .note .body a:hover {border-bottom-color:var(--accent)} html[data-ui='vivid'] .note .body a:hover {border-bottom-color:var(--accent)}
} }
/* 短文配图:Twitter 式网格(快发盒上传,≤4 张) */ /* 短文配图: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; html[data-ui='vivid'] .note .imgs {display:grid;gap:3px;margin-top:10px;
border-radius:var(--radius);overflow:hidden;} border-radius:var(--radius);overflow:hidden;}
html[data-ui='vivid'] .note .imgs img {display:block;width:100%;height:100%; html[data-ui='vivid'] .note .imgs img {display:block;width:100%;height:100%;
object-fit:cover;cursor:zoom-in;} 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.n2,
html[data-ui='vivid'] .note .imgs.n4 {grid-template-columns:1fr 1fr;} html[data-ui='vivid'] .note .imgs.n3,
html[data-ui='vivid'] .note .imgs.n3 {grid-template-columns:1fr 1fr;} html[data-ui='vivid'] .note .imgs.n4 {grid-template-columns:1fr 1fr;
html[data-ui='vivid'] .note .imgs.n2 img, aspect-ratio:2/1;}
html[data-ui='vivid'] .note .imgs.n4 img {aspect-ratio:1;} /* 行高必须 minmax(0,1fr):裸 1fr 自带 min-content 下限,图片的自然高度会把行
html[data-ui='vivid'] .note .imgs.n3 img {aspect-ratio:1;} 撑回 733px+,容器 aspect-ratio 定出的 491px 装不下,n3/n4 的第二行整行被
html[data-ui='vivid'] .note .imgs.n3 img:first-child {grid-row:span 2; 裁出可视区。0 下限让行严格对半分,图片交给 object-fit:cover 裁。 */
aspect-ratio:auto;height:100%;} 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'] .note .tags {display:flex;gap:14px;flex-wrap:wrap;margin-top:8px}
/* 操作行:回复 / 赞 / 查看。挂在链接外面(兄弟节点),左边距 8px 与
纸条文字、卡片正文同一条竖线。赞目前是本机赞(localStorage),不传服务端。 */
html[data-ui='vivid'] .acts {display:flex;gap:2px;padding:0 8px;margin-top:4px}
html[data-ui='vivid'] .acts button {display:inline-flex;align-items:center;gap:6px;
padding:7px 10px;border:0;border-radius:999px;background:transparent;
font-family:inherit;font-size:12.5px;font-weight:600;color:var(--muted);
cursor:pointer;touch-action:manipulation;
transition:color .2s var(--ease),background-color .2s var(--ease),transform 120ms ease-out;}
html[data-ui='vivid'] .acts button .ic {width:17px;height:17px}
@media (hover: hover) and (pointer: fine) {
html[data-ui='vivid'] .acts button:hover {color:var(--accent);background:var(--accent-soft)}
}
html[data-ui='vivid'] .acts button:active {transform:scale(.94)}
html[data-ui='vivid'] .acts button.liked {color:var(--accent)}
html[data-ui='vivid'] .pager {display:flex;gap:8px;align-items:center;justify-content:center; html[data-ui='vivid'] .pager {display:flex;gap:8px;align-items:center;justify-content:center;
margin-top:36px;flex-wrap:wrap;} margin-top:36px;flex-wrap:wrap;}
html[data-ui='vivid'] .pager button {font-size:13px;font-weight:700;padding:9px 16px;border-radius:999px; html[data-ui='vivid'] .pager button {font-size:13px;font-weight:700;padding:9px 16px;border-radius:999px;
+15 -11
View File
@@ -11,21 +11,25 @@
========================================================================== */ ========================================================================== */
@media (max-width:640px) { @media (max-width:640px) {
/* 左右 20px 对齐:hero 的标题和下面卡片列表的左边缘要在同一条竖线上 */ /* 左右 12px 全站统一离边框(站主要求):正文列 .article-layout 和评论区
html[data-ui='vivid'] .hero {padding:48px 20px 32px} (styles.css 的 .cm-sec 手机断点)本来就是 12px,这里把首页 hero /
html[data-ui='vivid'] .wrap {padding:8px 20px 36px} 列表页 wrap / 页头 page-head / 页脚 foot / showcase 一起对齐——
/* 阅读页在手机上只收回留白:.article-layout 走 12px 页边距,.paper 的内边距归零 手机上同一行内容不管翻到哪个页面,左缘都在同一条竖线上。 */
(桌面那 24px 是为了给 776 的中列凑出 --measure 的 728,手机上只会浪费宽度)。 html[data-ui='vivid'] .hero {padding:48px 12px 32px}
12px 是站主定的「不影响美观的前提下尽量贴边」,评论区(styles.css 的 .cm-sec html[data-ui='vivid'] .wrap {padding:8px 12px 36px}
手机断点)与此对齐,正文和评论共用同一条左缘。卡片样式本身已在 article.css html[data-ui='vivid'] .page-head {padding:56px 12px 32px}
里去掉了,手机桌面一致。 */
html[data-ui='vivid'] .article-layout {padding:20px 12px 40px} html[data-ui='vivid'] .article-layout {padding:20px 12px 40px}
html[data-ui='vivid'] .paper {padding:0} html[data-ui='vivid'] .paper {padding:0}
html[data-ui='vivid'] .about-card {padding:32px 22px} html[data-ui='vivid'] .about-card {padding:32px 16px}
/* showcase 的 .showcase 是 max-width + 自己的 padding(不带 .wrap 类), /* showcase 的 .showcase 是 max-width + 自己的 padding(不带 .wrap 类),
单独对齐;用 max() 保住横屏刘海侧的安全区 */ 单独对齐;用 max() 保住横屏刘海侧的安全区 */
html[data-ui='vivid'] .showcase {padding-left:max(20px, env(safe-area-inset-left)); html[data-ui='vivid'] .showcase {padding-left:max(12px, env(safe-area-inset-left));
padding-right:max(20px, env(safe-area-inset-right));} padding-right:max(12px, env(safe-area-inset-right));}
/* 页脚两侧跟内容对齐;底部 120px 是给旧 dock 留的,现在只有快发 FAB,
收到 56px 够它的悬浮高度 */
html[data-ui='vivid'] .foot {padding:28px 12px 56px}
/* 时间线条目间隔桌面 18px,手机上收一档:一屏能多看半条内容 */
html[data-ui='vivid'] .feed {gap:14px}
/* 标签 chips 在手机上会折成三四行,搜索框再挤一个 150px 控件 —— 都收起来, /* 标签 chips 在手机上会折成三四行,搜索框再挤一个 150px 控件 —— 都收起来,
只留类型分段,筛选条回到一行。 只留类型分段,筛选条回到一行。
Loaded 100 of 105 files, more files were not shown because too many files have changed in this diff. Show more