Compare commits
2
Commits
36fe7a978c
...
ecc560c1f0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecc560c1f0 | ||
|
|
8528499a69 |
@@ -86,6 +86,7 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe
|
|||||||
"webExternal": false, // 是否允许外网访问 web UI,false 时只监听本机 127.0.0.1
|
"webExternal": false, // 是否允许外网访问 web UI,false 时只监听本机 127.0.0.1
|
||||||
"authToken": "…", // web UI 管理接口的访问口令,首次启动自动生成
|
"authToken": "…", // web UI 管理接口的访问口令,首次启动自动生成
|
||||||
|
|
||||||
|
"notifyEnabled": true, // QQ 机器人(webhook)推送的渠道开关,关掉后即使填了地址也不推送
|
||||||
"notifyUrl": "", // 开播/下播等事件推送到该地址(HTTP POST JSON),留空不推送
|
"notifyUrl": "", // 开播/下播等事件推送到该地址(HTTP POST JSON),留空不推送
|
||||||
"notifyToken": "", // 推送时附带的 Bearer 口令,留空不带
|
"notifyToken": "", // 推送时附带的 Bearer 口令,留空不带
|
||||||
"notifyGroup": "", // 推送时带上的群号,机器人可以直接用它决定发到哪个群
|
"notifyGroup": "", // 推送时带上的群号,机器人可以直接用它决定发到哪个群
|
||||||
@@ -94,6 +95,12 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe
|
|||||||
"notifyRecordStart": false, // 开始录制直播视频时推送
|
"notifyRecordStart": false, // 开始录制直播视频时推送
|
||||||
"notifyMergeDone": false, // 自动合并完成时推送
|
"notifyMergeDone": false, // 自动合并完成时推送
|
||||||
|
|
||||||
|
"meowEnabled": false, // MeoW 推送的渠道开关
|
||||||
|
"meowNickname": "", // MeoW 昵称,推送地址里的路径参数
|
||||||
|
"meowMsgType": "text", // MeoW 消息类型:text(纯文本)或 html
|
||||||
|
"meowHtmlHeight": 200, // MeoW 在 msgType 为 html 时的显示高度(像素)
|
||||||
|
"meowImgUrl": "", // MeoW 通知图标 URL,建议 216×216 的 PNG,留空用默认图标
|
||||||
|
|
||||||
"ffmpegPath": "", // ffmpeg 可执行文件(或它所在文件夹)的路径,留空则自动查找
|
"ffmpegPath": "", // ffmpeg 可执行文件(或它所在文件夹)的路径,留空则自动查找
|
||||||
"recordFormat": "mp4", // 录播文件的封装格式:mp4、flv、ts、mkv
|
"recordFormat": "mp4", // 录播文件的封装格式:mp4、flv、ts、mkv
|
||||||
"recordSubdir": "record", // 录播原片和弹幕文件存放在保存目录下的子文件夹名
|
"recordSubdir": "record", // 录播原片和弹幕文件存放在保存目录下的子文件夹名
|
||||||
@@ -107,7 +114,7 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe
|
|||||||
|
|
||||||
录播原片和弹幕文件(`.ass`、`.nick.ass`、`.jsonl`)保存在保存目录下的`recordSubdir`子文件夹里(默认`record`),两者同名配对才能自动合并;合并后的成片保存在`mergeSubdir`子文件夹里(默认`merged`),留空则用默认值,两个名字不能相同。如果设置了`mergeOutputDir`,成片优先移动到那里。
|
录播原片和弹幕文件(`.ass`、`.nick.ass`、`.jsonl`)保存在保存目录下的`recordSubdir`子文件夹里(默认`record`),两者同名配对才能自动合并;合并后的成片保存在`mergeSubdir`子文件夹里(默认`merged`),留空则用默认值,两个名字不能相同。如果设置了`mergeOutputDir`,成片优先移动到那里。
|
||||||
|
|
||||||
`recordFormat`决定录播原片的封装格式,默认为`mp4`(和原版`config.Output`的默认值一致),也可以改成`flv`、`ts`、`mkv`,在 web UI 设置区用下拉框修改。录制始终是`-c copy`直接封装直播源、不重新编码,所以四种格式的**画质和码率完全一样,区别只在容器**。
|
`recordFormat`决定录播原片的封装格式,默认为`mp4`(和原版`config.Output`的默认值一致),也可以改成`flv`、`ts`、`mkv`,在 web UI 的`录制`分区用下拉框修改。录制始终是`-c copy`直接封装直播源、不重新编码,所以四种格式的**画质和码率完全一样,区别只在容器**。
|
||||||
|
|
||||||
正常停止(web UI 的停止按钮、`stoprecord`、`Ctrl+C`、`quit`)会先向 ffmpeg 发`q`让它正常收尾,**四种格式都能正常播放**——本机 ffmpeg 6.0 实测录 5 秒后发`q`:mp4 161 KB、flv 163 KB、ts 191 KB、mkv 160 KB,全部可读且时长 5.14 秒。但如果进程被强杀(任务管理器结束进程、断电、系统崩溃),差别就很大了——同样录制 8 秒后强杀:
|
正常停止(web UI 的停止按钮、`stoprecord`、`Ctrl+C`、`quit`)会先向 ffmpeg 发`q`让它正常收尾,**四种格式都能正常播放**——本机 ffmpeg 6.0 实测录 5 秒后发`q`:mp4 161 KB、flv 163 KB、ts 191 KB、mkv 160 KB,全部可读且时长 5.14 秒。但如果进程被强杀(任务管理器结束进程、断电、系统崩溃),差别就很大了——同样录制 8 秒后强杀:
|
||||||
|
|
||||||
@@ -124,9 +131,9 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe
|
|||||||
|
|
||||||
`webExternal`为 false 时 web UI 只监听`127.0.0.1`,只有本机能打开;设为 true 后监听所有网卡,同一局域网的设备可以用本机 IP 加端口访问。这项设置改动后 web 服务会自动重启。
|
`webExternal`为 false 时 web UI 只监听`127.0.0.1`,只有本机能打开;设为 true 后监听所有网卡,同一局域网的设备可以用本机 IP 加端口访问。这项设置改动后 web 服务会自动重启。
|
||||||
|
|
||||||
`authToken`是管理接口的访问口令,首次启动时自动生成(12 字节随机数的十六进制)并打印在运行日志里,忘记时可以打开`config.json`查看,也能在 web UI 设置区查看、复制和更换。**所有`/api/`接口都必须带口令**,页面本身可以匿名打开,打开后会要求输入口令并保存在浏览器本地。口令可以用三种方式提供:`X-Auth-Token`请求头、`Authorization: Bearer <口令>`、或者`?token=<口令>`查询参数。同一来源连续输错时每次会增加最多 2 秒延迟,避免被暴力尝试。手工把`config.json`里的`authToken`清空保存后,程序会自动重新生成一个,不会把自己锁在外面。
|
`authToken`是管理接口的访问口令,首次启动时自动生成(12 字节随机数的十六进制)并打印在运行日志里,忘记时可以打开`config.json`查看,也能在 web UI 的`设置`分区查看、复制和更换。**所有`/api/`接口都必须带口令**,页面本身可以匿名打开,打开后会要求输入口令并保存在浏览器本地。口令可以用三种方式提供:`X-Auth-Token`请求头、`Authorization: Bearer <口令>`、或者`?token=<口令>`查询参数。口令可以用任意字符,包括中文。注意 HTTP 请求头只接受 ISO-8859-1,浏览器遇到中文口令会直接拒绝发请求,所以页面发给服务端的`X-Auth-Token`是百分号编码过的口令,服务端原样和解码后的都认;用 curl 或脚本调接口时按原样填即可,不用自己编码。同一来源连续输错时每次会增加最多 2 秒延迟,避免被暴力尝试。手工把`config.json`里的`authToken`清空保存后,程序会自动重新生成一个,不会把自己锁在外面。
|
||||||
|
|
||||||
`defaultBitrate`是下载码率的默认值:在 web UI 里新增主播、且不单独指定码率时使用它,也可以在 web UI 设置区随时修改,只对新添加的主播生效,已有主播保持各自的值。
|
`defaultBitrate`是下载码率的默认值:在 web UI 里新增主播、且不单独指定码率时使用它,也可以在 web UI 的`录制`分区随时修改,只对新添加的主播生效,已有主播保持各自的值。
|
||||||
|
|
||||||
`ffmpegPath`用来指定 ffmpeg 的位置,可以填可执行文件本身(如`D:\ffmpeg\bin\ffmpeg.exe`)或它所在的文件夹,留空时按「PATH → 程序所在文件夹 → 程序所在文件夹的 bin、ffmpeg、ffmpeg/bin 子目录」的顺序自动查找。填相对路径时按程序所在目录解析,不会因为合并时会切换工作目录而失效(这是`ffmpeg.exe`这种只写文件名的写法能正常工作的原因)。录播和弹幕合并都依赖 ffmpeg,找不到时启动日志里会有明确提示。
|
`ffmpegPath`用来指定 ffmpeg 的位置,可以填可执行文件本身(如`D:\ffmpeg\bin\ffmpeg.exe`)或它所在的文件夹,留空时按「PATH → 程序所在文件夹 → 程序所在文件夹的 bin、ffmpeg、ffmpeg/bin 子目录」的顺序自动查找。填相对路径时按程序所在目录解析,不会因为合并时会切换工作目录而失效(这是`ffmpeg.exe`这种只写文件名的写法能正常工作的原因)。录播和弹幕合并都依赖 ffmpeg,找不到时启动日志里会有明确提示。
|
||||||
|
|
||||||
@@ -138,6 +145,21 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe
|
|||||||
|
|
||||||
web UI 默认通过`http://localhost:51880`访问(端口对应`webPort`),可以在网页里增删主播、切换每个主播的弹幕和录播开关、修改码率上限、修改保存目录和自动合并设置。
|
web UI 默认通过`http://localhost:51880`访问(端口对应`webPort`),可以在网页里增删主播、切换每个主播的弹幕和录播开关、修改码率上限、修改保存目录和自动合并设置。
|
||||||
|
|
||||||
|
页面按用途分成六个分区,顶部一条导航切换,地址栏会跟着带上`#分区名`,所以刷新、收藏、直接把链接发给别人都能落到同一个分区:
|
||||||
|
|
||||||
|
| 分区 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| 监控 | 添加主播、主播列表(默认打开的分区),以及运行日志 |
|
||||||
|
| 录制 | 保存目录和子文件夹、录播格式、默认码率上限、ffmpeg 路径 |
|
||||||
|
| 合并 | 自动合并开关、成片输出目录和合成码率、手动合并 |
|
||||||
|
| 通知 | 推送哪些事件,以及 QQ 机器人 / MeoW 两个渠道的配置和测试连通 |
|
||||||
|
| 文件 | 磁盘剩余容量、录播和成片的文件列表 |
|
||||||
|
| 设置 | 是否允许外网访问、管理口令的查看与更换 |
|
||||||
|
|
||||||
|
开关和下拉框改完立即保存;文本框(目录、地址、口令这些)填完点右上角的`保存设置`才生效,这一条在每个有文本框的分区里也写了提示。
|
||||||
|
|
||||||
|
顶栏右上角一直显示当前状态(`录制中 N` / `直播中 N` / `空闲`)。窄屏上按钮收起成短文案、标题居中,手机上也能正常用;页面跟随系统的深浅色设置,深色模式下日志和表单自动变深色,不用额外配置。
|
||||||
|
|
||||||
主播列表每行右侧的`停止`和`重新开始`按钮控制该主播当前的录制:`停止`会结束这一场的弹幕和录播(同时把该主播的弹幕、录播开关关掉,避免被监控循环自动重启),`重新开始`会重新开启开关并立刻开始新的一组录制文件。这两个按钮同时作用于弹幕和录播,只控制其中一项可以用对应的勾选框。
|
主播列表每行右侧的`停止`和`重新开始`按钮控制该主播当前的录制:`停止`会结束这一场的弹幕和录播(同时把该主播的弹幕、录播开关关掉,避免被监控循环自动重启),`重新开始`会重新开启开关并立刻开始新的一组录制文件。这两个按钮同时作用于弹幕和录播,只控制其中一项可以用对应的勾选框。
|
||||||
|
|
||||||
命令行模式运行时可以输入命令控制本程序,输入`help`查看全部命令的解释,输入`quit`退出程序。
|
命令行模式运行时可以输入命令控制本程序,输入`help`查看全部命令的解释,输入`quit`退出程序。
|
||||||
@@ -189,7 +211,7 @@ acfunlive -danmuburn xxx.jsonl -video xxx.flv -ffmpeg D:\tools\ffmpeg.exe
|
|||||||
|
|
||||||
### 手动合并
|
### 手动合并
|
||||||
|
|
||||||
web UI 的`手动合并`区用来自己挑视频和弹幕出片,适合手动停止录制过的一场、或者手里只有零散文件的情况。
|
web UI 的`合并`分区里的`手动合并`用来自己挑视频和弹幕出片,适合手动停止录制过的一场、或者手里只有零散文件的情况。
|
||||||
|
|
||||||
- **视频**:既可以在文本框里一行一个地填,也可以从录播目录的下拉列表里多选(选完自动填进文本框)。填的名字不带路径就按录播目录找,带路径的相对和绝对都能用,所以录播目录以外的文件也合得来。粘贴路径时带上的引号会被自动去掉。
|
- **视频**:既可以在文本框里一行一个地填,也可以从录播目录的下拉列表里多选(选完自动填进文本框)。填的名字不带路径就按录播目录找,带路径的相对和绝对都能用,所以录播目录以外的文件也合得来。粘贴路径时带上的引号会被自动去掉。
|
||||||
- **弹幕**:支持`.jsonl`和`.ass`。填`.ass`时会先还原成 jsonl 再合并(规则和`-danmuburn`一样,优先用同名的 jsonl),还原结果写回录播目录,页面的下拉列表里也会出现。
|
- **弹幕**:支持`.jsonl`和`.ass`。填`.ass`时会先还原成 jsonl 再合并(规则和`-danmuburn`一样,优先用同名的 jsonl),还原结果写回录播目录,页面的下拉列表里也会出现。
|
||||||
@@ -201,7 +223,18 @@ web UI 的`手动合并`区用来自己挑视频和弹幕出片,适合手动
|
|||||||
|
|
||||||
### 通知推送
|
### 通知推送
|
||||||
|
|
||||||
填好`notifyUrl`并勾选需要的事件后,程序会把事件以 JSON POST 到该地址,可以用来接自己的机器人(NoneBot、Koishi 等)或任何 HTTP 服务。
|
开播、下播、开始录制、合并完成这四个事件可以推到两个渠道,两个渠道各推各的、互不影响:
|
||||||
|
|
||||||
|
| 渠道 | 接收方 | 需要的配置 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| QQ 机器人 | 自己的机器人(NoneBot、Koishi 等)或任何 HTTP 服务 | `notifyUrl`(+ 可选的`notifyToken`、`notifyGroup`) |
|
||||||
|
| MeoW | 手机上 MeoW App 的通知 | `meowNickname` |
|
||||||
|
|
||||||
|
「推哪些事件」只有一套勾选(`notifyLiveOn`等四个),两个渠道共用;每个渠道另有自己的开关(`notifyEnabled`、`meowEnabled`)。关掉开关只是暂停推送,地址、昵称这些配置都会留着,随时可以再打开。**没勾选的事件、或两个渠道都没启用时,程序什么都不做。**
|
||||||
|
|
||||||
|
#### 渠道一:QQ 机器人
|
||||||
|
|
||||||
|
填好`notifyUrl`并勾选需要的事件后,程序会把事件以 JSON POST 到该地址。
|
||||||
|
|
||||||
推送的请求体:
|
推送的请求体:
|
||||||
|
|
||||||
@@ -219,15 +252,36 @@ web UI 的`手动合并`区用来自己挑视频和弹幕出片,适合手动
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`notifyToken`非空时会以`Authorization: Bearer <口令>`和`X-Auth-Token`两个头一起发出去。推送在后台进行且有 10 秒超时,失败只在日志里记警告,不会影响录制。同一场直播的开播通知只会推送一次。web UI 设置区的`发送测试通知`按钮可以用当前填写的地址直接测试(不需要先保存)。
|
`notifyToken`非空时会以`Authorization: Bearer <口令>`和`X-Auth-Token`两个头一起发出去。推送在后台进行且有 10 秒超时,失败只在日志里记警告,不会影响录制。同一场直播的开播通知只会推送一次。
|
||||||
|
|
||||||
`notifyGroup`是随推送下发的群号,留空则不带这个字段。它只是原样透传给机器人,程序自己不做任何判断——好处是群号、口令这些都能在 web UI 里改,不用去动机器人那边的配置。
|
`notifyGroup`是随推送下发的群号,留空则不带这个字段。它只是原样透传给机器人,程序自己不做任何判断——好处是群号、口令这些都能在 web UI 里改,不用去动机器人那边的配置。
|
||||||
|
|
||||||
接 NoneBot 的例子见`doc/momoko-acfunlive-notify.py`:把这个文件复制到 NoneBot 项目的插件目录即可,收到后直接把`text`发到群里。插件会优先使用推送里的`group`,只有推送没带时才回退到环境变量`ACFUNLIVE_NOTIFY_GROUP`,所以群号在 acfunlive 页面上改完立即生效,不用重启机器人。
|
接 NoneBot 的例子见`doc/momoko-acfunlive-notify.py`:把这个文件复制到 NoneBot 项目的插件目录即可,收到后直接把`text`发到群里。插件会优先使用推送里的`group`,只有推送没带时才回退到环境变量`ACFUNLIVE_NOTIFY_GROUP`,所以群号在 acfunlive 页面上改完立即生效,不用重启机器人。
|
||||||
|
|
||||||
|
#### 渠道二:MeoW
|
||||||
|
|
||||||
|
[MeoW](https://www.chuckfang.com/MeoW/api_doc.html) 是个消息推送 App,手机上装好、注册自己的昵称后,把昵称填进`meowNickname`打开开关,就能直接收到通知,不用自己架机器人。
|
||||||
|
|
||||||
|
- 消息内容用事件正文(就是上面 webhook 里的`text`),标题按事件取「开播提醒 / 下播提醒 / 开始录制 / 合并完成」
|
||||||
|
- 开播、开始录制这类事件会带上直播间地址,MeoW 里点通知就能跳回直播间;合并完成带的是本地成片路径,手机上打不开,所以不带跳转链接
|
||||||
|
- `meowMsgType`为`html`时会渲染成 HTML(第一行加粗、链接可点),`meowHtmlHeight`控制显示高度(默认 200,范围 40–2000);为`text`时就是纯文本,也是默认值
|
||||||
|
- `meowImgUrl`可以换掉通知图标,官方建议用 216×216 的 PNG
|
||||||
|
|
||||||
|
**注意 MeoW 的频控和封禁规则**:非会员是 3 秒 1 条、每分钟 15 条、每小时 60 条,超了返回 429;更麻烦的是**一天内累计 3 次推给不存在的昵称,它会把你的 IP 封掉**。所以程序做了保护:一旦某个昵称被 MeoW 判定为「昵称未注册」,就不再向它推送,页面上会红字提示原因,改掉昵称(或重启程序)才恢复。请先把昵称填对再打开开关。
|
||||||
|
|
||||||
|
#### 测试连通
|
||||||
|
|
||||||
|
两个渠道各有一个`测试连通`按钮,就在各自的配置下面:
|
||||||
|
|
||||||
|
- 用**页面上当前填的值**直接测,不用先保存;留空的项自动用已保存的值补齐
|
||||||
|
- 测试不受渠道开关和事件勾选影响,关着开关也能测(本来就是为了在打开之前先确认配置对不对)
|
||||||
|
- 成功后会显示实际结果:QQ 渠道显示 webhook 返回的 HTTP 状态码和耗时,MeoW 渠道显示它返回的`status`和耗时;失败则显示具体原因,比如`MeoW 返回 status 404(昵称未注册)`
|
||||||
|
|
||||||
|
webhook 返回 200 只代表**地址和口令没问题**,能不能真的收到还要看机器人那边有没有正常处理——这一点测试结果里也会写明。
|
||||||
|
|
||||||
### 文件管理
|
### 文件管理
|
||||||
|
|
||||||
web UI 的`文件管理`区可以列出录播文件夹和成片文件夹里的文件(名称、大小、修改时间),并显示保存目录所在磁盘的剩余和总容量。
|
web UI 的`文件`分区可以列出录播文件夹和成片文件夹里的文件(名称、大小、修改时间),并显示保存目录所在磁盘的剩余和总容量。
|
||||||
|
|
||||||
- `下载`:以附件形式下载,中文文件名用 RFC 5987 编码,不会乱码
|
- `下载`:以附件形式下载,中文文件名用 RFC 5987 编码,不会乱码
|
||||||
- `播放`:只对浏览器能直接播的格式(mp4、m4v、webm、ogv)显示,在新标签页里内联播放;`.flv` 浏览器放不了,只提供下载
|
- `播放`:只对浏览器能直接播的格式(mp4、m4v、webm、ogv)显示,在新标签页里内联播放;`.flv` 浏览器放不了,只提供下载
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ type configData struct {
|
|||||||
|
|
||||||
AuthToken string `json:"authToken"` // web UI 管理接口的访问口令,首次启动自动生成
|
AuthToken string `json:"authToken"` // web UI 管理接口的访问口令,首次启动自动生成
|
||||||
|
|
||||||
|
NotifyEnabled bool `json:"notifyEnabled"` // QQ 机器人(webhook)推送的渠道开关,关掉后即使填了地址也不推送
|
||||||
NotifyURL string `json:"notifyUrl"` // 事件推送到该地址(HTTP POST JSON),留空不推送
|
NotifyURL string `json:"notifyUrl"` // 事件推送到该地址(HTTP POST JSON),留空不推送
|
||||||
NotifyToken string `json:"notifyToken"` // 推送时附带的 Bearer 口令,留空不带
|
NotifyToken string `json:"notifyToken"` // 推送时附带的 Bearer 口令,留空不带
|
||||||
NotifyGroup string `json:"notifyGroup"` // 推送时带上的群号,机器人可以直接用它决定发到哪个群
|
NotifyGroup string `json:"notifyGroup"` // 推送时带上的群号,机器人可以直接用它决定发到哪个群
|
||||||
@@ -97,6 +98,12 @@ type configData struct {
|
|||||||
NotifyRecordStart bool `json:"notifyRecordStart"` // 开始录制直播视频时推送
|
NotifyRecordStart bool `json:"notifyRecordStart"` // 开始录制直播视频时推送
|
||||||
NotifyMergeDone bool `json:"notifyMergeDone"` // 自动合并完成时推送
|
NotifyMergeDone bool `json:"notifyMergeDone"` // 自动合并完成时推送
|
||||||
|
|
||||||
|
MeowEnabled bool `json:"meowEnabled"` // MeoW 推送的渠道开关
|
||||||
|
MeowNickname string `json:"meowNickname"` // MeoW 昵称,推送地址里的路径参数
|
||||||
|
MeowMsgType string `json:"meowMsgType"` // MeoW 消息类型:text(默认,纯文本)或 html
|
||||||
|
MeowHTMLHeight int `json:"meowHtmlHeight"` // MeoW 在 msgType 为 html 时的显示高度(像素),默认 200
|
||||||
|
MeowImgURL string `json:"meowImgUrl"` // MeoW 通知图标 URL,建议 216x216 的 PNG,留空用 MeoW 默认图标
|
||||||
|
|
||||||
FFmpegPath string `json:"ffmpegPath"` // ffmpeg 可执行文件(或所在文件夹)的路径,留空则自动查找
|
FFmpegPath string `json:"ffmpegPath"` // ffmpeg 可执行文件(或所在文件夹)的路径,留空则自动查找
|
||||||
|
|
||||||
RecordFormat string `json:"recordFormat"` // 录播视频的封装格式:mp4(默认,与原版一致)、flv、ts、mkv
|
RecordFormat string `json:"recordFormat"` // 录播视频的封装格式:mp4(默认,与原版一致)、flv、ts、mkv
|
||||||
@@ -195,6 +202,7 @@ var config = configData{
|
|||||||
WebPort: 51880,
|
WebPort: 51880,
|
||||||
RecordSubdir: defaultRecordSubdir,
|
RecordSubdir: defaultRecordSubdir,
|
||||||
MergeSubdir: defaultMergeSubdir,
|
MergeSubdir: defaultMergeSubdir,
|
||||||
|
NotifyEnabled: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Windows 下不能用作文件(夹)名的保留字
|
// Windows 下不能用作文件(夹)名的保留字
|
||||||
@@ -256,6 +264,10 @@ func loadConfig() {
|
|||||||
checkErr(err)
|
checkErr(err)
|
||||||
|
|
||||||
if json.Valid(data) {
|
if json.Valid(data) {
|
||||||
|
// 老设置文件里没有 notifyEnabled 这个字段,默认按「开启」处理,
|
||||||
|
// 否则升级后已经配好的推送会被静默关掉。写在 Unmarshal 前面,
|
||||||
|
// 文件里显式写了 false 时仍会被覆盖成 false
|
||||||
|
config.NotifyEnabled = true
|
||||||
err = json.Unmarshal(data, &config)
|
err = json.Unmarshal(data, &config)
|
||||||
checkErr(err)
|
checkErr(err)
|
||||||
} else {
|
} else {
|
||||||
@@ -372,6 +384,16 @@ func checkConfig() {
|
|||||||
}
|
}
|
||||||
// 录播格式写错不致命,回退到 mp4,免得整个程序起不来
|
// 录播格式写错不致命,回退到 mp4,免得整个程序起不来
|
||||||
config.RecordFormat = normalizeRecordFormat(config.RecordFormat)
|
config.RecordFormat = normalizeRecordFormat(config.RecordFormat)
|
||||||
|
// MeoW 这几项写错同样不致命:类型和高度回退到默认值,昵称不可用只警告
|
||||||
|
config.MeowNickname = strings.TrimSpace(config.MeowNickname)
|
||||||
|
config.MeowMsgType = normalizeMeowMsgType(config.MeowMsgType)
|
||||||
|
config.MeowImgURL = strings.TrimSpace(config.MeowImgURL)
|
||||||
|
if config.MeowHTMLHeight <= 0 {
|
||||||
|
config.MeowHTMLHeight = defaultMeowHTMLHeight
|
||||||
|
}
|
||||||
|
if msg := checkMeowNickname(config.MeowNickname, true); msg != "" {
|
||||||
|
lPrintWarnf("%s里的meowNickname不可用,不会推送 MeoW:%s", configFile, msg)
|
||||||
|
}
|
||||||
// ffmpeg 路径填错时不致命(可能只是还没打算录视频),清空后回退到自动查找
|
// ffmpeg 路径填错时不致命(可能只是还没打算录视频),清空后回退到自动查找
|
||||||
if msg := checkFFmpegPath(config.FFmpegPath); msg != "" {
|
if msg := checkFFmpegPath(config.FFmpegPath); msg != "" {
|
||||||
lPrintWarnf("%s里的ffmpegPath不可用,将改为自动查找 ffmpeg:%s", configFile, msg)
|
lPrintWarnf("%s里的ffmpegPath不可用,将改为自动查找 ffmpeg:%s", configFile, msg)
|
||||||
|
|||||||
+17
-1
@@ -76,8 +76,11 @@ web API 默认本地端口为 51880
|
|||||||
|
|
||||||
## 新版 web UI 的管理接口
|
## 新版 web UI 的管理接口
|
||||||
|
|
||||||
web UI(`-webui`,端口看`webPort`)用的是另一套`/api/`接口,全部需要带管理口令:请求头`X-Auth-Token: <口令>`,或者在 URL 上带`?token=<口令>`(下载链接用后者,因为浏览器跳转带不了请求头)。跟手动合并有关的是下面两个:
|
web UI(`-webui`,端口看`webPort`)用的是另一套`/api/`接口,全部需要带管理口令:请求头`X-Auth-Token: <口令>`,或者在 URL 上带`?token=<口令>`(下载链接用后者,因为浏览器跳转带不了请求头)。
|
||||||
|
|
||||||
|
口令支持任意字符,包括中文——不过 HTTP 请求头只接受 ISO-8859-1,浏览器遇到非 ASCII 的口令会直接拒绝发请求(报`String contains non ISO-8859-1 code point`),所以**页面发给服务端的`X-Auth-Token`是百分号编码过的口令**(`encodeURIComponent`)。服务端两种写法都认:先拿原样比,再拿解码后的比,所以手写 curl 或者脚本按原样把中文口令放进请求头、`Authorization: Bearer`、`?token=` 里也都能通过。用脚本调这套接口时不用管编码,原样填就行。
|
||||||
|
|
||||||
|
跟手动合并有关的是下面两个:
|
||||||
`POST /api/merge/info` 预览一次手动合并的计划,不落盘。
|
`POST /api/merge/info` 预览一次手动合并的计划,不落盘。
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -93,3 +96,16 @@ web UI(`-webui`,端口看`webPort`)用的是另一套`/api/`接口,全
|
|||||||
`POST /api/merge` 真正开始合并,请求体和上面一样,另外可以带`outName`指定输出文件名(不合法会被清理成安全的文件名,重名自动加后缀)。成功后立刻返回`{"outName":"…"}`,合并在后台跑。已经有一个手动合并任务在跑时返回 409。
|
`POST /api/merge` 真正开始合并,请求体和上面一样,另外可以带`outName`指定输出文件名(不合法会被清理成安全的文件名,重名自动加后缀)。成功后立刻返回`{"outName":"…"}`,合并在后台跑。已经有一个手动合并任务在跑时返回 409。
|
||||||
|
|
||||||
合并状态通过`GET /api/status`查看:`mergeBusy`(是否在跑)、`mergeOutName`(当前成片名)、`mergeErr`(失败原因)、`mergeDoneAt`(上次完成时间)。合并期间成片会出现在`GET /api/files`里且`inUse`为 true,此时`POST /api/file/remove`会返回 409 拒绝删除。
|
合并状态通过`GET /api/status`查看:`mergeBusy`(是否在跑)、`mergeOutName`(当前成片名)、`mergeErr`(失败原因)、`mergeDoneAt`(上次完成时间)。合并期间成片会出现在`GET /api/files`里且`inUse`为 true,此时`POST /api/file/remove`会返回 409 拒绝删除。
|
||||||
|
|
||||||
|
`POST /api/notify/test` 测试某个推送渠道能不能连通。用传进来的值直接测,留空的项自动用已保存的配置补齐,所以不用先保存设置。测试不受渠道开关和事件勾选影响。
|
||||||
|
|
||||||
|
```
|
||||||
|
// QQ 机器人(webhook)渠道,channel 不填就按这个
|
||||||
|
{ "channel": "qq", "url": "http://127.0.0.1:8080/acfunlive/notify", "token": "可选", "group": "可选" }
|
||||||
|
// MeoW 渠道
|
||||||
|
{ "channel": "meow", "nickname": "你的昵称", "msgType": "text", "imgUrl": "可选", "htmlHeight": 200 }
|
||||||
|
```
|
||||||
|
|
||||||
|
成功后返回`{"ok":true,"detail":"webhook 返回 HTTP 200,用时 0.31 秒。…"}`,`detail`就是页面上显示的那句结论。失败时返回 502 并在响应体里给中文原因(如`MeoW 返回 status 404(昵称未注册)`),参数不合法返回 400。
|
||||||
|
|
||||||
|
MeoW 的`status`和封禁规则会反映在`GET /api/status`的`meowBlocked`字段里:昵称没填、格式不对、或已被 MeoW 判过「未注册」时,这里会给出中文原因,非空就说明 MeoW 渠道当前推不出去。注意 MeoW 一天内累计 3 次推给不存在的昵称会封 IP,所以被判过不存在的昵称会被程序记住并停止重试,改昵称或重启程序才恢复。
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
// MeoW 消息推送,接口文档:https://www.chuckfang.com/MeoW/api_doc.html
|
||||||
|
// MeoW 的 HTTP 状态一律是 200,真正的结果在响应 json 的 status 字段里,所以要读 body 才能判断成败
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
meowMsgTypeText = "text"
|
||||||
|
meowMsgTypeHTML = "html"
|
||||||
|
|
||||||
|
// HTML 消息的显示高度,文档默认 200,limits 是防止填出个渲染不出来的值
|
||||||
|
defaultMeowHTMLHeight = 200
|
||||||
|
minMeowHTMLHeight = 40
|
||||||
|
maxMeowHTMLHeight = 2000
|
||||||
|
)
|
||||||
|
|
||||||
|
// 接口地址。文档也提供 http,但推送内容会带直播标题、昵称这些信息,统一走 https。
|
||||||
|
// 变量而不是常量,测试里可以换成自己的服务器
|
||||||
|
var meowAPIBase = "https://api.chuckfang.com/"
|
||||||
|
|
||||||
|
// MeoW 对「昵称不存在」很敏感:官方提示一天内累计 3 次输错昵称就会封 IP。
|
||||||
|
// 所以一旦确认某个昵称不可用就不再向它推送,等昵称被改掉(或重启程序)才恢复,
|
||||||
|
// 免得开播/下播/合并完成这些事件反复触发把用户 IP 封了
|
||||||
|
var meowBadNick struct {
|
||||||
|
sync.Mutex
|
||||||
|
nick string // 已知不可用的昵称,空表示没有
|
||||||
|
reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记下一个不可用的昵称
|
||||||
|
func markMeowBadNick(nick, reason string) {
|
||||||
|
nick = strings.TrimSpace(nick)
|
||||||
|
if nick == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
meowBadNick.Lock()
|
||||||
|
defer meowBadNick.Unlock()
|
||||||
|
if meowBadNick.nick == nick {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
meowBadNick.nick, meowBadNick.reason = nick, reason
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清掉「昵称不可用」的记录。生产里靠改昵称或重启程序恢复,这里主要给测试用
|
||||||
|
func resetMeowBadNick() {
|
||||||
|
meowBadNick.Lock()
|
||||||
|
defer meowBadNick.Unlock()
|
||||||
|
meowBadNick.nick, meowBadNick.reason = "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// 昵称是否已被判定不可用,返回原因(空字符串表示可以用)
|
||||||
|
func meowBadNickReason(nick string) string {
|
||||||
|
nick = strings.TrimSpace(nick)
|
||||||
|
if nick == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
meowBadNick.Lock()
|
||||||
|
defer meowBadNick.Unlock()
|
||||||
|
if meowBadNick.nick == nick {
|
||||||
|
return meowBadNick.reason
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// MeoW 昵称为什么推不出去(没填、格式不对,或已被 MeoW 判过不存在),空字符串表示没问题
|
||||||
|
func meowNickProblem(nick string) string {
|
||||||
|
if strings.TrimSpace(nick) == "" {
|
||||||
|
return "还没有填写昵称"
|
||||||
|
}
|
||||||
|
if msg := checkMeowNickname(nick, false); msg != "" {
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
return meowBadNickReason(nick)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MeoW 的响应体。文档「响应格式」那节写的是 msg,示例里写的是 message,两个都收
|
||||||
|
type meowResponse struct {
|
||||||
|
Status int `json:"status"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r meowResponse) detail() string {
|
||||||
|
if s := strings.TrimSpace(r.Message); s != "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(r.Msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 推送用的客户端。MeoW 是公网服务,超时给得比 webhook 宽松一点
|
||||||
|
var meowClient = &http.Client{Timeout: 15 * time.Second}
|
||||||
|
|
||||||
|
// MeoW 昵称是否可用。allowEmpty 为 true 时允许留空(表示还没配,不影响程序启动)
|
||||||
|
func checkMeowNickname(nick string, allowEmpty bool) string {
|
||||||
|
nick = strings.TrimSpace(nick)
|
||||||
|
if nick == "" {
|
||||||
|
if allowEmpty {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "还没填写 MeoW 昵称"
|
||||||
|
}
|
||||||
|
// 昵称是推送地址里的路径参数,文档明确不允许斜杠,问号井号这类也会把地址截断
|
||||||
|
if strings.ContainsAny(nick, `/\?#`) {
|
||||||
|
return "MeoW 昵称不能包含 / \\ ? # 这些字符:" + nick
|
||||||
|
}
|
||||||
|
if len([]rune(nick)) > 64 {
|
||||||
|
return "MeoW 昵称太长(超过 64 个字符):" + nick
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// MeoW 的消息类型,只认 text 和 html,其它和空值都回退到 text
|
||||||
|
func normalizeMeowMsgType(t string) string {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(t)) {
|
||||||
|
case meowMsgTypeHTML:
|
||||||
|
return meowMsgTypeHTML
|
||||||
|
case "", meowMsgTypeText:
|
||||||
|
return meowMsgTypeText
|
||||||
|
}
|
||||||
|
lPrintWarnf("不支持的 MeoW 消息类型 %q,改用 %s(可选:text、html)", t, meowMsgTypeText)
|
||||||
|
return meowMsgTypeText
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML 消息的显示高度,超出范围就夹到边界,非正数用默认值
|
||||||
|
func clampMeowHTMLHeight(h int) int {
|
||||||
|
if h <= 0 {
|
||||||
|
return defaultMeowHTMLHeight
|
||||||
|
}
|
||||||
|
if h < minMeowHTMLHeight {
|
||||||
|
return minMeowHTMLHeight
|
||||||
|
}
|
||||||
|
if h > maxMeowHTMLHeight {
|
||||||
|
return maxMeowHTMLHeight
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拼推送地址。msgType、htmlHeight 是请求参数,不能放进 json body 里
|
||||||
|
func meowURL(nick, msgType string, htmlHeight int) string {
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("msgType", msgType)
|
||||||
|
if msgType == meowMsgTypeHTML {
|
||||||
|
q.Set("htmlHeight", strconv.Itoa(clampMeowHTMLHeight(htmlHeight)))
|
||||||
|
}
|
||||||
|
return meowAPIBase + url.PathEscape(strings.TrimSpace(nick)) + "?" + q.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MeoW 的业务状态码说明
|
||||||
|
func meowStatusText(status int) string {
|
||||||
|
switch status {
|
||||||
|
case 200:
|
||||||
|
return "推送成功"
|
||||||
|
case 400:
|
||||||
|
return "标题或消息参数错误"
|
||||||
|
case 403:
|
||||||
|
return "内容被 MeoW 的策略拦下,不允许发送"
|
||||||
|
case 404:
|
||||||
|
return "昵称未注册"
|
||||||
|
case 429:
|
||||||
|
return "触发频率限制(非会员为 3 秒 1 条、每分钟 15 条、每小时 60 条)"
|
||||||
|
case 500:
|
||||||
|
return "MeoW 服务器错误"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// 截断过长的响应内容,避免把整个 body 打进日志或页面
|
||||||
|
func briefBody(raw []byte) string {
|
||||||
|
s := strings.TrimSpace(string(raw))
|
||||||
|
if len(s) > 300 {
|
||||||
|
s = s[:300] + "…"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// 推送一条消息到 MeoW。昵称是接收者,title 留空时 MeoW 会用它自己的默认标题
|
||||||
|
func pushMeow(nick, title, msg, jumpURL, imgURL, msgType string, htmlHeight int) error {
|
||||||
|
if err := checkMeowNickname(nick, false); err != "" {
|
||||||
|
return fmt.Errorf("%s", err)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(msg) == "" {
|
||||||
|
return fmt.Errorf("推送内容为空")
|
||||||
|
}
|
||||||
|
msgType = normalizeMeowMsgType(msgType)
|
||||||
|
|
||||||
|
// 空字段直接不放进去:body 里的 title 优先级高于路径参数,
|
||||||
|
// 传空字符串有可能把 MeoW 的默认标题顶掉
|
||||||
|
body := map[string]string{"msg": msg}
|
||||||
|
if s := strings.TrimSpace(title); s != "" {
|
||||||
|
body["title"] = s
|
||||||
|
}
|
||||||
|
if s := strings.TrimSpace(jumpURL); s != "" {
|
||||||
|
body["url"] = s
|
||||||
|
}
|
||||||
|
if s := strings.TrimSpace(imgURL); s != "" {
|
||||||
|
body["imgUrl"] = s
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodPost, meowURL(nick, msgType, htmlHeight), bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
|
||||||
|
resp, err := meowClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
|
|
||||||
|
var r meowResponse
|
||||||
|
if err := json.Unmarshal(raw, &r); err != nil {
|
||||||
|
return fmt.Errorf("MeoW 返回的不是合法 json(HTTP %d):%s", resp.StatusCode, briefBody(raw))
|
||||||
|
}
|
||||||
|
if r.Status != 200 {
|
||||||
|
// 昵称不存在是最危险的一种失败:MeoW 会对错误昵称记数并封 IP,
|
||||||
|
// 所以记下来,后续不再向这个昵称重试
|
||||||
|
if r.Status == 404 {
|
||||||
|
markMeowBadNick(nick, meowStatusText(404))
|
||||||
|
}
|
||||||
|
if text := meowStatusText(r.Status); text != "" {
|
||||||
|
return fmt.Errorf("MeoW 返回 status %d(%s)%s", r.Status, text, joinDetail(r.detail()))
|
||||||
|
}
|
||||||
|
return fmt.Errorf("MeoW 返回 status %d%s", r.Status, joinDetail(r.detail()))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinDetail(detail string) string {
|
||||||
|
if detail == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return ":" + detail
|
||||||
|
}
|
||||||
|
|
||||||
|
// 各事件在通知里的短标题,MeoW 的通知栏和 webhook 的正文都用得到
|
||||||
|
func notifyEventTitle(event string) string {
|
||||||
|
switch event {
|
||||||
|
case notifyEventLiveOn:
|
||||||
|
return "开播提醒"
|
||||||
|
case notifyEventLiveOff:
|
||||||
|
return "下播提醒"
|
||||||
|
case notifyEventRecordStart:
|
||||||
|
return "开始录制"
|
||||||
|
case notifyEventMergeDone:
|
||||||
|
return "合并完成"
|
||||||
|
}
|
||||||
|
return "AcFun 直播录制"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 看起来像 http/https 链接
|
||||||
|
func isHTTPURL(s string) bool {
|
||||||
|
s = strings.ToLower(strings.TrimSpace(s))
|
||||||
|
return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 把事件正文渲染成 MeoW 的 HTML 消息:第一行加粗,链接单独成行时做成可点的,其余每行一个段落
|
||||||
|
func meowHTMLText(text string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
first := true
|
||||||
|
for _, line := range strings.Split(strings.TrimSpace(text), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
esc := html.EscapeString(line)
|
||||||
|
switch {
|
||||||
|
case first:
|
||||||
|
b.WriteString("<p><b>" + esc + "</b></p>")
|
||||||
|
first = false
|
||||||
|
case isHTTPURL(line):
|
||||||
|
b.WriteString(`<p><a href="` + esc + `">` + esc + `</a></p>`)
|
||||||
|
default:
|
||||||
|
b.WriteString("<p>" + esc + "</p>")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -65,9 +65,18 @@ func notifiedLiveOnce(liveID string) bool {
|
|||||||
|
|
||||||
// 推送设置的一份快照,避免在推送过程中读到正在被修改的配置
|
// 推送设置的一份快照,避免在推送过程中读到正在被修改的配置
|
||||||
type notifySetting struct {
|
type notifySetting struct {
|
||||||
|
// QQ 机器人(webhook)渠道
|
||||||
|
qqEnabled bool
|
||||||
url string
|
url string
|
||||||
token string
|
token string
|
||||||
group string
|
group string
|
||||||
|
// MeoW 渠道
|
||||||
|
meowEnabled bool
|
||||||
|
meowNick string
|
||||||
|
meowMsgType string
|
||||||
|
meowHTML int
|
||||||
|
meowImg string
|
||||||
|
// 推哪些事件,两个渠道共用
|
||||||
liveOn bool
|
liveOn bool
|
||||||
liveOff bool
|
liveOff bool
|
||||||
recordStart bool
|
recordStart bool
|
||||||
@@ -78,9 +87,15 @@ func currentNotifySetting() notifySetting {
|
|||||||
streamers.RLock()
|
streamers.RLock()
|
||||||
defer streamers.RUnlock()
|
defer streamers.RUnlock()
|
||||||
return notifySetting{
|
return notifySetting{
|
||||||
|
qqEnabled: config.NotifyEnabled,
|
||||||
url: strings.TrimSpace(config.NotifyURL),
|
url: strings.TrimSpace(config.NotifyURL),
|
||||||
token: strings.TrimSpace(config.NotifyToken),
|
token: strings.TrimSpace(config.NotifyToken),
|
||||||
group: strings.TrimSpace(config.NotifyGroup),
|
group: strings.TrimSpace(config.NotifyGroup),
|
||||||
|
meowEnabled: config.MeowEnabled,
|
||||||
|
meowNick: strings.TrimSpace(config.MeowNickname),
|
||||||
|
meowMsgType: normalizeMeowMsgType(config.MeowMsgType),
|
||||||
|
meowHTML: clampMeowHTMLHeight(config.MeowHTMLHeight),
|
||||||
|
meowImg: strings.TrimSpace(config.MeowImgURL),
|
||||||
liveOn: config.NotifyLiveOn,
|
liveOn: config.NotifyLiveOn,
|
||||||
liveOff: config.NotifyLiveOff,
|
liveOff: config.NotifyLiveOff,
|
||||||
recordStart: config.NotifyRecordStart,
|
recordStart: config.NotifyRecordStart,
|
||||||
@@ -103,11 +118,41 @@ func (n notifySetting) enabled(event string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 推送一个事件。没配置推送地址或没开启该事件时什么都不做,
|
// QQ 机器人渠道是否具备推送条件:渠道开关打开且填了地址
|
||||||
|
func (n notifySetting) qqReady() bool {
|
||||||
|
return n.qqEnabled && n.url != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// MeoW 渠道是否具备推送条件:渠道开关打开、昵称可用、且这个昵称没被 MeoW 判过不存在
|
||||||
|
func (n notifySetting) meowBlockedReason() string {
|
||||||
|
return meowNickProblem(n.meowNick)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n notifySetting) meowReady() bool {
|
||||||
|
return n.meowEnabled && n.meowBlockedReason() == ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// 页面上要显示的 MeoW 提示,没问题时返回空字符串。调用方需要持有 streamers 锁
|
||||||
|
func meowStatusProblem() string {
|
||||||
|
if !config.MeowEnabled {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return meowNickProblem(config.MeowNickname)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 推送一个事件。没开启对应事件、或两个渠道都没启用时什么都不做。
|
||||||
// 推送在后台 goroutine 里进行,失败只打警告,不会影响录制
|
// 推送在后台 goroutine 里进行,失败只打警告,不会影响录制
|
||||||
func sendNotify(event string, uid int, name, title, url, file, text string) {
|
func sendNotify(event string, uid int, name, title, url, file, text string) {
|
||||||
n := currentNotifySetting()
|
n := currentNotifySetting()
|
||||||
if n.url == "" || !n.enabled(event) {
|
if !n.enabled(event) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
qq, meow := n.qqReady(), n.meowReady()
|
||||||
|
if !qq && !meow {
|
||||||
|
// MeoW 开着却推不了时说清楚原因,不然用户会以为推送整个坏了
|
||||||
|
if n.meowEnabled {
|
||||||
|
lPrintWarnf("MeoW 渠道已启用但推不出去(%s),本次%s通知没有推送", n.meowBlockedReason(), event)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
p := notifyPayload{
|
p := notifyPayload{
|
||||||
@@ -121,26 +166,50 @@ func sendNotify(event string, uid int, name, title, url, file, text string) {
|
|||||||
Text: text,
|
Text: text,
|
||||||
Time: getLogTime(),
|
Time: getLogTime(),
|
||||||
}
|
}
|
||||||
// 推送地址可能在推送过程中被改掉,这里把地址和口令带进闭包,保证用的是同一次配置
|
|
||||||
|
// 推送地址和口令可能在推送过程中被改掉,这里带进闭包,保证用的是同一次配置
|
||||||
|
if qq {
|
||||||
addr, token := n.url, n.token
|
addr, token := n.url, n.token
|
||||||
safeGo("推送"+event+"通知", func() {
|
safeGo("推送"+event+"通知到QQ机器人", func() {
|
||||||
if err := postNotify(addr, token, p); err != nil {
|
if _, err := postNotify(addr, token, p); err != nil {
|
||||||
lPrintWarnf("推送%s通知失败:%v", event, err)
|
lPrintWarnf("推送%s通知到 QQ 机器人失败:%v", event, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
lPrintf("已推送%s通知:%s", event, p.Text)
|
lPrintf("已推送%s通知到 QQ 机器人:%s", event, p.Text)
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if meow {
|
||||||
|
nick, img, msgType, msgHeight := n.meowNick, n.meowImg, n.meowMsgType, n.meowHTML
|
||||||
|
// 合并完成时带的是本地成片路径,手机上打不开,所以只认 http/https 的直播间地址
|
||||||
|
jump := p.URL
|
||||||
|
if !isHTTPURL(jump) {
|
||||||
|
jump = ""
|
||||||
|
}
|
||||||
|
eventTitle := notifyEventTitle(event)
|
||||||
|
content := p.Text
|
||||||
|
if msgType == meowMsgTypeHTML {
|
||||||
|
content = meowHTMLText(p.Text)
|
||||||
|
}
|
||||||
|
safeGo("推送"+event+"通知到MeoW", func() {
|
||||||
|
if err := pushMeow(nick, eventTitle, content, jump, img, msgType, msgHeight); err != nil {
|
||||||
|
lPrintWarnf("推送%s通知到 MeoW 失败:%v", event, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lPrintf("已推送%s通知到 MeoW(%s):%s", event, nick, p.Text)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 把事件 POST 到 webhook
|
// 把事件 POST 到 webhook,同时返回 HTTP 状态码(失败时也会带上,方便测试接口显示细节)
|
||||||
func postNotify(addr, token string, p notifyPayload) error {
|
func postNotify(addr, token string, p notifyPayload) (int, error) {
|
||||||
data, err := json.Marshal(p)
|
data, err := json.Marshal(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return 0, err
|
||||||
}
|
}
|
||||||
req, err := http.NewRequest(http.MethodPost, addr, bytes.NewReader(data))
|
req, err := http.NewRequest(http.MethodPost, addr, bytes.NewReader(data))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return 0, err
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||||
if token != "" {
|
if token != "" {
|
||||||
@@ -150,14 +219,14 @@ func postNotify(addr, token string, p notifyPayload) error {
|
|||||||
|
|
||||||
resp, err := notifyClient.Do(req)
|
resp, err := notifyClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return 0, err
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
return fmt.Errorf("HTTP %d %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
return resp.StatusCode, fmt.Errorf("HTTP %d %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
}
|
}
|
||||||
return nil
|
return resp.StatusCode, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查设置里的推送地址,返回错误信息,空字符串表示通过
|
// 检查设置里的推送地址,返回错误信息,空字符串表示通过
|
||||||
|
|||||||
+594
@@ -0,0 +1,594 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 把 MeoW 的接口地址临时换成本地测试服务器
|
||||||
|
func withMeowServer(t *testing.T, h http.HandlerFunc) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
srv := httptest.NewServer(h)
|
||||||
|
old := meowAPIBase
|
||||||
|
meowAPIBase = srv.URL + "/"
|
||||||
|
t.Cleanup(func() {
|
||||||
|
meowAPIBase = old
|
||||||
|
srv.Close()
|
||||||
|
})
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckMeowNickname(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
nick string
|
||||||
|
allow bool
|
||||||
|
wantOK bool
|
||||||
|
}{
|
||||||
|
{"JohnDoe", false, true},
|
||||||
|
{"小小怪", false, true},
|
||||||
|
{" JohnDoe ", false, true}, // 前后空白会被去掉
|
||||||
|
{"", true, true}, // 允许留空
|
||||||
|
{"", false, false}, // 要推送时不能为空
|
||||||
|
{"John/Doe", false, false},
|
||||||
|
{`John\Doe`, false, false},
|
||||||
|
{"John?Doe", false, false},
|
||||||
|
{"John#Doe", false, false},
|
||||||
|
{strings.Repeat("a", 65), false, false},
|
||||||
|
{strings.Repeat("a", 64), false, true},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got := checkMeowNickname(c.nick, c.allow)
|
||||||
|
if (got == "") != c.wantOK {
|
||||||
|
t.Errorf("checkMeowNickname(%q, %v) = %q,期望通过=%v", c.nick, c.allow, got, c.wantOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeMeowMsgTypeAndHeight(t *testing.T) {
|
||||||
|
for _, c := range []struct{ in, want string }{
|
||||||
|
{"", meowMsgTypeText},
|
||||||
|
{"text", meowMsgTypeText},
|
||||||
|
{"TEXT", meowMsgTypeText},
|
||||||
|
{" html ", meowMsgTypeHTML},
|
||||||
|
{"HTML", meowMsgTypeHTML},
|
||||||
|
{"markdown", meowMsgTypeText}, // 认不出来就回退
|
||||||
|
} {
|
||||||
|
if got := normalizeMeowMsgType(c.in); got != c.want {
|
||||||
|
t.Errorf("normalizeMeowMsgType(%q) = %q,期望 %q", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, c := range []struct{ in, want int }{
|
||||||
|
{0, defaultMeowHTMLHeight},
|
||||||
|
{-5, defaultMeowHTMLHeight},
|
||||||
|
{10, minMeowHTMLHeight},
|
||||||
|
{200, 200},
|
||||||
|
{99999, maxMeowHTMLHeight},
|
||||||
|
} {
|
||||||
|
if got := clampMeowHTMLHeight(c.in); got != c.want {
|
||||||
|
t.Errorf("clampMeowHTMLHeight(%d) = %d,期望 %d", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMeowURL(t *testing.T) {
|
||||||
|
got := meowURL("小小怪", meowMsgTypeText, 200)
|
||||||
|
// text 类型不该带上 htmlHeight
|
||||||
|
if strings.Contains(got, "htmlHeight") {
|
||||||
|
t.Errorf("text 类型不该带 htmlHeight:%s", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got, "msgType=text") {
|
||||||
|
t.Errorf("缺少 msgType:%s", got)
|
||||||
|
}
|
||||||
|
// 中文昵称必须转义,不能原样进地址
|
||||||
|
if strings.Contains(got, "小小怪") {
|
||||||
|
t.Errorf("昵称没做 URL 转义:%s", got)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(got, "/%E5%B0%8F%E5%B0%8F%E6%80%AA?msgType=text") {
|
||||||
|
t.Errorf("昵称转义结果不对:%s", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
got = meowURL("JohnDoe", meowMsgTypeHTML, 350)
|
||||||
|
if !strings.Contains(got, "msgType=html") || !strings.Contains(got, "htmlHeight=350") {
|
||||||
|
t.Errorf("html 类型的参数不对:%s", got)
|
||||||
|
}
|
||||||
|
// 超出范围的高度要夹住
|
||||||
|
if got := meowURL("JohnDoe", meowMsgTypeHTML, 99999); !strings.Contains(got, "htmlHeight=2000") {
|
||||||
|
t.Errorf("高度没夹住:%s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMeowHTMLText(t *testing.T) {
|
||||||
|
got := meowHTMLText("Xsphere 开播了\n今天画点啥\nhttps://live.acfun.cn/1\n<i>不该被解析</i>")
|
||||||
|
for _, want := range []string{
|
||||||
|
"<p><b>Xsphere 开播了</b></p>", // 第一行加粗
|
||||||
|
"<p>今天画点啥</p>", // 普通行
|
||||||
|
`<p><a href="https://live.acfun.cn/1">https://live.acfun.cn/1</a></p>`, // 链接可点
|
||||||
|
"<i>不该被解析</i>", // 内容要转义
|
||||||
|
} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Errorf("HTML 里缺少 %q,实际:%s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(got, "<i>") {
|
||||||
|
t.Errorf("内容没被转义,存在注入风险:%s", got)
|
||||||
|
}
|
||||||
|
if got := meowHTMLText(" "); got != "" {
|
||||||
|
t.Errorf("空内容应该得到空串,实际 %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 成功路径:确认请求本身拼对了,并且能正确解析文档里的两种响应字段(message / msg)
|
||||||
|
func TestPushMeowSuccess(t *testing.T) {
|
||||||
|
var gotPath, gotQuery, gotMethod, gotType string
|
||||||
|
var gotBody map[string]string
|
||||||
|
withMeowServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPath, gotQuery, gotMethod = r.URL.Path, r.URL.RawQuery, r.Method
|
||||||
|
gotType = r.Header.Get("Content-Type")
|
||||||
|
raw, _ := io.ReadAll(r.Body)
|
||||||
|
_ = json.Unmarshal(raw, &gotBody)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_, _ = w.Write([]byte(`{"status":200,"message":"推送成功"}`))
|
||||||
|
})
|
||||||
|
|
||||||
|
err := pushMeow("小小怪", "开播提醒", "Xsphere 开播了", "https://live.acfun.cn/1", "https://x/icon.png",
|
||||||
|
meowMsgTypeHTML, 350)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("推送失败:%v", err)
|
||||||
|
}
|
||||||
|
if gotMethod != http.MethodPost {
|
||||||
|
t.Errorf("方法应该是 POST,实际 %s", gotMethod)
|
||||||
|
}
|
||||||
|
if gotPath != "/小小怪" {
|
||||||
|
t.Errorf("路径不对:%q", gotPath)
|
||||||
|
}
|
||||||
|
if !strings.Contains(gotQuery, "msgType=html") || !strings.Contains(gotQuery, "htmlHeight=350") {
|
||||||
|
t.Errorf("查询参数不对:%q", gotQuery)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(gotType, "application/json") {
|
||||||
|
t.Errorf("Content-Type 不对:%q", gotType)
|
||||||
|
}
|
||||||
|
for k, want := range map[string]string{
|
||||||
|
"msg": "Xsphere 开播了",
|
||||||
|
"title": "开播提醒",
|
||||||
|
"url": "https://live.acfun.cn/1",
|
||||||
|
"imgUrl": "https://x/icon.png",
|
||||||
|
} {
|
||||||
|
if gotBody[k] != want {
|
||||||
|
t.Errorf("body 里的 %s = %q,期望 %q", k, gotBody[k], want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 空字段不能进 body:body 里的 title 优先级比路径参数高,传空串会把默认标题顶掉
|
||||||
|
func TestPushMeowOmitsEmptyFields(t *testing.T) {
|
||||||
|
var gotBody map[string]any
|
||||||
|
withMeowServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
raw, _ := io.ReadAll(r.Body)
|
||||||
|
_ = json.Unmarshal(raw, &gotBody)
|
||||||
|
_, _ = w.Write([]byte(`{"status":200,"message":"推送成功"}`))
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := pushMeow("JohnDoe", "", "只有正文", "", "", meowMsgTypeText, 0); err != nil {
|
||||||
|
t.Fatalf("推送失败:%v", err)
|
||||||
|
}
|
||||||
|
if len(gotBody) != 1 || gotBody["msg"] != "只有正文" {
|
||||||
|
t.Errorf("应该只发 msg 一个字段,实际 %v", gotBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 失败路径:文档里 HTTP 状态一律是 200,成败只能看 body 里的 status
|
||||||
|
func TestPushMeowStatusCodes(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"昵称未注册", `{"status":404,"message":"昵称未注册"}`, "昵称未注册"},
|
||||||
|
{"频率超限", `{"status":429,"msg":"too many requests"}`, "频率限制"},
|
||||||
|
{"内容被拦", `{"status":403,"message":"forbidden"}`, "策略"},
|
||||||
|
{"参数错误", `{"status":400,"message":"bad request"}`, "参数错误"},
|
||||||
|
{"服务器错误", `{"status":500,"msg":"服务器错误"}`, "MeoW 服务器错误"},
|
||||||
|
{"不是 json", `<!doctype html><html>502</html>`, "不是合法 json"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
withMeowServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(c.body))
|
||||||
|
})
|
||||||
|
err := pushMeow("JohnDoe", "标题", "正文", "", "", meowMsgTypeText, 0)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("status 不是 200 时必须报错")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), c.want) {
|
||||||
|
t.Errorf("错误信息里应包含 %q,实际:%v", c.want, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPushMeowRejectsBadInput(t *testing.T) {
|
||||||
|
// 昵称不合法时必须在发请求前就拦下来
|
||||||
|
if err := pushMeow("bad/nick", "标题", "正文", "", "", meowMsgTypeText, 0); err == nil {
|
||||||
|
t.Error("昵称带斜杠应该报错")
|
||||||
|
}
|
||||||
|
if err := pushMeow("JohnDoe", "标题", " ", "", "", meowMsgTypeText, 0); err == nil {
|
||||||
|
t.Error("内容为空应该报错")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MeoW 对错误昵称记数并封 IP,所以 404 之后必须停止重试,否则事件反复触发会把用户 IP 封掉
|
||||||
|
func TestMeowBadNickGuard(t *testing.T) {
|
||||||
|
const nick = "guard_test_nick"
|
||||||
|
resetMeowBadNick()
|
||||||
|
t.Cleanup(resetMeowBadNick)
|
||||||
|
|
||||||
|
if got := meowBadNickReason(nick); got != "" {
|
||||||
|
t.Fatalf("一开始不该有记录,实际 %q", got)
|
||||||
|
}
|
||||||
|
if got := meowNickProblem(nick); got != "" {
|
||||||
|
t.Fatalf("没被封之前应该没问题,实际 %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MeoW 返回 404 后,这个昵称要被记住
|
||||||
|
withMeowServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`{"status":404,"message":"昵称未注册"}`))
|
||||||
|
})
|
||||||
|
if err := pushMeow(nick, "标题", "正文", "", "", meowMsgTypeText, 0); err == nil {
|
||||||
|
t.Fatal("404 时应该报错")
|
||||||
|
}
|
||||||
|
reason := meowBadNickReason(nick)
|
||||||
|
if reason == "" {
|
||||||
|
t.Fatal("404 之后应该记下这个不可用的昵称")
|
||||||
|
}
|
||||||
|
if !strings.Contains(reason, "未注册") {
|
||||||
|
t.Errorf("记录的原因不对:%q", reason)
|
||||||
|
}
|
||||||
|
// 之后再推同一个昵称就该被拦住了
|
||||||
|
if got := meowNickProblem(nick); got != reason {
|
||||||
|
t.Errorf("meowNickProblem 应该返回被拦的原因,实际 %q", got)
|
||||||
|
}
|
||||||
|
// 两者都不该影响别的昵称
|
||||||
|
if got := meowBadNickReason("another_nick"); got != "" {
|
||||||
|
t.Errorf("别的昵称不该被牵连,实际 %q", got)
|
||||||
|
}
|
||||||
|
// meowReady 要据此判定推不出去
|
||||||
|
n := notifySetting{meowEnabled: true, meowNick: nick}
|
||||||
|
if n.meowReady() {
|
||||||
|
t.Error("昵称被判过不存在时 meowReady 应该是 false")
|
||||||
|
}
|
||||||
|
if n.meowBlockedReason() != reason {
|
||||||
|
t.Errorf("meowBlockedReason 应返回被拦原因,实际 %q", n.meowBlockedReason())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4xx / 5xx 里只有 404 会触发拦截,429 和 400 不该记进去
|
||||||
|
resetMeowBadNick()
|
||||||
|
for _, body := range []string{`{"status":429,"message":"限流"}`, `{"status":400,"message":"参数错"}`} {
|
||||||
|
withMeowServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(body))
|
||||||
|
})
|
||||||
|
_ = pushMeow(nick, "标题", "正文", "", "", meowMsgTypeText, 0)
|
||||||
|
if got := meowBadNickReason(nick); got != "" {
|
||||||
|
t.Errorf("status %s 不该触发拦截,实际记成了 %q", body, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 被拦之后,测试接口要直接拒绝并说清原因,而不是又打一次接口
|
||||||
|
func TestNotifyTestHandlerMeowBlocked(t *testing.T) {
|
||||||
|
savedConfig := config
|
||||||
|
savedLocation := configFileLocation
|
||||||
|
oldDir := configDir
|
||||||
|
if configDir == nil {
|
||||||
|
configDir = new(string)
|
||||||
|
}
|
||||||
|
resetMeowBadNick()
|
||||||
|
defer func() {
|
||||||
|
config = savedConfig
|
||||||
|
configFileLocation = savedLocation
|
||||||
|
configDir = oldDir
|
||||||
|
resetMeowBadNick()
|
||||||
|
}()
|
||||||
|
*configDir = t.TempDir()
|
||||||
|
configFileLocation = filepath.Join(*configDir, configFile)
|
||||||
|
|
||||||
|
markMeowBadNick("blocked_nick", meowStatusText(404))
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/notify/test",
|
||||||
|
strings.NewReader(`{"channel":"meow","nickname":"blocked_nick"}`))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
notifyTestHandler(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != 400 {
|
||||||
|
t.Errorf("被拦的昵称应该返回 400,实际 %d", rec.Code)
|
||||||
|
}
|
||||||
|
body := rec.Body.String()
|
||||||
|
if !strings.Contains(body, "不再重试") {
|
||||||
|
t.Errorf("提示要说明不会再重试,实际:%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 端到端:sendNotify 要按事件勾选和两个渠道开关正确分发
|
||||||
|
func TestSendNotifyFanOut(t *testing.T) {
|
||||||
|
savedConfig := config
|
||||||
|
resetMeowBadNick()
|
||||||
|
defer func() {
|
||||||
|
config = savedConfig
|
||||||
|
resetMeowBadNick()
|
||||||
|
}()
|
||||||
|
|
||||||
|
var mu sync.Mutex
|
||||||
|
var hooks []notifyPayload
|
||||||
|
webhook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var p notifyPayload
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&p)
|
||||||
|
mu.Lock()
|
||||||
|
hooks = append(hooks, p)
|
||||||
|
mu.Unlock()
|
||||||
|
_, _ = w.Write([]byte("ok"))
|
||||||
|
}))
|
||||||
|
defer webhook.Close()
|
||||||
|
|
||||||
|
var meows []map[string]string
|
||||||
|
var meowQuery []string
|
||||||
|
withMeowServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body map[string]string
|
||||||
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||||
|
mu.Lock()
|
||||||
|
meows = append(meows, body)
|
||||||
|
meowQuery = append(meowQuery, r.URL.RawQuery)
|
||||||
|
mu.Unlock()
|
||||||
|
_, _ = w.Write([]byte(`{"status":200,"message":"推送成功"}`))
|
||||||
|
})
|
||||||
|
|
||||||
|
config.NotifyURL = webhook.URL
|
||||||
|
config.NotifyToken = "hook-token"
|
||||||
|
config.NotifyGroup = "777163254"
|
||||||
|
config.NotifyLiveOn = true
|
||||||
|
config.MeowEnabled = true
|
||||||
|
config.MeowNickname = "小小怪"
|
||||||
|
config.MeowMsgType = "text"
|
||||||
|
|
||||||
|
counts := func() (int, int) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
return len(hooks), len(meows)
|
||||||
|
}
|
||||||
|
waitFor := func(tag string, wantHook, wantMeow int) {
|
||||||
|
t.Helper()
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
h, m := counts()
|
||||||
|
if h >= wantHook && m >= wantMeow {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(20 * time.Millisecond)
|
||||||
|
}
|
||||||
|
h, m := counts()
|
||||||
|
t.Fatalf("%s:等超时了,webhook %d 条(期望 %d)、MeoW %d 条(期望 %d)", tag, h, wantHook, m, wantMeow)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 两个渠道都开:应该各收到一条
|
||||||
|
config.NotifyEnabled = true
|
||||||
|
sendNotify(notifyEventLiveOn, 280178, "Xsphere", "今天画点啥", "https://live.acfun.cn/1", "",
|
||||||
|
"Xsphere 开播了\n今天画点啥\nhttps://live.acfun.cn/1")
|
||||||
|
waitFor("两个渠道都开", 1, 1)
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
h := hooks[0]
|
||||||
|
m := meows[0]
|
||||||
|
q := meowQuery[0]
|
||||||
|
mu.Unlock()
|
||||||
|
if h.Event != notifyEventLiveOn || h.Group != "777163254" || h.UID != 280178 {
|
||||||
|
t.Errorf("webhook 收到的内容不对:%+v", h)
|
||||||
|
}
|
||||||
|
if m["msg"] != "Xsphere 开播了\n今天画点啥\nhttps://live.acfun.cn/1" {
|
||||||
|
t.Errorf("MeoW 正文不对:%q", m["msg"])
|
||||||
|
}
|
||||||
|
if m["title"] != "开播提醒" {
|
||||||
|
t.Errorf("MeoW 标题不对:%q", m["title"])
|
||||||
|
}
|
||||||
|
if m["url"] != "https://live.acfun.cn/1" {
|
||||||
|
t.Errorf("MeoW 跳转链接不对:%q", m["url"])
|
||||||
|
}
|
||||||
|
if !strings.Contains(q, "msgType=text") {
|
||||||
|
t.Errorf("MeoW 查询参数不对:%q", q)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关掉 QQ 渠道,只剩 MeoW
|
||||||
|
config.NotifyEnabled = false
|
||||||
|
sendNotify(notifyEventLiveOn, 280178, "Xsphere", "今天画点啥", "https://live.acfun.cn/1", "", "再来一条")
|
||||||
|
waitFor("只开 MeoW", 1, 2)
|
||||||
|
|
||||||
|
// 关掉 MeoW,只剩 QQ
|
||||||
|
config.NotifyEnabled = true
|
||||||
|
config.MeowEnabled = false
|
||||||
|
sendNotify(notifyEventLiveOn, 280178, "Xsphere", "今天画点啥", "https://live.acfun.cn/1", "", "第三条")
|
||||||
|
waitFor("只开 QQ", 2, 2)
|
||||||
|
|
||||||
|
// 两个都关,一条都不该发
|
||||||
|
config.NotifyEnabled = false
|
||||||
|
sendNotify(notifyEventLiveOn, 280178, "Xsphere", "今天画点啥", "https://live.acfun.cn/1", "", "不该发出")
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
if h, m := counts(); h != 2 || m != 2 {
|
||||||
|
t.Errorf("两个渠道都关掉后不该再推送,实际 webhook %d 条、MeoW %d 条", h, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 事件没勾选时也不该发(渠道都开着)
|
||||||
|
config.NotifyEnabled = true
|
||||||
|
config.MeowEnabled = true
|
||||||
|
config.NotifyLiveOn = false
|
||||||
|
sendNotify(notifyEventLiveOn, 280178, "Xsphere", "今天画点啥", "https://live.acfun.cn/1", "", "事件没勾")
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
if h, m := counts(); h != 2 || m != 2 {
|
||||||
|
t.Errorf("事件没勾选时不该推送,实际 webhook %d 条、MeoW %d 条", h, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotifyEventTitle(t *testing.T) {
|
||||||
|
for event, want := range map[string]string{
|
||||||
|
notifyEventLiveOn: "开播提醒",
|
||||||
|
notifyEventLiveOff: "下播提醒",
|
||||||
|
notifyEventRecordStart: "开始录制",
|
||||||
|
notifyEventMergeDone: "合并完成",
|
||||||
|
} {
|
||||||
|
if got := notifyEventTitle(event); got != want {
|
||||||
|
t.Errorf("notifyEventTitle(%q) = %q,期望 %q", event, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 两个渠道的启用条件必须互相独立,并且都受各自开关控制
|
||||||
|
func TestNotifySettingChannelReady(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
n notifySetting
|
||||||
|
wantQQ, wantM bool
|
||||||
|
}{
|
||||||
|
{"都没开", notifySetting{}, false, false},
|
||||||
|
{"QQ 开了但没填地址", notifySetting{qqEnabled: true}, false, false},
|
||||||
|
{"QQ 开了且填了地址", notifySetting{qqEnabled: true, url: "http://x/y"}, true, false},
|
||||||
|
{"QQ 填了地址但没开", notifySetting{url: "http://x/y"}, false, false},
|
||||||
|
{"MeoW 开了但没昵称", notifySetting{meowEnabled: true}, false, false},
|
||||||
|
{"MeoW 开了且有昵称", notifySetting{meowEnabled: true, meowNick: "JohnDoe"}, false, true},
|
||||||
|
{"MeoW 昵称非法", notifySetting{meowEnabled: true, meowNick: "bad/nick"}, false, false},
|
||||||
|
{"两个都开了", notifySetting{qqEnabled: true, url: "http://x/y", meowEnabled: true, meowNick: "JohnDoe"}, true, true},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := c.n.qqReady(); got != c.wantQQ {
|
||||||
|
t.Errorf("%s:qqReady = %v,期望 %v", c.name, got, c.wantQQ)
|
||||||
|
}
|
||||||
|
if got := c.n.meowReady(); got != c.wantM {
|
||||||
|
t.Errorf("%s:meowReady = %v,期望 %v", c.name, got, c.wantM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 老设置文件里没有 notifyEnabled,升级后不能把已经配好的推送静默关掉
|
||||||
|
func TestNotifyEnabledBackwardCompat(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
savedConfig := config
|
||||||
|
savedLocation := configFileLocation
|
||||||
|
oldDir := configDir
|
||||||
|
if configDir == nil {
|
||||||
|
configDir = new(string)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
config = savedConfig
|
||||||
|
configFileLocation = savedLocation
|
||||||
|
configDir = oldDir
|
||||||
|
}()
|
||||||
|
|
||||||
|
*configDir = dir
|
||||||
|
configFileLocation = filepath.Join(dir, configFile)
|
||||||
|
|
||||||
|
write := func(content string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(configFileLocation, []byte(content), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 老配置:完全没提 notifyEnabled,但已经配好了地址
|
||||||
|
write(`{"notifyUrl":"http://127.0.0.1:8080/acfunlive/notify","notifyLiveOn":true}`)
|
||||||
|
loadConfig()
|
||||||
|
if !config.NotifyEnabled {
|
||||||
|
t.Errorf("老配置里没有 notifyEnabled 时应该默认开启,实际关闭了")
|
||||||
|
}
|
||||||
|
if config.NotifyURL == "" || !config.NotifyLiveOn {
|
||||||
|
t.Errorf("其它字段被破坏:url=%q liveOn=%v", config.NotifyURL, config.NotifyLiveOn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显式写了 true
|
||||||
|
write(`{"notifyUrl":"http://127.0.0.1:8080/acfunlive/notify","notifyEnabled":true}`)
|
||||||
|
loadConfig()
|
||||||
|
if !config.NotifyEnabled {
|
||||||
|
t.Errorf("显式写 true 时应该是开启")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显式写了 false,必须真的关掉
|
||||||
|
write(`{"notifyUrl":"http://127.0.0.1:8080/acfunlive/notify","notifyEnabled":false}`)
|
||||||
|
loadConfig()
|
||||||
|
if config.NotifyEnabled {
|
||||||
|
t.Errorf("显式写 false 时应该是关闭")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 测试接口:没填地址或昵称时给 400,填了就往对应渠道发
|
||||||
|
func TestNotifyTestHandler(t *testing.T) {
|
||||||
|
savedConfig := config
|
||||||
|
savedLocation := configFileLocation
|
||||||
|
oldDir := configDir
|
||||||
|
if configDir == nil {
|
||||||
|
configDir = new(string)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
config = savedConfig
|
||||||
|
configFileLocation = savedLocation
|
||||||
|
configDir = oldDir
|
||||||
|
}()
|
||||||
|
*configDir = t.TempDir()
|
||||||
|
configFileLocation = filepath.Join(*configDir, configFile)
|
||||||
|
|
||||||
|
var webhookHits int
|
||||||
|
webhook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
webhookHits++
|
||||||
|
_, _ = w.Write([]byte("ok"))
|
||||||
|
}))
|
||||||
|
defer webhook.Close()
|
||||||
|
|
||||||
|
var meowNick string
|
||||||
|
withMeowServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
meowNick = strings.TrimPrefix(r.URL.Path, "/")
|
||||||
|
_, _ = w.Write([]byte(`{"status":200,"message":"推送成功"}`))
|
||||||
|
})
|
||||||
|
|
||||||
|
call := func(body string) (int, string) {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/notify/test", strings.NewReader(body))
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
notifyTestHandler(rec, req)
|
||||||
|
return rec.Code, rec.Body.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 什么都没配
|
||||||
|
config.NotifyURL, config.MeowNickname = "", ""
|
||||||
|
if code, msg := call(`{"channel":"qq"}`); code != 400 || !strings.Contains(msg, "推送地址") {
|
||||||
|
t.Errorf("没填地址应该 400,实际 %d %s", code, msg)
|
||||||
|
}
|
||||||
|
if code, msg := call(`{"channel":"meow"}`); code != 400 || !strings.Contains(msg, "昵称") {
|
||||||
|
t.Errorf("没填昵称应该 400,实际 %d %s", code, msg)
|
||||||
|
}
|
||||||
|
if code, msg := call(`{"channel":"whatever"}`); code != 400 || !strings.Contains(msg, "未知的推送渠道") {
|
||||||
|
t.Errorf("未知渠道应该 400,实际 %d %s", code, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用页面上的值直接测 webhook,不需要先保存
|
||||||
|
code, msg := call(`{"channel":"qq","url":"` + webhook.URL + `","group":"777163254"}`)
|
||||||
|
if code != 200 || !strings.Contains(msg, `"ok":true`) || !strings.Contains(msg, "HTTP 200") {
|
||||||
|
t.Errorf("QQ 渠道测试应该成功,实际 %d %s", code, msg)
|
||||||
|
}
|
||||||
|
if webhookHits != 1 {
|
||||||
|
t.Errorf("webhook 应该被调用 1 次,实际 %d 次", webhookHits)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MeoW 渠道
|
||||||
|
code, msg = call(`{"channel":"meow","nickname":"小小怪","msgType":"html","htmlHeight":300}`)
|
||||||
|
if code != 200 || !strings.Contains(msg, "status 200") {
|
||||||
|
t.Errorf("MeoW 渠道测试应该成功,实际 %d %s", code, msg)
|
||||||
|
}
|
||||||
|
if meowNick != "小小怪" {
|
||||||
|
t.Errorf("MeoW 收到的昵称是 %q,期望 小小怪", meowNick)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 昵称带斜杠要被拦下(否则会打到别的路径上去)
|
||||||
|
if code, _ := call(`{"channel":"meow","nickname":"bad/nick"}`); code != 400 {
|
||||||
|
t.Errorf("非法昵称应该 400,实际 %d", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
+157
@@ -0,0 +1,157 @@
|
|||||||
|
// web UI 相关
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 测试期间换一个管理口令
|
||||||
|
func withAuthToken(t *testing.T, token string) {
|
||||||
|
t.Helper()
|
||||||
|
streamers.Lock()
|
||||||
|
old := config.AuthToken
|
||||||
|
config.AuthToken = token
|
||||||
|
streamers.Unlock()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
streamers.Lock()
|
||||||
|
config.AuthToken = old
|
||||||
|
streamers.Unlock()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 页面上发给服务端的 X-Auth-Token 是 encodeURIComponent 过的口令,
|
||||||
|
// 这里用 url.QueryEscape 造出同样的形状(对中文而言两者产出一致)
|
||||||
|
func pageHeader(r *http.Request, token string) *http.Request {
|
||||||
|
r.Header.Set("X-Auth-Token", url.QueryEscape(token))
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckAuthTokenPlainASCII(t *testing.T) {
|
||||||
|
withAuthToken(t, "a1b2c3d4e5f6")
|
||||||
|
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/api/status", nil)
|
||||||
|
r.Header.Set("X-Auth-Token", "a1b2c3d4e5f6")
|
||||||
|
if !checkAuthToken(r) {
|
||||||
|
t.Fatal("ASCII 口令应该通过")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 带 % 的口令:页面会编码成 %25,服务端解码后要能对上
|
||||||
|
withAuthToken(t, "ab%20cd")
|
||||||
|
r = pageHeader(httptest.NewRequest(http.MethodGet, "/api/status", nil), "ab%20cd")
|
||||||
|
if r.Header.Get("X-Auth-Token") != "ab%2520cd" {
|
||||||
|
t.Fatalf("编码结果不对:%q", r.Header.Get("X-Auth-Token"))
|
||||||
|
}
|
||||||
|
if !checkAuthToken(r) {
|
||||||
|
t.Fatal("含 % 的口令应该通过")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckAuthTokenNonASCII(t *testing.T) {
|
||||||
|
// 中文口令是最容易踩的:原先直接把中文塞进请求头,浏览器会抛
|
||||||
|
// TypeError(Failed to read the 'headers' property…),请求根本发不出去,
|
||||||
|
// 页面上只显示「无法连接到服务器」,用户被彻底锁在门外
|
||||||
|
withAuthToken(t, "我的口令abc123")
|
||||||
|
|
||||||
|
r := pageHeader(httptest.NewRequest(http.MethodGet, "/api/status", nil), "我的口令abc123")
|
||||||
|
if r.Header.Get("X-Auth-Token") == "我的口令abc123" {
|
||||||
|
t.Fatal("测试前提不成立:头里不该是原文")
|
||||||
|
}
|
||||||
|
if !checkAuthToken(r) {
|
||||||
|
t.Fatal("编码后的中文口令应该通过")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 手写 curl 按文档原样发中文口令,也要能通过
|
||||||
|
r = httptest.NewRequest(http.MethodGet, "/api/status", nil)
|
||||||
|
r.Header.Set("X-Auth-Token", "我的口令abc123")
|
||||||
|
if !checkAuthToken(r) {
|
||||||
|
t.Fatal("原样的中文口令也应该通过")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckAuthTokenAllSources(t *testing.T) {
|
||||||
|
withAuthToken(t, "口令abc123")
|
||||||
|
|
||||||
|
// Authorization: Bearer
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/api/status", nil)
|
||||||
|
r.Header.Set("Authorization", "Bearer "+url.QueryEscape("口令abc123"))
|
||||||
|
if !checkAuthToken(r) {
|
||||||
|
t.Fatal("Bearer 写法应该通过")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ?token=(net/http 已经把查询串解码好了)
|
||||||
|
r = httptest.NewRequest(http.MethodGet, "/api/status?token="+url.QueryEscape("口令abc123"), nil)
|
||||||
|
if !checkAuthToken(r) {
|
||||||
|
t.Fatal("查询参数写法应该通过")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下载链接里的 ?token= 也是这个路径,混着 % 的口令同样要对上
|
||||||
|
withAuthToken(t, "口令%20abc")
|
||||||
|
r = httptest.NewRequest(http.MethodGet, "/api/file?token="+url.QueryEscape("口令%20abc"), nil)
|
||||||
|
if !checkAuthToken(r) {
|
||||||
|
t.Fatal("查询参数里的混合口令应该通过")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckAuthTokenRejects(t *testing.T) {
|
||||||
|
withAuthToken(t, "正确的口令")
|
||||||
|
|
||||||
|
cases := map[string]*http.Request{
|
||||||
|
"口令错了": func() *http.Request {
|
||||||
|
return pageHeader(httptest.NewRequest(http.MethodGet, "/api/status", nil), "错误的口令")
|
||||||
|
}(),
|
||||||
|
"没带口令": httptest.NewRequest(http.MethodGet, "/api/status", nil),
|
||||||
|
"空口令头": func() *http.Request {
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/api/status", nil)
|
||||||
|
r.Header.Set("X-Auth-Token", "")
|
||||||
|
return r
|
||||||
|
}(),
|
||||||
|
"编码了但不是口令": func() *http.Request {
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/api/status", nil)
|
||||||
|
r.Header.Set("X-Auth-Token", "%E5%8F%A3%E4%BB%A4")
|
||||||
|
return r
|
||||||
|
}(),
|
||||||
|
}
|
||||||
|
for name, r := range cases {
|
||||||
|
if checkAuthToken(r) {
|
||||||
|
t.Fatalf("%s:不该通过", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 口令没配时一律拒绝,不能因为「都是空」就放行
|
||||||
|
withAuthToken(t, "")
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/api/status", nil)
|
||||||
|
r.Header.Set("X-Auth-Token", "")
|
||||||
|
if checkAuthToken(r) {
|
||||||
|
t.Fatal("口令为空时不该通过")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthTokenCandidatesOrder(t *testing.T) {
|
||||||
|
// 三种写法同时出现时按 Bearer → X-Auth-Token → ?token= 的顺序给候选,
|
||||||
|
// 顺序本身不影响结果,但别写出重复项(重复比较纯属浪费)
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/api/status?token=qqq", nil)
|
||||||
|
r.Header.Set("Authorization", "Bearer aaa")
|
||||||
|
r.Header.Set("X-Auth-Token", "bbb")
|
||||||
|
got := authTokenCandidates(r)
|
||||||
|
want := []string{"aaa", "bbb", "qqq"}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("候选数量不对:%q", got)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("候选顺序不对:%q,想要 %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authorization 不是 Bearer 前缀时不算数
|
||||||
|
r = httptest.NewRequest(http.MethodGet, "/api/status", nil)
|
||||||
|
r.Header.Set("Authorization", "Basic zzz")
|
||||||
|
r.Header.Set("X-Auth-Token", "bbb")
|
||||||
|
got = authTokenCandidates(r)
|
||||||
|
if len(got) != 1 || got[0] != "bbb" {
|
||||||
|
t.Fatalf("非 Bearer 的 Authorization 不该当候选:%q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user