Compare commits

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

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

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

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

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

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

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

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

验证
- 本地兜底全流程:上传 201、同内容去重复用、随机字节改名 .png 被
  内容嗅探拒绝、.svg 拒绝、公开路由 immutable 缓存头 + 内容一致、
  删除后存储与公开路由双清 404
- R2 真实链路(站主 .env):上传 store=r2、直链 200(过程中定位并修复
  S3Api 路径段折进 key 导致的直链 404,见 EndpointKeyPrefix)
- 后端 go build/test/vet 全绿;前端构建通过
2026-09-27 16:26:11 +08:00
74 changed files with 7247 additions and 351 deletions
+22
View File
@@ -0,0 +1,22 @@
AccessKey=
SecretAccessKey=
S3Api="https://<account_id>.r2.cloudflarestorage.com"
PublicURL=
Bucket=
# GitHub OAuth(评论登录):https://github.com/settings/developers
# 回调地址填 http://localhost:8080/api/auth/callback/github(线上换成正式域名)
ONE_GITHUB_CLIENT_ID=
ONE_GITHUB_CLIENT_SECRET=
# Telegram 登录(Login Widget):@BotFather 建 bot 拿 token,
# 再用 /setdomain 把域名登记给 bot(本地开发填 localhost)
ONE_TELEGRAM_BOT=
ONE_TELEGRAM_BOT_TOKEN=
# Google 登录:https://console.cloud.google.com/apis/credentials 建 OAuth 客户端,
# 回调地址填 http://localhost:8080/api/auth/callback/google(线上换成正式域名)
ONE_GOOGLE_CLIENT_ID=
ONE_GOOGLE_CLIENT_SECRET=
ONE_SECRET=# 任意字符串,用于加密
+19
View File
@@ -3,12 +3,31 @@ module oneblog
go 1.24
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/lib/pq v1.10.9
github.com/yuin/goldmark v1.7.13
golang.org/x/text v0.21.0
modernc.org/sqlite v1.39.0
)
require (
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.5 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.4 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.10.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.38.1 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.51.1 // indirect
github.com/aws/smithy-go v1.28.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
+38
View File
@@ -1,3 +1,39 @@
github.com/aws/aws-sdk-go-v2 v1.47.1 h1:uOIZnp4PK3ZhKI0dNrJrhTEsLxbpXHTAJlwoS1pvAtw=
github.com/aws/aws-sdk-go-v2 v1.47.1/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 h1:GPRlPwz40I2B2VrBEASOA3Bi77NyeqejNLkifosX0rs=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20/go.mod h1:g7PNzKcsOKWb4fkSRBA7BZVAS6Y8IcxzN+nRohhQ1Q8=
github.com/aws/aws-sdk-go-v2/config v1.33.6 h1:MBjkSTLczek/UgiK+EYPIoRTqE7gP8vtW3OFbFo7Nug=
github.com/aws/aws-sdk-go-v2/config v1.33.6/go.mod h1:grRAFzdAZJrwcbasJRg2MPvIrVjtlfXllHssN6+E1JE=
github.com/aws/aws-sdk-go-v2/credentials v1.20.6 h1:NpAFXCU7NzXNkdGK3zQTtsRJ+3v9tZQV0xcdRw8uBdw=
github.com/aws/aws-sdk-go-v2/credentials v1.20.6/go.mod h1:mcZCoiPnyMvP8VMNbygNX5lLqSlkYJIMPODylQMurOk=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.1 h1:8gALAAmacnIXh+z6VkdDanv4/IkG5APdg4DZLDTmLog=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.1/go.mod h1:Z7IJhJU+poOdJjUR2wpyY21ossQ1XS/R3Lk9Msq5kM4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4 h1:CLq4+8UHCI+ZZYl/EuJxXovaIVN2xeeT8JV+dsApQ5E=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4/go.mod h1:Wv4q5sAM04xAMkoOedxLx2inVf6K5FdxYp+A61L+q/0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.4 h1:dD4MR81I7YkpEBRk6UP9rocC2QnT3qVuXwzlYTtfGEs=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.4/go.mod h1:EcXV1kAFd5XwSkDHlj94gnF3q5CkJyYiIJfH8N0VmrE=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.4 h1:7Wo47d/xn/7KttCSBd8EGYeZ7ULRFRkUHr6vkZPBzVQ=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.4/go.mod h1:tDB2IVC1xC3vX8o+6uRlzhTxP3g1b77CZXFX/oD2FnQ=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 h1:bAdDl/HkGCcGPoe25ToSHEw23VIxt6CT5fLcg111BKg=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19/go.mod h1:KaUzbLxv4CeSxh6ZCl9B4m7CuFenS8kUEaDs+f/DQr4=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.5 h1:/TYsZXdA8UTa+WCtCYSAJIr1vwl0+eho6TUgJGwFFO8=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.5/go.mod h1:qPqp1Uwd/BqdhPufv6oem9j5J7HNsgc2V22dUiDPn+s=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.4 h1:29SvnfGhXjTl8ONxFwbj2rs6lbhiFXD2CgFQmbT/bXY=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.4/go.mod h1:wm04I5DMuNVvZHFe/dHnUxincvNbbK7AiNBbYsQivek=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.4 h1:pPiWfgeNxqluKEph7hvU88kuGKBPOWzO+Dk9t2zqqNs=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.4/go.mod h1:YlwGoIUDG/3kBQbdNOVs/xKZ9J01G8e/6D1mRBj9uTk=
github.com/aws/aws-sdk-go-v2/service/s3 v1.113.4 h1:n6kO3OlBvnDEksQpvBLbAldjHwGlu8kErvhHJkhlaRY=
github.com/aws/aws-sdk-go-v2/service/s3 v1.113.4/go.mod h1:9APRWGLFITKD+xzWSIyT9V7QV4bNlEuIieWlzXgGFlI=
github.com/aws/aws-sdk-go-v2/service/signin v1.10.1 h1:DzCCWLzcIRQ77F3DEUljud7bEjTgFOIKXP52NmVRyhU=
github.com/aws/aws-sdk-go-v2/service/signin v1.10.1/go.mod h1:xpo/geVldu8payT375WekctUzopG/hBU7miiqItMUlw=
github.com/aws/aws-sdk-go-v2/service/sso v1.38.1 h1:Umtl/0YZhng4xndfW3lKJrYYP7NLEjI6bGXVomwLcs0=
github.com/aws/aws-sdk-go-v2/service/sso v1.38.1/go.mod h1:rRD/dnm7q0HYE/I5TMaPgkWyyUGLcwuxHLABsLnQ3e0=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1 h1:orIWdNiLgzrhu/11RcPPKO/SBzUUymbUQuZbSPImghg=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1/go.mod h1:skwM/xsbR/1ReUTesv9BhpJp1VjajR7DWQnuVLwiXsQ=
github.com/aws/aws-sdk-go-v2/service/sts v1.51.1 h1:0HOqZXRvMytH6bFHVIc0oJX07sZjfhz0zXtjs6gdE8s=
github.com/aws/aws-sdk-go-v2/service/sts v1.51.1/go.mod h1:26zA0GhDrLo+yiLI2yXWxqB1PdsShfLikoI7GOEgugM=
github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ=
github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/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/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
@@ -23,6 +59,8 @@ golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM=
+488 -3
View File
@@ -4,22 +4,47 @@
package admin
import (
"context"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"oneblog/internal/config"
"oneblog/internal/httpx"
"oneblog/internal/hub"
"oneblog/internal/linkmeta"
"oneblog/internal/model"
"oneblog/internal/storage"
"oneblog/internal/store"
"oneblog/internal/thumbs"
)
type API struct {
Store *store.Store
Cfg *config.Config
Sessions *Sessions
// Hub 是评论变更广播(与公开 API 共享同一实例,main.go 装配);
// 审核通过 / 后台删除评论时让前台打开着的页面即时刷新。
Hub *hub.Hub
// 文件上传的存储后端与公开域名(main.go 装配,两个 API 共享同一实例)
Blobs storage.BlobStore
PublicBase string
// Thumbs 是缩略图磁盘缓存(main.go 装配)。删上传文件时顺手清掉它的
// 缩略图产物,否则已删图片会一直占着缓存。
Thumbs *thumbs.Store
loginOnce sync.Once
logins *loginLimiter
@@ -56,7 +81,28 @@ func (a *API) Routes() http.Handler {
mux.HandleFunc("/api/admin/tags/", a.guard(a.tagByID))
mux.HandleFunc("/api/admin/projects", a.guard(a.listProjects))
mux.HandleFunc("/api/admin/projects/", a.guard(a.projectByID))
mux.HandleFunc("/api/admin/files", a.guard(a.files))
mux.HandleFunc("/api/admin/files/import", a.guard(a.importFiles))
mux.HandleFunc("/api/admin/files/", a.guard(a.fileByID))
mux.HandleFunc("/api/admin/settings", a.guard(a.settings))
mux.HandleFunc("/api/admin/comments", a.guard(a.adminComments))
mux.HandleFunc("/api/admin/comments/", a.guard(a.adminCommentByID))
mux.HandleFunc("/api/admin/readers", a.guard(a.adminReaders))
mux.HandleFunc("/api/admin/readers/", a.guard(func(w http.ResponseWriter, r *http.Request) {
// 路径形如 /api/admin/readers/{id}/ban
rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/admin/readers/"), "/")
parts := strings.Split(rest, "/")
if len(parts) != 2 || parts[1] != "ban" {
httpx.NotFound(w)
return
}
id, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil || id <= 0 {
httpx.BadRequest(w, "bad reader id")
return
}
a.adminReaderBan(w, r, id)
}))
return mux
}
@@ -104,7 +150,7 @@ func (a *API) login(w http.ResponseWriter, r *http.Request) {
return
}
key := sourceKey(r)
if a.limiter().blocked(key) {
if a.limiter().Blocked(key) {
httpx.Error(w, http.StatusTooManyRequests, "失败次数过多,请 10 分钟后再试")
return
}
@@ -116,11 +162,11 @@ func (a *API) login(w http.ResponseWriter, r *http.Request) {
userOK := subtle.ConstantTimeCompare([]byte(in.Username), []byte(a.Cfg.AdminUser)) == 1
passOK := subtle.ConstantTimeCompare([]byte(in.Password), []byte(a.Cfg.AdminPass)) == 1
if !userOK || !passOK {
a.limiter().fail(key)
a.limiter().Add(key)
httpx.Unauthorized(w)
return
}
a.limiter().reset(key)
a.limiter().Reset(key)
token, exp := a.Sessions.Issue(a.Cfg.AdminUser)
http.SetCookie(w, &http.Cookie{
Name: cookieName,
@@ -184,6 +230,8 @@ func (a *API) listPosts(w http.ResponseWriter, r *http.Request) {
return
}
in.Status = store.NormalizeStatus(in.Status)
attachLinkCard(r.Context(), &in, nil)
autoMeta(&in, nil)
p, err := a.Store.Create(in)
if err != nil {
httpx.ServerError(w, err)
@@ -223,6 +271,16 @@ func (a *API) postByID(w http.ResponseWriter, r *http.Request) {
if in.Status != "" {
in.Status = store.NormalizeStatus(in.Status)
}
// 多读一次当前行:链接卡片没变就不必再打远端;摘要/封面自动
// 生成也以它为准——「新值与旧值都为空」才补,主动清空的保得住
var curCard *model.LinkCard
var curPost *model.Post
if cur, err := a.Store.Get(id); err == nil {
curCard = cur.LinkCard
curPost = &cur
}
attachLinkCard(r.Context(), &in, curCard)
autoMeta(&in, curPost)
p, err := a.Store.Update(id, in)
writeOne(w, p, err)
case http.MethodDelete:
@@ -545,3 +603,430 @@ func (a *API) bulkPosts(w http.ResponseWriter, r *http.Request) {
httpx.BadRequest(w, "action must be one of publish|draft|delete")
}
}
// ---------- files(上传与文件管理) ----------
// 单文件上限与类型白名单。SVG 拒绝:同源内联可执行脚本。
const (
maxFileUpload = 50 << 20
)
var allowFileExt = map[string]string{
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
".gif": "image/gif",
".avif": "image/avif",
".pdf": "application/pdf",
".zip": "application/zip",
".txt": "text/plain",
}
func (a *API) files(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
fp, err := a.Store.ListFiles(httpx.QueryInt(r, "page", 1), httpx.QueryInt(r, "size", 20), httpx.QueryString(r, "q"))
if err != nil {
httpx.ServerError(w, err)
return
}
for i := range fp.Items {
fp.Items[i].URL = storage.FileURL(fp.Items[i].Store, fp.Items[i].Key, a.Cfg.UploadsPublicBase)
}
httpx.OK(w, fp)
case http.MethodPost:
a.uploadFiles(w, r)
default:
httpx.Error(w, http.StatusMethodNotAllowed, "GET/POST required")
}
}
func (a *API) uploadFiles(w http.ResponseWriter, r *http.Request) {
// 64MB = 50MB 文件 + multipart 编码开销
r.Body = http.MaxBytesReader(w, r.Body, maxFileUpload+(8<<20))
if err := r.ParseMultipartForm(8 << 20); err != nil {
httpx.BadRequest(w, "上传失败:请求体超过上限(单文件 50MB)")
return
}
if r.MultipartForm != nil {
defer r.MultipartForm.RemoveAll()
}
fhs := r.MultipartForm.File["file"]
if len(fhs) == 0 {
httpx.BadRequest(w, "没有收到文件")
return
}
out := make([]model.File, 0, len(fhs))
for _, fh := range fhs {
f, err := a.storeOne(r.Context(), fh)
if err != nil {
if bu, ok := err.(badUpload); ok {
httpx.BadRequest(w, string(bu))
return
}
httpx.ServerError(w, err)
return
}
out = append(out, f)
}
// URL 统一在这里解析:storeOne 的「去重复用已有行」路径也不例外
for i := range out {
out[i].URL = storage.FileURL(out[i].Store, out[i].Key, a.Cfg.UploadsPublicBase)
}
httpx.Created(w, out)
}
// badUpload 区分「文件本身的问题」(类型 / 大小 → 400 直接告诉站主)与
// 服务器故障(500,已入库的部分保留)。
type badUpload string
func (e badUpload) Error() string { return string(e) }
// storeOne 校验、哈希、落盘单个文件。内容哈希做 key(同年月分目录),
// 同内容重复上传直接复用已有行,不产生孤儿对象。
func (a *API) storeOne(ctx context.Context, fh *multipart.FileHeader) (model.File, error) {
if fh.Size > maxFileUpload {
return model.File{}, badUpload(fmt.Sprintf("%s:超过单文件 50MB 上限", fh.Filename))
}
ext := strings.ToLower(filepath.Ext(fh.Filename))
mime, ok := allowFileExt[ext]
if !ok {
return model.File{}, badUpload(fmt.Sprintf("%s:不支持的类型 %q", fh.Filename, ext))
}
src, err := fh.Open()
if err != nil {
return model.File{}, err
}
defer src.Close()
// 头 512 字节给 http.DetectContentType 嗅探真实类型,全文流过 sha256,
// 同时落到临时文件(S3 PutObject 需要确定的 ContentLength)。
tmp, err := os.CreateTemp("", "one-upload-*")
if err != nil {
return model.File{}, err
}
defer os.Remove(tmp.Name())
hasher := sha256.New()
head := make([]byte, 512)
hn, _ := io.ReadFull(src, head)
head = head[:hn]
for _, w := range []io.Writer{tmp, hasher} {
if _, err := w.Write(head); err != nil {
tmp.Close()
return model.File{}, err
}
}
copied, err := io.Copy(io.MultiWriter(tmp, hasher), src)
if err != nil {
tmp.Close()
return model.File{}, err
}
if err := tmp.Close(); err != nil {
return model.File{}, err
}
size := int64(hn) + copied
// 嗅探结果必须与扩展名声明的类型完全一致(防改后缀绕过白名单)。
// 不给 octet-stream 留口子:一张「image/png」嗅探出 octet-stream
// 就绝不是 PNG——随机字节改名上传就是这么溜进来的。
detected := strings.SplitN(http.DetectContentType(head), ";", 2)[0]
if detected != mime {
return model.File{}, badUpload(fmt.Sprintf("%s:文件内容与扩展名不符", fh.Filename))
}
sum := hex.EncodeToString(hasher.Sum(nil))
return a.persistFile(ctx, fh.Filename, ext, mime, tmp.Name(), size, sum)
}
// persistFile 落库共享段:内容哈希做 key(同年月分目录)、去重复用、
// Put 对象存储、建行。storeOne(本地上传)与 importOne(外链转存)共用。
func (a *API) persistFile(ctx context.Context, name, ext, mime, tmpPath string, size int64, sum string) (model.File, error) {
key := fmt.Sprintf("%s/%s%s", time.Now().UTC().Format("2006/01"), sum[:12], ext)
// S3Api 端点带路径段时(如 .../oss),该段会折进对象 key——
// 数据库必须记录同样的完整 key,直链才不会 404
if p := storage.EndpointKeyPrefix(a.Cfg.S3Endpoint); p != "" {
key = p + "/" + key
}
// 内容去重:同一份内容只存一份,复用已有行。
// URL 必须按当前存储配置重新解析——去重路径不走下面的 created 赋值,
// 漏了它转存替换会拿到空 URL(真实事故:正文图片链接被清空)。
if exist, err := a.Store.GetFileByKey(key); err == nil {
exist.URL = storage.FileURL(exist.Store, exist.Key, a.Cfg.UploadsPublicBase)
return exist, nil
}
f, err := os.Open(tmpPath)
if err != nil {
return model.File{}, err
}
defer f.Close()
if err := a.Blobs.Put(ctx, key, f, size, mime); err != nil {
return model.File{}, err
}
created, err := a.Store.CreateFile(model.File{
Key: key,
Name: name,
Mime: mime,
Size: size,
SHA256: sum,
Store: a.Cfg.StorageDriver,
})
if err != nil {
return model.File{}, err
}
created.URL = storage.FileURL(created.Store, created.Key, a.Cfg.UploadsPublicBase)
return created, nil
}
// mimeToExt 是 allowFileExt 的反向映射:外链转存时内容嗅探出 mime,
// 反推扩展名(URL 本身可能不带后缀或后缀不可信)。
var mimeToExt = func() map[string]string {
m := make(map[string]string, len(allowFileExt))
for ext, mime := range allowFileExt {
m[mime] = ext
}
return m
}()
// importFiles 外链转存:POST /api/admin/files/import {"urls": [...]}。
// 站主把别处的图片贴进正文后一键搬进自己的存储——与手动上传同一套
// 白名单、内容嗅探与内容去重;抓取走 linkmeta 的 SSRF 防护拨号。
func (a *API) importFiles(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
httpx.Error(w, http.StatusMethodNotAllowed, "POST required")
return
}
var in struct {
URLs []string `json:"urls"`
}
if err := httpx.Decode(r, &in); err != nil {
httpx.BadRequest(w, "invalid body")
return
}
if len(in.URLs) == 0 || len(in.URLs) > 20 {
httpx.BadRequest(w, "urls 需要1-20个")
return
}
// files 带上 source(原址),前端按它做正文替换
type imported struct {
Source string `json:"source"`
File model.File `json:"file"`
}
files := make([]imported, 0, len(in.URLs))
errs := map[string]string{}
for _, u := range in.URLs {
f, err := a.importOne(r.Context(), u)
if err != nil {
if bu, ok := err.(badUpload); ok {
errs[u] = string(bu)
} else {
errs[u] = err.Error()
}
continue
}
files = append(files, imported{Source: u, File: f})
}
httpx.OK(w, map[string]any{"files": files, "errors": errs})
}
func (a *API) importOne(ctx context.Context, rawURL string) (model.File, error) {
cctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
data, ct, err := linkmeta.FetchBytes(cctx, rawURL, maxFileUpload)
if err != nil {
return model.File{}, fmt.Errorf("抓取失败:%w", err)
}
if len(data) == 0 {
return model.File{}, badUpload("空内容")
}
// 类型必须落在本站白名单里:嗅探优先(不信响应头,更不信 URL 后缀)
detected := strings.SplitN(http.DetectContentType(data[:512]), ";", 2)[0]
ext, ok := mimeToExt[detected]
if !ok {
return model.File{}, badUpload("不支持的类型 " + detected)
}
_ = ct
// 落临时文件:persistFile / S3 PutObject 都要确定的文件与长度
tmp, err := os.CreateTemp("", "one-import-*")
if err != nil {
return model.File{}, err
}
defer os.Remove(tmp.Name())
size, err := tmp.Write(data)
if err != nil {
tmp.Close()
return model.File{}, err
}
if err := tmp.Close(); err != nil {
return model.File{}, err
}
sum := sha256.Sum256(data)
// 名字取 URL 路径末段(仅用于文件管理页展示,不参与存储路径)
name := rawURL
if u, err := url.Parse(rawURL); err == nil && u.Path != "" {
if base := path.Base(u.Path); base != "" && base != "/" && base != "." {
name = base
}
}
return a.persistFile(ctx, name, ext, detected, tmp.Name(), int64(size), hex.EncodeToString(sum[:]))
}
func (a *API) fileByID(w http.ResponseWriter, r *http.Request) {
id, err := parseInt(strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/admin/files/"), "/"))
if err != nil {
httpx.BadRequest(w, "bad file id")
return
}
switch r.Method {
case http.MethodGet:
f, err := a.Store.GetFile(id)
if errors.Is(err, store.ErrNotFound) {
httpx.NotFound(w)
return
}
if err != nil {
httpx.ServerError(w, err)
return
}
f.URL = storage.FileURL(f.Store, f.Key, a.Cfg.UploadsPublicBase)
httpx.OK(w, f)
case http.MethodDelete:
f, err := a.Store.GetFile(id)
if errors.Is(err, store.ErrNotFound) {
httpx.NotFound(w)
return
}
if err != nil {
httpx.ServerError(w, err)
return
}
// 先删对象存储再删行:存储端失败时行保留,可以重试
if err := a.Blobs.Delete(r.Context(), f.Key); err != nil {
httpx.ServerError(w, err)
return
}
if _, err := a.Store.DeleteFile(id); err != nil {
httpx.ServerError(w, err)
return
}
if a.Thumbs != nil {
a.Thumbs.Purge(f.SHA256)
}
httpx.OK(w, map[string]any{"ok": true})
default:
httpx.Error(w, http.StatusMethodNotAllowed, "GET/DELETE required")
}
}
// ---------- comments(评论审核与管理) ----------
func (a *API) adminComments(w http.ResponseWriter, r *http.Request) {
status := httpx.QueryString(r, "status")
page := httpx.QueryInt(r, "page", 1)
size := httpx.QueryInt(r, "size", 20)
fp, err := a.Store.ListCommentsAdmin(status, page, size)
if err != nil {
httpx.ServerError(w, err)
return
}
httpx.OK(w, fp)
}
func (a *API) adminCommentByID(w http.ResponseWriter, r *http.Request) {
rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/admin/comments/"), "/")
id, err := parseInt(rest)
if err != nil {
httpx.BadRequest(w, "bad comment id")
return
}
switch r.Method {
case http.MethodPut:
// 审核动作:{"status": "visible" | "pending"}
var in struct {
Status string `json:"status"`
}
if err := httpx.Decode(r, &in); err != nil {
httpx.BadRequest(w, "invalid body")
return
}
if in.Status != "visible" && in.Status != "pending" {
httpx.BadRequest(w, "status 只支持 visible / pending")
return
}
if err := a.Store.SetCommentStatus(id, in.Status); err != nil {
if errors.Is(err, store.ErrNotFound) {
httpx.NotFound(w)
return
}
httpx.ServerError(w, err)
return
}
if in.Status == "visible" && a.Hub != nil {
if c, err := a.Store.GetComment(id); err == nil {
a.Hub.Broadcast(c.PostID)
}
}
httpx.OK(w, map[string]any{"ok": true})
case http.MethodDelete:
// 后台删除同样走软删(墓碑保楼层)
if err := a.Store.DeleteComment(id); err != nil {
if errors.Is(err, store.ErrNotFound) {
httpx.NotFound(w)
return
}
httpx.ServerError(w, err)
return
}
if a.Hub != nil {
if c, err := a.Store.GetComment(id); err == nil {
a.Hub.Broadcast(c.PostID)
}
}
httpx.OK(w, map[string]any{"ok": true})
default:
httpx.Error(w, http.StatusMethodNotAllowed, "PUT/DELETE required")
}
}
// ---------- readers(评论用户与禁言) ----------
func (a *API) adminReaders(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
readers, err := a.Store.ListReaders()
if err != nil {
httpx.ServerError(w, err)
return
}
httpx.OK(w, map[string]any{"readers": readers})
default:
httpx.Error(w, http.StatusMethodNotAllowed, "GET required")
}
}
func (a *API) adminReaderBan(w http.ResponseWriter, r *http.Request, id int64) {
var in struct {
Banned bool `json:"banned"`
}
if err := httpx.Decode(r, &in); err != nil {
httpx.BadRequest(w, "invalid body")
return
}
// 站主身份(管理员会话映射出来的读者)不允许禁言
if rd, err := a.Store.GetReader(id); err == nil && rd.Provider == "admin" {
httpx.BadRequest(w, "不能禁言站主身份")
return
}
if err := a.Store.SetReaderBanned(id, in.Banned); err != nil {
if errors.Is(err, store.ErrNotFound) {
httpx.NotFound(w)
return
}
httpx.ServerError(w, err)
return
}
httpx.OK(w, map[string]any{"ok": true, "banned": in.Banned})
}
+7 -53
View File
@@ -1,18 +1,17 @@
package admin
import (
"net"
"net/http"
"sync"
"time"
"oneblog/internal/ratelimit"
)
// loginLimiter 限制每个来源 IP 的登录失败次数(滑动窗口),
// 防止默认/弱口令被在线暴力破解。成功登录后计数清零。
type loginLimiter struct {
mu sync.Mutex
hits map[string][]time.Time
}
// 实现挪到了 internal/ratelimit(读者登录/评论写入共用),这里保留
// 原有调用面与常量。
type loginLimiter = ratelimit.Window
const (
maxLoginFails = 10
@@ -20,55 +19,10 @@ const (
)
func newLoginLimiter() *loginLimiter {
return &loginLimiter{hits: make(map[string][]time.Time)}
return ratelimit.New(maxLoginFails, loginWindow)
}
func (l *loginLimiter) blocked(key string) bool {
l.mu.Lock()
defer l.mu.Unlock()
return len(l.recent(key, time.Now())) >= maxLoginFails
}
func (l *loginLimiter) fail(key string) {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
l.hits[key] = append(l.recent(key, now), now)
}
func (l *loginLimiter) reset(key string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.hits, key)
}
// recent 返回窗口内的失败时间;调用方必须持有 l.mu。
func (l *loginLimiter) recent(key string, now time.Time) []time.Time {
hs := l.hits[key]
cut := now.Add(-loginWindow)
i := 0
for ; i < len(hs); i++ {
if hs[i].After(cut) {
break
}
}
if i > 0 {
hs = hs[i:]
l.hits[key] = hs
}
if len(hs) == 0 {
delete(l.hits, key)
}
return hs
}
func sourceKey(r *http.Request) string {
// 只信连接层地址;X-Forwarded-For 可被伪造,不作为限速键。
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}
func sourceKey(r *http.Request) string { return ratelimit.SourceKey(r) }
// isTLS 判断最终用户看到的是不是 HTTPS(含反代 X-Forwarded-Proto)。
func isTLS(r *http.Request) bool {
+45
View File
@@ -0,0 +1,45 @@
package admin
import (
"context"
"time"
"oneblog/internal/linkmeta"
"oneblog/internal/model"
)
// linkFetchBudget 是给外链抓取的时间预算:发布不该被一个慢站点拖住,
// 超时就当作「这篇没有卡片」。
const linkFetchBudget = 2500 * time.Millisecond
// linkFetch 是要不要抓远端的注入点:默认走带 SSRF 防护的真实抓取,
// 测试里换成假实现(真实抓取在测试环境会被自己的防护拦掉)。
var linkFetch = linkmeta.Fetch
// attachLinkCard 按正文重算短文链接卡片,结果写进 in.LinkCard。
//
// 只处理短文(长文在时间线已有封面媒体位,再加卡片会抢位置);
// 链接没变就沿用旧卡片,不重复抓远端;正文里删掉链接、或抓取失败,
// 一律传空卡片把旧的清掉——宁可没有卡片,也不要挂着别的地址抓来的旧数据。
func attachLinkCard(ctx context.Context, in *model.PostInput, cur *model.LinkCard) {
if in.Kind != model.KindShort || in.ContentMd == "" {
return
}
u := linkmeta.FirstURL(in.ContentMd)
if u == "" {
in.LinkCard = &model.LinkCard{}
return
}
if cur != nil && cur.URL == u {
in.LinkCard = cur
return
}
cctx, cancel := context.WithTimeout(ctx, linkFetchBudget)
defer cancel()
card, err := linkFetch(cctx, u)
if err != nil {
in.LinkCard = &model.LinkCard{}
return
}
in.LinkCard = card
}
+112
View File
@@ -0,0 +1,112 @@
package admin
import (
"context"
"errors"
"testing"
"oneblog/internal/model"
"oneblog/internal/store"
)
func TestAttachLinkCard(t *testing.T) {
old := linkFetch
t.Cleanup(func() { linkFetch = old })
t.Run("长文不动", func(t *testing.T) {
called := false
linkFetch = func(context.Context, string) (*model.LinkCard, error) {
called = true
return nil, nil
}
in := &model.PostInput{Kind: model.KindLong, ContentMd: "看 https://a.cn"}
attachLinkCard(context.Background(), in, nil)
if in.LinkCard != nil || called {
t.Fatalf("长文不该抓取,card=%+v called=%v", in.LinkCard, called)
}
})
t.Run("没链接则清空", func(t *testing.T) {
linkFetch = func(context.Context, string) (*model.LinkCard, error) {
t.Fatal("没链接不该抓")
return nil, nil
}
in := &model.PostInput{Kind: model.KindShort, ContentMd: "纯文本一句"}
attachLinkCard(context.Background(), in, &model.LinkCard{URL: "https://old.cn", Title: "旧"})
if in.LinkCard == nil || !in.LinkCard.Empty() {
t.Fatalf("应传空卡片清掉旧的,got %+v", in.LinkCard)
}
})
t.Run("链接未变则沿用不重抓", func(t *testing.T) {
linkFetch = func(context.Context, string) (*model.LinkCard, error) {
t.Fatal("同一个链接不该重复抓")
return nil, nil
}
cur := &model.LinkCard{URL: "https://a.cn", Title: "已抓过"}
in := &model.PostInput{Kind: model.KindShort, ContentMd: "看 https://a.cn"}
attachLinkCard(context.Background(), in, cur)
if in.LinkCard != cur {
t.Fatalf("应沿用旧卡片,got %+v", in.LinkCard)
}
})
t.Run("新链接则抓取", func(t *testing.T) {
var gotURL string
linkFetch = func(_ context.Context, u string) (*model.LinkCard, error) {
gotURL = u
return &model.LinkCard{URL: u, Title: "新卡片"}, nil
}
in := &model.PostInput{Kind: model.KindShort, ContentMd: "换成 https://b.cn/x。"}
attachLinkCard(context.Background(), in, &model.LinkCard{URL: "https://a.cn", Title: "旧"})
if gotURL != "https://b.cn/x" {
t.Fatalf("结尾标点没剪掉: %q", gotURL)
}
if in.LinkCard == nil || in.LinkCard.Title != "新卡片" {
t.Fatalf("card=%+v", in.LinkCard)
}
})
t.Run("抓取失败则清空", func(t *testing.T) {
linkFetch = func(context.Context, string) (*model.LinkCard, error) {
return nil, errors.New("timeout")
}
in := &model.PostInput{Kind: model.KindShort, ContentMd: "https://slow.cn"}
attachLinkCard(context.Background(), in, &model.LinkCard{URL: "https://a.cn", Title: "旧"})
if in.LinkCard == nil || !in.LinkCard.Empty() {
t.Fatalf("失败应清空,got %+v", in.LinkCard)
}
})
}
func TestLinkCardRoundTrip(t *testing.T) {
a, _ := newTestAPI(t)
card := &model.LinkCard{URL: "https://example.cn/x", Title: "示例标题", Desc: "描述", Site: "example", Image: "https://cdn/i.png"}
p, err := a.Store.Create(model.PostInput{Kind: model.KindShort, ContentMd: "看 https://example.cn/x",
Status: model.StatusPublished, LinkCard: card})
if err != nil {
t.Fatal(err)
}
if p.LinkCard == nil || p.LinkCard.Title != "示例标题" || p.LinkCard.Image != "https://cdn/i.png" {
t.Fatalf("创建后没读回卡片: %+v", p.LinkCard)
}
// 列表接口也要带上(时间线靠它渲染)
page, err := a.Store.List(store.ListOptions{Page: 1, Size: 10})
if err != nil {
t.Fatal(err)
}
if len(page.Items) != 1 || page.Items[0].LinkCard == nil {
t.Fatalf("列表未带卡片: %+v", page.Items)
}
// 空卡片 = 清空
if _, err := a.Store.Update(p.ID, model.PostInput{LinkCard: &model.LinkCard{}}); err != nil {
t.Fatal(err)
}
got, err := a.Store.Get(p.ID)
if err != nil {
t.Fatal(err)
}
if got.LinkCard != nil {
t.Fatalf("应已清空,got %+v", got.LinkCard)
}
}
+68
View File
@@ -0,0 +1,68 @@
// 长文的自动摘要与自动封面:站主没填这两项时从正文派生——
// 摘要取正文开头的一段纯文本,封面取正文第一张图。
// 更新路径只在「新值与旧值都为空」时才生成,站主主动清空的要保得住。
package admin
import (
"regexp"
"strings"
"oneblog/internal/model"
)
const autoSummaryLen = 110
var (
mdImageRe = regexp.MustCompile(`!\[[^\]]*\]\(([^)\s]+)[^)]*\)`)
mdCodeFence = regexp.MustCompile("(?s)```.*?```")
mdLinkRe = regexp.MustCompile(`\[([^\]]*)\]\([^)]*\)`)
mdHeadingRe = regexp.MustCompile(`(?m)^#{1,6}\s+.*$`) // 标题行整行不进摘要(与文章标题重复)
mdNoiseRe = regexp.MustCompile(`(?m)^([>\s*-]+|\d+\.\s)+`)
mdEmphasis = strings.NewReplacer("**", "", "__", "", "~~", "", "*", "", "_", "")
wsRe = regexp.MustCompile(`\s+`)
)
// autoMeta 按需填充 in.Summary / in.CoverURL。
// cur 为更新前的旧文章(新建时传 nil)。
func autoMeta(in *model.PostInput, cur *model.Post) {
if in.Kind != model.KindLong || strings.TrimSpace(in.ContentMd) == "" {
return
}
if strings.TrimSpace(in.Summary) == "" && (cur == nil || strings.TrimSpace(cur.Summary) == "") {
in.Summary = summarizeMarkdown(in.ContentMd, autoSummaryLen)
}
if strings.TrimSpace(in.CoverURL) == "" && (cur == nil || strings.TrimSpace(cur.CoverURL) == "") {
in.CoverURL = firstImage(in.ContentMd)
}
}
// summarizeMarkdown 剥掉 markdown 语法后取正文开头一段纯文本
func summarizeMarkdown(md string, maxRunes int) string {
s := mdCodeFence.ReplaceAllString(md, " ") // 代码块整段不进摘要
s = mdHeadingRe.ReplaceAllString(s, " ")
s = mdImageRe.ReplaceAllString(s, " ")
s = mdLinkRe.ReplaceAllString(s, "$1")
s = mdNoiseRe.ReplaceAllString(s, " ")
s = mdEmphasis.Replace(s)
s = strings.ReplaceAll(s, "`", "")
s = wsRe.ReplaceAllString(s, " ")
s = strings.TrimSpace(s)
runes := []rune(s)
if len(runes) > maxRunes {
return string(runes[:maxRunes]) + "…"
}
return s
}
// firstImage 取正文第一张图片的地址(markdown 图;http/站内路径均可)
func firstImage(md string) string {
m := mdImageRe.FindStringSubmatch(md)
if m == nil {
return ""
}
u := m[1]
if strings.HasPrefix(u, "http://") || strings.HasPrefix(u, "https://") || strings.HasPrefix(u, "/uploads/") {
return u
}
return ""
}
+62
View File
@@ -0,0 +1,62 @@
package admin
import (
"strings"
"testing"
"oneblog/internal/model"
)
func TestSummarizeMarkdown(t *testing.T) {
md := "## 标题\n\n这是**正文**的第一段,含[链接](https://x.com)与图片:\n\n![封面](https://a.com/1.png)\n\n```go\ncode ignored\n```\n\n后续内容"
got := summarizeMarkdown(md, 110)
for _, bad := range []string{"#", "**", "![]", "```", "code ignored"} {
if strings.Contains(got, bad) {
t.Errorf("summary 含语法残留 %q: %q", bad, got)
}
}
if !strings.Contains(got, "链接") || !strings.HasPrefix(got, "这是") {
t.Errorf("summary 应以正文开头并保留链接文字: %q", got)
}
long := strings.Repeat("长", 200)
if got := summarizeMarkdown(long, 110); len([]rune(got)) != 111 || !strings.HasSuffix(got, "…") {
t.Errorf("超长应截到 110+省略号, got %d runes", len([]rune(got)))
}
}
func TestFirstImage(t *testing.T) {
md := "前言\n\n![封面](https://a.com/1.png)\n\n![第二张](https://a.com/2.png)"
if got := firstImage(md); got != "https://a.com/1.png" {
t.Errorf("firstImage = %q", got)
}
if got := firstImage("没有图片"); got != "" {
t.Errorf("无图应返回空, got %q", got)
}
}
func TestAutoMeta(t *testing.T) {
in := model.PostInput{Kind: model.KindLong, ContentMd: "# T\n\n正文内容 ![图](https://a.com/x.png)"}
autoMeta(&in, nil)
if in.Summary == "" || in.CoverURL != "https://a.com/x.png" {
t.Errorf("空字段应自动填充: summary=%q cover=%q", in.Summary, in.CoverURL)
}
// 站主已填的不能覆盖
in2 := model.PostInput{Kind: model.KindLong, ContentMd: "正文", Summary: "手写的", CoverURL: ""}
autoMeta(&in2, nil)
if in2.Summary != "手写的" {
t.Errorf("手写摘要不应被覆盖: %q", in2.Summary)
}
// 更新路径:新值与旧值都空才补——主动清空的保得住
cleared := model.PostInput{Kind: model.KindLong, ContentMd: "正文", Summary: "", CoverURL: ""}
cur := model.Post{Summary: "旧摘要", CoverURL: "旧封面"}
autoMeta(&cleared, &cur)
if cleared.Summary != "" || cleared.CoverURL != "" {
t.Errorf("主动清空不应复活: summary=%q cover=%q", cleared.Summary, cleared.CoverURL)
}
// 新旧都空 → 生成
never := model.PostInput{Kind: model.KindLong, ContentMd: "正文 ![图](/uploads/a.png)"}
autoMeta(&never, &model.Post{})
if never.CoverURL != "/uploads/a.png" {
t.Errorf("新旧都空应取首图: %q", never.CoverURL)
}
}
+121 -2
View File
@@ -6,22 +6,64 @@ import (
"encoding/xml"
"errors"
"html"
"io"
"net/http"
"strconv"
"strings"
"time"
"oneblog/internal/auth"
"oneblog/internal/config"
"oneblog/internal/httpx"
"oneblog/internal/hub"
"oneblog/internal/model"
"oneblog/internal/ratelimit"
"oneblog/internal/render"
"oneblog/internal/storage"
"oneblog/internal/store"
"oneblog/internal/thumbs"
)
type API struct {
Store *store.Store
Cfg *config.Config
Blobs storage.BlobStore // 文件上传的存储后端(main.go 装配,与 admin 共享)
// Thumbs 是缩略图磁盘缓存(main.go 装配,DataDir/.thumbnail_cache)。
// 为 nil 时 /uploads/thumb/ 路由直接回原图。
Thumbs *thumbs.Store
// 评论区读者会话与 GitHub OAuth(main.go 装配)
ReaderSessions *auth.ReaderSessions
GH auth.GitHub
// AdminSessions 是后台管理员会话验证器(admin.Sessions 满足它)。
// 管理员登录后台后无需再走读者登录即可用站主身份评论。
AdminSessions interface {
Verify(token string) (string, error)
}
// 其余登录方式(main.go 装配,未配置的自动不开放)
GG auth.Google
TG auth.Telegram
// Hub 是评论变更的进程内广播(SSE 用;与后台 admin 共享同一实例)
Hub *hub.Hub
// 限流(Routes 里惰性初始化):读者登录失败按 IP 计、评论写入按读者计。
// 登录入口此前裸奔——OAuth 跳转本身难刷,但 state 校验失败、
// Telegram 伪造签名这类恶意请求需要一个兜底。
authFails *ratelimit.Window
commentNew *ratelimit.Window
}
const (
maxAuthFails = 20 // 窗口内允许的登录失败(含伪造回调)
authFailWindow = 10 * time.Minute
maxComments = 5 // 每个读者每窗口最多发几条
commentWindow = time.Minute
)
func (a *API) Routes() http.Handler {
if a.authFails == nil {
a.authFails = ratelimit.New(maxAuthFails, authFailWindow)
a.commentNew = ratelimit.New(maxComments, commentWindow)
}
mux := http.NewServeMux()
mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) {
if err := a.Store.Ping(r.Context()); err != nil {
@@ -30,6 +72,18 @@ func (a *API) Routes() http.Handler {
}
httpx.OK(w, map[string]any{"ok": true, "driver": a.Cfg.Driver})
})
// 读者登录与评论
mux.HandleFunc("/api/auth/providers", a.authProviders)
mux.HandleFunc("/api/auth/me", a.authMe)
mux.HandleFunc("/api/auth/logout", a.authLogout)
mux.HandleFunc("/api/auth/github/login", a.githubLogin)
mux.HandleFunc("/api/auth/callback/github", a.githubCallback)
mux.HandleFunc("/api/auth/google/login", a.googleLogin)
mux.HandleFunc("/api/auth/callback/google", a.googleCallback)
mux.HandleFunc("/api/auth/telegram", a.telegramAuth)
mux.HandleFunc("/api/comments", a.comments)
mux.HandleFunc("/api/comments/stream", a.commentsStream)
mux.HandleFunc("/api/comments/", a.commentSub)
mux.HandleFunc("/api/site", a.site)
mux.HandleFunc("/api/posts", a.listPosts)
mux.HandleFunc("/api/posts/", a.getPost)
@@ -54,7 +108,8 @@ func (a *API) site(w http.ResponseWriter, r *http.Request) {
httpx.ServerError(w, err)
return
}
httpx.OK(w, st)
// uploads_public_base 告诉前端哪些图片直链是自己的存储(可转 /uploads/thumb/ 缩略图)
httpx.OK(w, map[string]any{"settings": st, "uploads_public_base": a.Cfg.UploadsPublicBase})
}
func listOptions(r *http.Request, defSize int) store.ListOptions {
@@ -74,6 +129,12 @@ func (a *API) listPosts(w http.ResponseWriter, r *http.Request) {
httpx.ServerError(w, err)
return
}
// 时间线上短文直接铺正文:出口处做盘古之白(库里存的是原始渲染结果)
for i := range page.Items {
if page.Items[i].Kind == model.KindShort {
page.Items[i].ContentHTML = render.PanguHTML(page.Items[i].ContentHTML)
}
}
httpx.OK(w, page)
}
@@ -102,6 +163,8 @@ func (a *API) getPost(w http.ResponseWriter, r *http.Request) {
if err != nil {
nb = nil
}
// 详情正文出口做盘古之白(存库不动,改的是渲染层)
p.ContentHTML = render.PanguHTML(p.ContentHTML)
httpx.OK(w, struct {
model.Post
Neighbors []model.Post `json:"neighbors"`
@@ -201,7 +264,7 @@ func (a *API) RSS(w http.ResponseWriter, r *http.Request) {
}
desc := p.Summary
if desc == "" {
desc = trimRunes(stripTags(p.ContentHTML), 160)
desc = trimRunes(stripTags(render.PanguHTML(p.ContentHTML)), 160)
}
item := rssItem{
Title: title,
@@ -275,3 +338,59 @@ func orDefault(s, def string) string {
}
return s
}
// ---------- uploads(上传文件的公开访问) ----------
// UploadsHandler 供 main.go 挂在根 mux 的 /uploads/ 前缀上。按 key 查索引行,
// 经存储层流式返回本体;R2 + 公开域名时 302 到直链(后端不出流量)。
// key 必须在 files 表里有行——防止拿任意对象名探测存储端。
func (a *API) UploadsHandler() http.Handler {
return http.HandlerFunc(a.uploads)
}
func (a *API) uploads(w http.ResponseWriter, r *http.Request) {
key := strings.TrimPrefix(r.URL.Path, "/uploads/")
if key == "" || strings.Contains(key, "..") {
httpx.NotFound(w)
return
}
f, err := a.Store.GetFileByKey(key)
if errors.Is(err, store.ErrNotFound) {
httpx.NotFound(w)
return
}
if err != nil {
httpx.ServerError(w, err)
return
}
// R2 且配了公开域名:302 到直链,后端不出流量
if f.Store == "r2" && a.Cfg.UploadsPublicBase != "" {
http.Redirect(w, r, storage.FileURL(f.Store, f.Key, a.Cfg.UploadsPublicBase), http.StatusFound)
return
}
rc, size, err := a.Blobs.Open(r.Context(), f.Key)
if err != nil {
httpx.NotFound(w)
return
}
defer rc.Close()
// ServeContent 自带 Range(视频/音频拖动进度条必需)、ETag 比对与 304。
// key 含内容哈希:内容不变则 URL 不变,可永久缓存。
w.Header().Set("Content-Type", f.Mime)
w.Header().Set("ETag", `"`+f.SHA256+`"`)
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
if rs, ok := rc.(io.ReadSeeker); ok {
http.ServeContent(w, r, f.Name, time.Time{}, rs)
return
}
// 非本地存储拿不到 Seeker 时退回流式拷贝
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
if match := r.Header.Get("If-None-Match"); match != "" && match == `"`+f.SHA256+`"` {
w.WriteHeader(http.StatusNotModified)
return
}
io.Copy(w, rc)
}
+96
View File
@@ -0,0 +1,96 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"oneblog/internal/auth"
"oneblog/internal/config"
"oneblog/internal/db"
"oneblog/internal/model"
"oneblog/internal/store"
)
func newTestAPI(t *testing.T) (*API, http.Handler) {
t.Helper()
d, err := db.Open("sqlite", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
t.Cleanup(func() { d.Close() })
st, err := store.New(d)
if err != nil {
t.Fatalf("store: %v", err)
}
a := &API{
Store: st,
Cfg: &config.Config{SiteURL: "http://localhost:8080"},
ReaderSessions: auth.NewReaderSessions("test-secret", time.Hour),
TG: auth.Telegram{Bot: "testbot", Token: "123:abc"},
}
settings, err := st.GetSettings()
if err != nil {
t.Fatalf("settings: %v", err)
}
settings.CommentsEnabled = true
if err := st.UpdateSettings(settings); err != nil {
t.Fatalf("enable comments: %v", err)
}
return a, a.Routes()
}
// Telegram 伪造签名反复重试应触发 IP 限速
func TestTelegramAuthRateLimited(t *testing.T) {
_, h := newTestAPI(t)
body := `{"id":1,"first_name":"x","hash":"deadbeef"}`
var rec *httptest.ResponseRecorder
for i := 0; i < maxAuthFails; i++ {
req := httptest.NewRequest(http.MethodPost, "/api/auth/telegram", strings.NewReader(body))
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("attempt %d: got %d, want 403", i+1, rec.Code)
}
}
req := httptest.NewRequest(http.MethodPost, "/api/auth/telegram", strings.NewReader(body))
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("after %d failures: got %d, want 429", maxAuthFails, rec.Code)
}
}
// 评论写入按读者限速:第 maxComments+1 条被拒
func TestCommentRateLimited(t *testing.T) {
a, h := newTestAPI(t)
p, err := a.Store.Create(model.PostInput{Kind: model.KindLong, Title: "t", Slug: "t",
ContentMd: "x", Status: model.StatusPublished})
if err != nil {
t.Fatal(err)
}
rd, err := a.Store.UpsertReader(model.Reader{Provider: "github", Handle: "u1", Name: "u1"})
if err != nil {
t.Fatal(err)
}
tok, _ := a.ReaderSessions.Issue(rd.ID)
post := func(i int) *httptest.ResponseRecorder {
body, _ := json.Marshal(map[string]any{"post_id": p.ID, "body_md": "好"})
req := httptest.NewRequest(http.MethodPost, "/api/comments", strings.NewReader(string(body)))
req.AddCookie(&http.Cookie{Name: auth.ReaderCookie, Value: tok})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
for i := 0; i < maxComments; i++ {
if rec := post(i); rec.Code != http.StatusCreated {
t.Fatalf("comment %d: got %d %s", i+1, rec.Code, rec.Body.String())
}
}
if rec := post(maxComments); rec.Code != http.StatusTooManyRequests {
t.Fatalf("over limit: got %d, want 429", rec.Code)
}
}
+432
View File
@@ -0,0 +1,432 @@
// 读者登录与评论的公开接口。登录走 GitHub OAuth 整页跳转;
// 会话是 httpOnly cookie(one_reader),与后台会话(one_session)互不相通。
//
// 审核:设置里开了「先审后显」时,新评论 status=pending——
// 只有作者自己能在列表里看到(带「审核中」角标),站主通过后才公开。
package api
import (
"crypto/rand"
"encoding/hex"
"errors"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"oneblog/internal/auth"
"oneblog/internal/httpx"
"oneblog/internal/model"
"oneblog/internal/ratelimit"
"oneblog/internal/render"
"oneblog/internal/store"
)
const (
readerCookie = "one_reader"
oauthStateCook = "one_oauth_state"
oauthBackCook = "one_oauth_back"
maxCommentLen = 500
editWindow = 10 * time.Minute
)
// readerID 从会话 cookie 解出读者 ID;匿名返回 false。
// 后台管理员已登录(one_session)时直接映射为站主读者身份——
// 站主发评论不必再走一遍 GitHub 登录。
func (a *API) readerID(r *http.Request) (int64, bool) {
rd, ok, err := a.resolveReader(r)
if err != nil || !ok {
return 0, false
}
return rd.ID, true
}
// resolveReader 解出当前访客的读者身份:读者会话优先,
// 其次是后台管理员会话(自动 upsert 一个 provider=admin 的站主读者)。
func (a *API) resolveReader(r *http.Request) (model.Reader, bool, error) {
if ck, err := r.Cookie(auth.ReaderCookie); err == nil && ck.Value != "" {
if id, verr := a.ReaderSessions.Verify(ck.Value); verr == nil {
rd, gerr := a.Store.GetReader(id)
if gerr == nil {
return rd, true, nil
}
}
}
if a.AdminSessions != nil {
if ck, err := r.Cookie("one_session"); err == nil && ck.Value != "" {
if _, verr := a.AdminSessions.Verify(ck.Value); verr == nil {
rd, oerr := a.ownerReader()
if oerr == nil {
return rd, true, nil
}
}
}
}
return model.Reader{}, false, nil
}
// ownerReader 取(或创建)站主评论身份:provider=admin,名字用站点作者名。
func (a *API) ownerReader() (model.Reader, error) {
name := "站主"
if st, err := a.Store.GetSettings(); err == nil && st.AuthorName != "" {
name = st.AuthorName
}
return a.Store.UpsertReader(model.Reader{
Provider: "admin", Handle: a.Cfg.AdminUser, Name: name,
})
}
func (a *API) authMe(w http.ResponseWriter, r *http.Request) {
var user any // 匿名时 {user: null},前端判空即「未登录」
if reader, ok, err := a.resolveReader(r); err == nil && ok {
user = map[string]any{
"id": reader.ID, "name": reader.Name, "handle": reader.Handle,
"avatar_url": reader.AvatarURL, "url": reader.URL,
"provider": reader.Provider, "is_owner": reader.Provider == "admin", "banned": reader.Banned,
}
}
httpx.OK(w, map[string]any{"user": user})
}
func (a *API) authLogout(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{Name: auth.ReaderCookie, Value: "", Path: "/", MaxAge: -1})
httpx.OK(w, map[string]any{"ok": true})
}
// githubLogin 跳转 GitHub 授权页。state 防 CSRF 存短命 cookie;
// 授权完成回到 callback 后必须带上同一个值。
// 同时把发起登录的前台 origin 记下来(one_oauth_back),
// callback 用它跳回去——开发时前端 3000 / 后端 8080 分离才不会落错站。
func (a *API) githubLogin(w http.ResponseWriter, r *http.Request) {
if !a.GH.Enabled() {
httpx.NotFound(w)
return
}
state := randHex(16)
http.SetCookie(w, &http.Cookie{Name: oauthStateCook, Value: state, Path: "/",
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 读者 → 发会话 →
// 回到首页。
func (a *API) githubCallback(w http.ResponseWriter, r *http.Request) {
if !a.GH.Enabled() {
httpx.NotFound(w)
return
}
ip := ratelimit.SourceKey(r)
if a.authFails.Blocked(ip) {
httpx.Error(w, http.StatusTooManyRequests, "登录失败次数过多,请稍后再试")
return
}
ck, err := r.Cookie(oauthStateCook)
if err != nil || ck.Value == "" || ck.Value != r.FormValue("state") {
a.authFails.Add(ip)
httpx.BadRequest(w, "state 不匹配,请重新登录")
return
}
gh, err := a.GH.Exchange(r.Context(), r.FormValue("code"), a.Cfg.SiteURL+"/api/auth/callback/github")
if err != nil {
a.authFails.Add(ip)
httpx.ServerError(w, err)
return
}
u, err := a.GH.FetchUser(r.Context(), gh)
if err != nil {
a.authFails.Add(ip)
httpx.ServerError(w, err)
return
}
name := u.Name
if name == "" {
name = u.Login
}
reader, err := a.Store.UpsertReader(model.Reader{
Provider: "github", Handle: u.Login, Name: name,
AvatarURL: u.AvatarURL, URL: u.HTMLURL,
})
if err != nil {
httpx.ServerError(w, err)
return
}
http.Redirect(w, r, a.issueReaderCookie(w, r, reader.ID), http.StatusFound)
}
func randHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// ---------- comments(评论的读取与发表) ----------
func (a *API) comments(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
a.listComments(w, r)
case http.MethodPost:
a.createComment(w, r)
default:
httpx.Error(w, http.StatusMethodNotAllowed, "GET/POST required")
}
}
func (a *API) listComments(w http.ResponseWriter, r *http.Request) {
postID := httpx.QueryInt(r, "post_id", 0)
if postID <= 0 {
httpx.BadRequest(w, "post_id required")
return
}
viewer, _ := a.readerID(r)
newest := httpx.QueryString(r, "sort") == "newest"
roots, err := a.Store.ListCommentsByPost(int64(postID), viewer, newest)
if err != nil {
httpx.ServerError(w, err)
return
}
httpx.OK(w, map[string]any{
"items": roots, "total": len(roots),
"page": 1, "size": len(roots),
})
}
// createComment 发表评论(含回复)。登录 + 未禁言 + 评论开关开着;
// 审核开关开着时新评论进「待审」。站主身份(管理员会话)不受禁言与审核约束。
func (a *API) createComment(w http.ResponseWriter, r *http.Request) {
reader, ok, err := a.resolveReader(r)
if err != nil {
httpx.Error(w, http.StatusUnauthorized, "登录已过期,刷新页面重新登录")
return
}
if !ok {
httpx.Error(w, http.StatusUnauthorized, "登录后才能评论")
return
}
isOwner := reader.Provider == "admin"
if reader.Banned && !isOwner {
httpx.Error(w, http.StatusForbidden, "你已被禁言,暂时无法评论")
return
}
// 写入限速:每读者每分钟最多 maxComments 条(站主豁免,批量回复不该被卡)
var rateKey string
if !isOwner {
rateKey = strconv.FormatInt(reader.ID, 10)
if a.commentNew.Blocked(rateKey) {
httpx.Error(w, http.StatusTooManyRequests, "发得太快了,休息一分钟再试")
return
}
}
st, err := a.Store.GetSettings()
if err != nil {
httpx.ServerError(w, err)
return
}
if !st.CommentsEnabled {
httpx.Error(w, http.StatusForbidden, "评论未开放")
return
}
var in struct {
PostID int64 `json:"post_id"`
ParentID int64 `json:"parent_id"`
BodyMd string `json:"body_md"`
}
if err := httpx.Decode(r, &in); err != nil {
httpx.BadRequest(w, "invalid body")
return
}
body := strings.TrimSpace(in.BodyMd)
if body == "" {
httpx.BadRequest(w, "评论内容不能为空")
return
}
if len([]rune(body)) > maxCommentLen {
httpx.BadRequest(w, "评论最多 500 字")
return
}
if _, err := a.Store.Get(in.PostID); err != nil {
httpx.BadRequest(w, "文章不存在")
return
}
var parent model.Comment
root := int64(0)
if in.ParentID > 0 {
p, err := a.Store.GetComment(in.ParentID)
if err != nil {
httpx.BadRequest(w, "回复的评论不存在")
return
}
if p.PostID != in.PostID {
httpx.BadRequest(w, "回复的评论不属于这篇文章")
return
}
parent = p
root = parent.RootID
if root == 0 {
root = parent.ID
}
}
status := "visible"
if st.CommentsReview && !isOwner {
status = "pending"
}
c, err := a.Store.CreateComment(model.Comment{
PostID: in.PostID, UserID: reader.ID, ParentID: in.ParentID, RootID: root,
BodyMd: body, BodyHTML: render.Markdown(body), Status: status,
})
if err != nil {
httpx.ServerError(w, err)
return
}
a.Hub.Broadcast(in.PostID)
if rateKey != "" {
a.commentNew.Add(rateKey) // 只计成功写入:空正文这类手滑不扣配额
}
httpx.Created(w, c)
}
// commentSub /api/comments/{id} 与 /api/comments/{root}/thread 的分发
func (a *API) commentSub(w http.ResponseWriter, r *http.Request) {
rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/api/comments/"), "/")
if rest == "" {
httpx.NotFound(w)
return
}
// {root}/thread:楼内回复翻页(当前实现全量内嵌,这里兜底返回剩余)
if strings.HasSuffix(rest, "/thread") {
rootID, err := strconv.ParseInt(strings.TrimSuffix(rest, "/thread"), 10, 64)
if err != nil {
httpx.BadRequest(w, "bad root id")
return
}
a.commentThread(w, r, rootID)
return
}
id, err := strconv.ParseInt(rest, 10, 64)
if err != nil {
httpx.BadRequest(w, "bad comment id")
return
}
switch r.Method {
case http.MethodPut:
a.editComment(w, r, id)
case http.MethodDelete:
a.deleteComment(w, r, id)
default:
httpx.Error(w, http.StatusMethodNotAllowed, "PUT/DELETE required")
}
}
func (a *API) commentThread(w http.ResponseWriter, r *http.Request, rootID int64) {
root, err := a.Store.GetComment(rootID)
if err != nil {
httpx.ServerError(w, err)
return
}
cursor := httpx.QueryInt(r, "cursor", 0)
items := []model.Comment{}
if cursor >= 0 && cursor < len(root.Replies) {
items = root.Replies[cursor:]
}
httpx.OK(w, map[string]any{"items": items, "cursor": "", "reply_count": root.ReplyCount})
}
// editComment 作者改自己的评论:10 分钟内有效,且未被禁言未删除
func (a *API) editComment(w http.ResponseWriter, r *http.Request, id int64) {
readerID, ok := a.readerID(r)
if !ok {
httpx.Error(w, http.StatusUnauthorized, "登录已过期")
return
}
c, err := a.Store.GetComment(id)
if errors.Is(err, store.ErrNotFound) {
httpx.NotFound(w)
return
}
if err != nil {
httpx.ServerError(w, err)
return
}
if c.UserID != readerID {
httpx.Error(w, http.StatusForbidden, "只能编辑自己的评论")
return
}
if c.IsDeleted {
httpx.NotFound(w)
return
}
if time.Since(mustParse(c.CreatedAt)) > editWindow {
httpx.Error(w, http.StatusForbidden, "超过可编辑时间")
return
}
var in struct {
BodyMd string `json:"body_md"`
}
if err := httpx.Decode(r, &in); err != nil {
httpx.BadRequest(w, "invalid body")
return
}
body := strings.TrimSpace(in.BodyMd)
if body == "" {
httpx.BadRequest(w, "评论内容不能为空")
return
}
if len([]rune(body)) > maxCommentLen {
httpx.BadRequest(w, "评论最多 500 字")
return
}
if err := a.Store.UpdateCommentBody(id, body, render.Markdown(body)); err != nil {
httpx.ServerError(w, err)
return
}
updated, err := a.Store.GetComment(id)
if err != nil {
httpx.ServerError(w, err)
return
}
a.Hub.Broadcast(updated.PostID)
httpx.OK(w, updated)
}
// deleteComment 作者软删自己的评论(留壳保楼层)
func (a *API) deleteComment(w http.ResponseWriter, r *http.Request, id int64) {
readerID, ok := a.readerID(r)
if !ok {
httpx.Error(w, http.StatusUnauthorized, "登录已过期")
return
}
c, err := a.Store.GetComment(id)
if errors.Is(err, store.ErrNotFound) {
httpx.NotFound(w)
return
}
if err != nil {
httpx.ServerError(w, err)
return
}
if c.UserID != readerID {
httpx.Error(w, http.StatusForbidden, "只能删除自己的评论")
return
}
if err := a.Store.DeleteComment(id); err != nil {
httpx.ServerError(w, err)
return
}
a.Hub.Broadcast(c.PostID)
httpx.OK(w, map[string]any{"ok": true})
}
func mustParse(s string) time.Time {
t, err := time.Parse(time.RFC3339, s)
if err != nil {
return time.Time{}
}
return t
}
+192
View File
@@ -0,0 +1,192 @@
// Google / Telegram 登录的 HTTP 端点。GitHub 的在 comments.go——
// 三个 Provider 共用同一套读者会话与 upsert 逻辑,只是凭据交换方式不同:
// GitHub / Google 是授权码换 token,Telegram 是官方 widget 直接带签名资料。
package api
import (
"encoding/json"
"io"
"net/http"
"net/url"
"strconv"
"time"
"oneblog/internal/auth"
"oneblog/internal/httpx"
"oneblog/internal/model"
"oneblog/internal/ratelimit"
)
// 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})
}
// issueReaderCookie 登录成功后的公共收尾:发读者会话 + 决定跳回去的地址
func (a *API) issueReaderCookie(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: int((30 * 24 * time.Hour).Seconds())})
// 回到发起登录的前台;没有记录(直接敲 URL 进来的)就回站点根
back := a.Cfg.SiteURL
if ck, err := r.Cookie(oauthBackCook); err == nil && ck.Value != "" {
if u, err := url.Parse(ck.Value); err == nil && (u.Scheme == "http" || u.Scheme == "https") && u.Host != "" && u.Path == "" {
back = u.Scheme + "://" + u.Host
}
}
http.SetCookie(w, &http.Cookie{Name: oauthBackCook, Value: "", Path: "/", MaxAge: -1})
return back
}
// googleLogin 跳 Google 授权页(state 防 CSRF 同 GitHub)
func (a *API) googleLogin(w http.ResponseWriter, r *http.Request) {
if !a.GG.Enabled() {
httpx.NotFound(w)
return
}
state := randHex(16)
http.SetCookie(w, &http.Cookie{Name: oauthStateCook, Value: state, Path: "/",
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.GG.LoginURL(a.Cfg.SiteURL+"/api/auth/callback/google", state), http.StatusFound)
}
// googleCallback 用 code 换身份:Google 用户 → upsert 读者 → 发会话
func (a *API) googleCallback(w http.ResponseWriter, r *http.Request) {
if !a.GG.Enabled() {
httpx.NotFound(w)
return
}
ip := ratelimit.SourceKey(r)
if a.authFails.Blocked(ip) {
httpx.Error(w, http.StatusTooManyRequests, "登录失败次数过多,请稍后再试")
return
}
ck, err := r.Cookie(oauthStateCook)
if err != nil || ck.Value == "" || ck.Value != r.FormValue("state") {
a.authFails.Add(ip)
httpx.BadRequest(w, "state 不匹配,请重新登录")
return
}
accessToken, err := a.GG.Exchange(r.Context(), r.FormValue("code"), a.Cfg.SiteURL+"/api/auth/callback/google")
if err != nil {
a.authFails.Add(ip)
httpx.ServerError(w, err)
return
}
u, err := a.GG.FetchUser(r.Context(), accessToken)
if err != nil {
a.authFails.Add(ip)
httpx.ServerError(w, err)
return
}
// handle 优先用已验证邮箱(可读),否则退回 sub(Google 的稳定唯一 id)
handle := u.Sub
if u.EmailVerified && u.Email != "" {
handle = u.Email
}
name := u.Name
if name == "" {
name = handle
}
reader, err := a.Store.UpsertReader(model.Reader{
Provider: "google", Handle: handle, Name: name,
AvatarURL: u.Picture,
})
if err != nil {
httpx.ServerError(w, err)
return
}
http.Redirect(w, r, a.issueReaderCookie(w, r, reader.ID), 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)
}
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 即可见
token, _ := a.ReaderSessions.Issue(reader.ID)
http.SetCookie(w, &http.Cookie{Name: auth.ReaderCookie, Value: token, Path: "/",
HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: int((30 * 24 * time.Hour).Seconds())})
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": false, "banned": reader.Banned,
}})
}
func tgProfileURL(username string) string {
if username == "" {
return ""
}
return "https://t.me/" + username
}
+52
View File
@@ -0,0 +1,52 @@
// 评论变更的 SSE 端点:GET /api/comments/stream?post_id=N。
// 事件里只有「这篇文章的评论变了」——不带任何内容,前端收到后
// 自己 refetch。这样未审核的评论内容不会经 SSE 泄给围观者。
package api
import (
"fmt"
"net/http"
"strconv"
"time"
"oneblog/internal/httpx"
)
// commentsStream 长连推送。EventSource 断线会自动重连,所以这里
// 只需要:连上即发注释行、25s 心跳防代理掐线、事件触发刷新。
func (a *API) commentsStream(w http.ResponseWriter, r *http.Request) {
postID, err := strconv.ParseInt(r.URL.Query().Get("post_id"), 10, 64)
if err != nil || postID <= 0 {
httpx.BadRequest(w, "post_id required")
return
}
fl, ok := w.(http.Flusher)
if !ok {
httpx.Error(w, http.StatusInternalServerError, "streaming unsupported")
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
// 反向代理(nginx 等)默认缓冲会吞掉流式响应
w.Header().Set("X-Accel-Buffering", "no")
fmt.Fprint(w, ": connected\n\n")
fl.Flush()
ch, off := a.Hub.Subscribe(postID)
defer off()
heartbeat := time.NewTicker(25 * time.Second)
defer heartbeat.Stop()
for {
select {
case <-r.Context().Done():
return
case <-heartbeat.C:
fmt.Fprint(w, ": ping\n\n")
fl.Flush()
case <-ch:
fmt.Fprintf(w, "event: comments\ndata: {\"post_id\":%d}\n\n", postID)
fl.Flush()
}
}
}
+142
View File
@@ -0,0 +1,142 @@
// 缩略图懒生成:GET /uploads/thumb/{key}?w=960
//
// 首次请求从存储端读一次原图,缩放编码后落盘缓存(DataDir/.thumbnail_cache),
// 之后直接供缓存——时间线首屏不再拉原图。非图片 / 解码失败 / 冷却期内
// 一律 302 回原图:前端 <img> 拿 302 是无感的。
package api
import (
"errors"
"net/http"
"os"
"strconv"
"strings"
"oneblog/internal/httpx"
"oneblog/internal/model"
"oneblog/internal/storage"
"oneblog/internal/store"
"oneblog/internal/thumbs"
)
const (
thumbDefaultW = 960
thumbMinW = 64
thumbMaxW = 1600
)
func (a *API) ThumbHandler() http.Handler {
return http.HandlerFunc(a.thumb)
}
func (a *API) thumb(w http.ResponseWriter, r *http.Request) {
if a.Thumbs == nil {
httpx.NotFound(w)
return
}
key := strings.TrimPrefix(r.URL.Path, "/uploads/thumb/")
if key == "" || strings.Contains(key, "..") {
httpx.NotFound(w)
return
}
width := thumbDefaultW
if s := r.URL.Query().Get("w"); s != "" {
if n, err := strconv.Atoi(s); err == nil && n >= thumbMinW && n <= thumbMaxW {
width = n
}
}
f, err := a.Store.GetFileByKey(key)
if errors.Is(err, store.ErrNotFound) {
httpx.NotFound(w)
return
}
if err != nil {
httpx.ServerError(w, err)
return
}
if !thumbs.Supported(f.Mime) || f.SHA256 == "" {
a.thumbFallback(w, r, f)
return
}
// 快路径:缓存命中直接供(锁外)
if p := a.Thumbs.FindCached(f.SHA256, width); p != "" {
a.serveThumbFile(w, r, p, f)
return
}
// 冷却期:近期失败过,不再尝试
if a.Thumbs.FailedRecently(f.SHA256) {
a.thumbFallback(w, r, f)
return
}
// 慢路径:同 key+宽度并发只生成一次,后来者等锁后读缓存
lk := a.Thumbs.Lock(f.SHA256, width)
lk.Lock()
defer lk.Unlock()
if p := a.Thumbs.FindCached(f.SHA256, width); p != "" {
a.serveThumbFile(w, r, p, f)
return
}
rc, _, err := a.Blobs.Open(r.Context(), f.Key)
if err != nil {
a.Thumbs.MarkFailed(f.SHA256)
a.thumbFallback(w, r, f)
return
}
src, err := thumbs.ReadAllLimited(rc, thumbs.MaxSrcBytes)
rc.Close()
if err != nil {
a.Thumbs.MarkFailed(f.SHA256)
a.thumbFallback(w, r, f)
return
}
out, outMime, _, err := thumbs.Generate(src, f.Mime, width)
if err != nil {
a.Thumbs.MarkFailed(f.SHA256)
a.thumbFallback(w, r, f)
return
}
p, err := a.Thumbs.Put(f.SHA256, width, out, outMime)
if err != nil {
// 写缓存失败不拖累本次响应:产物就在内存里
a.serveThumbBytes(w, outMime, out, f)
return
}
a.serveThumbFile(w, r, p, f)
}
// thumbFallback 回原图:R2 + 公开域名是 302 直链,本地/未配域名回 /uploads/ 路由。
func (a *API) thumbFallback(w http.ResponseWriter, r *http.Request, f model.File) {
http.Redirect(w, r, storage.FileURL(f.Store, f.Key, a.Cfg.UploadsPublicBase), http.StatusFound)
}
// serveThumbFile 用 ServeContent 供缓存文件:自带 Range/Last-Modified/304。
func (a *API) serveThumbFile(w http.ResponseWriter, r *http.Request, path string, f model.File) {
fp, err := os.Open(path)
if err != nil {
a.thumbFallback(w, r, f)
return
}
defer fp.Close()
fi, err := fp.Stat()
if err != nil {
httpx.NotFound(w)
return
}
a.Thumbs.Touch(path) // 记一次「最近用过」,供容量闸按 LRU 淘汰
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
http.ServeContent(w, r, path, fi.ModTime(), fp)
}
func (a *API) serveThumbBytes(w http.ResponseWriter, mime string, data []byte, f model.File) {
w.Header().Set("Content-Type", mime)
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
w.Header().Set("ETag", `"`+f.SHA256+`"`)
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(data)
}
+172
View File
@@ -0,0 +1,172 @@
// Package auth 提供评论区两侧的基础设施:读者会话(cookie one_reader,
// payload 带 reader: 前缀,与后台令牌不可互换)和 GitHub OAuth 客户端。
package auth
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// ReaderCookie 是读者会话的 cookie 名(与后台的 one_session 区分开)
const ReaderCookie = "one_reader"
// ReaderSessions 签发 / 校验读者会话令牌:
// base64("reader:<readerID>:<expiryUnix>") + "." + HMAC-SHA256。
type ReaderSessions struct {
secret []byte
ttl time.Duration
}
func NewReaderSessions(secret string, ttl time.Duration) *ReaderSessions {
if ttl <= 0 {
ttl = 30 * 24 * time.Hour
}
return &ReaderSessions{secret: []byte(secret), ttl: ttl}
}
var ErrBadSession = errors.New("invalid reader session")
func (s *ReaderSessions) Issue(readerID int64) (string, time.Time) {
exp := time.Now().Add(s.ttl)
payload := readerPayload(readerID, exp)
enc := base64.RawURLEncoding.EncodeToString([]byte(payload))
return enc + "." + s.sign(payload), exp
}
func (s *ReaderSessions) Verify(token string) (int64, error) {
parts := strings.Split(token, ".")
if len(parts) != 2 {
return 0, ErrBadSession
}
raw, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
return 0, ErrBadSession
}
// 必须以 reader: 开头——后台令牌(base64("admin:<exp>"))复制过来也无效
if len(raw) < 10 || string(raw)[:7] != "reader:" {
return 0, ErrBadSession
}
// 余下是 "<id>:<exp>"
rest := string(raw)[7:]
i := strings.IndexByte(rest, ':')
if i <= 0 {
return 0, ErrBadSession
}
id, err := strconv.ParseInt(rest[:i], 10, 64)
if err != nil {
return 0, ErrBadSession
}
expUnix, err := strconv.ParseInt(rest[i+1:], 10, 64)
if err != nil {
return 0, ErrBadSession
}
if time.Now().After(time.Unix(expUnix, 0)) {
return 0, ErrBadSession
}
// 签名放在最后验证:payload 与 exp 都验过再比对 MAC
if !hmac.Equal([]byte(s.sign(string(raw))), []byte(parts[1])) {
return 0, ErrBadSession
}
return id, nil
}
func readerPayload(id int64, exp time.Time) string {
return "reader:" + strconv.FormatInt(id, 10) + ":" + strconv.FormatInt(exp.Unix(), 10)
}
func (s *ReaderSessions) sign(payload string) string {
mac := hmac.New(sha256.New, s.secret)
mac.Write([]byte(payload))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
// ---------- GitHub OAuth ----------
// GitHub 持有 OAuth 应用凭据。没配 = GitHub 登录不开放,
// 前台登录卡自动不显示该入口。
type GitHub struct {
ClientID string
ClientSecret string
}
func (g GitHub) Enabled() bool { return g.ClientID != "" && g.ClientSecret != "" }
const (
githubAuthURL = "https://github.com/login/oauth/authorize"
githubTokenURL = "https://github.com/login/oauth/access_token"
githubUserURL = "https://api.github.com/user"
)
// LoginURL 生成 GitHub 授权页跳转地址
func (g GitHub) LoginURL(redirectURI, state string) string {
v := url.Values{}
v.Set("client_id", g.ClientID)
v.Set("redirect_uri", redirectURI)
v.Set("scope", "read:user")
v.Set("state", state)
return githubAuthURL + "?" + v.Encode()
}
// GitHubUser 是 GitHub 用户接口里我们关心的字段
type GitHubUser struct {
Login string `json:"login"`
Name string `json:"name"`
AvatarURL string `json:"avatar_url"`
HTMLURL string `json:"html_url"`
}
// Exchange 用授权码换 access token
func (g GitHub) Exchange(ctx context.Context, code, redirectURI string) (string, error) {
v := url.Values{}
v.Set("client_id", g.ClientID)
v.Set("client_secret", g.ClientSecret)
v.Set("code", code)
v.Set("redirect_uri", redirectURI)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, githubTokenURL, strings.NewReader(v.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer res.Body.Close()
var out struct {
AccessToken string `json:"access_token"`
}
if err := json.NewDecoder(res.Body).Decode(&out); err != nil || out.AccessToken == "" {
return "", fmt.Errorf("github: token exchange failed")
}
return out.AccessToken, nil
}
// FetchUser 拉取 GitHub 用户资料
func (g GitHub) FetchUser(ctx context.Context, accessToken string) (GitHubUser, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, githubUserURL, nil)
if err != nil {
return GitHubUser{}, err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
res, err := http.DefaultClient.Do(req)
if err != nil {
return GitHubUser{}, err
}
defer res.Body.Close()
var u GitHubUser
if err := json.NewDecoder(res.Body).Decode(&u); err != nil || u.Login == "" {
return GitHubUser{}, fmt.Errorf("github: fetch user failed")
}
return u, nil
}
+95
View File
@@ -0,0 +1,95 @@
package auth
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
)
// Google OAuth2(OIDC 简化用法:openid email profile 三个 scope,
// userinfo 接口拿资料)。配置来源 .env 的 ONE_GOOGLE_CLIENT_ID / SECRET。
const (
googleAuthURL = "https://accounts.google.com/o/oauth2/v2/auth"
googleTokenURL = "https://oauth2.googleapis.com/token"
googleUserURL = "https://openidconnect.googleapis.com/v1/userinfo"
)
type Google struct {
ClientID string
ClientSecret string
}
func (g Google) Enabled() bool { return g.ClientID != "" && g.ClientSecret != "" }
func (g Google) LoginURL(redirectURI, state string) string {
v := url.Values{}
v.Set("client_id", g.ClientID)
v.Set("redirect_uri", redirectURI)
v.Set("response_type", "code")
v.Set("scope", "openid email profile")
v.Set("state", state)
return googleAuthURL + "?" + v.Encode()
}
// GoogleUser 是 userinfo 接口里我们关心的字段。
// sub 是 Google 账号的稳定唯一 id;邮箱需要已验证才当 handle 用。
type GoogleUser struct {
Sub string `json:"sub"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
Name string `json:"name"`
Picture string `json:"picture"`
}
func (g Google) Exchange(ctx context.Context, code, redirectURI string) (string, error) {
v := url.Values{}
v.Set("client_id", g.ClientID)
v.Set("client_secret", g.ClientSecret)
v.Set("code", code)
v.Set("redirect_uri", redirectURI)
v.Set("grant_type", "authorization_code")
req, err := http.NewRequestWithContext(ctx, http.MethodPost, googleTokenURL, strings.NewReader(v.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
res, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer res.Body.Close()
var out struct {
AccessToken string `json:"access_token"`
}
if err := json.NewDecoder(res.Body).Decode(&out); err != nil || out.AccessToken == "" {
return "", fmt.Errorf("google: token exchange failed")
}
return out.AccessToken, nil
}
func (g Google) FetchUser(ctx context.Context, accessToken string) (GoogleUser, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, googleUserURL, nil)
if err != nil {
return GoogleUser{}, err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
res, err := http.DefaultClient.Do(req)
if err != nil {
return GoogleUser{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return GoogleUser{}, fmt.Errorf("google: userinfo %d", res.StatusCode)
}
var u GoogleUser
if err := json.NewDecoder(res.Body).Decode(&u); err != nil {
return GoogleUser{}, err
}
if u.Sub == "" {
return GoogleUser{}, fmt.Errorf("google: userinfo missing sub")
}
return u, nil
}
+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
}
+66
View File
@@ -3,6 +3,7 @@ package config
import (
"crypto/rand"
"encoding/hex"
"log"
"os"
"path/filepath"
"strings"
@@ -19,6 +20,33 @@ type Config struct {
DataDir string
SiteURL string
InsecureDev bool
// 评论区 GitHub 登录(OAuth App 凭据,站主在 GitHub 上创建后填入)
GitHubClientID string
GitHubClientSecret string
// 评论区 Google 登录(OAuth 客户端凭据,Google Cloud Console 创建)
GoogleClientID string
GoogleClientSecret string
// 评论区 Telegram 登录(Login Widget):Bot 是用户名(不含 @,下发给
// 前端 widget),Token 用于验签。两者都要,缺一该入口不开放。
TelegramBot string
TelegramToken string
// 对象存储(文件上传)。变量名与站主 .env 里的写法一致(站主已整理):
// S3Api = R2 的 S3 API 端点(https://<账户ID>.r2.cloudflarestorage.com,
// 控制台 R2 概览可复制),上传走它 —— 公开域名收不了上传请求
// PublicURL = 公开访问域名(r2.dev / 绑定的自定义域名),文件直链走它
// AccessKey / SecretAccessKey / Bucket = R2 凭据与桶名
// 五项齐全 → 上传走 R2、直链走 PublicURL;缺任一项回落本地磁盘
// (DataDir/uploads),并在启动日志提示一句。
StorageDriver string // r2 | local
S3Endpoint string // env: S3Api
R2Bucket string
R2AccessKey string
R2SecretKey string
UploadsPublicBase string // env: PublicURL
}
func getenv(k, def string) string {
@@ -77,6 +105,44 @@ func Load() (*Config, error) {
c.InsecureDev = os.Getenv("ONE_ADMIN_PASSWORD") == ""
// 对象存储:变量名按站主 .env 里整理好的来(无 ONE_ 前缀)。
c.UploadsPublicBase = strings.TrimRight(getenv("PublicURL", ""), "/")
c.S3Endpoint = getenv("S3Api", "")
c.R2AccessKey = getenv("AccessKey", "")
c.R2SecretKey = getenv("SecretAccessKey", "")
c.R2Bucket = getenv("Bucket", "")
if c.S3Endpoint != "" && c.R2Bucket != "" && c.R2AccessKey != "" && c.R2SecretKey != "" {
c.StorageDriver = "r2"
} else {
c.StorageDriver = "local"
// 配了一半(有凭据没端点之类)时给一句启动日志,别让站主猜。
// 只报字段名,不报值。
var missing []string
if c.S3Endpoint == "" {
missing = append(missing, "S3Api")
}
if c.R2Bucket == "" {
missing = append(missing, "Bucket")
}
if c.R2AccessKey == "" {
missing = append(missing, "AccessKey")
}
if c.R2SecretKey == "" {
missing = append(missing, "SecretAccessKey")
}
if len(missing) > 0 {
log.Printf("storage: R2 配置缺 %s,上传回落本地磁盘", strings.Join(missing, "、"))
}
}
// 评论区 GitHub 登录(OAuth App 凭据,站主在 GitHub 上创建后填入)
c.GitHubClientID = getenv("ONE_GITHUB_CLIENT_ID", "")
c.GitHubClientSecret = getenv("ONE_GITHUB_CLIENT_SECRET", "")
c.GoogleClientID = getenv("ONE_GOOGLE_CLIENT_ID", "")
c.GoogleClientSecret = getenv("ONE_GOOGLE_CLIENT_SECRET", "")
c.TelegramBot = getenv("ONE_TELEGRAM_BOT", "")
c.TelegramToken = getenv("ONE_TELEGRAM_BOT_TOKEN", "")
return c, nil
}
+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 ""
}
+94
View File
@@ -29,6 +29,26 @@ type Post struct {
// ContentLen 是正文字符数:列表接口不返回全文,但后台列表要显示字数。
ContentLen int64 `json:"content_len"`
Tags []string `json:"tags"`
// Images 是短文配图(快发盒上传,≤4 张,Twitter 式网格展示),
// 以 JSON 数组存在 posts.images 列里,正文 content_md 不含它们。
Images []string `json:"images"`
// LinkCard 是正文里第一个外链的预览卡片(发布时抓一次,存 posts.link_card)。
// nil = 没有链接或抓取失败,前台不渲染卡片。
LinkCard *LinkCard `json:"link_card,omitempty"`
}
// LinkCard 是链接预览卡片:由 internal/linkmeta 抓取,以 JSON 存在 posts 表里。
type LinkCard struct {
URL string `json:"url"`
Title string `json:"title,omitempty"`
Desc string `json:"desc,omitempty"`
Site string `json:"site,omitempty"`
Image string `json:"image,omitempty"`
}
// Empty 报告卡片有没有可展示的字段(只有 URL 不算)。
func (c *LinkCard) Empty() bool {
return c == nil || (c.Title == "" && c.Desc == "" && c.Image == "")
}
type PostInput struct {
@@ -42,6 +62,10 @@ type PostInput struct {
PublishedAt string `json:"published_at"`
Tags []string `json:"tags"`
ReadingMinutes *int `json:"reading_minutes"`
// Images 是短文配图 URL(快发盒上传),nil = 不修改、空数组 = 清空
Images []string `json:"images"`
// LinkCard 由服务端在保存时按正文内容重算(站主不必发这个字段)
LinkCard *LinkCard `json:"link_card,omitempty"`
}
type Tag struct {
@@ -100,6 +124,37 @@ type Page struct {
Size int `json:"size"`
}
// File 是一条上传文件的索引行。内容本体在对象存储里(store 字段记来源:
// r2 | local),key 是存储端的对象名,URL 由 API 层按「来源 + PublicBase」
// 在响应时解析——切存储端不破坏存量链接。
type File struct {
ID int64 `json:"id"`
Key string `json:"key"`
Name string `json:"name"`
Mime string `json:"mime"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
Store string `json:"store"`
URL string `json:"url"`
CreatedAt string `json:"created_at"`
}
// FilePage 是文件管理的分页容器(Items 用 File,与文章的 Page 区分开)。
type FilePage struct {
Items []File `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
}
// CommentPage 是后台评论管理的分页容器(含文章标题与发表者)。
type CommentPage struct {
Items []Comment `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Size int `json:"size"`
}
// Dashboard is the snapshot rendered on /admin (homepage).
type Dashboard struct {
TotalPosts int `json:"total_posts"`
@@ -138,6 +193,10 @@ type Settings struct {
// SocialLinks 以 JSON 数组形式存在 settings KV 里(key: social_links),
// 解析失败/为空时前台拿到空数组,区块自动隐藏。
SocialLinks []SocialLink `json:"social_links"`
// 评论开关(comments_enabled):关着时前台整个评论区不渲染。
CommentsEnabled bool `json:"comments_enabled"`
// 审核开关(comments_review):开着时新评论先进「待审」,站主通过后才公开。
CommentsReview bool `json:"comments_review"`
// LightSkinID is the front-end skin used when the client (or system)
// prefers light. Valid values: paper / sage / rose.
// Dark side is fixed to ink for now — kept implicit so we can add
@@ -163,3 +222,38 @@ type Settings struct {
// sanitizing it would only break the snippet.
CustomJS string `json:"custom_js"`
}
// Reader 是评论区的登录用户(GitHub OAuth)。Banned = 禁言中。
type Reader struct {
ID int64 `json:"id"`
Provider string `json:"provider"`
Handle string `json:"handle"`
Name string `json:"name"`
AvatarURL string `json:"avatar_url"`
URL string `json:"url"`
Banned bool `json:"banned"`
// CommentCount 是累计评论数(后台用户列表展示用)
CommentCount int64 `json:"comment_count"`
CreatedAt string `json:"created_at"`
}
// Comment 是一条评论。回复扁平存储(parent_id/root_id),渲染时挂到 replies。
// User 是发表者快照;is_deleted = 软删(留壳显示「已删除」,保住楼层上下文)。
type Comment struct {
ID int64 `json:"id"`
PostID int64 `json:"post_id"`
ParentID int64 `json:"parent_id"`
RootID int64 `json:"root_id"`
User *Reader `json:"user"`
UserID int64 `json:"user_id"`
BodyMd string `json:"body_md"`
BodyHTML string `json:"body_html"`
Status string `json:"status"`
IsDeleted bool `json:"is_deleted"`
CreatedAt string `json:"created_at"`
EditedAt string `json:"edited_at"`
Replies []Comment `json:"replies"`
ReplyCount int `json:"reply_count"`
// PostTitle 是文章标题(后台评论列表联表带出,仅管理接口填充)
PostTitle string `json:"post_title"`
}
+73
View File
@@ -0,0 +1,73 @@
// Package ratelimit 是一个极小的按来源 IP 滑动窗口限速器,
// 后台登录与读者登录 / 评论写入共用同一实现。
// 只计失败/写入这类「不该高频」的事件;键取连接层地址,
// X-Forwarded-For 可伪造,不作为限速键。
package ratelimit
import (
"net"
"net/http"
"sync"
"time"
)
type Window struct {
mu sync.Mutex
hits map[string][]time.Time
max int
window time.Duration
}
func New(max int, window time.Duration) *Window {
return &Window{hits: make(map[string][]time.Time), max: max, window: window}
}
// Blocked 报告 key 在窗口内是否已达上限。
func (l *Window) Blocked(key string) bool {
l.mu.Lock()
defer l.mu.Unlock()
return len(l.recent(key, time.Now())) >= l.max
}
// Add 记录一次事件。
func (l *Window) Add(key string) {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
l.hits[key] = append(l.recent(key, now), now)
}
// Reset 清空 key 的计数(如登录成功后)。
func (l *Window) Reset(key string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.hits, key)
}
// recent 返回窗口内的事件时间;调用方必须持有 l.mu。
func (l *Window) recent(key string, now time.Time) []time.Time {
hs := l.hits[key]
cut := now.Add(-l.window)
i := 0
for ; i < len(hs); i++ {
if hs[i].After(cut) {
break
}
}
if i > 0 {
hs = hs[i:]
l.hits[key] = hs
}
if len(hs) == 0 {
delete(l.hits, key)
}
return hs
}
// SourceKey 取连接层来源地址(IPv6 去掉端口)。
func SourceKey(r *http.Request) string {
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}
+92
View File
@@ -0,0 +1,92 @@
// 盘古之白:在 CJK 与拉丁字母/数字之间补一个空格,中文混排的阅读观感
// 差别很大(「把MVP搬进仓库」→「把 MVP 搬进仓库」)。
// 在 HTML 层做:标签原样保留,<code>/<pre>/<script>/<style> 内的文本
// 一律不动(代码语义容不得我们加空格),其余文本段做补空格。
package render
import (
"regexp"
"strings"
"unicode"
)
var tagRe = regexp.MustCompile(`<[^>]*>`)
// code 上下文里的文本不处理
var codeOpenRe = regexp.MustCompile(`(?i)<(code|pre|script|style|kbd|samp)\b[^>]*>`)
var codeCloseRe = regexp.MustCompile(`(?i)</(code|pre|script|style|kbd|samp)>`)
// CJK 统一表意区 + 扩展A/兼容 + 日文假名已在 2E80-9FFF 覆盖,
// 再补全角标点区(FF00-FFEF)与 CJK 标点(3000-303F)
var cjkRe = regexp.MustCompile(`[\x{2E80}-\x{9FFF}\x{3000}-\x{303F}\x{F900}-\x{FAFF}\x{FF00}-\x{FFEF}]`)
var latinTailRe = regexp.MustCompile(`([\x{2E80}-\x{9FFF}\x{3000}-\x{303F}\x{F900}-\x{FAFF}\x{FF00}-\x{FFEF}])([A-Za-z0-9_$@])`)
var latinHeadRe = regexp.MustCompile(`([A-Za-z0-9+%,.;:!?%)\]])([\x{2E80}-\x{9FFF}\x{3000}-\x{303F}\x{F900}-\x{FAFF}\x{FF00}-\x{FFEF}])`)
func isCJK(r rune) bool {
return cjkRe.MatchString(string(r))
}
// panguText 对纯文本做补空格。已经隔着空格的不动(正则天然要求相邻)。
func panguText(s string) string {
if !hasCJKAndLatin(s) {
return s
}
prev := ""
for prev != s {
prev = s
s = latinTailRe.ReplaceAllString(s, "$1 $2")
s = latinHeadRe.ReplaceAllString(s, "$1 $2")
}
return s
}
func hasCJKAndLatin(s string) bool {
var cjk, latin bool
for _, r := range s {
if isCJK(r) {
cjk = true
} else if unicode.IsLetter(r) || unicode.IsNumber(r) {
latin = true
}
if cjk && latin {
return true
}
}
return false
}
// PanguHTML 给渲染出的正文 HTML 做盘古之白。流式扫描:标签原样输出,
// 普通文本段补空格,代码上下文里的文本跳过。
func PanguHTML(html string) string {
if !strings.Contains(html, "<") {
return html
}
var b strings.Builder
inCode := false
for {
loc := tagRe.FindStringIndex(html)
if loc == nil {
rest := html
if !inCode {
rest = panguText(rest)
}
b.WriteString(rest)
return b.String()
}
head, tag := html[:loc[0]], html[loc[0]:loc[1]]
if !inCode {
head = panguText(head)
}
b.WriteString(head)
b.WriteString(tag)
if !inCode {
if m := codeOpenRe.FindStringSubmatch(tag); m != nil {
// 自闭合或行内 code 直接开着也按进/出配对处理(</code> 总会出现)
inCode = true
}
} else if codeCloseRe.MatchString(tag) {
inCode = false
}
html = html[loc[1]:]
}
}
+40
View File
@@ -0,0 +1,40 @@
package render
import "testing"
func TestPanguText(t *testing.T) {
cases := map[string]string{
"把MVP搬进仓库": "把 MVP 搬进仓库",
"今天写了3个小时代码": "今天写了 3 个小时代码",
"使用Go和Vue3开发": "使用 Go 和 Vue3 开发",
"纯中文没有混排": "纯中文没有混排",
"已经隔了空格的 Go 语言": "已经隔了空格的 Go 语言",
"数字100在前": "数字 100 在前",
"英文后接标点。followed": "英文后接标点。 followed",
}
for in, want := range cases {
if got := panguText(in); got != want {
t.Errorf("panguText(%q) = %q, want %q", in, got, want)
}
}
}
func TestPanguHTML(t *testing.T) {
cases := map[string]string{
// 普通文本:处理
"<p>把MVP搬进仓库</p>": "<p>把 MVP 搬进仓库</p>",
// 行内 code:不动
"<p>运行<code>go build ./...</code>即可</p>": "<p>运行<code>go build ./...</code>即可</p>",
// pre 代码块:整个内部不动
"<pre><code>把MVP搬进仓库\nfmt.Println(1)</code></pre>": "<pre><code>把MVP搬进仓库\nfmt.Println(1)</code></pre>",
// 属性里不是文本:不动(src 含字母数字混合不受影响)
`<img alt="测试图1" src="a1.png">`: `<img alt="测试图1" src="a1.png">`,
// 嵌套:code 结束后恢复处理
"<p>先<code>跑起来</code>再看结果100%</p>": "<p>先<code>跑起来</code>再看结果 100%</p>",
}
for in, want := range cases {
if got := PanguHTML(in); got != want {
t.Errorf("PanguHTML(%q) = %q, want %q", in, got, want)
}
}
}
+79
View File
@@ -0,0 +1,79 @@
package storage
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
// LocalStore 把文件落在本地磁盘(DataDir/uploads),零配置兜底。
// key 允许带「2026/09/」这类前缀,实现里做路径清洗防目录穿越。
type LocalStore struct {
Root string
}
func NewLocal(root string) *LocalStore {
return &LocalStore{Root: root}
}
func (s *LocalStore) path(key string) (string, error) {
clean := filepath.Clean("/" + strings.TrimPrefix(key, "/"))[1:] // 锚到根再去掉,防 ../
if clean == "" || strings.HasPrefix(clean, "..") {
return "", fmt.Errorf("bad key: %q", key)
}
return filepath.Join(s.Root, filepath.FromSlash(clean)), nil
}
func (s *LocalStore) Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error {
dst, err := s.path(key)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(dst), ".upload-*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if _, err := io.Copy(tmp, r); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), dst)
}
func (s *LocalStore) Open(ctx context.Context, key string) (io.ReadCloser, int64, error) {
dst, err := s.path(key)
if err != nil {
return nil, 0, err
}
f, err := os.Open(dst)
if err != nil {
return nil, 0, err
}
st, err := f.Stat()
if err != nil {
f.Close()
return nil, 0, err
}
return f, st.Size(), nil
}
func (s *LocalStore) Delete(ctx context.Context, key string) error {
dst, err := s.path(key)
if err != nil {
return err
}
if err := os.Remove(dst); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
+74
View File
@@ -0,0 +1,74 @@
package storage
import (
"context"
"fmt"
"io"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
)
// R2Store 走 Cloudflare R2 的 S3 兼容 API。endpoint 是账户级端点
// (https://<accountid>.r2.cloudflarestorage.com),R2 两种寻址都支持,
// 这里用 path-style 最稳。
//
// 端点 URL 里的路径段(如果有)不交给 SDK —— path-style 下它会被折进
// 对象 key,导致「数据库 key」和「实际对象 key」对不上(直链 404)。
// 这里只取 scheme://host 当端点;路径段由 EndpointKeyPrefix 暴露给
// 上传 handler 拼进 key,三处(存储 / 数据库 / 直链)从此一致。
type R2Store struct {
client *s3.Client
bucket string
}
func NewR2(endpoint, bucket, accessKey, secretKey string) (*R2Store, error) {
if endpoint == "" || bucket == "" {
return nil, fmt.Errorf("r2: endpoint/bucket required")
}
// R2 不认 region,SDK 又必须有——官方文档给的值就是 "auto"。
cfg, err := config.LoadDefaultConfig(context.TODO(),
config.WithRegion("auto"),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")),
config.WithBaseEndpoint(StripEndpointPath(endpoint)),
)
if err != nil {
return nil, fmt.Errorf("r2: load aws config: %w", err)
}
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
o.UsePathStyle = true
})
return &R2Store{client: client, bucket: bucket}, nil
}
func (s *R2Store) Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error {
_, err := s.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
Body: r,
ContentLength: aws.Int64(size),
ContentType: aws.String(contentType),
})
return err
}
func (s *R2Store) Open(ctx context.Context, key string) (io.ReadCloser, int64, error) {
out, err := s.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
})
if err != nil {
return nil, 0, err
}
return out.Body, *out.ContentLength, nil
}
func (s *R2Store) Delete(ctx context.Context, key string) error {
_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(key),
})
return err
}
+54
View File
@@ -0,0 +1,54 @@
// Package storage 是文件上传的存储抽象。R2(S3 兼容 API)是第一个实现,
// 本地磁盘是零配置兜底;以后接其他存储端 = 再写一个 BlobStore 实现,
// 从 FromEnv 的选择逻辑里加一个分支,调用方(admin API / 公开路由)不变。
//
// URL 不在存储层决定:文件行里记 store 来源,读取时按「来源 + PublicBase」
// 解析直链或后端代理路由,因此切换存储端不破坏存量链接。
package storage
import (
"context"
"io"
"net/url"
"strings"
)
// BlobStore 是对象存储的最小面。size 只是提示(S3 PutObject 需要预知长度,
// 用临时文件或内存缓冲满足),实现方不得依赖它做校验——类型与大小上限
// 在上传 handler 里统一把关。
type BlobStore interface {
Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) error
Open(ctx context.Context, key string) (io.ReadCloser, int64, error)
Delete(ctx context.Context, key string) error
}
// FileURL 解析文件的公开访问地址:R2 上的文件且配了公开域名(PublicBase,
// 尾部不带 /)走 CDN 直链;其余(本地兜底、或没配公开域名)走后端的
// /uploads/key 流式路由。调用方传入文件行里的 store 来源。
func FileURL(storeName, key, publicBase string) string {
if storeName == "r2" && publicBase != "" {
return publicBase + "/" + key
}
return "/uploads/" + key
}
// EndpointKeyPrefix 提取端点 URL 里的路径段(去首尾斜杠)作为上传 key 的
// 前缀:S3Api 写成 https://host/oss 时,对象 key 会带上 oss/ 前缀,
// 数据库 / 直链 / 存储端三方都用这个前缀对齐。没有路径段返回空串。
func EndpointKeyPrefix(endpoint string) string {
u, err := url.Parse(strings.TrimSpace(endpoint))
if err != nil {
return ""
}
return strings.Trim(u.Path, "/")
}
// StripEndpointPath 去掉端点 URL 的路径,只留 scheme://host[:port]:
// SDK 的 BaseEndpoint 用它(路径段已经折进 key 前缀,见 EndpointKeyPrefix)。
func StripEndpointPath(endpoint string) string {
u, err := url.Parse(strings.TrimSpace(endpoint))
if err != nil {
return endpoint
}
return u.Scheme + "://" + u.Host
}
+562 -21
View File
@@ -86,6 +86,40 @@ func (s *Store) migrate() error {
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`, ai),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS files (
id %s,
key TEXT NOT NULL,
name TEXT NOT NULL,
mime TEXT NOT NULL DEFAULT '',
size INTEGER NOT NULL DEFAULT 0,
sha256 TEXT NOT NULL DEFAULT '',
store TEXT NOT NULL DEFAULT 'local',
created_at TEXT NOT NULL
)`, ai),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS users (
id %s,
provider TEXT NOT NULL,
handle TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
avatar_url TEXT NOT NULL DEFAULT '',
url TEXT NOT NULL DEFAULT '',
banned INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
UNIQUE(provider, handle)
)`, ai),
fmt.Sprintf(`CREATE TABLE IF NOT EXISTS comments (
id %s,
post_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
parent_id INTEGER NOT NULL DEFAULT 0,
root_id INTEGER NOT NULL DEFAULT 0,
body_md TEXT NOT NULL DEFAULT '',
body_html TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'visible',
is_deleted INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
edited_at TEXT NOT NULL DEFAULT ''
)`, ai),
}
for _, q := range stmts {
if _, err := s.db.Exec(s.db.Q(q)); err != nil {
@@ -99,6 +133,10 @@ func (s *Store) migrate() error {
columnAdds := []string{
`ALTER TABLE posts ADD COLUMN cover_url TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE tags ADD COLUMN color TEXT NOT NULL DEFAULT ''`,
// 短文配图(快发盒上传,Twitter 式网格展示),JSON 数组
`ALTER TABLE posts ADD COLUMN images TEXT NOT NULL DEFAULT '[]'`,
// 正文首个外链的预览卡片(og 标题/描述/封面),JSON 对象或空串
`ALTER TABLE posts ADD COLUMN link_card TEXT NOT NULL DEFAULT ''`,
}
for _, q := range columnAdds {
if _, err := s.db.Exec(s.db.Q(q)); err != nil && !strings.Contains(err.Error(), "already exists") &&
@@ -114,6 +152,11 @@ func (s *Store) migrate() error {
{"idx_tags_slug", `CREATE UNIQUE INDEX IF NOT EXISTS idx_tags_slug ON tags(slug)`},
{"idx_post_tags_tag", `CREATE INDEX IF NOT EXISTS idx_post_tags_tag ON post_tags(tag_id)`},
{"idx_projects_slug", `CREATE UNIQUE INDEX IF NOT EXISTS idx_projects_slug ON projects(slug)`},
{"idx_files_key", `CREATE UNIQUE INDEX IF NOT EXISTS idx_files_key ON files(key)`},
{"idx_files_created", `CREATE INDEX IF NOT EXISTS idx_files_created ON files(created_at DESC)`},
{"idx_comments_post", `CREATE INDEX IF NOT EXISTS idx_comments_post ON comments(post_id, created_at)`},
{"idx_comments_user", `CREATE INDEX IF NOT EXISTS idx_comments_user ON comments(user_id)`},
{"idx_comments_status", `CREATE INDEX IF NOT EXISTS idx_comments_status ON comments(status, created_at DESC)`},
}
for _, ix := range indexes {
if _, err := s.db.Exec(s.db.Q(ix.ddl)); err != nil && !strings.Contains(err.Error(), "already exists") {
@@ -126,13 +169,15 @@ func (s *Store) migrate() error {
func (s *Store) seedSettings() error {
defs := map[string]string{
"site_title": "ONE · 一个博客",
"site_desc": "长文与短文,同一种节奏。",
"author_name": "ONE",
"author_bio": "写点长的,也写点短的。",
"footer_note": "© ONE · 一个博客",
"icp": "",
"posts_per_page": "10",
"site_title": "ONE · 一个博客",
"site_desc": "长文与短文,同一种节奏。",
"author_name": "ONE",
"author_bio": "写点长的,也写点短的。",
"footer_note": "© ONE · 一个博客",
"icp": "",
"posts_per_page": "10",
"comments_enabled": "1",
"comments_review": "0",
}
for k, v := range defs {
if s.db.Dialect == db.Postgres {
@@ -195,6 +240,9 @@ func settingsFromMap(m map[string]string) model.Settings {
st.CustomCSS = decodeCSSMap(m["custom_css"])
st.CustomJS = m["custom_js"]
st.SocialLinks = decodeSocialLinks(m["social_links"])
// 开关类:'1' / 'true' 都算开,其余(含空)算关
st.CommentsEnabled = m["comments_enabled"] == "1" || strings.EqualFold(m["comments_enabled"], "true")
st.CommentsReview = m["comments_review"] == "1" || strings.EqualFold(m["comments_review"], "true")
if n := atoi(m["posts_per_page"]); n > 0 {
st.PostsPerPage = n
}
@@ -377,8 +425,11 @@ func (s *Store) UpdateSettings(st model.Settings) error {
"custom_css": encodeCSSMap(st.CustomCSS),
// 原样存:站主自己的代码,不做任何转义/清洗。
"custom_js": st.CustomJS,
// 空数组存空串:KV 里不留 "null"。
// 社交 / 源码链接:JSON 数组,空数组存 "[]"。
"social_links": encodeSocialLinks(st.SocialLinks),
// 开关统一存 '1' / '0'。
"comments_enabled": b2s(st.CommentsEnabled),
"comments_review": b2s(st.CommentsReview),
}
for k, v := range sets {
if s.db.Dialect == db.Postgres {
@@ -428,21 +479,71 @@ func sanitizeOrder(o string) string {
}
const postCols = `id, kind, title, slug, summary, cover_url, content_md, content_html, status,
published_at, created_at, updated_at, reading_minutes, LENGTH(content_md)`
published_at, created_at, updated_at, reading_minutes, LENGTH(content_md), images, link_card`
// listCols 用于列表/时间线:不传 content_md(前端不用),
// 长文 content_html 只截 600 字符供无摘要时提取纯文本,短文保留全文渲染。
const listCols = `id, kind, title, slug, summary, cover_url,
'' AS content_md,
CASE WHEN kind = 'short' THEN content_html ELSE substr(content_html, 1, 600) END AS content_html,
status, published_at, created_at, updated_at, reading_minutes, LENGTH(content_md)`
status, published_at, created_at, updated_at, reading_minutes, LENGTH(content_md), images, link_card`
// images 列的 JSON 编解码(列存 '[]',Go 侧 []string;坏数据静默为空)。
func decodeImages(s string) []string {
out := []string{}
if strings.TrimSpace(s) == "" {
return out
}
if err := json.Unmarshal([]byte(s), &out); err != nil {
return []string{}
}
return out
}
func encodeImages(imgs []string) string {
if len(imgs) == 0 {
return "[]"
}
b, err := json.Marshal(imgs)
if err != nil {
return "[]"
}
return string(b)
}
// link_card 列的 JSON 编解码:空串/坏数据一律当作「没有卡片」。
func decodeLinkCard(s string) *model.LinkCard {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
var c model.LinkCard
if err := json.Unmarshal([]byte(s), &c); err != nil || c.Empty() {
return nil
}
return &c
}
func encodeLinkCard(c *model.LinkCard) string {
if c.Empty() {
return ""
}
b, err := json.Marshal(c)
if err != nil {
return ""
}
return string(b)
}
func scanPost(rows interface{ Scan(...any) error }) (model.Post, error) {
var p model.Post
var imgs, card string
err := rows.Scan(&p.ID, &p.Kind, &p.Title, &p.Slug, &p.Summary, &p.CoverURL,
&p.ContentMd, &p.ContentHTML, &p.Status, &p.PublishedAt, &p.CreatedAt, &p.UpdatedAt,
&p.ReadingMinutes, &p.ContentLen)
&p.ReadingMinutes, &p.ContentLen, &imgs, &card)
p.Tags = []string{}
p.Images = decodeImages(imgs)
p.LinkCard = decodeLinkCard(card)
return p, err
}
@@ -615,9 +716,10 @@ func (s *Store) GetBySlug(slug string) (model.Post, error) {
}
func scanPostInto(row *sql.Row, p *model.Post) error {
var imgs, card string
err := row.Scan(&p.ID, &p.Kind, &p.Title, &p.Slug, &p.Summary, &p.CoverURL,
&p.ContentMd, &p.ContentHTML, &p.Status, &p.PublishedAt, &p.CreatedAt, &p.UpdatedAt,
&p.ReadingMinutes, &p.ContentLen)
&p.ReadingMinutes, &p.ContentLen, &imgs, &card)
if err == sql.ErrNoRows {
return ErrNotFound
}
@@ -625,6 +727,8 @@ func scanPostInto(row *sql.Row, p *model.Post) error {
return err
}
p.Tags = []string{}
p.Images = decodeImages(imgs)
p.LinkCard = decodeLinkCard(card)
return nil
}
@@ -869,17 +973,19 @@ func (s *Store) Create(in model.PostInput) (model.Post, error) {
var id int64
q := s.db.Q(`INSERT INTO posts (kind,title,slug,summary,cover_url,content_md,content_html,status,
published_at,created_at,updated_at,reading_minutes)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`)
published_at,created_at,updated_at,reading_minutes,images,link_card)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
if s.db.Dialect == db.Postgres {
err := s.db.QueryRow(q, p.Kind, p.Title, p.Slug, p.Summary, p.CoverURL, p.ContentMd, p.ContentHTML,
p.Status, p.PublishedAt, p.CreatedAt, p.UpdatedAt, p.ReadingMinutes).Scan(&id)
p.Status, p.PublishedAt, p.CreatedAt, p.UpdatedAt, p.ReadingMinutes, encodeImages(in.Images),
encodeLinkCard(in.LinkCard)).Scan(&id)
if err != nil {
return p, err
}
} else {
res, err := s.db.Exec(q, p.Kind, p.Title, p.Slug, p.Summary, p.CoverURL, p.ContentMd, p.ContentHTML,
p.Status, p.PublishedAt, p.CreatedAt, p.UpdatedAt, p.ReadingMinutes)
p.Status, p.PublishedAt, p.CreatedAt, p.UpdatedAt, p.ReadingMinutes, encodeImages(in.Images),
encodeLinkCard(in.LinkCard))
if err != nil {
return p, err
}
@@ -889,7 +995,12 @@ func (s *Store) Create(in model.PostInput) (model.Post, error) {
}
}
p.ID = id
if err := s.setTags(id, in.Tags); err != nil {
// 标签是长文的组织方式;短文(类推微博)一律不打标签
tags := in.Tags
if p.Kind == model.KindShort {
tags = nil
}
if err := s.setTags(id, tags); err != nil {
return p, err
}
return s.Get(id)
@@ -927,6 +1038,14 @@ func (s *Store) Update(id int64, in model.PostInput) (model.Post, error) {
// current value (empty string included), so any update round-trips with
// whatever the user last saved.
p.CoverURL = strings.TrimSpace(in.CoverURL)
// Images 同理:nil = 不修改(快发盒只发新帖,后台编辑器全量回传)
if in.Images != nil {
p.Images = in.Images
}
// LinkCard 由 admin 层按正文重算后传入;空卡片(含只有 URL)= 清空
if in.LinkCard != nil {
p.LinkCard = in.LinkCard
}
p.UpdatedAt = now()
if in.ReadingMinutes != nil && *in.ReadingMinutes > 0 {
p.ReadingMinutes = *in.ReadingMinutes
@@ -935,13 +1054,18 @@ func (s *Store) Update(id int64, in model.PostInput) (model.Post, error) {
}
if _, err := s.db.Exec(s.db.Q(`UPDATE posts SET kind=?,title=?,slug=?,summary=?,cover_url=?,content_md=?,
content_html=?,status=?,published_at=?,updated_at=?,reading_minutes=? WHERE id=?`),
content_html=?,status=?,published_at=?,updated_at=?,reading_minutes=?,images=?,link_card=? WHERE id=?`),
p.Kind, p.Title, p.Slug, p.Summary, p.CoverURL, p.ContentMd, p.ContentHTML, p.Status,
p.PublishedAt, p.UpdatedAt, p.ReadingMinutes, id); err != nil {
p.PublishedAt, p.UpdatedAt, p.ReadingMinutes, encodeImages(p.Images), encodeLinkCard(p.LinkCard), id); err != nil {
return p, err
}
if in.Tags != nil {
if err := s.setTags(id, in.Tags); err != nil {
// 短文一律无标签:切换类型或直接保存时都把旧标签清掉
if in.Tags != nil || p.Kind == model.KindShort {
tags := in.Tags
if p.Kind == model.KindShort {
tags = nil
}
if err := s.setTags(id, tags); err != nil {
return p, err
}
}
@@ -1391,3 +1515,420 @@ func (s *Store) scanPostsInto(query string, args []any, dst *[]model.Post) error
*dst = out
return nil
}
// ---------- files ----------
func scanFile(sc interface{ Scan(...any) error }) (model.File, error) {
var f model.File
err := sc.Scan(&f.ID, &f.Key, &f.Name, &f.Mime, &f.Size, &f.SHA256, &f.Store, &f.CreatedAt)
return f, err
}
func (s *Store) CreateFile(f model.File) (model.File, error) {
f.CreatedAt = now()
q := s.db.Q(`INSERT INTO files (key,name,mime,size,sha256,store,created_at) VALUES (?,?,?,?,?,?,?)`)
var id int64
if s.db.Dialect == db.Postgres {
err := s.db.QueryRow(q+` RETURNING id`, f.Key, f.Name, f.Mime, f.Size, f.SHA256, f.Store, f.CreatedAt).Scan(&id)
if err != nil {
return model.File{}, err
}
} else {
res, err := s.db.Exec(q, f.Key, f.Name, f.Mime, f.Size, f.SHA256, f.Store, f.CreatedAt)
if err != nil {
return model.File{}, err
}
if id, err = res.LastInsertId(); err != nil {
return model.File{}, err
}
}
f.ID = id
return f, nil
}
// ListFiles 按创建时间倒序分页,q 模糊匹配原始文件名 / key。
func (s *Store) ListFiles(page, size int, q string) (model.FilePage, error) {
if page < 1 {
page = 1
}
if size < 1 || size > 100 {
size = 20
}
where := ""
var args []any
if query := strings.TrimSpace(q); query != "" {
where = ` WHERE name LIKE ? OR key LIKE ?`
like := "%" + query + "%"
args = append(args, like, like)
}
var total int
if err := s.db.QueryRow(s.db.Q(`SELECT COUNT(*) FROM files`+where), args...).Scan(&total); err != nil {
return model.FilePage{}, err
}
args = append(args, size, (page-1)*size)
rows, err := s.db.Query(s.db.Q(`SELECT id,key,name,mime,size,sha256,store,created_at FROM files`+
where+` ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`), args...)
if err != nil {
return model.FilePage{}, err
}
defer rows.Close()
out := []model.File{}
for rows.Next() {
f, err := scanFile(rows)
if err != nil {
return model.FilePage{}, err
}
out = append(out, f)
}
return model.FilePage{Items: out, Total: total, Page: page, Size: size}, rows.Err()
}
func (s *Store) GetFile(id int64) (model.File, error) {
f, err := scanFile(s.db.QueryRow(s.db.Q(`SELECT id,key,name,mime,size,sha256,store,created_at FROM files WHERE id = ?`), id))
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return model.File{}, ErrNotFound
}
return model.File{}, err
}
return f, nil
}
func (s *Store) GetFileByKey(key string) (model.File, error) {
f, err := scanFile(s.db.QueryRow(s.db.Q(`SELECT id,key,name,mime,size,sha256,store,created_at FROM files WHERE key = ?`), key))
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return model.File{}, ErrNotFound
}
return model.File{}, err
}
return f, nil
}
// DeleteFile 删索引行并返回被删的行(调用方负责先删对象存储里的本体)。
func (s *Store) DeleteFile(id int64) (model.File, error) {
f, err := s.GetFile(id)
if err != nil {
return model.File{}, err
}
res, err := s.db.Exec(s.db.Q(`DELETE FROM files WHERE id = ?`), id)
if err != nil {
return model.File{}, err
}
if n, _ := res.RowsAffected(); n == 0 {
return model.File{}, ErrNotFound
}
return f, nil
}
// b2s 布尔转 KV 开关值
func b2s(b bool) string {
if b {
return "1"
}
return "0"
}
// ---------- readers(评论区的登录用户) ----------
// UpsertReader 按 (provider, handle) 找人:找到就更新资料,找不到就建档。
// banned 是状态位,不随资料更新覆盖。
func (s *Store) UpsertReader(r model.Reader) (model.Reader, error) {
r.CreatedAt = now()
q := s.db.Q(`INSERT INTO users (provider,handle,name,avatar_url,url,banned,created_at)
VALUES (?,?,?,?,?,0,?)
ON CONFLICT (provider,handle) DO UPDATE SET
name=excluded.name, avatar_url=excluded.avatar_url, url=excluded.url`)
// SQLite 的 ON CONFLICT 语法 Postgres 也认(现代版);老库退化走下面分支
if s.db.Dialect == db.Postgres {
if _, err := s.db.Exec(s.db.Q(`INSERT INTO users (provider,handle,name,avatar_url,url,banned,created_at)
VALUES (?,?,?,?,?,0,?) ON CONFLICT (provider,handle) DO UPDATE SET
name=excluded.name, avatar_url=excluded.avatar_url, url=excluded.url`),
r.Provider, r.Handle, r.Name, r.AvatarURL, r.URL, r.CreatedAt); err != nil {
return model.Reader{}, err
}
return s.GetReaderByProviderHandle(r.Provider, r.Handle)
}
if _, err := s.db.Exec(q, r.Provider, r.Handle, r.Name, r.AvatarURL, r.URL, r.CreatedAt); err != nil {
return model.Reader{}, err
}
return s.GetReaderByProviderHandle(r.Provider, r.Handle)
}
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))
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return model.Reader{}, ErrNotFound
}
return model.Reader{}, err
}
return r, nil
}
func (s *Store) GetReaderByProviderHandle(provider, handle string) (model.Reader, error) {
r, err := scanReader(s.db.QueryRow(s.db.Q(`SELECT id,provider,handle,name,avatar_url,url,banned,created_at FROM users WHERE provider = ? AND handle = ?`), provider, handle))
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return model.Reader{}, ErrNotFound
}
return model.Reader{}, err
}
return r, nil
}
func (s *Store) SetReaderBanned(id int64, banned bool) error {
res, err := s.db.Exec(s.db.Q(`UPDATE users SET banned = ? WHERE id = ?`), b2i(banned), id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// ListReaders 后台的用户列表:带评论数,禁言中的排前面
func (s *Store) ListReaders() ([]model.Reader, error) {
rows, err := s.db.Query(s.db.Q(`SELECT u.id,u.provider,u.handle,u.name,u.avatar_url,u.url,u.banned,u.created_at,
(SELECT COUNT(*) FROM comments c WHERE c.user_id = u.id AND c.is_deleted = 0) AS cnt
FROM users u ORDER BY u.banned DESC, u.created_at DESC`))
if err != nil {
return nil, err
}
defer rows.Close()
out := []model.Reader{}
for rows.Next() {
var r model.Reader
var cnt int64
if err := rows.Scan(&r.ID, &r.Provider, &r.Handle, &r.Name, &r.AvatarURL, &r.URL, &r.Banned, &r.CreatedAt, &cnt); err != nil {
return nil, err
}
r.CommentCount = cnt
out = append(out, r)
}
return out, rows.Err()
}
func b2i(b bool) int64 {
if b {
return 1
}
return 0
}
func scanReader(sc interface{ Scan(...any) error }) (model.Reader, error) {
var r model.Reader
var banned int64
err := sc.Scan(&r.ID, &r.Provider, &r.Handle, &r.Name, &r.AvatarURL, &r.URL, &banned, &r.CreatedAt)
r.Banned = banned == 1
return r, err
}
// ---------- comments ----------
const commentCols = `c.id, c.post_id, c.parent_id, c.root_id, c.user_id, c.body_md, c.body_html,
c.status, c.is_deleted, c.created_at, c.edited_at,
u.id, u.provider, u.handle, u.name, u.avatar_url, u.url, u.banned, u.created_at`
func scanComment(sc interface{ Scan(...any) error }) (model.Comment, error) {
var c model.Comment
var u model.Reader
var banned int64
err := sc.Scan(&c.ID, &c.PostID, &c.ParentID, &c.RootID, &c.UserID, &c.BodyMd, &c.BodyHTML,
&c.Status, &c.IsDeleted, &c.CreatedAt, &c.EditedAt,
&u.ID, &u.Provider, &u.Handle, &u.Name, &u.AvatarURL, &u.URL, &banned, &u.CreatedAt)
if err != nil {
return c, err
}
c.User = &model.Reader{ID: u.ID, Provider: u.Provider, Handle: u.Handle, Name: u.Name,
AvatarURL: u.AvatarURL, URL: u.URL, Banned: banned == 1, CreatedAt: u.CreatedAt}
c.Replies = []model.Comment{}
return c, nil
}
// CreateComment 新建评论;parent/root 归属与审核状态由调用方决定
func (s *Store) CreateComment(c model.Comment) (model.Comment, error) {
c.CreatedAt = now()
q := s.db.Q(`INSERT INTO comments (post_id,user_id,parent_id,root_id,body_md,body_html,status,created_at)
VALUES (?,?,?,?,?,?,?,?)`)
var id int64
if s.db.Dialect == db.Postgres {
err := s.db.QueryRow(q+` RETURNING id`, c.PostID, c.UserID, c.ParentID, c.RootID,
c.BodyMd, c.BodyHTML, c.Status, c.CreatedAt).Scan(&id)
if err != nil {
return model.Comment{}, err
}
} else {
res, err := s.db.Exec(q, c.PostID, c.UserID, c.ParentID, c.RootID, c.BodyMd, c.BodyHTML, c.Status, c.CreatedAt)
if err != nil {
return model.Comment{}, err
}
if id, err = res.LastInsertId(); err != nil {
return model.Comment{}, err
}
}
return s.GetComment(id)
}
// GetComment 单条(含用户)
func (s *Store) GetComment(id int64) (model.Comment, error) {
c, err := scanComment(s.db.QueryRow(s.db.Q(`SELECT `+commentCols+` FROM comments c
JOIN users u ON u.id = c.user_id WHERE c.id = ?`), id))
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return model.Comment{}, ErrNotFound
}
return model.Comment{}, err
}
return c, nil
}
// ListCommentsByPost 一篇文章的公开评论树:顶层可见 + 访客自己的待审;
// 每条顶层内嵌全部可见回复。deleted 的行保留(墓碑:正文清空,楼层不塌)。
func (s *Store) ListCommentsByPost(postID, viewerID int64, newestFirst bool) ([]model.Comment, error) {
order := `ASC`
if newestFirst {
order = `DESC`
}
rows, err := s.db.Query(s.db.Q(`SELECT `+commentCols+` FROM comments c
JOIN users u ON u.id = c.user_id
WHERE c.post_id = ? AND c.parent_id = 0
AND (c.status = 'visible' OR (c.status = 'pending' AND c.user_id = ?))
ORDER BY c.created_at `+order), postID, viewerID)
if err != nil {
return nil, err
}
defer rows.Close()
roots := []model.Comment{}
idx := map[int64]int{}
for rows.Next() {
c, err := scanComment(rows)
if err != nil {
return nil, err
}
c.Replies = []model.Comment{}
idx[c.ID] = len(roots)
roots = append(roots, c)
}
if err := rows.Err(); err != nil {
return nil, err
}
// 回复:可见的(+访客自己的待审),按时间正序挂在各自根下
rows2, err := s.db.Query(s.db.Q(`SELECT `+commentCols+` FROM comments c
JOIN users u ON u.id = c.user_id
WHERE c.post_id = ? AND c.parent_id <> 0
AND (c.status = 'visible' OR (c.status = 'pending' AND c.user_id = ?))
ORDER BY c.created_at ASC`), postID, viewerID)
if err != nil {
return nil, err
}
defer rows2.Close()
for rows2.Next() {
c, err := scanComment(rows2)
if err != nil {
return nil, err
}
if at, ok := idx[c.RootID]; ok {
roots[at].Replies = append(roots[at].Replies, c)
}
}
for i := range roots {
roots[i].ReplyCount = len(roots[i].Replies)
}
return roots, rows2.Err()
}
// ListCommentsAdmin 后台的评论列表(平铺,含用户与文章标题),status 过滤
func (s *Store) ListCommentsAdmin(status string, page, size int) (model.CommentPage, error) {
if page < 1 {
page = 1
}
if size < 1 || size > 100 {
size = 20
}
where := ""
var args []any
switch status {
case "pending", "visible":
where = ` WHERE c.status = '` + status + `' AND c.is_deleted = 0`
default:
where = ` WHERE c.is_deleted = 0`
}
var total int
if err := s.db.QueryRow(s.db.Q(`SELECT COUNT(*) FROM comments c`+where), args...).Scan(&total); err != nil {
return model.CommentPage{}, err
}
args = append(args, size, (page-1)*size)
rows, err := s.db.Query(s.db.Q(`SELECT `+commentCols+`, COALESCE(p.title, '') FROM comments c
JOIN users u ON u.id = c.user_id
JOIN posts p ON p.id = c.post_id`+where+`
ORDER BY c.created_at DESC LIMIT ? OFFSET ?`), args...)
if err != nil {
return model.CommentPage{}, err
}
defer rows.Close()
out := []model.Comment{}
for rows.Next() {
c, title, err := scanCommentAdmin(rows)
if err != nil {
return model.CommentPage{}, err
}
c.PostTitle = title
out = append(out, c)
}
return model.CommentPage{Items: out, Total: total, Page: page, Size: size}, rows.Err()
}
// CommentPage 后台评论管理的分页容器
type modelCommentPageAlias = struct{}
func scanCommentAdmin(sc interface{ Scan(...any) error }) (model.Comment, string, error) {
var c model.Comment
var u model.Reader
var title string
var banned int64
err := sc.Scan(&c.ID, &c.PostID, &c.ParentID, &c.RootID, &c.UserID, &c.BodyMd, &c.BodyHTML,
&c.Status, &c.IsDeleted, &c.CreatedAt, &c.EditedAt,
&u.ID, &u.Provider, &u.Handle, &u.Name, &u.AvatarURL, &u.URL, &banned, &u.CreatedAt, &title)
c.User = &model.Reader{ID: u.ID, Provider: u.Provider, Handle: u.Handle, Name: u.Name,
AvatarURL: u.AvatarURL, URL: u.URL, Banned: banned == 1, CreatedAt: u.CreatedAt}
return c, title, err
}
// SetCommentStatus 审核通过 / 退回待审
func (s *Store) SetCommentStatus(id int64, status string) error {
res, err := s.db.Exec(s.db.Q(`UPDATE comments SET status = ? WHERE id = ?`), status, id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// DeleteComment 软删:留壳(「该评论已删除」),正文清空
func (s *Store) DeleteComment(id int64) error {
res, err := s.db.Exec(s.db.Q(`UPDATE comments SET is_deleted = 1, body_md = '', body_html = '' WHERE id = ?`), id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// UpdateCommentBody 编辑后的正文回写
func (s *Store) UpdateCommentBody(id int64, md, html string) error {
res, err := s.db.Exec(s.db.Q(`UPDATE comments SET body_md = ?, body_html = ?, edited_at = ? WHERE id = ?`),
md, html, now(), id)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
+377
View File
@@ -0,0 +1,377 @@
// Package thumbs 懒生成上传图片的缩略图并落盘缓存(memos 模式):
// 首次请求时从存储端读一次原图,缩放编码后进缓存,此后不再碰原文件;
// 生成失败写 .failed 标记,1 小时内不反复重试。
package thumbs
import (
"bytes"
"fmt"
"image"
"image/gif"
"image/jpeg"
"image/png"
"io"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
const (
// MaxSrcBytes 超过此大小的原图不生成(防止大图拖垮请求),调用方回原图。
MaxSrcBytes = 25 << 20 // 25MB
// maxDim 单边像素上限,超过视为畸形图(解压炸弹防线)。
maxDim = 10000
// JPEGQuality 转码输出质量。
JPEGQuality = 82
// failedTTL 失败后的重试冷却。
failedTTL = time.Hour
)
// Supported 报告该 mime 是否走缩略图管线(标准库能完整解码的静图)。
func Supported(mime string) bool {
switch mime {
case "image/jpeg", "image/png", "image/gif":
return true
}
return false
}
// Generate 把原图字节缩放到宽 w(只缩不放)。已够小的原生格式图原样直通
// (返回 passThrough=true,调用方直接落盘 src),其余统一转 JPEG——
// 缩略图不需要 alpha,透明区铺白底。
func Generate(src []byte, mime string, w int) (out []byte, outMime string, passThrough bool, err error) {
cfg, _, err := image.DecodeConfig(bytes.NewReader(src))
if err != nil {
return nil, "", false, fmt.Errorf("decode config: %w", err)
}
if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width > maxDim || cfg.Height > maxDim {
return nil, "", false, fmt.Errorf("bad dimensions %dx%d", cfg.Width, cfg.Height)
}
if cfg.Width <= w && Supported(mime) {
return src, mime, true, nil
}
var img image.Image
switch mime {
case "image/jpeg":
img, err = jpeg.Decode(bytes.NewReader(src))
case "image/png":
img, err = png.Decode(bytes.NewReader(src))
case "image/gif":
img, err = gif.Decode(bytes.NewReader(src)) // 首帧,缩略图不做动画
default:
return nil, "", false, fmt.Errorf("unsupported mime %q", mime)
}
if err != nil {
return nil, "", false, fmt.Errorf("decode: %w", err)
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, Resize(img, w), &jpeg.Options{Quality: JPEGQuality}); err != nil {
return nil, "", false, err
}
return buf.Bytes(), "image/jpeg", false, nil
}
// Resize 盒均值(box-average)缩放到宽 nw(等比,只缩不放)。
// 非预乘 RGBA 按 alpha 加权平均,透明像素不计入颜色、铺白底。纯标准库。
func Resize(src image.Image, nw int) *image.RGBA {
b := src.Bounds()
ow, oh := b.Dx(), b.Dy()
if nw <= 0 || nw >= ow {
nw = ow
}
nh := (oh*nw + ow/2) / ow
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
flat := flatten(src, b)
for y := 0; y < nh; y++ {
y0 := y * oh / nh
y1 := (y+1)*oh/nh + 1
if y1 > oh {
y1 = oh
}
if y1 <= y0 {
y1 = y0 + 1
}
for x := 0; x < nw; x++ {
x0 := x * ow / nw
x1 := (x+1)*ow/nw + 1
if x1 > ow {
x1 = ow
}
if x1 <= x0 {
x1 = x0 + 1
}
var r, g, bl, a, n uint64
for sy := y0; sy < y1; sy++ {
row := sy * ow * 4
for sx := x0; sx < x1; sx++ {
i := row + sx*4
al := uint64(flat[i+3])
r += uint64(flat[i]) * al
g += uint64(flat[i+1]) * al
bl += uint64(flat[i+2]) * al
a += al
n++
}
}
d := y*nw*4 + x*4
if a == 0 || n == 0 {
dst.Pix[d], dst.Pix[d+1], dst.Pix[d+2], dst.Pix[d+3] = 255, 255, 255, 255
continue
}
dst.Pix[d] = uint8(r / a)
dst.Pix[d+1] = uint8(g / a)
dst.Pix[d+2] = uint8(bl / a)
dst.Pix[d+3] = uint8(a / n)
}
}
return dst
}
// flatten 把任意 image.Image 转成 w*h 扁平非预乘 RGBA 字节。
func flatten(src image.Image, b image.Rectangle) []byte {
w, h := b.Dx(), b.Dy()
out := make([]byte, w*h*4)
i := 0
for y := b.Min.Y; y < b.Max.Y; y++ {
for x := b.Min.X; x < b.Max.X; x++ {
r, g, bl, a := src.At(x, y).RGBA() // 0..0xffff,预乘
pa := a >> 8
if pa == 0 {
out[i], out[i+1], out[i+2], out[i+3] = 0, 0, 0, 0
} else {
out[i] = uint8(min255((r >> 8) * 0xff / pa))
out[i+1] = uint8(min255((g >> 8) * 0xff / pa))
out[i+2] = uint8(min255((bl >> 8) * 0xff / pa))
out[i+3] = uint8(pa)
}
i += 4
}
}
return out
}
func min255(v uint32) uint32 {
if v > 0xff {
return 0xff
}
return v
}
// Store 是缩略图磁盘缓存:
//
// {Dir}/{sha[:2]}/{sha[:32]}-{w}.jpg 成功产物(直通可能是 .png/.gif)
// {Dir}/{sha[:2]}/{sha[:32]}.failed 失败标记
//
// 文件名以原图内容哈希(files.sha256)为键:内容变则键变,缓存天然失效。
// 缓存只增不减会慢慢吃磁盘,所以 Put 之后按 MaxBytes 做容量闸淘汰(LRU:
// 命中时 Touch 更新 mtime,淘汰时先下手最久没人要的)。
type Store struct {
Dir string
MaxBytes int64
mu sync.Mutex
locks map[string]*sync.Mutex
lastSweep time.Time
sweeping bool
}
// DefaultMaxBytes 是缓存总量上限:约 1700 张 150KB 的缩略图,
// 远超单用户博客的实际存量,纯粹是防多年以后无限涨。
const DefaultMaxBytes = 256 << 20
// sweepInterval 限制扫描频率——淘汰是后台维护,不该拖慢写缓存的请求。
const sweepInterval = 10 * time.Minute
func NewStore(dir string) *Store {
return &Store{Dir: dir, MaxBytes: DefaultMaxBytes, locks: make(map[string]*sync.Mutex)}
}
func (s *Store) dir(sha string) string { return filepath.Join(s.Dir, sha[:2]) }
func (s *Store) jpegPath(sha string, w int) string {
return filepath.Join(s.dir(sha), fmt.Sprintf("%s-%d.jpg", sha[:32], w))
}
func (s *Store) failedPath(sha string) string {
return filepath.Join(s.dir(sha), sha[:32]+".failed")
}
// FindCached 返回已存在的缓存文件(jpg 优先,其次直通的 png/gif),没有则空串。
func (s *Store) FindCached(sha string, w int) string {
base := strings.TrimSuffix(s.jpegPath(sha, w), ".jpg")
for _, ext := range []string{".jpg", ".png", ".gif"} {
if fi, err := os.Stat(base + ext); err == nil && !fi.IsDir() {
return base + ext
}
}
return ""
}
// Put 原子写入缓存文件(内容直通时保留原生格式),成功则清掉失败标记。
func (s *Store) Put(sha string, w int, data []byte, mime string) (string, error) {
p := s.jpegPath(sha, w)
if mime != "image/jpeg" {
p = strings.TrimSuffix(p, ".jpg") + extFor(mime)
}
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
return "", err
}
tmp := p + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return "", err
}
if err := os.Rename(tmp, p); err != nil {
os.Remove(tmp)
return "", err
}
_ = os.Remove(s.failedPath(sha))
s.maybeSweep()
return p, nil
}
// Touch 把缓存文件的 mtime 拨到现在:命中一次就等于「最近还要用」,
// 供容量闸按 LRU 淘汰。失败无所谓(只影响淘汰顺序)。
func (s *Store) Touch(path string) {
now := time.Now()
_ = os.Chtimes(path, now, now)
}
// Purge 清掉某个原图(按内容哈希)的全部缩略图与失败标记。
// 删除上传文件时调用,否则缓存会一直留着已删图片的产物。
func (s *Store) Purge(sha string) {
if len(sha) < 32 {
return
}
entries, err := os.ReadDir(s.dir(sha))
if err != nil {
return
}
for _, e := range entries {
if strings.HasPrefix(e.Name(), sha[:32]) { // {sha}-{w}.jpg / {sha}.failed
_ = os.Remove(filepath.Join(s.dir(sha), e.Name()))
}
}
_ = os.Remove(s.dir(sha)) // 目录空了就收掉
}
// FailedRecently 报告是否在冷却期(近期生成失败过)。
func (s *Store) FailedRecently(sha string) bool {
fi, err := os.Stat(s.failedPath(sha))
return err == nil && time.Since(fi.ModTime()) < failedTTL
}
// MarkFailed 落失败标记。
func (s *Store) MarkFailed(sha string) {
if err := os.MkdirAll(s.dir(sha), 0o755); err == nil {
_ = os.WriteFile(s.failedPath(sha), []byte("thumbs: generation failed\n"), 0o644)
}
}
// Lock 取该(哈希, 宽度)的互斥锁:并发首请求只生成一次,其余等待后读缓存。
func (s *Store) Lock(sha string, w int) *sync.Mutex {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.locks) > lockCacheMax {
clear(s.locks) // 粗粒度回收:正在持有的锁不受影响(调用方拿着指针),
} // 清掉的只是历史条目,最坏情况是同一图并发重生成一次
k := fmt.Sprintf("%s-%d", sha[:32], w)
l, ok := s.locks[k]
if !ok {
l = &sync.Mutex{}
s.locks[k] = l
}
return l
}
// lockCacheMax 限制锁表大小(每张图最多 3 种宽度),博客体量下足够。
const lockCacheMax = 4096
// maybeSweep 在写入新缓存后检查总量;超上限就按 mtime 从旧到新删,
// 删到 3/4 水位(留滞回,避免每次写入都刚好卡在边界反复删)。
// 每 sweepInterval 最多跑一次,且同一时刻只有一个在跑。
func (s *Store) maybeSweep() {
if s.MaxBytes <= 0 {
return
}
s.mu.Lock()
if s.sweeping || time.Since(s.lastSweep) < sweepInterval {
s.mu.Unlock()
return
}
s.sweeping, s.lastSweep = true, time.Now()
s.mu.Unlock()
defer func() {
s.mu.Lock()
s.sweeping = false
s.mu.Unlock()
}()
var files []fileInfo
var total int64
_ = filepath.WalkDir(s.Dir, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
// 半途而废的临时文件(进程被杀)直接清
if strings.HasSuffix(path, ".tmp") {
_ = os.Remove(path)
return nil
}
fi, err := d.Info()
if err != nil {
return nil
}
total += fi.Size()
files = append(files, fileInfo{path: path, size: fi.Size(), mtime: fi.ModTime()})
return nil
})
if total <= s.MaxBytes {
return
}
sort.Slice(files, func(i, j int) bool { return files[i].mtime.Before(files[j].mtime) })
lowWater := s.MaxBytes * 3 / 4
for _, f := range files {
if total <= lowWater {
break
}
if err := os.Remove(f.path); err == nil {
total -= f.size
}
}
}
type fileInfo struct {
path string
size int64
mtime time.Time
}
func extFor(mime string) string {
switch mime {
case "image/png":
return ".png"
case "image/gif":
return ".gif"
}
return ".jpg"
}
// ReadAllLimited 读至多 max 字节,超限报错(防止把 25MB+ 原图吞进内存)。
func ReadAllLimited(r io.Reader, max int64) ([]byte, error) {
data, err := io.ReadAll(io.LimitReader(r, max+1))
if err != nil {
return nil, err
}
if int64(len(data)) > max {
return nil, fmt.Errorf("source exceeds %d bytes", max)
}
return data, nil
}
+172
View File
@@ -0,0 +1,172 @@
package thumbs
import (
"bytes"
"image"
"image/color"
"image/jpeg"
"image/png"
"io/fs"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func makePNG(t *testing.T, w, h int, transparent bool) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
// 左红右绿两半,方便验证盒均值没把通道混错
c := color.RGBA{255, 0, 0, 255}
if x >= w/2 {
c = color.RGBA{0, 255, 0, 255}
}
if transparent {
c = color.RGBA{0, 0, 0, 0}
}
img.Set(x, y, c)
}
}
var b bytes.Buffer
if err := png.Encode(&b, img); err != nil {
t.Fatal(err)
}
return b.Bytes()
}
func TestGenerateDownscale(t *testing.T) {
src := makePNG(t, 800, 400, false)
out, mime, pass, err := Generate(src, "image/png", 400)
if err != nil {
t.Fatal(err)
}
if pass {
t.Fatal("800px 图在 w=400 不应直通")
}
if mime != "image/jpeg" {
t.Fatalf("mime=%s", mime)
}
cfg, _, err := image.DecodeConfig(bytes.NewReader(out))
if err != nil {
t.Fatal(err)
}
if cfg.Width != 400 || cfg.Height != 200 {
t.Fatalf("got %dx%d, want 400x200", cfg.Width, cfg.Height)
}
img, err := jpeg.Decode(bytes.NewReader(out))
if err != nil {
t.Fatal(err)
}
// 盒均值后左右两半的中心仍是红/绿(容差给 JPEG 质量 82 留余量)
r, g, _, _ := img.At(100, 100).RGBA()
if r>>8 < 200 || g>>8 > 60 {
t.Fatalf("左半应偏红: r=%d g=%d", r>>8, g>>8)
}
r, g, _, _ = img.At(300, 100).RGBA()
if g>>8 < 200 || r>>8 > 60 {
t.Fatalf("右半应偏绿: r=%d g=%d", r>>8, g>>8)
}
}
func TestGeneratePassThrough(t *testing.T) {
src := makePNG(t, 300, 150, false)
out, mime, pass, err := Generate(src, "image/png", 960)
if err != nil {
t.Fatal(err)
}
if !pass || mime != "image/png" || !bytes.Equal(out, src) {
t.Fatalf("小图应原样直通 pass=%v mime=%s", pass, mime)
}
}
func TestGenerateRejectsGarbage(t *testing.T) {
if _, _, _, err := Generate([]byte("not an image"), "image/jpeg", 400); err == nil {
t.Fatal("垃圾字节应报错")
}
}
func TestFlattenAlphaUnpremultiply(t *testing.T) {
// 50% 透明的纯红:alpha=128 预乘 R=128,还原应为 R≈255
img := image.NewNRGBA(image.Rect(0, 0, 1, 1))
img.Set(0, 0, color.NRGBA{255, 0, 0, 128})
flat := flatten(img, img.Bounds())
if flat[0] < 240 || flat[3] != 128 {
t.Fatalf("unpremultiply wrong: r=%d a=%d", flat[0], flat[3])
}
}
func TestSweepEvictsOldest(t *testing.T) {
s := NewStore(t.TempDir())
s.MaxBytes = 2500 // 每个文件 1000B,留得下 2 个
sha := func(i int) string { return strings.Repeat(string(rune('a'+i)), 64) }
// 三个宽度:写入顺序即 mtime 顺序(下面再显式拨时间)
for i := 0; i < 3; i++ {
if _, err := s.Put(sha(i), 480, bytes.Repeat([]byte("x"), 1000), "image/jpeg"); err != nil {
t.Fatal(err)
}
}
// 跳过节流,让这次 Put 真的触发扫描
s.lastSweep = time.Time{}
oldest := time.Now().Add(-3 * time.Hour)
for i := 0; i < 2; i++ { // 前两个假装很久没人访问
os.Chtimes(s.jpegPath(sha(i), 480), oldest, oldest)
}
if _, err := s.Put(sha(3), 480, bytes.Repeat([]byte("x"), 1000), "image/jpeg"); err != nil {
t.Fatal(err)
}
var total int64
var kept []string
filepath.WalkDir(s.Dir, func(p string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
fi, _ := d.Info()
total += fi.Size()
kept = append(kept, d.Name())
return nil
})
if total > s.MaxBytes {
t.Fatalf("sweep 后仍超限: total=%d max=%d files=%v", total, s.MaxBytes, kept)
}
if len(kept) >= 4 {
t.Fatalf("一个都没淘汰,说明扫描没跑: files=%v", kept)
}
// 最新写入的那个必须还在
if s.FindCached(sha(3), 480) == "" {
t.Fatalf("刚写入的缓存被误删: %v", kept)
}
}
func TestPurgeRemovesAllEntriesForFile(t *testing.T) {
s := NewStore(t.TempDir())
sha := strings.Repeat("f", 64)
for _, w := range []int{480, 960} {
if _, err := s.Put(sha, w, []byte("data"), "image/jpeg"); err != nil {
t.Fatal(err)
}
}
s.MarkFailed(sha)
other := strings.Repeat("a", 64)
if _, err := s.Put(other, 480, []byte("keep"), "image/jpeg"); err != nil {
t.Fatal(err)
}
s.Purge(sha)
for _, w := range []int{480, 960} {
if p := s.FindCached(sha, w); p != "" {
t.Fatalf("purge 后仍残留 %s", p)
}
}
if s.FailedRecently(sha) {
t.Fatal("失败标记没清掉")
}
if s.FindCached(other, 480) == "" {
t.Fatal("误删了别的文件的缓存")
}
}
+53 -2
View File
@@ -18,9 +18,13 @@ import (
"oneblog/internal/admin"
"oneblog/internal/api"
"oneblog/internal/auth"
"oneblog/internal/config"
"oneblog/internal/db"
"oneblog/internal/hub"
"oneblog/internal/storage"
"oneblog/internal/store"
"oneblog/internal/thumbs"
)
func main() {
@@ -46,12 +50,51 @@ func main() {
log.Fatalf("store: %v", err)
}
public := &api.API{Store: st, Cfg: cfg}
adminAPI := admin.NewAPI(st, cfg, admin.NewSessions(cfg.SessionSec, 7*24*time.Hour))
// 文件上传的存储后端:R2 配置齐全用 R2,否则本地磁盘兜底。
// 两个 API 共享同一实例;公开访问路由 /uploads/ 也走它。
var blobs storage.BlobStore
switch cfg.StorageDriver {
case "r2":
blobs, err = storage.NewR2(cfg.S3Endpoint, cfg.R2Bucket, cfg.R2AccessKey, cfg.R2SecretKey)
if err != nil {
log.Fatalf("storage: %v", err)
}
default:
blobs = storage.NewLocal(filepath.Join(cfg.DataDir, "uploads"))
}
// 评论区的读者会话(cookie one_reader),30 天有效
readerSessions := auth.NewReaderSessions(cfg.SessionSec, 30*24*time.Hour)
// 后台管理员会话(cookie one_session),前台评论也用它识别站主身份
adminSessions := admin.NewSessions(cfg.SessionSec, 7*24*time.Hour)
// 评论变更广播(SSE):公开端订阅、双端发布,共用同一实例
commentHub := hub.New()
// 缩略图懒生成 + 磁盘缓存(首次请求生成一次,之后直接供缓存)
thumbCache := thumbs.NewStore(filepath.Join(cfg.DataDir, ".thumbnail_cache"))
public := &api.API{
Store: st,
Cfg: cfg,
Blobs: blobs,
Thumbs: thumbCache,
ReaderSessions: readerSessions,
GH: auth.GitHub{ClientID: cfg.GitHubClientID, ClientSecret: cfg.GitHubClientSecret},
GG: auth.Google{ClientID: cfg.GoogleClientID, ClientSecret: cfg.GoogleClientSecret},
TG: auth.Telegram{Bot: cfg.TelegramBot, Token: cfg.TelegramToken},
AdminSessions: adminSessions,
Hub: commentHub,
}
adminAPI := admin.NewAPI(st, cfg, adminSessions)
adminAPI.Hub = commentHub
adminAPI.Blobs = blobs
adminAPI.Thumbs = thumbCache // 删文件时连带清掉它的缩略图
root := http.NewServeMux()
root.Handle("/api/admin/", adminAPI.Routes())
public.Mount(root)
// 更长的前缀优先匹配:/uploads/thumb/ 走缩略图,其余 /uploads/ 走原图流式
root.Handle("/uploads/thumb/", public.ThumbHandler())
root.Handle("/uploads/", public.UploadsHandler())
root.Handle("/", spaHandler(cfg.WebDist))
srv := &http.Server{
@@ -149,6 +192,14 @@ type statusRecorder struct {
func (s *statusRecorder) WriteHeader(code int) { s.status = code; s.ResponseWriter.WriteHeader(code) }
// Flush 透传给底层 writer——SSE 这类流式响应靠它判定可刷新,
// 包装器不实现 Flusher 的话流式端点会直接不可用。
func (s *statusRecorder) Flush() {
if f, ok := s.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
func placeholderPage() string {
return `<!doctype html><meta charset="utf-8"><title>ONE</title>
<style>body{font-family:-apple-system,"PingFang SC",sans-serif;background:#faf7f1;color:#33302b;
+5
View File
@@ -10,6 +10,11 @@
"preview": "vite preview"
},
"dependencies": {
"@codemirror/commands": "^6.11.1",
"@codemirror/lang-markdown": "^6.5.2",
"@codemirror/language": "^6.12.4",
"@codemirror/state": "^6.7.6",
"@codemirror/view": "^6.43.13",
"@milkdown/crepe": "^7.22.1",
"dompurify": "^3.1.6",
"vue": "^3.4.0",
+63 -48
View File
@@ -8,6 +8,21 @@ importers:
.:
dependencies:
'@codemirror/commands':
specifier: ^6.11.1
version: 6.11.1
'@codemirror/lang-markdown':
specifier: ^6.5.2
version: 6.5.2
'@codemirror/language':
specifier: ^6.12.4
version: 6.12.4
'@codemirror/state':
specifier: ^6.7.6
version: 6.7.6
'@codemirror/view':
specifier: ^6.43.13
version: 6.43.13
'@milkdown/crepe':
specifier: ^7.22.1
version: 7.22.1(prosemirror-model@1.25.11)(prosemirror-state@1.4.4)(prosemirror-view@1.42.4)(typescript@5.9.3)
@@ -140,14 +155,14 @@ packages:
'@codemirror/search@6.7.2':
resolution: {integrity: sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==}
'@codemirror/state@6.7.5':
resolution: {integrity: sha512-QjLbZmY1Au3JiRrDVYFLRD0BZ3SOKS9pR3yjIkd7u27YY8TFD9/Q9fhPnLV5l1mHFSo3hHU/N31vpwEJOx4owQ==}
'@codemirror/state@6.7.6':
resolution: {integrity: sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==}
'@codemirror/theme-one-dark@6.1.3':
resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==}
'@codemirror/view@6.43.12':
resolution: {integrity: sha512-Nv0vxQ19NAqvB/c2pFzjIzFlzzJl7jmdtNkwOwGbn0Ks9mFAzibvumz7cQem5cRsFA2cEw2fg+uHZGbcHupLQQ==}
'@codemirror/view@6.43.13':
resolution: {integrity: sha512-sihaFrUzAsYBQsL9J2t69y8nfMQGwcYmggAZsk+kjPbjYZMyuf2hU8tUNTZ+P+isb6XRr8JE22TZlJxBoVdH1A==}
'@esbuild/aix-ppc64@0.21.5':
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
@@ -1118,15 +1133,15 @@ snapshots:
'@codemirror/autocomplete@6.20.3':
dependencies:
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5
'@codemirror/view': 6.43.12
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
'@lezer/common': 1.5.2
'@codemirror/commands@6.11.1':
dependencies:
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5
'@codemirror/view': 6.43.12
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
'@lezer/common': 1.5.2
'@codemirror/lang-angular@0.1.4':
@@ -1147,7 +1162,7 @@ snapshots:
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5
'@codemirror/state': 6.7.6
'@lezer/common': 1.5.2
'@lezer/css': 1.3.6
@@ -1155,7 +1170,7 @@ snapshots:
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5
'@codemirror/state': 6.7.6
'@lezer/common': 1.5.2
'@lezer/go': 1.0.1
@@ -1165,8 +1180,8 @@ snapshots:
'@codemirror/lang-css': 6.3.1
'@codemirror/lang-javascript': 6.2.5
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5
'@codemirror/view': 6.43.12
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
'@lezer/common': 1.5.2
'@lezer/css': 1.3.6
'@lezer/html': 1.3.13
@@ -1181,8 +1196,8 @@ snapshots:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
'@codemirror/lint': 6.9.7
'@codemirror/state': 6.7.5
'@codemirror/view': 6.43.12
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
'@lezer/common': 1.5.2
'@lezer/javascript': 1.5.5
@@ -1191,8 +1206,8 @@ snapshots:
'@codemirror/autocomplete': 6.20.3
'@codemirror/lang-html': 6.4.12
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5
'@codemirror/view': 6.43.12
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
@@ -1215,8 +1230,8 @@ snapshots:
'@codemirror/autocomplete': 6.20.3
'@codemirror/lang-html': 6.4.12
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5
'@codemirror/view': 6.43.12
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
@@ -1226,8 +1241,8 @@ snapshots:
'@codemirror/autocomplete': 6.20.3
'@codemirror/lang-html': 6.4.12
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5
'@codemirror/view': 6.43.12
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
'@lezer/common': 1.5.2
'@lezer/markdown': 1.7.2
@@ -1235,7 +1250,7 @@ snapshots:
dependencies:
'@codemirror/lang-html': 6.4.12
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5
'@codemirror/state': 6.7.6
'@lezer/common': 1.5.2
'@lezer/php': 1.0.6
@@ -1243,7 +1258,7 @@ snapshots:
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5
'@codemirror/state': 6.7.6
'@lezer/common': 1.5.2
'@lezer/python': 1.1.19
@@ -1256,7 +1271,7 @@ snapshots:
dependencies:
'@codemirror/lang-css': 6.3.1
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5
'@codemirror/state': 6.7.6
'@lezer/common': 1.5.2
'@lezer/sass': 1.1.0
@@ -1264,7 +1279,7 @@ snapshots:
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5
'@codemirror/state': 6.7.6
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
@@ -1289,8 +1304,8 @@ snapshots:
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5
'@codemirror/view': 6.43.12
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
'@lezer/common': 1.5.2
'@lezer/xml': 1.0.6
@@ -1298,7 +1313,7 @@ snapshots:
dependencies:
'@codemirror/autocomplete': 6.20.3
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5
'@codemirror/state': 6.7.6
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
@@ -1332,8 +1347,8 @@ snapshots:
'@codemirror/language@6.12.4':
dependencies:
'@codemirror/state': 6.7.5
'@codemirror/view': 6.43.12
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
'@lezer/common': 1.5.2
'@lezer/highlight': 1.2.3
'@lezer/lr': 1.4.10
@@ -1345,30 +1360,30 @@ snapshots:
'@codemirror/lint@6.9.7':
dependencies:
'@codemirror/state': 6.7.5
'@codemirror/view': 6.43.12
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
crelt: 1.0.7
'@codemirror/search@6.7.2':
dependencies:
'@codemirror/state': 6.7.5
'@codemirror/view': 6.43.12
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
crelt: 1.0.7
'@codemirror/state@6.7.5':
'@codemirror/state@6.7.6':
dependencies:
'@marijn/find-cluster-break': 1.0.4
'@codemirror/theme-one-dark@6.1.3':
dependencies:
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5
'@codemirror/view': 6.43.12
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
'@lezer/highlight': 1.2.3
'@codemirror/view@6.43.12':
'@codemirror/view@6.43.13':
dependencies:
'@codemirror/state': 6.7.5
'@codemirror/state': 6.7.6
crelt: 1.0.7
style-mod: 4.1.4
w3c-keyname: 2.2.8
@@ -1550,11 +1565,11 @@ snapshots:
'@marijn/find-cluster-break@1.0.4': {}
'@milkdown/components@7.22.1(@codemirror/language@6.12.4)(@codemirror/state@6.7.5)(@codemirror/view@6.43.12)(typescript@5.9.3)':
'@milkdown/components@7.22.1(@codemirror/language@6.12.4)(@codemirror/state@6.7.6)(@codemirror/view@6.43.13)(typescript@5.9.3)':
dependencies:
'@codemirror/language': 6.12.4
'@codemirror/state': 6.7.5
'@codemirror/view': 6.43.12
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
'@floating-ui/dom': 1.8.0
'@milkdown/core': 7.22.1
'@milkdown/ctx': 7.22.1
@@ -1594,10 +1609,10 @@ snapshots:
'@codemirror/commands': 6.11.1
'@codemirror/language': 6.12.4
'@codemirror/language-data': 6.5.2
'@codemirror/state': 6.7.5
'@codemirror/state': 6.7.6
'@codemirror/theme-one-dark': 6.1.3
'@codemirror/view': 6.43.12
'@milkdown/kit': 7.22.1(@codemirror/language@6.12.4)(@codemirror/state@6.7.5)(@codemirror/view@6.43.12)(typescript@5.9.3)
'@codemirror/view': 6.43.13
'@milkdown/kit': 7.22.1(@codemirror/language@6.12.4)(@codemirror/state@6.7.6)(@codemirror/view@6.43.13)(typescript@5.9.3)
'@types/lodash-es': 4.17.12
clsx: 2.1.1
codemirror: 6.0.2
@@ -1621,9 +1636,9 @@ snapshots:
'@milkdown/exception@7.22.1': {}
'@milkdown/kit@7.22.1(@codemirror/language@6.12.4)(@codemirror/state@6.7.5)(@codemirror/view@6.43.12)(typescript@5.9.3)':
'@milkdown/kit@7.22.1(@codemirror/language@6.12.4)(@codemirror/state@6.7.6)(@codemirror/view@6.43.13)(typescript@5.9.3)':
dependencies:
'@milkdown/components': 7.22.1(@codemirror/language@6.12.4)(@codemirror/state@6.7.5)(@codemirror/view@6.43.12)(typescript@5.9.3)
'@milkdown/components': 7.22.1(@codemirror/language@6.12.4)(@codemirror/state@6.7.6)(@codemirror/view@6.43.13)(typescript@5.9.3)
'@milkdown/core': 7.22.1
'@milkdown/ctx': 7.22.1
'@milkdown/exception': 7.22.1
@@ -2036,8 +2051,8 @@ snapshots:
'@codemirror/language': 6.12.4
'@codemirror/lint': 6.9.7
'@codemirror/search': 6.7.2
'@codemirror/state': 6.7.5
'@codemirror/view': 6.43.12
'@codemirror/state': 6.7.6
'@codemirror/view': 6.43.13
commander@15.0.0: {}
+29 -1
View File
@@ -1,13 +1,39 @@
<script setup>
import { computed, watch } from 'vue'
import { computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRoute } from 'vue-router'
import TopBar from './components/TopBar.vue'
import ThemeSwitcher from './components/ThemeSwitcher.vue'
import ImgLightbox from './components/ImgLightbox.vue'
import YohakuTop from './ui/yohaku/YohakuTop.vue'
import YohakuFoot from './ui/yohaku/YohakuFoot.vue'
import { site, setAdminScope } from './site'
const route = useRoute()
// 正文代码块复制按钮(事件委托:复制按钮是 enhanceProse 注入的静态 HTML)
function onCopyClick(e) {
const btn = e.target.closest?.('.pre-copy')
if (!btn) return
const pre = btn.closest('.pre-wrap')?.querySelector('pre')
if (!pre) return
navigator.clipboard
.writeText(pre.innerText.replace(/\n$/, ''))
.then(() => {
btn.textContent = '已复制'
btn.classList.add('done')
setTimeout(() => {
btn.textContent = '复制'
btn.classList.remove('done')
}, 1600)
})
.catch(() => {
btn.textContent = '复制失败'
setTimeout(() => (btn.textContent = '复制'), 1600)
})
}
onMounted(() => document.addEventListener('click', onCopyClick))
onBeforeUnmount(() => document.removeEventListener('click', onCopyClick))
const isAdmin = computed(() => route.path.startsWith('/admin'))
// vivid 只在公开前台生效:后台永远走 classic 那套(--admin-* token + 原 data-ui/data-theme),
// 不受 ui_id 影响。vivid.css 会命中 .btn / .input 这类全局类,后台表单正在用,
@@ -36,6 +62,8 @@ watch(
<RouterView />
<YohakuFoot v-if="isVivid" />
<!-- classic 的页脚信息在 RightRail 卡片下方的小字区(Twitter 式侧栏页脚) -->
<!-- 图片预览层:时间线 / 详情页的配图点开时全屏展示 -->
<ImgLightbox />
<!-- 桌面端:左栏底部放 inline 形态;窄屏:左栏收起,浮动按钮兜底。
vivid 的顶部导航自带切换器,浮动那个就不重复出现了。 -->
<ThemeSwitcher v-if="!isVivid" variant="floating" class="theme-floating-only" />
+12
View File
@@ -1,5 +1,6 @@
<script setup>
import { computed, onMounted, onBeforeUnmount, ref, watch } from 'vue'
import ToastStack from './ToastStack.vue'
import { useRoute, useRouter, RouterLink, RouterView } from 'vue-router'
import { checkAuth } from './auth'
import { adminApi, session } from '../api'
@@ -59,6 +60,8 @@ const crumb = computed(() => {
const p = route.path
if (p.endsWith('/new')) return '写新的'
if (/^\/admin\/\d+/.test(p)) return '编辑文章'
if (p.endsWith('/files')) return '文件'
if (p.endsWith('/comments')) return '评论'
if (p.endsWith('/tags')) return '标签'
if (p.endsWith('/settings')) return '设置'
if (p === '/admin' || p === '/admin/') return '总览'
@@ -94,6 +97,7 @@ watch(() => route.path, () => loadCounts())
<template>
<div v-if="ready" class="admin-shell">
<ToastStack />
<aside class="admin-side">
<div class="brand">
<RouterLink to="/admin" class="name">{{ site.site_title || 'ONE' }}</RouterLink>
@@ -119,6 +123,14 @@ watch(() => route.path, () => loadCounts())
<span class="ic">◈</span>
<span>作品</span>
</RouterLink>
<RouterLink to="/admin/files" class="item">
<span class="ic">▣</span>
<span>文件</span>
</RouterLink>
<RouterLink to="/admin/comments" class="item">
<span class="ic">❝</span>
<span>评论</span>
</RouterLink>
<div class="group">操作</div>
<RouterLink to="/admin/new" class="item">
<span class="ic">✎</span>
+218
View File
@@ -0,0 +1,218 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { toastOk, toastErr } from './toast'
import { useRoute, useRouter } from 'vue-router'
import { adminApi } from '../api'
import { relativeDate, stripTags } from '../utils'
// 评论管理:待审队列优先,通过 / 删除 / 禁言都在行内完成。
// 删除走软删(墓碑保楼层);禁言按读者维度,禁言中的行上有标记。
const route = useRoute()
const router = useRouter()
const items = ref([])
const total = ref(0)
const loading = ref(true)
const error = ref('')
const status = ref(route.query.status === 'any' || route.query.status === 'visible' ? route.query.status : 'pending')
const page = computed(() => Number(route.query.page || 1))
const size = 20
let seq = 0
async function load() {
const my = ++seq
loading.value = true
error.value = ''
try {
const data = await adminApi.comments({ status: status.value, page: page.value, size })
if (my !== seq) return
items.value = data.items || []
total.value = data.total || 0
} catch (e) {
if (my !== seq) return
error.value = e.message || '加载失败'
items.value = []
} finally {
if (my === seq) loading.value = false
}
}
onMounted(load)
watch([status, page], load)
function setStatus(v) {
status.value = v
router.replace({ query: { ...route.query, status: v === 'pending' ? undefined : v, page: 1 } })
}
async function approve(id) {
await adminApi.approveComment(id)
toastOk('已通过,前台可见')
load()
}
async function remove(id) {
if (!window.confirm('删除这条评论?正文清空,楼层保留为「已删除」。')) return
await adminApi.deleteComment(id)
toastOk('已删除')
load()
}
async function banToggle(c) {
const banned = !c.user?.banned
if (!window.confirm(banned ? `禁言「${c.user?.name || '该用户'}」?禁言后其无法再发表评论。` : '解除禁言?')) return
await adminApi.setReaderBanned(c.user?.id, banned)
toastOk(banned ? '已禁言' : '已解除禁言')
c.user = { ...(c.user || {}), banned }
}
const tabs = [
{ label: '待审', value: 'pending' },
{ label: '已通过', value: 'visible' },
{ label: '全部', value: 'any' }
]
</script>
<template>
<section>
<header style="display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 14px;">
<h1 style="font-family: var(--serif); font-size: 22px;">
评论 <span style="font-family: var(--sans); font-size: 13px; color: var(--admin-muted); font-weight: 400;">{{ total }}</span>
</h1>
</header>
<div class="posts-toolbar" style="margin-bottom: 16px;">
<button class="chip" :class="{ on: status === 'pending' }" @click="setStatus('pending')">待审</button>
<button class="chip" :class="{ on: status === 'visible' }" @click="setStatus('visible')">已通过</button>
<button class="chip" :class="{ on: status === 'any' }" @click="setStatus('any')">全部</button>
</div>
<div v-if="loading" class="loading">载入中…</div>
<div v-else-if="error" class="empty">{{ error }}</div>
<div v-else-if="!items.length" class="empty">
{{ status === 'pending' ? '没有待审的评论。' : '还没有评论。' }}
</div>
<template v-else>
<div v-for="c in items" :key="c.id" class="cm-row">
<div class="cm-ava" aria-hidden="true">{{ (c.user?.name || '?').slice(0, 1) }}</div>
<div class="cm-body-col">
<div class="cm-row-head">
<span class="cm-name" :title="c.user?.handle || ''">{{ c.user?.name || '匿名' }}</span>
<span v-if="c.user?.banned" class="cm-flag">禁言中</span>
<span v-if="c.status === 'pending'" class="cm-flag pend">待审</span>
<span class="cm-time" :title="c.created_at">{{ relativeDate(c.created_at) }}</span>
</div>
<div class="cm-text">{{ stripTags(c.body_html) }}</div>
<div class="cm-post">on {{ c.post_title || '无题' }}</div>
</div>
<div class="cm-acts">
<a v-if="c.status === 'pending'" href="#" @click.prevent="approve(c.id)">通过</a>
<a href="#" class="danger" @click.prevent="remove(c.id)">删除</a>
<a href="#" @click.prevent="banToggle(c)">{{ c.user?.banned ? '解除禁言' : '禁言' }}</a>
</div>
</div>
<nav
v-if="pageCount > 1"
aria-label="分页"
style="display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 18px;"
>
<button class="btn" :disabled="page <= 1" @click="router.replace({ query: { ...route.query, page: page - 1 } })" aria-label="上一页">
← 上一页
</button>
<span style="font-size: 13px; color: var(--admin-muted);" aria-live="polite">第 {{ page }} / {{ pageCount }} 页</span>
<button class="btn" :disabled="page >= pageCount" @click="router.replace({ query: { ...route.query, page: page + 1 } })" aria-label="下一页">
下一页 →
</button>
</nav>
</template>
</section>
</template>
<style scoped>
.cm-row {
display: grid;
grid-template-columns: 40px minmax(0, 1fr) auto;
gap: 12px;
padding: 14px 4px;
border-bottom: 1px solid var(--admin-line-soft);
}
.cm-ava {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--admin-paper-sunken);
color: var(--admin-ink);
display: grid;
place-items: center;
font-family: var(--serif);
font-size: 16px;
user-select: none;
}
.cm-row-head {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 8px;
font-size: 13px;
color: var(--admin-muted);
}
.cm-name {
font-weight: 600;
color: var(--admin-ink);
}
.cm-flag {
padding: 0 5px;
border: 1px solid var(--admin-line);
border-radius: 2px;
font-size: 11px;
color: var(--admin-muted);
}
.cm-flag.pend {
color: #9a5b45;
border-color: #ddcdc2;
}
.cm-time {
color: var(--admin-faint);
}
.cm-text {
margin-top: 4px;
font-size: 14.5px;
line-height: 1.75;
color: var(--admin-ink);
overflow-wrap: anywhere;
}
.cm-post {
margin-top: 4px;
font-size: 12px;
color: var(--admin-faint);
}
.cm-acts {
display: flex;
gap: 12px;
align-items: baseline;
font-size: 12.5px;
}
.cm-acts a {
color: var(--admin-muted);
}
.cm-acts a:hover {
color: var(--admin-accent);
}
.cm-acts a.danger:hover {
color: #b4553f;
}
</style>
+393 -47
View File
@@ -1,10 +1,16 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { toast, toastOk, toastErr, toastUpdate, toastDone } from './toast'
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
import { adminApi } from '../api'
import { site } from '../site'
import { Crepe, CrepeFeature } from '@milkdown/crepe'
import '@milkdown/crepe/theme/common/style.css'
import '@milkdown/crepe/theme/frame.css'
import { EditorView as CMView, keymap } from '@codemirror/view'
import { EditorState } from '@codemirror/state'
import { markdown as markdownLang } from '@codemirror/lang-markdown'
import { history, defaultKeymap, historyKeymap, indentWithTab } from '@codemirror/commands'
const route = useRoute()
const router = useRouter()
@@ -141,6 +147,11 @@ async function initEditor() {
featureConfigs: {
[CrepeFeature.Placeholder]: {
text: form.kind === 'short' ? '写点什么…' : '开始写,或按 / 唤出命令菜单'
},
// 图片上传:wysiwyg 里的粘贴 / 拖拽 / 插图按钮全部走后台文件上传,
// onUpload 返回 URL 后由 Crepe 自己插节点
[CrepeFeature.ImageBlock]: {
onUpload: (file) => uploadOne(file)
}
}
})
@@ -179,6 +190,10 @@ onBeforeUnmount(() => {
crepe.destroy()
crepe = null
}
if (cmView) {
cmView.destroy()
cmView = null
}
})
// ---------- mode switching ----------
@@ -190,6 +205,10 @@ async function switchMode(next) {
crepe.destroy()
crepe = null
}
// CM 懒挂载;已挂载就把 wysiwyg 期间产生的 markdown 灌回来
await nextTick()
mountCM()
syncCM()
} else {
// MD → WYSIWYG: spin up crepe with the current markdown
await nextTick()
@@ -262,6 +281,9 @@ async function autosave() {
function applySaved(saved) {
form.slug = saved.slug
form.status = saved.status
// 自动摘要/自动封面在服务端补齐的,回写表单让站主看见(清空仍会保留为空)
if (typeof saved.summary === 'string') form.summary = saved.summary
if (typeof saved.cover_url === 'string') form.cover_url = saved.cover_url
publishedLocal.value = isoToLocal(saved.published_at)
}
@@ -298,7 +320,7 @@ function buildPayload() {
async function save(status) {
if (status) form.status = status
if (!isEdit.value && !form.content_md.trim() && !form.title.trim()) {
error.value = '先写点什么再保存'
toastErr('先写点什么再保存')
return
}
saving.value = true
@@ -312,12 +334,17 @@ async function save(status) {
const created = await adminApi.createPost(payload)
localStorage.removeItem(DRAFT_KEY)
dirty.value = false
toastOk('已创建')
router.replace(`/admin/${created.id}`)
return
}
dirty.value = false
savedAt.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
// 手动保存(按钮/⌘S/发布)给回音;自动保存静默,不打扰
const label = status === 'published' ? '已发布' : status === 'draft' ? '已转回草稿' : '已保存'
toastOk(label + ' · ' + savedAt.value)
} catch (e) {
toastErr(e.message || '保存失败')
error.value = e.message || '保存失败'
} finally {
saving.value = false
@@ -360,6 +387,14 @@ onBeforeRouteLeave(() => {
// ---------- 标签 ----------
// 标签是长文的组织方式;切到短文时把已选的清掉(后端落库时也会强制丢弃)
watch(
() => form.kind,
(k) => {
if (k === 'short') form.tags = []
}
)
function addTag() {
const v = tagInput.value.trim().replace(/[,,]$/, '')
if (!v) return
@@ -386,6 +421,82 @@ function clearCover() {
// ---------- 工具栏:Markdown 模式插入 + 通用动作 ----------
const mdPane = ref(null)
// ---------- CodeMirror 源码模式 ----------
// md 页签不是裸 textarea:语法高亮 + 行内历史(⌘Z),粘贴/拖图片直接上传。
// markdown 仍是唯一真相源——CM 的改动写回 form.content_md,
// 外部改动(转存替换、wysiwyg 切回)用 syncCM 全量灌回。
let cmView = null
const cmTheme = CMView.theme({
'&': { color: 'var(--admin-ink)', backgroundColor: 'transparent', fontSize: '13.5px' },
'.cm-content': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', lineHeight: '1.75', padding: '12px 0 40vh' },
'.cm-scroller': { overflow: 'auto', maxHeight: '70vh' },
'.cm-line': { padding: '0 2px' },
'&.cm-focused': { outline: 'none' },
'.cm-cursor': { borderLeftColor: 'var(--admin-accent)' },
'.cm-selectionBackground, ::selection': { backgroundColor: 'color-mix(in srgb, var(--admin-accent) 18%, transparent) !important' },
'.cm-activeLine': { backgroundColor: 'color-mix(in srgb, var(--admin-accent) 6%, transparent)' },
'.cm-heading, .cm-header': { color: 'var(--admin-accent)', fontWeight: '600' },
'.cm-link, .cm-url': { color: 'var(--admin-accent)', textDecoration: 'underline' },
'.cm-emphasis': { fontStyle: 'italic' },
'.cm-strong': { fontWeight: '700' },
'.cm-code, .cm-monospace': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', color: 'var(--admin-muted)' }
})
function handleEditorFiles(files, view) {
const list = [...files].filter((f) => f.type.startsWith('image/'))
if (!list.length) return false
view.dispatch({
changes: { from: view.state.selection.main.from, insert: ' ' }
})
;[...list].forEach((f) => uploadOne(f, { insertImage: true }))
return true
}
function mountCM() {
if (cmView || !mdPane.value) return
cmView = new CMView({
parent: mdPane.value,
state: EditorState.create({
doc: form.content_md,
extensions: [
history(),
keymap.of([
{ key: 'Mod-b', run: () => { tbBold(); return true } },
{ key: 'Mod-i', run: () => { tbItalic(); return true } },
{ key: 'Mod-e', run: () => { tbCode(); return true } },
{ key: 'Mod-k', run: () => { openLink(); return true } }
]),
keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]),
markdownLang(),
cmTheme,
CMView.updateListener.of((u) => {
if (u.docChanged) form.content_md = u.state.doc.toString()
}),
CMView.domEventHandlers({
paste: (e, view) => handleEditorFiles(e.clipboardData?.files, view),
drop: (e, view) => {
if (e.dataTransfer?.files?.length) {
e.preventDefault()
return handleEditorFiles(e.dataTransfer.files, view)
}
return false
}
})
]
})
})
}
// 用 form.content_md 的当前值整段灌回(进入 md 页签、转存替换后调用)
function syncCM() {
if (!cmView) return
const cur = cmView.state.doc.toString()
if (cur === form.content_md) return
cmView.dispatch({
changes: { from: 0, to: cur.length, insert: form.content_md }
})
}
const showLinkInput = ref(false)
const showImageInput = ref(false)
const linkText = ref('')
@@ -394,22 +505,31 @@ const imageUrl = ref('')
const imageAlt = ref('')
function insertAtCursor(text, selStart, selEnd) {
if (cmView) {
if (selStart == null) {
const s = cmView.state.selection.main
selStart = s.from
selEnd = s.to
}
cmView.dispatch({
changes: { from: selStart, to: selEnd, insert: text },
selection: { anchor: selStart + text.length }
})
cmView.focus()
return
}
const before = form.content_md.slice(0, selStart)
const after = form.content_md.slice(selEnd)
form.content_md = before + text + after
nextTick(() => {
if (!mdPane.value) return
const newPos = selStart + text.length
mdPane.value.focus()
mdPane.value.setSelectionRange(newPos, newPos)
})
}
function withSelection(fn) {
const el = mdPane.value
const start = el ? el.selectionStart : form.content_md.length
const end = el ? el.selectionEnd : form.content_md.length
fn(start, end)
if (cmView) {
const sel = cmView.state.selection.main
fn(sel.from, sel.to)
return
}
fn(form.content_md.length, form.content_md.length)
}
function tbBold() {
@@ -494,6 +614,15 @@ function tbTask() {
function tbHr() {
withSelection((s, e) => insertAtCursor('\n---\n', s, e))
}
function tbTable() {
withSelection((s, e) =>
insertAtCursor(
'\n| 列 A | 列 B |\n| --- | --- |\n| 内容 | 内容 |\n| 内容 | 内容 |\n',
s,
e
)
)
}
function openLink() {
showLinkInput.value = true
@@ -520,20 +649,201 @@ function commitImage() {
showImageInput.value = false
}
// ---------- 上传(封面 / 编辑器插图共用) ----------
// toast 用共享的 ./toast(ToastStack 渲染在 AdminLayout)。
// 后台「高级 → 自定义 JS」无关;走 /api/admin/files(FormData)。
const uploadingImage = ref(false)
// 与后端 allowFileExt / 50MB 上限一致:上传前本地校验,省一趟白跑
const ALLOW_UPLOAD_EXT = ['jpg', 'jpeg', 'png', 'webp', 'gif', 'avif', 'pdf', 'zip', 'txt']
const MAX_UPLOAD_MB = 50
function validateFile(file) {
const ext = (file.name.split('.').pop() || '').toLowerCase()
if (!ALLOW_UPLOAD_EXT.includes(ext)) {
return `不支持的类型 .${ext}(允许:${ALLOW_UPLOAD_EXT.join('/')})`
}
if (file.size > MAX_UPLOAD_MB * 1024 * 1024) {
return `${file.name} 超过 ${MAX_UPLOAD_MB}MB 上限`
}
return ''
}
// opts.cmView 传了就把结果以 markdown 图片插到该编辑器光标处(md 页签粘贴/拖拽)
async function uploadOne(file, opts = {}) {
const bad = validateFile(file)
if (bad) {
toast(bad, 'err')
throw new Error(bad)
}
const tid = toast(`上传 ${file.name} 0%`, 'info', 60000)
try {
const data = await adminApi.uploadFile(file, (p) =>
toastUpdate(tid, `上传 ${file.name} ${Math.round(p * 100)}%`)
)
const f = Array.isArray(data) ? data[0] : null
if (!f || !f.url) throw new Error('上传失败')
toastDone(tid, `${file.name} 上传完成`)
if (opts.cmView) {
const v = opts.cmView
const pos = v.state.selection.main.from
v.dispatch({ changes: { from: pos, insert: `![](${f.url})\n` } })
}
return f.url
} catch (e) {
toastUpdate(tid, `${file.name} 上传失败:${e.message || '未知错误'}`, 'err')
error.value = e.message || '上传失败'
throw e
}
}
// ---------- 外链图片转存 ----------
// xLog 的 Cloud:正文里的外链 <img> 一键抓回自己的存储(后端走 SSRF
// 防护拨号 + 同一套白名单/去重),成功后原地替换 markdown 里的 URL。
const importing = ref(false)
function externalImages(md) {
const own = [site.uploads_public_base, location.origin].filter(Boolean)
const out = []
const re = /!\[[^\]]*\]\((https?:\/\/[^)\s]+)\)/g
let m
while ((m = re.exec(md))) {
const u = m[1]
if (!own.some((b) => u.startsWith(b)) && !u.startsWith('/uploads/')) out.push(u)
}
return [...new Set(out)]
}
async function importExternal() {
if (importing.value) return
const urls = externalImages(form.content_md)
if (!urls.length) {
toast('正文里没有外链图片', 'info')
return
}
importing.value = true
const tid = toast(`转存外链图片 0/${urls.length}…`, 'info', 120000)
try {
const data = await adminApi.importFiles(urls)
// 后端按原址回:source -> 转存后的文件(同内容去重时可能是已有行)
const ok = new Map((data.files || []).map((e) => [e.source, e.file]))
const errs = data.errors || {}
let done = 0
let changed = false
form.content_md = form.content_md.replace(
/!\[([^\]]*)\]\((https?:\/\/[^)\s]+)\)/g,
(whole, alt, u) => {
if (!ok.has(u)) return whole
done++
toastUpdate(tid, `转存外链图片 ${done}/${urls.length}…`)
changed = true
return `![${alt}](${ok.get(u).url})`
}
)
if (changed) syncCM()
const fail = Object.keys(errs).length
if (fail) {
toastUpdate(tid, `转存完成:${done} 成功,${fail} 失败(${Object.values(errs)[0]})`, fail ? 'err' : 'ok')
} else {
toastDone(tid, `转存完成:${done} 张已入自己的存储`)
}
} catch (e) {
toastUpdate(tid, '转存失败:' + (e.message || ''), 'err')
} finally {
importing.value = false
}
}
const coverInput = ref(null)
async function onCoverPick(e) {
const file = e.target.files && e.target.files[0]
e.target.value = '' // 允许重复选同一个文件
if (!file) return
uploadingImage.value = true
try {
form.cover_url = await uploadOne(file)
} finally {
uploadingImage.value = false
}
}
const imageInput = ref(null)
async function onImagePick(e) {
const file = e.target.files && e.target.files[0]
e.target.value = ''
if (!file) return
uploadingImage.value = true
try {
imageUrl.value = await uploadOne(file)
if (!imageAlt.value) imageAlt.value = file.name.replace(/\.[^.]+$/, '')
commitImage()
} finally {
uploadingImage.value = false
}
}
// md 面板的粘贴 / 拖拽图片:上传后在原光标处插入 markdown。
// 上传是异步的,先记光标位置,逐张插入时用 pos 追踪偏移。
function captureSel() {
const el = mdPane.value
const s = el ? el.selectionStart : form.content_md.length
const e = el ? el.selectionEnd : s
return { s, e: Math.max(s, e) }
}
async function onMdFiles(files, sel) {
let pos = sel.s
for (const f of files) {
try {
const url = await uploadOne(f)
const md = `![](${url})\n`
insertAtCursor(md, pos, pos)
pos += md.length
} catch (_) {
/* uploadOne 已把错误写进 error */
}
}
}
function onMdPaste(e) {
const files = [...(e.clipboardData?.files || [])].filter((f) => f.type.startsWith('image/'))
if (!files.length) return
e.preventDefault()
onMdFiles(files, captureSel())
}
function onMdDrop(e) {
const files = [...(e.dataTransfer?.files || [])].filter((f) => f.type.startsWith('image/'))
if (!files.length) return
e.preventDefault()
onMdFiles(files, captureSel())
}
function insertDate() {
withSelection((s, e) => insertAtCursor(new Date().toISOString().slice(0, 10), s, e))
}
// 工具栏按钮的统一定义(用作 v-for 渲染)
// 快捷键的展示形态(xLog 同款):Mod 在 Mac 上显示 ⌘,其余显示 Ctrl
const isMac = typeof navigator !== 'undefined' && /Mac|iP(hone|ad|od)/.test(navigator.platform || '')
function keyDisplay(shortcut) {
if (!shortcut) return ''
return '(' + shortcut
.replace('Mod', isMac ? '⌘' : 'Ctrl')
.replace(/-([a-z])$/i, (_, c) => c.toUpperCase()) + ')'
}
const tbGroups = computed(() => [
{
label: '格式',
items: [
{ key: 'b', label: 'B', title: '加粗', run: tbBold, show: mode.value === 'md' },
{ key: 'i', label: 'I', title: '斜体', run: tbItalic, italic: true, show: mode.value === 'md' },
{ key: 's', label: 'S', title: '删除线', run: tbStrike, show: mode.value === 'md' },
{ key: 'code', label: '<>', title: '行内代码', run: tbCode, mono: true, show: mode.value === 'md' },
{ key: 'cb', label: '```', title: '代码块', run: tbCodeBlock, mono: true, show: mode.value === 'md' }
{ key: 'b', title: '加粗', shortcut: 'Mod-b', run: tbBold, show: mode.value === 'md',
icon: 'M6 4h8a4 4 0 0 1 0 8H6zM6 12h9a4 4 0 0 1 0 8H6z', fill: true },
{ key: 'i', title: '斜体', shortcut: 'Mod-i', run: tbItalic, show: mode.value === 'md',
icon: 'M19 4h-9M14 20H5M15 4L9 20' },
{ key: 's', title: '删除线', run: tbStrike, show: mode.value === 'md',
icon: 'M16 4H9a3 3 0 0 0-2.83 4M14 12a4 4 0 0 1 0 8H6M4 12h16' },
{ key: 'code', title: '行内代码', shortcut: 'Mod-e', run: tbCode, show: mode.value === 'md',
icon: 'M16 18l6-6-6-6M8 6l-6 6 6 6' },
{ key: 'cb', title: '代码块', run: tbCodeBlock, show: mode.value === 'md',
icon: 'M9 10l-2 2.5L9 15M15 10l2 2.5-2 2.5M4 5h16a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V6a1 1 0 0 1 1-1z' }
]
},
{
@@ -541,19 +851,29 @@ const tbGroups = computed(() => [
items: [
{ key: 'h2', label: 'H2', title: '二级标题', run: tbH2, show: mode.value === 'md' },
{ key: 'h3', label: 'H3', title: '三级标题', run: tbH3, show: mode.value === 'md' },
{ key: 'q', label: '"', title: '引用', run: tbQuote, show: mode.value === 'md' },
{ key: 'ul', label: '•', title: '无序列表', run: tbUl, show: mode.value === 'md' },
{ key: 'ol', label: '1.', title: '有序列表', run: tbOl, show: mode.value === 'md' },
{ key: 'task', label: '☐', title: '任务列表', run: tbTask, show: mode.value === 'md' },
{ key: 'hr', label: '—', title: '分隔线', run: tbHr, show: mode.value === 'md' }
{ key: 'q', title: '引用', run: tbQuote, show: mode.value === 'md',
icon: 'M3 21c3 0 7-1 7-8V5c0-1.25-.76-2.02-2-2H4c-1.25 0-2 .75-2 1.97V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .01-1 1.03V20c0 1 0 1 1 1zM15 21c3 0 7-1 7-8V5c0-1.25-.76-2.02-2-2h-4c-1.25 0-2 .75-2 1.97V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .01-1 1.03V20c0 1 0 1 1 1z', fill: true },
{ key: 'ul', title: '无序列表', run: tbUl, show: mode.value === 'md',
icon: 'M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01' },
{ key: 'ol', title: '有序列表', run: tbOl, show: mode.value === 'md',
icon: 'M10 6h11M10 12h11M10 18h11M4 6h1v4M4 10h2M6 18H4c0-1 2-2 2-3s-1-1.5-2-1' },
{ key: 'task', title: '任务列表', run: tbTask, show: mode.value === 'md',
icon: 'M9 11l3 3L22 4M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11' },
{ key: 'hr', title: '分隔线', run: tbHr, show: mode.value === 'md',
icon: 'M5 12h14' }
]
},
{
label: '插入',
items: [
{ key: 'link', label: '🔗', title: '链接', run: openLink, show: mode.value === 'md' },
{ key: 'img', label: '🖼', title: '图片', run: openImage, show: mode.value === 'md' },
{ key: 'date', label: '📅', title: '插入今天日期', run: insertDate, show: mode.value === 'md' }
{ key: 'link', title: '链接', shortcut: 'Mod-k', run: openLink, show: mode.value === 'md',
icon: 'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71' },
{ key: 'img', title: '图片', run: openImage, show: mode.value === 'md',
icon: 'M21 15l-5-5L5 21M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zM8.5 8.5h.01' },
{ key: 'table', title: '表格', run: tbTable, show: mode.value === 'md',
icon: 'M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zM3 10h18M10 21V10' },
{ key: 'date', title: '插入今天日期', run: insertDate, show: mode.value === 'md',
icon: 'M16 2v4M8 2v4M3 10h18M5 4h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z' }
]
}
])
@@ -600,6 +920,14 @@ const tbGroups = computed(() => [
<div class="editor-mode">
<button :class="{ on: mode === 'wysiwyg' }" @click="switchMode('wysiwyg')">富文本</button>
<button :class="{ on: mode === 'md' }" @click="switchMode('md')">Markdown</button>
<button
class="btn"
:disabled="importing"
title="把正文里的外链图片抓回自己的存储"
@click="importExternal"
>
{{ importing ? '转存中…' : '⇲ 转存外链图' }}
</button>
</div>
</div>
@@ -619,11 +947,18 @@ const tbGroups = computed(() => [
:key="it.key"
type="button"
class="tb-btn"
:class="{ italic: it.italic, mono: it.mono }"
:title="it.title"
:title="it.title + keyDisplay(it.shortcut)"
:aria-label="it.title"
@click="it.run()"
>{{ it.label }}</button>
>
<svg v-if="it.icon" viewBox="0 0 24 24" aria-hidden="true"
:fill="it.fill ? 'currentColor' : 'none'"
:stroke="it.fill ? 'none' : 'currentColor'" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path :d="it.icon" />
</svg>
<span v-else class="tb-text">{{ it.label }}</span>
</button>
</div>
</div>
@@ -670,20 +1005,25 @@ const tbGroups = computed(() => [
aria-label="图片 URL"
@keydown.enter="commitImage"
/>
<button class="btn btn-primary" @click="commitImage">插入</button>
<button class="btn" @click="showImageInput = false">取消</button>
<div style="display: flex; gap: 6px;">
<button class="btn btn-primary" style="flex: 1;" @click="commitImage">插入</button>
<button class="btn" style="flex: 1;" :disabled="uploadingImage" @click="imageInput.click()">
{{ uploadingImage ? '上传中…' : '上传' }}
</button>
<button class="btn" @click="showImageInput = false">取消</button>
</div>
<input ref="imageInput" type="file" accept="image/*" hidden @change="onImagePick" />
</div>
<!-- editor area -->
<div v-show="mode === 'wysiwyg'" ref="editorEl"></div>
<textarea
v-show="mode === 'md'"
ref="mdPane"
class="md-pane"
v-model="form.content_md"
placeholder="直接写 Markdown…"
aria-label="Markdown 正文"
></textarea>
<!-- md 页签:CodeMirror 6(语法高亮/历史/粘贴拖拽上传),markdown 唯一真相源 -->
<div v-show="mode === 'md'" ref="mdPane" class="md-pane" aria-label="Markdown 正文"></div>
<!-- 轻提示栈 -->
<div class="toast-stack" aria-live="polite">
<div v-for="t in toasts" :key="t.id" class="toast" :class="t.type">{{ t.text }}</div>
</div>
<div class="editor-stats">
<span><strong>{{ cjkChars }}</strong> 汉字</span>
@@ -715,15 +1055,21 @@ const tbGroups = computed(() => [
<div v-if="form.cover_url" class="cover-mini" :style="{ backgroundImage: `url(${form.cover_url})` }">
<button class="x" @click="clearCover" aria-label="移除封面">×</button>
</div>
<input
id="cover"
v-model="form.cover_url"
placeholder="封面图 URL(可省)"
type="url"
inputmode="url"
spellcheck="false"
aria-label="封面图 URL"
/>
<div class="cover-row">
<input
id="cover"
v-model="form.cover_url"
placeholder="封面图 URL(可省)"
type="url"
inputmode="url"
spellcheck="false"
aria-label="封面图 URL"
/>
<button class="btn" type="button" :disabled="uploadingImage" @click="coverInput.click()">
{{ uploadingImage ? '上传中…' : '上传' }}
</button>
</div>
<input ref="coverInput" type="file" accept="image/*" hidden @change="onCoverPick" />
<p style="margin: 4px 0 0; font-size: 12px; color: var(--admin-muted);">回车或失焦即生效;前台卡片与详情页头图会用到。</p>
</div>
@@ -749,7 +1095,7 @@ const tbGroups = computed(() => [
></textarea>
</div>
<div class="field">
<div v-if="form.kind !== 'short'" class="field">
<label>标签</label>
<div v-if="form.tags.length" style="display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px;">
<span v-for="t in form.tags" :key="t" class="chip">
+298
View File
@@ -0,0 +1,298 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { toastOk, toastErr } from './toast'
import { useRoute, useRouter } from 'vue-router'
import { adminApi } from '../api'
import { relativeDate } from '../utils'
// 文件管理:上传(点击 / 拖拽,多选)、网格缩略图、复制链接、删除。
// 缩略图与链接都用后端解析好的 f.url(R2 直链或 /uploads 流式路由)。
const route = useRoute()
const router = useRouter()
const items = ref([])
const total = ref(0)
const loading = ref(true)
const error = ref('')
const page = computed(() => Number(route.query.page || 1))
const size = 24
const uploading = ref(false)
const uploadError = ref('')
const dragOver = ref(false)
const copiedId = ref(0)
const fileInput = ref(null)
let seq = 0
async function load() {
const my = ++seq
loading.value = true
error.value = ''
try {
const data = await adminApi.files({ page: page.value, q: String(route.query.q || '') })
if (my !== seq) return
items.value = data.items || []
total.value = data.total || 0
} catch (e) {
if (my !== seq) return
error.value = e.message || '加载失败'
items.value = []
} finally {
if (my === seq) loading.value = false
}
}
onMounted(load)
watch(() => route.query.page, load)
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / size)))
// ---------- 上传 ----------
async function upload(files) {
if (!files || !files.length || uploading.value) return
uploading.value = true
uploadError.value = ''
try {
const fd = new FormData()
for (const f of files) fd.append('file', f)
await adminApi.uploadFiles(fd)
toastOk(`已上传 ${files.length} 个文件`)
await load()
} catch (e) {
toastErr(e.message || '上传失败')
uploadError.value = e.message || '上传失败'
} finally {
uploading.value = false
}
}
function onPick(e) {
upload(e.target.files)
e.target.value = '' // 允许重复选同一个文件
}
function onDrop(e) {
dragOver.value = false
upload(e.dataTransfer.files)
}
// ---------- 操作 ----------
async function copyUrl(f) {
try {
await navigator.clipboard.writeText(f.url)
toastOk('链接已复制')
copiedId.value = f.id
setTimeout(() => {
if (copiedId.value === f.id) copiedId.value = 0
}, 2000)
} catch (_) {
// 非 https 环境没有 clipboard API:退回手动复制
window.prompt('复制此链接:', f.url)
}
}
async function remove(f) {
if (!window.confirm(`删除「${f.name}」?存储里的文件会一并删除,引用它的文章将失效。`)) return
try {
await adminApi.deleteFile(f.id)
toastOk(`已删除「${f.name}」`)
// 当前页删空时退一页,其余直接重载
if (items.value.length === 1 && page.value > 1) router.replace({ query: { ...route.query, page: page.value - 1 } })
else load()
} catch (e) {
toastErr(e.message || '删除失败')
error.value = e.message || '删除失败'
}
}
// ---------- 展示助手 ----------
function humanSize(n) {
if (n < 1024) return n + ' B'
if (n < 1048576) return (n / 1024).toFixed(1) + ' KB'
if (n < 1073741824) return (n / 1048576).toFixed(1) + ' MB'
return (n / 1073741824).toFixed(2) + ' GB'
}
function ext(name) {
const i = name.lastIndexOf('.')
return i < 0 ? '文件' : name.slice(i + 1).toUpperCase()
}
// 拖拽悬停态在离开窗口时复位
function onWindowDragEnd() {
dragOver.value = false
}
onMounted(() => window.addEventListener('dragend', onWindowDragEnd))
onBeforeUnmount(() => window.removeEventListener('dragend', onWindowDragEnd))
</script>
<template>
<section>
<header style="display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 14px;">
<h1 style="font-family: var(--serif); font-size: 22px;">
文件 <span style="font-family: var(--sans); font-size: 13px; color: var(--admin-muted); font-weight: 400;">{{ total }}</span>
</h1>
</header>
<div
class="dropzone"
:class="{ over: dragOver, uploading }"
role="button"
tabindex="0"
aria-label="上传文件"
@click="fileInput && fileInput.click()"
@keydown.enter="fileInput && fileInput.click()"
@dragover.prevent="dragOver = true"
@dragleave.prevent="dragOver = false"
@drop.prevent="onDrop"
>
<input ref="fileInput" type="file" multiple hidden accept=".jpg,.jpeg,.png,.webp,.gif,.avif,.pdf,.zip,.txt" @change="onPick" />
<span v-if="uploading">上传中…</span>
<span v-else>点击选择或把文件拖到这里上传 · 图片 / pdf / zip / txt · 单文件 ≤ 50MB</span>
</div>
<p v-if="uploadError" class="upload-error" role="alert">{{ uploadError }}</p>
<div v-if="loading" class="loading">载入中…</div>
<div v-else-if="error" class="empty">{{ error }}</div>
<div v-else-if="!items.length" class="empty">还没有上传过文件。</div>
<template v-else>
<div class="file-grid">
<div v-for="f in items" :key="f.id" class="file-card">
<div class="thumb">
<img
v-if="f.mime.startsWith('image/')"
:src="f.url"
alt=""
loading="lazy"
decoding="async"
referrerpolicy="no-referrer"
/>
<span v-else class="ph" aria-hidden="true">{{ ext(f.name) }}</span>
</div>
<div class="name" :title="f.name">{{ f.name }}</div>
<div class="meta">
<span>{{ humanSize(f.size) }}</span>
<span style="margin: 0 5px;" aria-hidden="true">·</span>
<span :title="f.created_at">{{ relativeDate(f.created_at) }}</span>
</div>
<div class="actions">
<a href="#" @click.prevent="copyUrl(f)">{{ copiedId === f.id ? '已复制 ✓' : '复制链接' }}</a>
<a :href="f.url" target="_blank" rel="noopener">打开</a>
<a href="#" class="danger" @click.prevent="remove(f)">删除</a>
</div>
</div>
</div>
<nav
v-if="pageCount > 1"
aria-label="分页"
style="display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 18px;"
>
<button class="btn" :disabled="page <= 1" @click="router.replace({ query: { ...route.query, page: page - 1 } })" aria-label="上一页">
← 上一页
</button>
<span style="font-size: 13px; color: var(--admin-muted);" aria-live="polite">第 {{ page }} / {{ pageCount }} 页</span>
<button class="btn" :disabled="page >= pageCount" @click="router.replace({ query: { ...route.query, page: page + 1 } })" aria-label="下一页">
下一页 →
</button>
</nav>
</template>
</section>
</template>
<style scoped>
.dropzone {
border: 1.5px dashed var(--admin-line);
border-radius: 6px;
padding: 22px;
text-align: center;
color: var(--admin-muted);
font-size: 13px;
margin-bottom: 16px;
cursor: pointer;
transition: border-color 0.2s, background-color 0.2s;
}
.dropzone:hover,
.dropzone.over {
border-color: var(--admin-accent);
background: color-mix(in srgb, var(--admin-accent) 6%, transparent);
}
.dropzone.uploading {
cursor: wait;
}
.upload-error {
color: var(--admin-danger);
font-size: 13px;
margin: -8px 0 12px;
}
.file-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
}
.file-card {
border: 1px solid var(--admin-line);
border-radius: 6px;
background: var(--admin-card);
padding: 10px;
}
.thumb {
height: 110px;
border-radius: 4px;
background: var(--admin-paper-sunken);
display: grid;
place-items: center;
overflow: hidden;
margin-bottom: 8px;
}
.thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.thumb .ph {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.05em;
color: var(--admin-faint);
}
.name {
font-size: 13px;
font-weight: 600;
color: var(--admin-ink);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta {
font-size: 11.5px;
color: var(--admin-muted);
margin: 3px 0 8px;
}
.actions {
display: flex;
gap: 10px;
font-size: 12.5px;
}
.actions a {
color: var(--admin-muted);
}
.actions a:hover {
color: var(--admin-accent);
}
.actions a.danger:hover {
color: var(--admin-danger);
}
</style>
+5
View File
@@ -1,5 +1,7 @@
<script setup>
import { ref } from 'vue'
import { toastOk, toastErr } from './toast'
import ToastStack from './ToastStack.vue'
import { useRouter } from 'vue-router'
import { adminApi, session } from '../api'
import { site } from '../site'
@@ -17,8 +19,10 @@ async function submit() {
const data = await adminApi.login(username.value, password.value)
session.user = username.value
void data
toastOk('登录成功')
router.push('/admin')
} catch (e) {
toastErr('用户名或密码不对')
error.value = '用户名或密码不对'
} finally {
busy.value = false
@@ -27,6 +31,7 @@ async function submit() {
</script>
<template>
<ToastStack />
<div class="page">
<form class="card" @submit.prevent="submit">
<p class="eyebrow">后台</p>
+3 -2
View File
@@ -1,5 +1,6 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { toastOk, toastErr } from './toast'
import { useRoute, useRouter } from 'vue-router'
import { adminApi } from '../api'
import { formatDateShort, relativeDate } from '../utils'
@@ -125,7 +126,7 @@ async function bulk(action) {
await adminApi.bulkPosts([...selected.value], action)
await load()
} catch (e) {
alert(e.message || '操作失败')
toastErr(e.message || '操作失败')
}
}
@@ -135,7 +136,7 @@ async function remove(id, title) {
await adminApi.deletePost(id)
await load()
} catch (e) {
alert(e.message || '删除失败')
toastErr(e.message || '删除失败')
}
}
+7 -6
View File
@@ -1,5 +1,6 @@
<script setup>
import { onMounted, ref } from 'vue'
import { toastOk, toastErr } from './toast'
import { adminApi } from '../api'
const projects = ref([])
@@ -40,7 +41,7 @@ onMounted(load)
async function create() {
if (!form.value.title.trim()) {
alert('请填写作品名称')
toastErr('请填写作品名称')
return
}
try {
@@ -56,7 +57,7 @@ async function create() {
form.value = blank()
await load()
} catch (e) {
alert(e.message || '创建失败')
toastErr(e.message || '创建失败')
}
}
@@ -81,7 +82,7 @@ function cancelEdit() {
async function saveEdit() {
if (!draft.value.title.trim()) {
alert('请填写作品名称')
toastErr('请填写作品名称')
return
}
try {
@@ -97,7 +98,7 @@ async function saveEdit() {
editing.value = null
await load()
} catch (e) {
alert(e.message || '保存失败')
toastErr(e.message || '保存失败')
}
}
@@ -107,7 +108,7 @@ async function toggleStatus(p) {
await adminApi.updateProject(p.id, { status: next })
await load()
} catch (e) {
alert(e.message || '更新失败')
toastErr(e.message || '更新失败')
}
}
@@ -117,7 +118,7 @@ async function remove(p) {
await adminApi.deleteProject(p.id)
await load()
} catch (e) {
alert(e.message || '删除失败')
toastErr(e.message || '删除失败')
}
}
</script>
+22 -16
View File
@@ -1,5 +1,6 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { toastOk, toastErr } from './toast'
import { adminApi } from '../api'
import { SKIN_CONSTANTS, UI_CONSTANTS } from '../site'
import { SECTIONS } from '../ui/sections'
@@ -35,14 +36,11 @@ function textToSocial(text) {
.filter(Boolean)
}
// 这里只能选亮色皮肤(paper / sage / rose)。暗色端固定 ink,由访客在前台
// 右下角 ThemeSwitcher 切到「深色」或「自动 + 系统暗」时启用。
// 皮肤已精简:亮端固定纸感,暗端固定墨色(2026 重设计)。下面只做展示。
const lightSkins = [
{ id: 'paper', label: '纸感(默认)', bg: '#faf7f1', fg: '#3d7f9c' },
{ id: 'sage', label: '鼠尾草', bg: '#eef0e6', fg: '#5f7b5c' },
{ id: 'rose', label: '玫瑰', bg: '#f7eee6', fg: '#a55a4a' }
{ id: 'paper', label: '纸感(默认)', bg: '#faf7f1', fg: '#3d7f9c' }
]
const darkSkin = { id: 'ink', label: '墨色', bg: '#1f1d1a', fg: '#c3b58f' }
const darkSkin = { id: 'ink', label: '墨色', bg: '#191817', fg: '#d9b97c' }
// ---------- 界面与自定义(第二套 UI) ----------
@@ -147,6 +145,8 @@ async function load() {
settings.value.ui_id = UI_CONSTANTS.DEFAULT
}
socialText.value = socialToText(settings.value.social_links)
settings.value.comments_enabled = !!settings.value.comments_enabled
settings.value.comments_review = !!settings.value.comments_review
// 服务端保证非 null,这里再兜一层,并预建分区键,
// 这样 v-model 绑定的文本框一开始就有值可写。
if (!settings.value.custom_css || typeof settings.value.custom_css !== 'object') {
@@ -184,7 +184,9 @@ async function save() {
}
}
savedAt.value = new Date().toLocaleTimeString('zh-CN', { hour12: false })
toastOk('设置已保存')
} catch (e) {
toastErr('保存失败:' + (e.message || ''))
error.value = e.message || '保存失败'
} finally {
saving.value = false
@@ -239,19 +241,12 @@ async function save() {
<div class="field">
<label>亮色皮肤</label>
<div class="theme-swatches">
<div
v-for="sk in lightSkins"
:key="sk.id"
class="sw"
:class="{ on: settings.light_skin_id === sk.id }"
:style="{ background: sk.bg, borderColor: settings.light_skin_id === sk.id ? sk.fg : 'var(--admin-line)' }"
@click="settings.light_skin_id = sk.id"
>
<span :style="{ background: sk.fg }">{{ sk.label }}</span>
<div class="sw on" :style="{ background: lightSkins[0].bg, borderColor: lightSkins[0].fg }" :title="lightSkins[0].label + '(固定)'">
<span :style="{ background: lightSkins[0].fg }">{{ lightSkins[0].label }}</span>
</div>
</div>
<p style="margin: 6px 0 0; font-size: 12px; color: var(--admin-muted);">
访客在前台选择「自动」且系统偏好浅色时使用。
亮端固定为纸感。访客在前台选择「自动」且系统偏好浅色时使用。
</p>
</div>
@@ -382,6 +377,17 @@ async function save() {
<label>ICP 备案号</label>
<input v-model="settings.icp" class="input" spellcheck="false" placeholder="如 京 ICP 备 12345678 号" />
</div>
<div class="field">
<label>评论</label>
<label style="display: flex; align-items: center; gap: 8px; font-size: 13.5px; color: var(--admin-ink); cursor: pointer;">
<input v-model="settings.comments_enabled" type="checkbox" />
开放评论(关着时前台整个评论区不渲染)
</label>
<label style="display: flex; align-items: center; gap: 8px; font-size: 13.5px; color: var(--admin-ink); cursor: pointer; margin-top: 6px;">
<input v-model="settings.comments_review" type="checkbox" />
新评论需审核,通过后公开
</label>
</div>
<div class="field">
<label>社交链接(每行一条:名称 | 链接)</label>
<textarea
+6 -5
View File
@@ -1,5 +1,6 @@
<script setup>
import { onMounted, ref } from 'vue'
import { toastOk, toastErr } from './toast'
import { adminApi } from '../api'
const tags = ref([])
@@ -30,7 +31,7 @@ async function create() {
newColor.value = ''
await load()
} catch (e) {
alert(e.message || '创建失败')
toastErr(e.message || '创建失败')
}
}
@@ -39,7 +40,7 @@ async function update(t) {
await adminApi.updateTag(t.id, { name: t.name, color: t.color })
await load()
} catch (e) {
alert(e.message || '更新失败')
toastErr(e.message || '更新失败')
}
}
@@ -49,7 +50,7 @@ async function remove(t) {
await adminApi.deleteTag(t.id)
await load()
} catch (e) {
alert(e.message || '删除失败')
toastErr(e.message || '删除失败')
}
}
@@ -69,7 +70,7 @@ function cancelMerge() {
async function doMerge(fromTag) {
const toId = Number(mergeInto.value)
if (!toId || toId === fromTag.id) {
alert('选一个不同的目标标签')
toastErr('选一个不同的目标标签')
return
}
if (!window.confirm(`把「${fromTag.name}」合并到选中的标签?此操作不可撤销。`)) return
@@ -78,7 +79,7 @@ async function doMerge(fromTag) {
cancelMerge()
await load()
} catch (e) {
alert(e.message || '合并失败')
toastErr(e.message || '合并失败')
}
}
</script>
+11
View File
@@ -0,0 +1,11 @@
<script setup>
// 后台共享 toast 的渲染层:挂在 AdminLayout(所有管理页)与 LoginView。
// 样式在全局 styles.css(.toast-stack / .toast),与编辑器共用一份。
import { toasts } from './toast'
</script>
<template>
<div class="toast-stack" aria-live="polite">
<div v-for="t in toasts" :key="t.id" class="toast" :class="t.type">{{ t.text }}</div>
</div>
</template>
+47
View File
@@ -0,0 +1,47 @@
import { reactive } from 'vue'
// 后台共享的轻提示:所有视图的操作反馈(成功/失败/进度)从这里发,
// ToastStack.vue 负责渲染(挂在 AdminLayout 与 LoginView)。
// EditorView 的上传/转存也走它——原先那套局部实现已并入。
export const toasts = reactive([])
let seq = 0
export function toast(text, type = 'info', ttl = 2600) {
const id = ++seq
toasts.push({ id, text, type })
setTimeout(() => {
const i = toasts.findIndex((t) => t.id === id)
if (i >= 0) toasts.splice(i, 1)
}, ttl)
return id
}
export function toastOk(text) {
return toast(text, 'ok', 2200)
}
export function toastErr(text) {
return toast(text, 'err', 4200)
}
// 更新一条已存在的 toast(上传进度用)
export function toastUpdate(id, text, type = 'info') {
const t = toasts.find((x) => x.id === id)
if (t) {
t.text = text
t.type = type
}
}
// 标记成功并很快消失(进度条收尾)
export function toastDone(id, text) {
toastUpdate(id, text, 'ok')
const idx = toasts.findIndex((t) => t.id === id)
if (idx >= 0) {
const t = toasts[idx]
setTimeout(() => {
const i = toasts.indexOf(t)
if (i >= 0) toasts.splice(i, 1)
}, 1600)
}
}
+63 -1
View File
@@ -82,7 +82,69 @@ export const adminApi = {
updateProject: (id, body) => request('/api/admin/projects/' + id, { method: 'PUT', body }),
deleteProject: (id) => request('/api/admin/projects/' + id, { method: 'DELETE' }),
settings: () => request('/api/admin/settings'),
saveSettings: (body) => request('/api/admin/settings', { method: 'PUT', body })
saveSettings: (body) => request('/api/admin/settings', { method: 'PUT', body }),
// ---------- 评论管理 ----------
comments: (params = {}) => request('/api/admin/comments?' + new URLSearchParams(params)),
approveComment: (id) =>
request('/api/admin/comments/' + id, { method: 'PUT', body: { status: 'visible' } }),
deleteComment: (id) => request('/api/admin/comments/' + id, { method: 'DELETE' }),
readers: (params = {}) => request('/api/admin/readers?' + new URLSearchParams(params)),
setReaderBanned: (id, banned) =>
request('/api/admin/readers/' + id + '/ban', { method: 'POST', body: { banned } }),
// ---------- 文件上传 ----------
files: (params = {}) => request('/api/admin/files?' + new URLSearchParams(params)),
deleteFile: (id) => request('/api/admin/files/' + id, { method: 'DELETE' }),
// 上传走 FormData:request() 是 JSON helper,这里单独 fetch。
// 401 同样广播 one:unauthorized,错误消息从 JSON body 里取(与 request 一致)。
// 单文件上传(XHR):fetch 拿不到上传进度,编辑器的进度提示走这里
uploadFile: (file, onProgress) =>
new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.open('POST', base + '/api/admin/files')
xhr.withCredentials = true
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && onProgress) onProgress(e.loaded / e.total)
}
xhr.onload = () => {
if (xhr.status === 401) {
window.dispatchEvent(new CustomEvent('one:unauthorized'))
reject(new Error('未登录或登录已过期'))
return
}
let data = {}
try {
data = JSON.parse(xhr.responseText)
} catch {}
if (xhr.status >= 200 && xhr.status < 300) resolve(data)
else reject(new Error(data.error || `上传失败(${xhr.status})`))
}
xhr.onerror = () => reject(new Error('网络错误,上传中止'))
const fd = new FormData()
fd.append('file', file)
xhr.send(fd)
}),
// 外链转存:把正文里的外链图片抓回自己的存储,返回 { files, errors }
importFiles: (urls) => request('/api/admin/files/import', { method: 'POST', body: { urls } }),
uploadFiles: async (formData) => {
const res = await fetch(base + '/api/admin/files', {
method: 'POST',
credentials: 'include',
body: formData
})
if (res.status === 401) {
window.dispatchEvent(new CustomEvent('one:unauthorized'))
const err = new Error('未登录或登录已过期')
err.status = 401
throw err
}
const data = await res.json().catch(() => ({}))
if (!res.ok) {
const err = new Error(data.error || `上传失败(${res.status})`)
err.status = res.status
throw err
}
return data
}
}
// 读者(评论区)身份。会话是服务端 httpOnly cookie(one_reader),
+211
View File
@@ -0,0 +1,211 @@
<script setup>
import { onBeforeUnmount, watch } from 'vue'
import { lightboxState as st, closeLightbox, stepLightbox } from '../lightbox'
// 图片预览层(手机重做版):App.vue 全局挂一次。
//
// 手机上出过两次事故,这次的实现原则:
// 1. 不碰 body 的 overflow——之前用 inline overflow 锁背景,把样式表的
// overflow-x: clip 覆盖成 hidden,横向从「裁剪」变「可滚动容器」,
// 整页被拖宽。现在滚动封锁靠遮罩自己的 @wheel/@touchmove.prevent:
// 事件不落到页面里,背景自然滚不动,滚动位置也天然保留。
// 2. 图片尺寸全部用百分比(max-width/height: 100%),不引入 vw/vh/dvh——
// 这些单位在全面屏 URL 栏收展时各有各的坑,遮罩本身就是视口大小。
// 3. 手势全归遮罩:touch-action: none + touchend 位移判滑动翻页,
// 双击缩放/长按菜单/下拉刷新这些浏览器手势都不会来捣乱。
// 4. 桌面能力不变:Esc / 方向键 / 点空白关闭 / 悬停箭头。
function onKey(e) {
if (e.key === 'Escape') closeLightbox()
if (e.key === 'ArrowLeft') stepLightbox(-1)
if (e.key === 'ArrowRight') stepLightbox(1)
}
// 吸顶栏带 backdrop-filter,真机上其合成层会盖在遮罩之上
// (z-index 更高也拦不住)——开预览时整个藏掉
watch(
() => st.open,
(v) => {
if (v) window.addEventListener('keydown', onKey)
else window.removeEventListener('keydown', onKey)
document.body.classList.toggle('lb-open', v)
}
)
onBeforeUnmount(() => {
window.removeEventListener('keydown', onKey)
document.body.classList.remove('lb-open')
})
// 滑动翻页:横向位移超 44px 且明显大于纵向才算一次滑动
let touchX = 0
let touchY = 0
function onTouchStart(e) {
touchX = e.touches[0].clientX
touchY = e.touches[0].clientY
}
function onTouchEnd(e) {
if (st.list.length < 2) return
const dx = e.changedTouches[0].clientX - touchX
const dy = e.changedTouches[0].clientY - touchY
if (Math.abs(dx) > 44 && Math.abs(dx) > Math.abs(dy) * 1.4) {
stepLightbox(dx < 0 ? 1 : -1)
}
}
</script>
<template>
<Teleport to="body">
<Transition name="lb" :duration="220">
<div
v-if="st.open"
class="lb-overlay"
@click="closeLightbox"
@touchstart.passive="onTouchStart"
@touchend.passive="onTouchEnd"
@wheel.prevent
@touchmove.prevent
>
<img class="lb-img" :src="st.list[st.index]" alt="" @click.stop />
<button v-if="st.list.length > 1" class="lb-nav prev" type="button" aria-label="上一张" @click.stop="stepLightbox(-1)">‹</button>
<button v-if="st.list.length > 1" class="lb-nav next" type="button" aria-label="下一张" @click.stop="stepLightbox(1)">›</button>
<button class="lb-close" type="button" aria-label="关闭预览" @click="closeLightbox">×</button>
<span v-if="st.list.length > 1" class="lb-count">{{ st.index + 1 }} / {{ st.list.length }}</span>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.lb-overlay {
position: fixed;
inset: 0;
z-index: 130;
background: rgba(12, 11, 10, 0.92);
display: flex;
align-items: center;
justify-content: center;
/* 遮罩全权处理手势:杜绝双击缩放/长按菜单/下拉刷新来添乱 */
touch-action: none;
}
/* 百分比尺寸:遮罩即视口,图片永远不会超出屏幕半像素 */
.lb-img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: 6px;
box-shadow: 0 30px 80px -20px rgba(0, 0, 0, 0.6);
user-select: none;
-webkit-user-drag: none;
}
.lb-nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 44px;
height: 44px;
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.85);
font-size: 26px;
line-height: 1;
cursor: pointer;
display: grid;
place-items: center;
padding-bottom: 4px;
transition: background-color 0.15s, color 0.15s;
}
.lb-nav:hover {
background: rgba(255, 255, 255, 0.18);
color: #fff;
}
.lb-nav.prev {
left: 22px;
}
.lb-nav.next {
right: 22px;
}
.lb-close {
position: absolute;
top: 18px;
right: 22px;
width: 38px;
height: 38px;
border: 1px solid rgba(255, 255, 255, 0.25);
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.85);
font-size: 17px;
line-height: 1;
cursor: pointer;
transition: background-color 0.15s, color 0.15s;
}
.lb-close:hover {
background: rgba(255, 255, 255, 0.18);
color: #fff;
}
.lb-count {
position: absolute;
bottom: 18px;
left: 50%;
transform: translateX(-50%);
color: rgba(255, 255, 255, 0.7);
font-size: 12.5px;
letter-spacing: 0.08em;
font-variant-numeric: tabular-nums;
}
/* 手机:滑动翻页为主,箭头贴边缩小;位置全走安全区 */
@media (pointer: coarse) {
.lb-img {
border-radius: 0;
}
.lb-nav {
width: 40px;
height: 40px;
font-size: 22px;
background: rgba(255, 255, 255, 0.12);
}
.lb-nav.prev {
left: calc(6px + env(safe-area-inset-left));
}
.lb-nav.next {
right: calc(6px + env(safe-area-inset-right));
}
.lb-close {
top: calc(10px + env(safe-area-inset-top));
right: calc(10px + env(safe-area-inset-right));
width: 42px;
height: 42px;
}
.lb-count {
bottom: calc(14px + env(safe-area-inset-bottom));
}
}
.lb-enter-active {
transition: opacity 0.2s ease;
}
.lb-leave-active {
transition: opacity 0.18s ease;
}
.lb-enter-from,
.lb-leave-to {
opacity: 0;
}
</style>
+124
View File
@@ -0,0 +1,124 @@
<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));
}
.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>
+108 -8
View File
@@ -1,7 +1,12 @@
<script setup>
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { site } from '../site'
import { relativeDate, stripTags, minutesLabel, sanitizeHtml } from '../utils'
import { openLightbox } from '../lightbox'
import { relativeDate, stripTags, minutesLabel, sanitizeHtml, thumbURL, thumbifyHtml } from '../utils'
import LinkCard from './LinkCard.vue'
const router = useRouter()
const props = defineProps({
post: { type: Object, required: true }
@@ -9,10 +14,18 @@ const props = defineProps({
const isShort = computed(() => props.post.kind === 'short')
// 时间线里短文不铺全文,超过 5 行折叠;长文只显示摘要
// 整卡可点进详情(Twitter 式);标签 / 配图自己会停冒泡不误跳。
// 用户在选中文字时不跳转(复制内容是第一意图)。
function openPost() {
if (window.getSelection()?.toString()) return
router.push(`/post/${props.post.slug}`)
}
// 时间线里短文不铺全文,超过 5 行折叠;长文只显示摘要。
// 正文里的 <img> 换缩略图并补懒加载(详情仍是原图)。
const body = computed(() => {
if (!isShort.value) return props.post.summary || stripTags(props.post.content_html || '')
return sanitizeHtml(props.post.content_html)
return sanitizeHtml(thumbifyHtml(props.post.content_html))
})
// 长文没写摘要时,从正文里截一段纯文本
@@ -22,10 +35,13 @@ const summaryText = computed(() => {
})
const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUpperCase())
// 配图缩略图宽度:单图全宽格取宽一些,多图小格 480 足够(2x 屏也清晰)
const gridW = computed(() => (props.post.images && props.post.images.length === 1 ? 720 : 480))
</script>
<template>
<article class="row-feed" :class="{ short: isShort }">
<article class="row-feed" :class="{ short: isShort }" @click="openPost">
<div class="avatar" aria-hidden="true">{{ initial }}</div>
<div class="body">
@@ -42,13 +58,38 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
<h2 class="title">{{ post.title || '无题' }}</h2>
</RouterLink>
<!-- 长文:摘要;短文:正文直接铺开 -->
<!-- 长文:摘要 + 封面媒体位;短文:正文直接铺开 -->
<div v-if="isShort" class="prose short-body" v-html="body"></div>
<div v-else class="summary">{{ summaryText }}</div>
<template v-else>
<div class="summary">{{ summaryText }}</div>
<div v-if="post.cover_url" class="imgs cover">
<img :src="thumbURL(post.cover_url, 640)" alt="" loading="lazy" decoding="async" />
</div>
</template>
<!-- 短文配图:Twitter 式网格(1 全宽 / 2 并排 / 3 左大右二 / 4 四宫格);点图开预览 -->
<div
v-if="isShort && post.images && post.images.length"
class="imgs"
:class="'n' + Math.min(post.images.length, 4)"
>
<img
v-for="(u, i) in post.images.slice(0, 4)"
:key="i"
:src="thumbURL(u, gridW)"
alt=""
loading="lazy"
decoding="async"
@click.stop="openLightbox(post.images, i)"
/>
</div>
<!-- 外链预览卡片:正文里第一个链接,发布时后端已抓好 -->
<LinkCard v-if="isShort && post.link_card" :card="post.link_card" />
<div class="meta">
<RouterLink :to="`/post/${post.slug}`" class="read">
{{ isShort ? '查看' : '继续阅读' }} →
<RouterLink :to="`/post/${post.slug}`" class="read" :title="isShort ? '查看' : '阅读全文'">
<time :datetime="post.published_at">{{ relativeDate(post.published_at) }}</time>
</RouterLink>
<span v-if="!isShort" class="mins">{{ minutesLabel(post) }}</span>
<span v-if="post.tags && post.tags.length" class="tags">
@@ -57,6 +98,7 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
:key="t"
:to="`/tag/${encodeURIComponent(t)}`"
class="tag-chip"
@click.stop
>
{{ t }}
</RouterLink>
@@ -76,6 +118,7 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
padding: 14px 30px;
margin: 0 -16px;
border-bottom: 1px solid var(--line-soft);
cursor: pointer;
transition: background 0.15s ease;
}
@@ -154,6 +197,57 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
margin: 0;
}
/* 短文配图:Twitter 式网格。1 张全宽(限高裁切),2/4 等分,3 张左大右二 */
.imgs {
display: grid;
gap: 3px;
margin-top: 10px;
border-radius: 12px;
overflow: hidden;
}
.imgs img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
cursor: zoom-in;
}
.imgs.n1 img {
max-height: 440px;
}
.imgs.n2,
.imgs.n4 {
grid-template-columns: 1fr 1fr;
}
.imgs.n3 {
grid-template-columns: 1fr 1fr;
}
.imgs.n2 img,
.imgs.n4 img {
aspect-ratio: 1;
}
.imgs.n3 img {
aspect-ratio: 1;
}
.imgs.n3 img:first-child {
grid-row: span 2;
aspect-ratio: auto;
height: 100%;
}
/* 长文封面:与短文配图同款圆角媒体位,但点击语义是进文章而非预览 */
.imgs.cover img {
max-height: 340px;
cursor: pointer;
}
.meta {
display: flex;
align-items: center;
@@ -164,7 +258,13 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
color: var(--faint);
}
/* 时间戳即入口(整卡可点后的键盘 / 视觉兜底):平时弱化,hover 亮起 */
.read {
color: var(--faint);
transition: color 0.15s ease;
}
.read:hover {
color: var(--accent);
}
+140 -2
View File
@@ -23,8 +23,40 @@ const publishing = ref(false)
const error = ref('')
const sheetOpen = ref(false)
// 配图:选图即上传(走 /api/admin/files),最多 4 张,发布时随帖子带上
const images = ref([]) // [{ url, name }]
const MAX_IMAGES = 4
const imgInput = ref(null)
const imgUploading = ref(false)
function pickImages() {
imgInput.value?.click()
}
async function onImagesPick(e) {
const files = [...(e.target.files || [])].slice(0, MAX_IMAGES - images.value.length)
e.target.value = '' // 允许分批续选
if (!files.length) return
imgUploading.value = true
error.value = ''
try {
const fd = new FormData()
for (const f of files) fd.append('file', f)
const data = await adminApi.uploadFiles(fd)
for (const f of data) images.value.push({ url: f.url, name: f.name })
} catch (e2) {
error.value = e2.message || '图片上传失败'
} finally {
imgUploading.value = false
}
}
function removeImage(i) {
images.value.splice(i, 1)
}
const initial = computed(() => (userName.value || 'O').trim().slice(0, 1).toUpperCase())
const canPost = computed(() => draft.value.trim().length > 0 && !publishing.value)
const canPost = computed(
() => (draft.value.trim().length > 0 || images.value.length > 0) && !publishing.value
)
onMounted(async () => {
try {
@@ -53,9 +85,11 @@ async function publish() {
await adminApi.createPost({
kind: 'short',
content_md: draft.value.trim(),
status: 'published'
status: 'published',
images: images.value.map((i) => i.url)
})
draft.value = ''
images.value = []
sheetOpen.value = false
emit('published')
} catch (e) {
@@ -79,13 +113,30 @@ async function publish() {
<span class="qs-av">{{ initial }}</span>
<div class="qs-body">
<textarea v-model="draft" class="qs-input" rows="2" placeholder="记点短的…" @keydown="onKey" />
<div v-if="images.length" class="qs-thumbs">
<div v-for="(im, i) in images" :key="im.url" class="qs-thumb">
<img :src="im.url" alt="">
<button type="button" class="x" :aria-label="`移除 ${im.name}`" @click="removeImage(i)">×</button>
</div>
</div>
<div class="qs-acts">
<button
type="button"
class="qs-img-btn"
:disabled="images.length >= MAX_IMAGES || imgUploading"
title="添加图片(最多 4 张)"
aria-label="添加图片"
@click="pickImages"
>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>
</button>
<span v-if="error" class="qs-err">{{ error }}</span>
<span v-else class="qs-tip">Ctrl / ⌘ + Enter 发布</span>
<button type="button" class="qs-send" :disabled="!canPost" @click="publish">
{{ publishing ? '发布中…' : '发布' }}
</button>
</div>
<input ref="imgInput" type="file" accept="image/*" multiple hidden @change="onImagesPick" />
</div>
</div>
</div>
@@ -102,13 +153,30 @@ async function publish() {
<button type="button" class="qs-close" aria-label="关闭" @click="sheetOpen = false">✕</button>
</div>
<textarea v-model="draft" class="qs-input" rows="4" placeholder="记点短的…" @keydown="onKey" />
<div v-if="images.length" class="qs-thumbs">
<div v-for="(im, i) in images" :key="im.url" class="qs-thumb">
<img :src="im.url" alt="">
<button type="button" class="x" :aria-label="`移除 ${im.name}`" @click="removeImage(i)">×</button>
</div>
</div>
<div class="qs-acts">
<button
type="button"
class="qs-img-btn"
:disabled="images.length >= MAX_IMAGES || imgUploading"
title="添加图片(最多 4 张)"
aria-label="添加图片"
@click="pickImages"
>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/></svg>
</button>
<span v-if="error" class="qs-err">{{ error }}</span>
<span v-else class="qs-tip">Ctrl / ⌘ + Enter 发布</span>
<button type="button" class="qs-send" :disabled="!canPost" @click="publish">
{{ publishing ? '发布中…' : '发布' }}
</button>
</div>
<input ref="imgInput" type="file" accept="image/*" multiple hidden @change="onImagesPick" />
</div>
</div>
</Transition>
@@ -201,6 +269,76 @@ async function publish() {
cursor: default;
}
/* 配图:缩略图条 + 添加按钮(最多 4 张,Twitter 同款) */
.qs-thumbs {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.qs-thumb {
position: relative;
width: 64px;
height: 64px;
border-radius: 8px;
overflow: hidden;
border: 1px solid var(--line);
}
.qs-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.qs-thumb .x {
position: absolute;
top: 2px;
right: 2px;
width: 16px;
height: 16px;
border: 0;
border-radius: 50%;
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: 11px;
line-height: 1;
cursor: pointer;
display: grid;
place-items: center;
}
.qs-img-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 0;
border-radius: 6px;
background: none;
color: var(--muted);
cursor: pointer;
transition: color 0.15s, background-color 0.15s;
}
.qs-img-btn:hover:not(:disabled) {
color: var(--accent);
background: var(--accent-soft);
}
.qs-img-btn:disabled {
opacity: 0.4;
cursor: default;
}
.qs-img-btn svg {
width: 18px;
height: 18px;
fill: currentColor;
}
/* 手机:FAB 常驻右下,点开上拉 sheet */
.qs-fab {
display: none;
+125 -20
View File
@@ -1,13 +1,42 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { publicApi } from '../api'
import { site } from '../site'
import { socialIcon } from '../socialIcons'
import { relativeDate } from '../utils'
import { relativeDate, stripTags, thumbURL } from '../utils'
// vivid 是单栏布局,右栏整体不渲染(省掉 latest/tags 两次请求)。
const isVivid = computed(() => site.ui_id === 'vivid')
// 文章页目录(PostView 传入 headings;空数组时不渲染这张卡)。
// 高亮跟随滚动:视口上方最近的那个标题算「当前」。
const props = defineProps({
toc: { type: Array, default: () => [] }
})
const activeId = ref('')
let tocObserver = null
function watchToc() {
if (tocObserver) tocObserver.disconnect()
if (!props.toc.length) return
tocObserver = new IntersectionObserver(
(entries) => {
for (const e of entries) {
if (e.isIntersecting) activeId.value = e.target.id
}
},
{ rootMargin: '0px 0px -65% 0px' }
)
nextTick(() => {
for (const t of props.toc) {
const el = document.getElementById(t.id)
if (el) tocObserver.observe(el)
}
})
}
watch(() => props.toc, watchToc, { deep: false })
onBeforeUnmount(() => tocObserver && tocObserver.disconnect())
// 项目源码仓库是固定入口(本站 git remote),不来自后台配置 —— 和版权一样
// 固定渲染在自己的那排;后台填的社交账号单独一排,URL 撞车的去重。
const PROJECT_REPO = 'https://git.gopher.ink/mirrors2/ONE'
@@ -27,6 +56,26 @@ const links = [
const latest = ref([])
const tags = ref([])
// 短文没有标题,侧栏拿正文开头当标识(flomo/Twitter 的做法),
// 纯图片短文显示占位;有配图时右侧带一张小缩略图。
const EXCERPT_LEN = 22
function excerpt(p) {
if (p.kind !== 'short') return p.title || '无题'
// 先剥 <a>:贴链接的短文开头是 URL 本身,摘出来没有信息量;
// 剥完没字了(纯链接贴)就退到链接卡片的标题
const noLinks = (p.content_html || '').replace(/<a\b[^>]*>[\s\S]*?<\/a>/gi, ' ')
const text = stripTags(noLinks).replace(/\s+/g, ' ').trim()
if (text) return text.length > EXCERPT_LEN ? text.slice(0, EXCERPT_LEN) + '…' : text
if (p.link_card && p.link_card.title) {
const t = p.link_card.title
return t.length > EXCERPT_LEN ? t.slice(0, EXCERPT_LEN) + '…' : t
}
return p.images && p.images.length ? '📷 图片' : '短文'
}
function thumbOf(p) {
return p.kind === 'short' && p.images && p.images.length ? thumbURL(p.images[0], 160) : ''
}
onMounted(async () => {
if (isVivid.value) return
try {
@@ -42,6 +91,15 @@ onMounted(async () => {
<template>
<aside v-if="!isVivid" class="rail-right">
<section v-if="toc.length" class="card toc-card">
<p class="eyebrow">目录</p>
<ul class="toc-list">
<li v-for="t in toc" :key="t.id" :class="{ h3: t.level >= 3, on: activeId === t.id }">
<a :href="'#' + t.id" :class="{ on: activeId === t.id }">{{ t.text }}</a>
</li>
</ul>
</section>
<section class="card">
<p class="eyebrow">关于这里</p>
<p class="bio">{{ site.author_bio || site.site_desc }}</p>
@@ -52,12 +110,22 @@ onMounted(async () => {
<p class="eyebrow">最近更新</p>
<ul class="list">
<li v-for="p in latest" :key="p.id">
<RouterLink :to="`/post/${p.slug}`" class="row">
<span class="title">
{{ p.kind === 'short' ? '短文' : p.title || '无题' }}
<span v-if="p.kind === 'short'" class="badge">短</span>
<RouterLink :to="`/post/${p.slug}`" class="row" :class="{ withThumb: thumbOf(p) }">
<span class="row-main">
<span class="title">
{{ excerpt(p) }}
<span v-if="p.kind === 'short'" class="badge">短</span>
</span>
<span class="date">{{ relativeDate(p.published_at) }}</span>
</span>
<span class="date">{{ relativeDate(p.published_at) }}</span>
<img
v-if="thumbOf(p)"
class="thumb"
:src="thumbOf(p)"
alt=""
loading="lazy"
decoding="async"
/>
</RouterLink>
</li>
</ul>
@@ -78,15 +146,6 @@ onMounted(async () => {
已删:右栏在窄屏会堆到信息流下方,卡片之后正好接页脚小字,
所有宽度都不缺位。 -->
<footer class="rail-foot">
<nav class="foot-links" aria-label="页脚导航">
<template v-for="(l, i) in links" :key="l.to">
<span v-if="i" class="dot">·</span>
<RouterLink :to="l.to">{{ l.label }}</RouterLink>
</template>
<span class="dot">·</span>
<a href="/rss.xml">RSS</a>
</nav>
<!-- 后台填的社交账号:单独一排 -->
<div v-if="social.length" class="social-row">
<a
v-for="s in social"
@@ -102,6 +161,14 @@ onMounted(async () => {
<span v-else>{{ s.label }}</span>
</a>
</div>
<nav class="foot-links" aria-label="页脚导航">
<template v-for="(l, i) in links" :key="l.to">
<span v-if="i" class="dot">·</span>
<RouterLink :to="l.to">{{ l.label }}</RouterLink>
</template>
<span class="dot">·</span>
<a href="/rss.xml">RSS</a>
</nav>
<!-- 项目信息:固定的源码图标 + 版权,永远在 -->
<div class="foot-meta">
<a class="social-icon" :href="PROJECT_REPO" title="项目源码" aria-label="项目源码" target="_blank" rel="noopener">
@@ -145,6 +212,24 @@ onMounted(async () => {
color: var(--accent);
}
/* 文章目录:当前小节高亮,层级缩进 */
.toc-list {
list-style: none;
margin: 8px 0 0;
padding: 0;
font-size: 13px;
line-height: 1.6;
}
.toc-list li {margin: 2px 0;
border-left: 2px solid transparent;
padding-left: 8px;}
.toc-list li.h3 {padding-left: 20px;
font-size: 12.5px;}
.toc-list li.on {border-left-color: var(--accent);}
.toc-list a {color: var(--muted);
text-decoration: none;}
.toc-list li.on a, .toc-list a:hover {color: var(--accent);}
.list {
list-style: none;
margin: 8px 0 0;
@@ -152,7 +237,9 @@ onMounted(async () => {
}
.row {
display: block;
display: flex;
align-items: center;
gap: 10px;
padding: 7px 0;
border-bottom: 1px dashed var(--line-soft);
}
@@ -161,10 +248,27 @@ onMounted(async () => {
border-bottom: 0;
}
/* 文字列吃满剩余宽度,缩略图固定在右 */
.row-main {
flex: 1;
min-width: 0;
}
.title {
display: block;
font-size: 14px;
line-height: 1.7;
overflow-wrap: anywhere;
}
.thumb {
flex: 0 0 auto;
width: 34px;
height: 34px;
object-fit: cover;
border-radius: 6px;
background: var(--card);
border: 1px solid var(--line-soft);
}
.badge {
@@ -219,12 +323,13 @@ onMounted(async () => {
opacity: 0.6;
}
/* 图标 + 版权同排:源码等入口是单色小图标,hover 才亮成强调色 */
/* 社交按钮:页脚第一排居中 */
.social-row {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 2px;
justify-content: center;
gap: 6px;
margin-bottom: 10px;
}
.foot-meta {
+18 -8
View File
@@ -27,26 +27,36 @@ function isOn(l) {
return l.to === '/' ? route.path === '/' : route.path.startsWith(l.to)
}
// 下滑藏顶栏、上滑唤回。dead zone 滤掉手指微抖;滚回页首(没滚出顶栏多远)
// 或菜单开着时强制显示 —— 纸片锚在顶栏下面,顶栏藏了纸片会悬空。
// 上滑唤回要累计上移 40px 才算数:页面加载后字体/内容稳定时高度会收缩,
// 浏览器把滚动位置回弹几十像素,那种「假上滑」不该把顶栏拉回来。
// 下滑藏顶栏、上滑唤回。迟滞用「同向累计」:往复拖动时正负抵消,
// 持续同向滚动超过阈值才切换状态——之前的单事件方向判定
// (下滑 4px 就藏、回弹就现身)在阈值附近徘徊时会抽搐。
// 滚回页首(y ≤ 80)或菜单开着时强制显示 —— 纸片锚在顶栏下面,
// 顶栏藏了纸片会悬空。
let lastY = typeof window === 'undefined' ? 0 : window.scrollY
let hideY = 0
let acc = 0
function onScroll() {
const y = window.scrollY
if (open.value || y <= 80) {
hidden.value = false
lastY = y
acc = 0
return
}
const delta = y - lastY
lastY = y
if (delta > 4) {
if (delta === 0) return
// 同向累计;反向则从新方向重新累计(小幅往复互相抵消)
if (acc === 0 || (delta > 0) === (acc > 0)) {
acc += delta
} else {
acc = delta
}
if (!hidden.value && acc > 80 && y > 120) {
hidden.value = true
hideY = y
} else if (delta < -4 && hideY - y > 40) {
acc = 0
} else if (hidden.value && acc < -60) {
hidden.value = false
acc = 0
}
}
@@ -25,14 +25,14 @@ const props = defineProps({
const s = props.api.s
const EDIT_WINDOW_MS = 10 * 60 * 1000
const name = computed(() => (props.c.author && props.c.author.name) || '已注销用户')
const name = computed(() => (props.c.user && props.c.user.name) || '已注销用户')
const relativeTime = computed(() => relativeDate(props.c.created_at))
const isOwner = computed(() => !!(props.c.author && props.c.author.is_owner))
const isMine = computed(() => !!(reader.user && props.c.author && reader.user.id === props.c.author.id))
const isOwner = computed(() => !!(props.c.user && props.c.user.provider === 'admin'))
const isMine = computed(() => !!(reader.user && props.c.user && reader.user.id === props.c.user.id))
// 外链:优先作者自填的 url,其次 GitHub 主页
const authorHref = computed(() => {
const a = props.c.author
const a = props.c.user
if (!a) return ''
if (a.url) return a.url
if (a.provider === 'github' && a.handle) return `https://github.com/${a.handle}`
@@ -41,12 +41,11 @@ const authorHref = computed(() => {
const canEdit = computed(() => {
if (props.c.is_deleted) return false
if (isOwner.value) return true
if (!isMine.value) return false
const t = new Date(props.c.created_at).getTime()
return Number.isFinite(t) && Date.now() - t <= EDIT_WINDOW_MS
})
const canDelete = computed(() => !props.c.is_deleted && (isMine.value || isOwner.value))
const canDelete = computed(() => !props.c.is_deleted && isMine.value)
const replying = computed(() => s.replyTo && s.replyTo.id === props.c.id)
const editing = computed(() => s.editing && s.editing.id === props.c.id)
@@ -118,7 +117,7 @@ const atInline = computed(
function replyNameOf(r) {
if (!r.parent_id || r.parent_id === props.c.id) return ''
const t = (props.c.replies || []).find((x) => x.id === r.parent_id)
return t ? (t.author && t.author.name) || '' : ''
return t ? (t.user && t.user.name) || '' : ''
}
</script>
@@ -126,15 +125,15 @@ function replyNameOf(r) {
<li class="cm-item" :class="{ 'is-deleted': c.is_deleted, 'is-reply': depth > 0 }">
<div class="cm-av" :class="{ 'is-owner': isOwner }">
<img
v-if="c.author && c.author.avatar_url"
:src="c.author.avatar_url"
v-if="c.user && c.user.avatar_url"
:src="c.user.avatar_url"
:alt="`${name} 的头像`"
loading="lazy"
>
<span v-else class="cm-av-fb">{{ name.slice(0, 1) }}</span>
<i v-if="c.author && c.author.provider" class="cm-pv" :title="PROVIDER_LABEL[c.author.provider] || c.author.provider">
<svg v-if="BRANDS[c.author.provider]" viewBox="0 0 24 24" aria-hidden="true">
<path :d="BRANDS[c.author.provider]" />
<i v-if="c.user && c.user.provider" class="cm-pv" :title="PROVIDER_LABEL[c.user.provider] || c.user.provider">
<svg v-if="BRANDS[c.user.provider]" viewBox="0 0 24 24" aria-hidden="true">
<path :d="BRANDS[c.user.provider]" />
</svg>
</i>
</div>
@@ -1,5 +1,5 @@
<script setup>
import { computed, nextTick, onMounted, ref } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { site } from '../../site'
import { reader, loadReader, signIn, signOut } from '../../reader'
import { readerApi } from '../../api'
@@ -20,7 +20,19 @@ const props = defineProps({
})
const api = useComments(props.postId)
const { s, remaining, load, setSort, submit, saveEdit, remove, loadMoreReplies, startReply, startEdit, cancel } = api
const { s, remaining, load, refresh, setSort, submit, saveEdit, remove, loadMoreReplies, startReply, startEdit, cancel } = api
// 新评论实时出现:订阅这篇文章的 SSE 频道,后端广播「评论变了」就静默刷新。
// EventSource 自带断线重连;路由换文章时关旧开新。
let stream = null
function openStream() {
if (stream) stream.close()
if (!props.postId || typeof EventSource === 'undefined') return
stream = new EventSource(`/api/comments/stream?post_id=${props.postId}`)
stream.addEventListener('comments', () => refresh())
}
watch(() => props.postId, openStream, { immediate: true })
onBeforeUnmount(() => stream && stream.close())
// 总开关在站点设置里。后端阶段二才会下发 comments_enabled —— 在那之前整个
// 区块不渲染,线上访客不会看到一个请求 404 的半成品;后端一加就自动出现。
@@ -117,7 +129,7 @@ onMounted(() => {
</div>
</div>
<!-- 登录卡 / 输入框 -->
<!-- 登录卡 / 禁言 / 输入框 -->
<div v-if="!reader.loaded" class="cm-gate cm-gate-skel" aria-hidden="true" />
<div v-else-if="!reader.user" class="cm-gate">
<p class="cm-gate-t">使用社交账号登录</p>
@@ -141,6 +153,10 @@ onMounted(() => {
<!-- Telegram 官方按钮落到这里 -->
<div ref="tgBox" class="cm-tg" />
</div>
<div v-else-if="reader.user.banned" class="cm-gate cm-gate-banned">
<p class="cm-gate-t">你已被禁言,暂时无法参与评论。</p>
<p class="cm-gate-hint">如有疑问请联系站主。</p>
</div>
<div v-else class="cm-compose">
<div class="cm-av cm-av-me">
<img v-if="reader.user.avatar_url" :src="reader.user.avatar_url" alt="" loading="lazy">
+19
View File
@@ -59,6 +59,24 @@ export function useComments(postId) {
load(true)
}
// SSE 触发的静默刷新:回到第一页按当前排序重拉,不亮加载态——
// 别人发了新评论时页面只是「悄悄长出」新内容,不该闪一下。
// 返回 true 表示列表真的变了(顶层条数有差异)。
async function refresh() {
try {
const data = await readerApi.comments(postId, { sort: s.sort, page: 1, size: PAGE_SIZE })
if (s.loading || s.loadingMore || s.replyTo || s.editing) return false
const items = (data && data.items) || []
const changed = (data && data.total) !== s.total || items.some((c, i) => !s.items[i] || s.items[i].id !== c.id || s.items[i].edited_at !== c.edited_at)
s.items = items
s.page = 1
s.total = (data && data.total) || 0
return changed
} catch {
return false
}
}
// 发表 / 回复。成功后直接插进列表,不整页重拉 —— 位置按当前排序决定,
// 用户刚发的东西必须马上看得见。
async function submit(bodyMd, parent = null) {
@@ -167,6 +185,7 @@ export function useComments(postId) {
s,
remaining,
load,
refresh,
setSort,
submit,
saveEdit,
+33
View File
@@ -0,0 +1,33 @@
import { reactive } from 'vue'
// 全站通用的图片预览(lightbox)。任何地方的配图都可以
// openLightbox(list, index) 打开:list 是同组图片的 URL 数组,index 是
// 点击的那张。Esc / 点空白 / 关闭钮收起,左右键或箭头在同组图片间切换。
//
// 注意:这里绝不能碰 body 的 inline overflow——样式表的 overflow-x: clip
// 会被整个 overflow: hidden 覆盖,横向从「裁剪」变「可滚动容器」,
// 手机上整页能被拖宽(真实事故)。背景滚动由 ImgLightbox 遮罩自己的
// @wheel/@touchmove.prevent 拦截,滚动位置天然保留。
const state = reactive({
list: [],
index: 0,
open: false
})
export const lightboxState = state
export function openLightbox(list, index = 0) {
if (!list || !list.length) return
state.list = list
state.index = Math.max(0, Math.min(index, list.length - 1))
state.open = true
}
export function closeLightbox() {
state.open = false
}
export function stepLightbox(delta) {
if (!state.open || state.list.length < 2) return
state.index = (state.index + delta + state.list.length) % state.list.length
}
+2
View File
@@ -17,6 +17,8 @@ const routes = [
{ path: '', name: 'admin-dashboard', component: () => import('./admin/DashboardView.vue') },
{ path: 'posts', name: 'admin-posts', component: () => import('./admin/PostsView.vue') },
{ path: 'new', name: 'admin-new', component: () => import('./admin/EditorView.vue') },
{ path: 'files', name: 'admin-files', component: () => import('./admin/FilesView.vue') },
{ path: 'comments', name: 'admin-comments', component: () => import('./admin/CommentsView.vue') },
{ path: ':id', name: 'admin-edit', component: () => import('./admin/EditorView.vue') },
{ path: 'tags', name: 'admin-tags', component: () => import('./admin/TagsView.vue') },
{ path: 'projects', name: 'admin-projects', component: () => import('./admin/ProjectsView.vue') },
+10 -6
View File
@@ -1,10 +1,11 @@
import { reactive, watch } from 'vue'
import { publicApi } from './api'
import { setThumbBase } from './utils'
// 暗色皮肤固定为 ink —— 是当前唯一支持的暗端皮肤。
const DARK_SKIN = 'ink'
// 亮色皮肤的合法集合(后台 Settings 也只让站主从这里选)
const LIGHT_SKINS = ['paper', 'sage', 'rose']
const LIGHT_SKINS = ['paper']
// 客户端显示模式 —— 持久化在 localStorage
const MODE_KEY = 'one.themeMode'
@@ -52,7 +53,7 @@ function systemPrefersDark() {
// 模式 → 实际 data-theme 值
//
// light / auto 亮端 → site.light_skin_id(站主在后台选的"基础亮色皮肤")
// light / auto 亮端 → 固定纸感(paper);皮肤选择已精简,暗端固定墨色
// dark → ink(强制暗色)
//
// 后台的"基础亮色皮肤"是访客在任何「浅色偏好」下都会看到的;
@@ -100,7 +101,7 @@ export const site = reactive({
loaded: false
})
// data-theme → 前台皮肤。classic: paper/sage/rose/ink;vivid: vivid/vivid-dark
// data-theme → 前台皮肤。classic: paper/ink(亮端固定纸感);vivid: vivid/vivid-dark
// data-admin-theme → 后台明暗(light / dark),跟 site.themeMode 同步
// data-ui → 整站 UI 版本(classic / vivid)
//
@@ -128,7 +129,7 @@ function applyTheme(animate = false) {
setTimeout(() => el.classList.remove('theme-anim'), 300)
}
const isDark = site.themeMode === 'dark' || (site.themeMode === 'auto' && systemPrefersDark())
// vivid 自带亮/暗两套调色板,不复用 paper/sage/rose —— 否则两套 token 会互相打架。
// vivid 自带亮/暗两套调色板,不复用 classic 的纸感/墨色 —— 否则两套 token 会互相打架。
const vivid = !adminScope && site.ui_id === 'vivid'
const theme = vivid
? (isDark ? 'vivid-dark' : 'vivid')
@@ -152,8 +153,11 @@ if (typeof window !== 'undefined' && window.matchMedia) {
export async function loadSite() {
try {
const data = await publicApi.site()
Object.assign(site, data)
const resp = await publicApi.site()
// 响应结构:{ settings: {…}, uploads_public_base }
const { uploads_public_base, settings } = resp || {}
setThumbBase(uploads_public_base)
Object.assign(site, settings)
// 防御性:服务端返回的 light_skin_id 必须是白名单之一,否则兜底 paper
if (!isValidLightSkin(site.light_skin_id)) site.light_skin_id = 'paper'
// ui_id 同理;同时回写本地缓存,让下一次首屏不用等接口就知道该用哪套 UI
+168 -71
View File
@@ -31,56 +31,34 @@
--col-main: 640px;
--col-right: 264px;
--gutter: 28px;}
:root[data-theme='ink'] {--paper: #1f1d1a;
--card: #25221e;
--paper-sunken: #2b2722;
--ink: #ece5d2;
--ink-soft: #c3b58f;
--muted: #8c8270;
--faint: #6a6155;
--line: #3a352e;
--line-soft: #2f2b25;
--accent: #c3a972;
--accent-soft: rgba(195, 169, 114, 0.14);
--accent-line: rgba(195, 169, 114, 0.4);}
:root[data-theme='sage'] {--paper: #eef0e6;
--card: #f6f7ef;
--paper-sunken: #e2e6d6;
--ink: #2a3127;
--ink-soft: #475042;
--muted: #6f7a68;
--faint: #94a08d;
--line: #d6dcc9;
--line-soft: #e6e9d9;
--accent: #5f7b5c;
--accent-soft: rgba(95, 123, 92, 0.12);
--accent-line: rgba(95, 123, 92, 0.38);}
:root[data-theme='rose'] {--paper: #f7eee6;
--card: #fdf5ee;
--paper-sunken: #ecdfd3;
--ink: #3a2922;
--ink-soft: #5e463d;
--muted: #9a7c6f;
--faint: #b89c91;
--line: #ead9cb;
--line-soft: #f0e2d5;
--accent: #a55a4a;
--accent-soft: rgba(165, 90, 74, 0.1);
--accent-line: rgba(165, 90, 74, 0.38);}
:root[data-admin-theme='dark'] {--admin-bg: #1a1815;
:root[data-theme='ink'] {/* 墨:暖炭底、暖白字、鎏金点缀。比旧版更中性(褪掉黄褐相),
层次靠三档灰阶拉开;强调金提亮提纯,暗底上对比更足 */
--paper: #191817;
--card: #201e1c;
--paper-sunken: #262420;
--ink: #e7e3da;
--ink-soft: #b8b2a6;
--muted: #867f73;
--faint: #5f594f;
--line: #34302b;
--line-soft: #292623;
--accent: #d9b97c;
--accent-soft: rgba(217, 185, 124, 0.12);
--accent-line: rgba(217, 185, 124, 0.4);}
:root[data-admin-theme='dark'] {--admin-bg: #191817;
--admin-paper: var(--admin-bg);
--admin-card: #25221e;
--admin-paper-sunken: #2f2b25;
--admin-ink: #ece5d2;
--admin-ink-soft: #c3b58f;
--admin-muted: #9a8f7e;
--admin-faint: #6e6557;
--admin-line: #3a352e;
--admin-line-soft: #2f2b25;
--admin-accent: #c3a972;
--admin-accent-soft: rgba(195, 169, 114, 0.16);
--admin-accent-line: rgba(195, 169, 114, 0.42);
--admin-on-accent: #1a1815;
--admin-card: #201e1c;
--admin-paper-sunken: #262420;
--admin-ink: #e7e3da;
--admin-ink-soft: #b8b2a6;
--admin-muted: #948c7f;
--admin-faint: #635c51;
--admin-line: #34302b;
--admin-line-soft: #292623;
--admin-accent: #d9b97c;
--admin-accent-soft: rgba(217, 185, 124, 0.16);
--admin-accent-line: rgba(217, 185, 124, 0.42);
--admin-on-accent: #191817;
--admin-danger: #d68d75;
--admin-shadow: 0 4px 14px rgba(0, 0, 0, 0.4);
--admin-overlay: rgba(0, 0, 0, 0.6);}
@@ -222,9 +200,18 @@ html.topbar-hidden {--topbar-h: 0px;}
}
.btn:disabled {opacity: 0.5;
cursor: not-allowed;}
/* 编辑器封面:URL 输入框 + 上传按钮同行 */
.cover-row {display: flex;
gap: 6px;
align-items: center;}
.cover-row > input {flex: 1;
min-width: 0;}
.prose {font-size: 16.5px;
line-height: var(--lh);
color: var(--ink);}
color: var(--ink);
/* anywhere 而非 break-word:长 URL/长 token 要参与 min-content 计算,
否则时间线正文里的裸链接会把列撑破(真机上浏览器缩放适配后整页缩小+右侧留白) */
overflow-wrap: anywhere;}
.prose > :first-child {margin-top: 22px;}
.prose p {margin: 0 0 1.1em;}
.prose > p:first-of-type::first-letter {float: left;
@@ -248,6 +235,17 @@ html.topbar-hidden {--topbar-h: 0px;}
margin-top: 8px;
background: var(--accent);}
.prose h4 {font-size: 16.5px;}
/* 悬停浮现的标题锚点(enhanceProse 注入,指向 heading id) */
.prose .h-anchor {opacity: 0;
margin-left: 8px;
font-family: var(--mono);
font-size: 0.72em;
color: var(--faint);
text-decoration: none;
border-bottom: 0;
transition: opacity .2s;}
.prose h2:hover .h-anchor, .prose h3:hover .h-anchor, .prose h4:hover .h-anchor {opacity: 1;}
.prose .h-anchor:hover {color: var(--accent);}
.prose blockquote {margin: 1.4em 0;
padding: 2px 0 2px 18px;
border-left: 2px solid var(--accent-line);
@@ -267,12 +265,32 @@ html.topbar-hidden {--topbar-h: 0px;}
background: var(--paper-sunken);
padding: 1px 5px;
border-radius: 3px;}
.prose .pre-wrap {position: relative;}
.pre-copy {position: absolute;
top: 8px;
right: 8px;
z-index: 1;
padding: 3px 10px;
border: 0;
border-radius: 5px;
font-size: 12px;
color: inherit;
background: color-mix(in srgb, var(--ink) 14%, transparent);
opacity: 0;
cursor: pointer;
transition: opacity .15s, transform .08s;}
.pre-copy:hover {opacity: 1;}
.pre-wrap:hover .pre-copy {opacity: 0.85;}
.pre-copy:active {transform: translateY(2px);}
.pre-copy.done {opacity: 1;
color: var(--accent);}
.prose pre {background: var(--paper-sunken);
border: 1px solid var(--line-soft);
border-radius: 4px;
padding: 14px 16px;
overflow-x: auto;
line-height: 1.7;}
border-radius: 10px;
padding: 16px 18px;
overflow: auto;
line-height: 1.7;
max-height: 30.5em;}
.prose pre code {background: none;
padding: 0;
font-size: 13.5px;}
@@ -283,14 +301,22 @@ html.topbar-hidden {--topbar-h: 0px;}
background: var(--line);
margin: 2em 0;}
.prose table {width: 100%;
border-collapse: collapse;
margin: 1.4em 0;
font-size: 15px;}
.prose th, .prose td {border: 1px solid var(--line);
padding: 7px 10px;
border-collapse: separate;
border-spacing: 0;
margin: 1.6em 0;
font-size: 14.5px;
border: 1px solid var(--line);
border-radius: 10px;
overflow: hidden;}
.prose th, .prose td {border: 0;
border-bottom: 1px solid var(--line-soft);
padding: 11px 16px;
text-align: left;}
.prose tr > * + * {border-left: 1px solid var(--line-soft);}
.prose tbody tr:last-child td {border-bottom: 0;}
.prose th {background: var(--paper-sunken);
font-weight: 600;}
.prose tbody tr:nth-child(even) {background: color-mix(in srgb, var(--paper-sunken) 55%, transparent);}
.prose-short {font-size: 20px;
line-height: 1.9;}
.prose-short > p:first-of-type::first-letter {float: none;
@@ -298,6 +324,32 @@ html.topbar-hidden {--topbar-h: 0px;}
line-height: inherit;
margin: 0;
color: inherit;}
/* 时间线里的短文同样不放大首字:一句话的纸条压个大字像截断
(详情页 .prose-short 同此;长文详情的首字下沉保留) */
.prose.short-body > p:first-of-type::first-letter {float: none;
font-size: inherit;
line-height: inherit;
font-family: inherit;
margin: 0;
color: inherit;}
/* 短文配图:Twitter 式网格(快发盒上传,≤4 张;详情页用) */
.post-imgs {display: grid;
gap: 3px;
margin-top: 18px;
border-radius: 6px;
overflow: hidden;}
.post-imgs img {display: block;
width: 100%;
height: 100%;
object-fit: cover;}
.post-imgs.n2, .post-imgs.n4 {grid-template-columns: 1fr 1fr;}
.post-imgs.n3 {grid-template-columns: 1fr 1fr;}
.post-imgs.n2 img, .post-imgs.n4 img {aspect-ratio: 1;}
.post-imgs.n3 img {aspect-ratio: 1;}
.post-imgs.n3 img:first-child {grid-row: span 2;
aspect-ratio: auto;
height: 100%;}
.post-imgs.n1 img {max-height: 560px;}
.empty {padding: 48px 0;
text-align: center;
color: var(--muted);
@@ -803,17 +855,37 @@ html.topbar-hidden {--topbar-h: 0px;}
0%, 100% {opacity: 1;}
50% {opacity: 0.3;}
}
/* md 页签容器现在是 CodeMirror 挂载点(原为 textarea) */
.md-pane {width: 100%;
min-height: 460px;
border: 1px solid var(--admin-line);
border-radius: 3px;
background: var(--admin-card);
padding: 18px 22px;
font-family: var(--mono);
font-size: 14px;
line-height: 1.7;
padding: 6px 14px;
color: var(--admin-ink);
resize: vertical;
overflow: hidden;}
.md-pane:focus-within {border-color: var(--admin-accent);}
/* 轻提示栈:上传进度 / 转存结果 / 校验失败 */
.toast-stack {position: fixed;
right: 18px;
bottom: 18px;
z-index: 90;
display: flex;
flex-direction: column;
gap: 8px;
max-width: 360px;}
.toast {padding: 9px 14px;
border-radius: 6px;
font-size: 13px;
line-height: 1.6;
color: var(--admin-ink);
background: var(--admin-card);
border: 1px solid var(--admin-line);
box-shadow: 0 6px 24px rgba(0,0,0,.12);
overflow-wrap: anywhere;}
.toast.ok {border-color: color-mix(in srgb, var(--admin-accent) 55%, transparent);}
.toast.err {border-color: #c26a55;
color: #b4553f;
outline: none;}
.md-pane:focus {border-color: var(--admin-accent);}
.toolbar {display: flex;
@@ -836,12 +908,13 @@ html.topbar-hidden {--topbar-h: 0px;}
color: var(--admin-muted);
margin-right: 4px;
text-transform: uppercase;}
.tb-btn {width: 28px;
height: 28px;
/* xLog 式工具栏按钮:方形圆角、线性图标、悬停出底框 */
.tb-btn {width: 32px;
height: 32px;
border: 1px solid transparent;
border-radius: 3px;
border-radius: 6px;
background: transparent;
color: var(--admin-ink-soft);
color: var(--admin-muted);
cursor: pointer;
font-size: 12.5px;
display: inline-flex;
@@ -854,9 +927,12 @@ html.topbar-hidden {--topbar-h: 0px;}
border-color: var(--admin-line);
color: var(--admin-ink);}
}
.tb-btn.italic {font-style: italic;}
.tb-btn.mono {font-family: var(--mono);
font-size: 11.5px;}
.tb-btn svg {width: 17px;
height: 17px;}
.tb-text {font-family: var(--mono);
font-size: 11.5px;
font-weight: 700;
color: inherit;}
.tb-popover {display: flex;
gap: 6px;
padding: 8px;
@@ -1352,3 +1428,24 @@ a.cm-name-a:hover {color: var(--accent);}
.cm-more-r {padding: 10px 0;}
.cm-input {font-size: 16px;}
}
/* 编辑器图片缩放把手:Milkdown 图片块 hover 底部出现拖拽条(上下拖调高度,
比例存进 markdown 的 alt)。默认半透明让访客知道它在那儿。 */
.admin-shell .image-resize-handle {opacity: 0.45;}
.admin-shell .image-wrapper:hover .image-resize-handle {opacity: 1;}
/* lightbox 打开时藏掉吸顶/固定栏:一是全屏观感,二是绕开真机上
backdrop-filter 元素合成层盖过遮罩的 Chromium 绘制问题 */
body.lb-open .sticky-head,
body.lb-open .topbar,
body.lb-open .reading-bar {visibility: hidden;}
/* classic 文章页阅读进度条:视口顶部细线,vivid 的同款能力 */
.reading-bar {position: fixed;
top: 0;
left: 0;
height: 2px;
width: 0;
background: var(--accent);
z-index: 60;
transition: width .1s linear;
pointer-events: none;}
+24 -2
View File
@@ -1,7 +1,9 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { sanitizeHtml } from '../../utils'
import { applyImageRatio, sanitizeHtml } from '../../utils'
import { openLightbox } from '../../lightbox'
import CommentSection from '../../components/comments/CommentSection.vue'
import LinkCard from '../../components/LinkCard.vue'
// 余白文章页。对应设计稿 viewPost() 的三栏「纸」:
// 左 side-l : 时间线(当前篇前后各两篇,标签来自位置)
@@ -166,7 +168,27 @@ function onTocClick(e) {
<!-- 短文不渲染标题:正文第一句就是全部,再给个截断标题是重复 -->
<h1 v-if="isShort" class="sr-only">{{ title }}</h1>
<div class="prose" :class="{ 'prose-note': isShort }" v-html="sanitizeHtml(props.contentHtml)" />
<div class="prose" :class="{ 'prose-note': isShort }" v-html="applyImageRatio(sanitizeHtml(props.contentHtml))" />
<!-- 短文配图:Twitter 式网格(快发盒上传);点图开预览 -->
<div
v-if="isShort && props.post.images && props.post.images.length"
class="post-imgs"
:class="'n' + Math.min(props.post.images.length, 4)"
>
<img
v-for="(u, i) in props.post.images.slice(0, 4)"
:key="i"
:src="u"
alt=""
loading="lazy"
decoding="async"
@click="openLightbox(props.post.images, i)"
/>
</div>
<!-- 外链预览卡片:正文里第一个链接,发布时后端已抓好 -->
<LinkCard v-if="isShort && props.post.link_card" :card="props.post.link_card" />
<div class="end">
<RouterLink v-for="t in props.post.tags || []" :key="t" class="tg" to="/tags">{{ t }}</RouterLink>
+24 -2
View File
@@ -1,5 +1,8 @@
<script setup>
import { RouterLink } from 'vue-router'
import { openLightbox } from '../../lightbox'
import { thumbURL, thumbifyHtml } from '../../utils'
import LinkCard from '../../components/LinkCard.vue'
// 余白列表。对应设计稿的 <div class="feed">:长文是卡片(封面 + 标题 + 摘要 + 元信息 + 标签),
// 短文是纸条(直接铺正文,元信息在正文后)。两种密度混在同一条时间线里,这是设计稿的主张。
@@ -34,7 +37,7 @@ const shortTitle = (p) => plain(p.content_html).slice(0, 40) + '…'
<!-- 长文:卡片 -->
<RouterLink v-if="p.kind === 'long'" class="card" :to="`/post/${encodeURIComponent(p.slug)}`">
<div v-if="p.cover_url" class="cov">
<img :src="p.cover_url" alt="" loading="lazy">
<img :src="thumbURL(p.cover_url, 640)" alt="" loading="lazy">
</div>
<div class="body">
<h2>{{ p.title }}</h2>
@@ -53,7 +56,26 @@ const shortTitle = (p) => plain(p.content_html).slice(0, 40) + '…'
<!-- 短文:纸条,正文直接铺开 -->
<RouterLink v-else class="note" :to="`/post/${encodeURIComponent(p.slug)}`">
<div class="body" v-html="p.content_html" />
<div class="body" v-html="thumbifyHtml(p.content_html)" />
<!-- 短文配图:Twitter 式网格(快发盒上传);点图开预览(不跳详情) -->
<div
v-if="p.images && p.images.length"
class="imgs"
:class="'n' + Math.min(p.images.length, 4)"
>
<img
v-for="(u, i) in p.images.slice(0, 4)"
:key="i"
:src="thumbURL(u, p.images.length === 1 ? 720 : 480)"
alt=""
loading="lazy"
decoding="async"
@click.prevent.stop="openLightbox(p.images, i)"
/>
</div>
<!-- 外链预览卡片:正文里第一个链接,发布时后端已抓好 -->
<LinkCard v-if="p.link_card" :card="p.link_card" />
<div class="meta">
<span class="badge short">SHORT</span>
<time :datetime="p.published_at">{{ fmtDateTime(p.published_at) }}</time>
+20
View File
@@ -1,6 +1,12 @@
<script setup>
import { RouterLink } from 'vue-router'
import { site } from '../../site'
import { socialIcon } from '../../socialIcons'
// 社交按钮与 classic 页脚同源:后台「社交链接」+ 固定源码仓库入口
const PROJECT_REPO = 'https://git.gopher.ink/mirrors2/ONE'
const social = (site.social_links || []).concat([{ label: '源码', url: PROJECT_REPO }])
.filter((s) => s && s.url)
// 余白页脚。对应设计稿的 <footer class="foot">。
//
@@ -21,6 +27,20 @@ const links = [
<template>
<footer class="foot">
<div class="quote">What&rsquo;s left unsaid holds the most weight.</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>{{ site.footer_note }}<template v-if="site.icp"> · {{ site.icp }}</template></div>
<div class="foot-nav">
<template v-for="(l, i) in links" :key="l.to">
+2 -1
View File
@@ -4,6 +4,7 @@ import YohakuHero from './YohakuHero.vue'
import YohakuToolbar from './YohakuToolbar.vue'
import YohakuFeed from './YohakuFeed.vue'
import YohakuPager from './YohakuPager.vue'
import { thumbURL } from '../../utils'
// 首页装配。对应设计稿 viewHome() 的 DOM 顺序:
// hero → wrap(toolbar + feed + empty + pager) → showcase(作品前三件 + 查看所有)。
@@ -81,7 +82,7 @@ const emit = defineEmits(['update:kind', 'update:tag', 'update:query', 'go'])
to="/projects"
>
<div v-if="p.cover_url" class="cov">
<img :src="p.cover_url" alt="" loading="lazy">
<img :src="thumbURL(p.cover_url, 480)" alt="" loading="lazy">
<span v-if="p.status" class="st">{{ p.status }}</span>
</div>
<div class="body">
+25 -2
View File
@@ -96,8 +96,12 @@ html[data-ui='vivid'] .prose code {font-family:var(--mono);font-size:.88em;
padding:2px 7px;border-radius:6px;}
html[data-ui='vivid'] .prose pre {background:color-mix(in srgb,var(--ink) 92%,var(--accent));
color:#e8e8f0;padding:18px 20px;border-radius:12px;
overflow-x:auto;font-size:13.5px;line-height:1.7;
box-shadow:var(--shadow-lg);}
overflow:auto;font-size:13.5px;line-height:1.7;
box-shadow:var(--shadow-lg);
max-height:30.5em;}
/* 复制按钮(vivid 配色:浅字浮在深底上) */
html[data-ui='vivid'] .pre-copy {color:#e8e8f0;
background:rgba(255,255,255,.14);}
/* 暗色下「92% ink」混出的是浅底,浅底配浅字没法读;暗端换深底微混强调色 */
html[data-ui='vivid'][data-theme='vivid-dark'] .prose pre {
background:color-mix(in srgb,var(--paper) 86%,var(--accent));
@@ -130,6 +134,11 @@ html[data-ui='vivid'] .paper > .end {margin-top:40px;padding-top:24px;border-top
display:flex;gap:14px;flex-wrap:wrap;align-items:center;}
html[data-ui='vivid'] .paper > .end .tg {padding:6px 0}
/* 短文配图:Twitter 式网格(快发盒上传,≤4 张;跟 prose 同宽,圆角跟 vivid 走) */
html[data-ui='vivid'] .post-imgs {margin-top: 6px;
border-radius: var(--radius);}
html[data-ui='vivid'] .post-imgs.n1 img {max-height: 520px;}
/* ==========================================================================
把 classic 的 .prose 挡回去
--------------------------------------------------------------------------
@@ -210,3 +219,17 @@ html[data-ui='vivid'] .sr-only {position: absolute;
white-space: nowrap;
border: 0;}
html[data-ui='vivid'] .toc a:active { color: var(--accent); }
/* ---- 表格(xLog 式:圆角容器 + 斑马纹,替代满格线) ---- */
html[data-ui='vivid'] .prose table {width:100%;
border-collapse:separate;border-spacing:0;
margin:1.8em 0;font-size:14px;
border:1px solid var(--line);border-radius:12px;overflow:hidden;}
html[data-ui='vivid'] .prose th, html[data-ui='vivid'] .prose td {border:0;
border-bottom:1px solid var(--line-soft);
padding:12px 18px;text-align:left;}
html[data-ui='vivid'] .prose tr > * + * {border-left:1px solid var(--line-soft);}
html[data-ui='vivid'] .prose tbody tr:last-child td {border-bottom:0;}
html[data-ui='vivid'] .prose th {background:var(--accent-soft);font-weight:600;}
html[data-ui='vivid'] .prose tbody tr:nth-child(even) {background:var(--card);}
/* 标题锚点 + 复制按钮沿用 classic 的 .h-anchor/.pre-copy 基础样式 */
+9
View File
@@ -169,6 +169,15 @@ html[data-ui='vivid'] .foot {max-width:1100px;margin:40px auto 0;padding:28px 28
border-top:1px solid var(--line);
text-align:center;color:var(--muted);font-size:12.5px;line-height:2;}
html[data-ui='vivid'] .foot a {color:var(--accent);font-weight:600;transition:opacity .25s}
/* 页脚社交排:图标居中一行,与 classic 页脚同步 */
html[data-ui='vivid'] .foot-social {display:flex;justify-content:center;align-items:center;
gap:18px;margin:14px 0 18px;}
html[data-ui='vivid'] .foot-social a {color:var(--soft);font-weight:400;
display:inline-flex;align-items:center;justify-content:center;
width:30px;height:30px;border-radius:8px;transition:color .25s,background .25s;}
html[data-ui='vivid'] .foot-social a:hover {color:var(--accent);background:var(--accent-soft);}
html[data-ui='vivid'] .foot-social svg {width:17px;height:17px;fill:currentColor;}
html[data-ui='vivid'] .foot-social span {font-size:12px;}
@media (hover: hover) and (pointer: fine) {
html[data-ui='vivid'] .foot a:hover {opacity:.75}
}
+13
View File
@@ -128,6 +128,19 @@ html[data-ui='vivid'] .note .body a {color:var(--accent);font-weight:600;
@media (hover: hover) and (pointer: fine) {
html[data-ui='vivid'] .note .body a:hover {border-bottom-color:var(--accent)}
}
/* 短文配图:Twitter 式网格(快发盒上传,≤4 张) */
html[data-ui='vivid'] .note .imgs {display:grid;gap:3px;margin-top:10px;
border-radius:var(--radius);overflow:hidden;}
html[data-ui='vivid'] .note .imgs img {display:block;width:100%;height:100%;
object-fit:cover;cursor:zoom-in;}
html[data-ui='vivid'] .note .imgs.n2,
html[data-ui='vivid'] .note .imgs.n4 {grid-template-columns:1fr 1fr;}
html[data-ui='vivid'] .note .imgs.n3 {grid-template-columns:1fr 1fr;}
html[data-ui='vivid'] .note .imgs.n2 img,
html[data-ui='vivid'] .note .imgs.n4 img {aspect-ratio:1;}
html[data-ui='vivid'] .note .imgs.n3 img {aspect-ratio:1;}
html[data-ui='vivid'] .note .imgs.n3 img:first-child {grid-row:span 2;
aspect-ratio:auto;height:100%;}
html[data-ui='vivid'] .note .tags {display:flex;gap:14px;flex-wrap:wrap;margin-top:8px}
html[data-ui='vivid'] .pager {display:flex;gap:8px;align-items:center;justify-content:center;
margin-top:36px;flex-wrap:wrap;}
+71 -1
View File
@@ -3,7 +3,77 @@ import DOMPurify from 'dompurify'
// 后端 goldmark 已转义原始 HTML,这里再过一道 DOMPurify 作纵深防御,
// 所有 v-html 出口必须经过它。
export function sanitizeHtml(html) {
return DOMPurify.sanitize(html || '')
return enhanceProse(DOMPurify.sanitize(html || ''))
}
// ---------- 缩略图 ----------
// 时间线只展示小图:把我们存储上的图片直链改写为 /uploads/thumb/{key}?w=,
// 后端懒生成并永久缓存。识别规则:以 uploads_public_base 开头的直链,
// 或同源的 /uploads/ 相对路径(本地存储兜底)。外链(图床/头像)原样返回。
// base 由 site.js 拉到站点设置后经 setThumbBase 注入(不走 import 避免循环依赖)。
const IMG_EXT = /\.(jpe?g|png|gif)([?#].*)?$/i
let thumbBase = ''
export function setThumbBase(base) {
thumbBase = (base || '').replace(/\/+$/, '')
}
export function thumbURL(u, w = 480) {
if (!u || typeof u !== 'string') return u
let key = ''
if (thumbBase && u.startsWith(thumbBase + '/')) key = u.slice(thumbBase.length + 1)
else if (u.startsWith('/uploads/') && !u.startsWith('/uploads/thumb/')) key = u.slice('/uploads/'.length)
else return u
if (!IMG_EXT.test(key)) return u
return '/uploads/thumb/' + key + '?w=' + w
}
// ---------- xLog 式排版增强 ----------
// 对消毒后的正文 HTML 做三件展示层增强(markdown/库里内容不动):
// 1) pre 包一层容器并注入悬停复制按钮;
// 2) 带 id 的标题(withHeadingIds 生成)尾部加 # 锚点;
// 3) (盘古之白在后端渲染出口做,见 backend/internal/render/pangu.go)
export function enhanceProse(html) {
if (!html) return html
// 复制按钮:包在 .pre-wrap 里,按钮悬浮于 pre 右上
html = html
.replace(/<pre\b/g, '<div class="pre-wrap"><button type="button" class="pre-copy" aria-label="复制代码">复制</button><pre')
.replace(/<\/pre>/g, '</pre></div>')
// 标题锚点:h2-h4 且有 id 的,末尾补一个 # 链接(悬停浮现)
html = html.replace(
/<h([2-4])([^>]*\bid="([^"]+)"[^>]*)>([\s\S]*?)<\/h\1>/g,
(m, lvl, attrs, id, inner) =>
`<h${lvl}${attrs}>${inner}<a class="h-anchor" href="#${id}" aria-label="标题链接">#</a></h${lvl}>`
)
return html
}
// 编辑器拖拽缩放的比例展示:Milkdown 图片块把高度比例写进 markdown 的 alt
// (![0.6](url)),渲染出的 <img alt="0.6"> 在这里还原成对应的宽度并居中。
// 纯数字 alt 才生效(>=1 的当 100% 处理),正常图片的描述性 alt 不受影响。
export function applyImageRatio(html) {
if (!html) return html
return html.replace(/<img\b[^>]*>/gi, (tag) => {
const m = tag.match(/\balt="(\d*\.?\d+)"/i)
if (!m) return tag
const ratio = Number.parseFloat(m[1])
if (!Number.isFinite(ratio) || ratio <= 0 || ratio >= 1) return tag
const style = `display:block;width:${(ratio * 100).toFixed(1)}%;max-width:100%;height:auto;margin-inline:auto;`
if (/\bstyle="/i.test(tag)) return tag.replace(/\bstyle="/i, 'style="' + style)
return tag.replace(/^<img/i, '<img style="' + style + '"')
})
}
// 时间线正文 HTML(短文全文 / 长文 600 字符截断)里的 <img> 换缩略图 + 补懒加载。
// 只在展示层重写,库里存的正文不动。截断的 HTML 可能带半截标签,
// 正则只匹配完整的 <img ...src="..."> ,坏尾巴交给 sanitize/浏览器容错。
export function thumbifyHtml(html, w = 480) {
if (!html) return html
return html.replace(/<img\b[^>]*?>/gi, (tag) => {
let out = tag.replace(/\bsrc="([^"]*)"/i, (m, src) => 'src="' + thumbURL(src, w) + '"')
if (!/\bloading=/i.test(out)) out = out.replace(/^<img/i, '<img loading="lazy"')
return out
})
}
// 用 Intl.DateTimeFormat — locale 感知,未来要 i18n 只换 locale 即可
+41 -5
View File
@@ -1,13 +1,15 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import LeftNav from '../components/LeftNav.vue'
import RightRail from '../components/RightRail.vue'
import YohakuArticle from '../ui/yohaku/YohakuArticle.vue'
import CommentSection from '../components/comments/CommentSection.vue'
import LinkCard from '../components/LinkCard.vue'
import { openLightbox } from '../lightbox'
import { publicApi } from '../api'
import { site, applyDocTitle } from '../site'
import { formatDate, minutesLabel, sanitizeHtml } from '../utils'
import { applyImageRatio, formatDate, minutesLabel, sanitizeHtml } from '../utils'
const route = useRoute()
const post = ref(null)
@@ -31,6 +33,14 @@ function withHeadingIds(html) {
const contentHtml = ref('')
const headings = ref([])
// 顶部阅读进度条(classic):滚动过正文才算数,短文太短不显示
const progress = ref(0)
function onScroll() {
const doc = document.documentElement
const total = doc.scrollHeight - window.innerHeight
progress.value = total > 300 ? Math.min(100, (window.scrollY / total) * 100) : 0
}
async function load() {
loading.value = true
error.value = ''
@@ -40,7 +50,7 @@ async function load() {
try {
post.value = await publicApi.post(route.params.slug)
const prepared = withHeadingIds(post.value.content_html)
contentHtml.value = prepared.html
contentHtml.value = applyImageRatio(prepared.html)
headings.value = prepared.headings
applyDocTitle(post.value.kind === 'short' ? '短文' : post.value.title)
} catch (e) {
@@ -50,7 +60,11 @@ async function load() {
}
}
onMounted(load)
onMounted(() => {
load()
window.addEventListener('scroll', onScroll, { passive: true })
})
onBeforeUnmount(() => window.removeEventListener('scroll', onScroll))
watch(() => route.params.slug, load)
const isShort = computed(() => post.value && post.value.kind === 'short')
@@ -69,6 +83,8 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
/>
<div v-else class="shell">
<!-- 阅读进度:贴在视口顶部的细线 -->
<div class="reading-bar" :style="{ width: progress + '%' }" aria-hidden="true"></div>
<div class="layout">
<!-- LeftNav / RightRail 之前 import 了却没渲染,导致整页掉进 236px 的左列。
补上,和 HomeView 的三栏一致。 -->
@@ -106,6 +122,26 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
v-html="sanitizeHtml(contentHtml)"
></div>
<!-- 短文配图:Twitter 式网格(快发盒上传);点图开预览 -->
<div
v-if="isShort && post.images && post.images.length"
class="post-imgs"
:class="'n' + Math.min(post.images.length, 4)"
>
<img
v-for="(u, i) in post.images.slice(0, 4)"
:key="i"
:src="u"
alt=""
loading="lazy"
decoding="async"
@click="openLightbox(post.images, i)"
/>
</div>
<!-- 外链预览卡片:正文里第一个链接,发布时后端已抓好 -->
<LinkCard v-if="isShort && post.link_card" :card="post.link_card" />
<footer class="foot">
<div v-if="post.tags && post.tags.length" class="tags">
<RouterLink
@@ -124,7 +160,7 @@ const initial = computed(() => (site.author_name || 'O').trim().slice(0, 1).toUp
<!-- 评论区(和 vivid 共用同一个组件,见 components/comments/) -->
<CommentSection v-if="post" :post-id="post.id" />
</main>
<RightRail />
<RightRail :toc="isShort ? [] : headings" />
</div>
</div>
</template>
+2
View File
@@ -16,6 +16,8 @@ export default defineConfig({
port: 3000,
proxy: {
'/api': { target, changeOrigin: true },
// 上传文件与缩略图路由都在后端(/uploads/、/uploads/thumb/)
'/uploads': { target, changeOrigin: true },
'/rss.xml': { target, changeOrigin: true },
'/feed': { target, changeOrigin: true },
},