From ecc560c1f0ae98fec6b1735be1cdf6319ed0aa42 Mon Sep 17 00:00:00 2001 From: tamakyi Date: Fri, 25 Sep 2026 14:19:44 +0800 Subject: [PATCH] =?UTF-8?q?web=20UI=20=E6=8C=89=E7=94=A8=E9=80=94=E5=88=86?= =?UTF-8?q?=E6=88=90=E5=85=AD=E4=B8=AA=E5=88=86=E5=8C=BA=EF=BC=8C=E6=95=B4?= =?UTF-8?q?=E4=BD=93=E6=94=B9=E7=89=88=E5=B9=B6=E8=B7=9F=E9=9A=8F=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E6=B7=B1=E6=B5=85=E8=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 需求是「界面比较杂乱,做一下分类,UI 也现代化一些」。原来所有设置挤在一张 「设置」卡片里,从保存目录一路排到管理口令,加上通知推送的两个渠道,一屏 拉不到底,找东西全靠翻。 1) 分成六个分区,顶部一条导航切换 - 监控:添加主播 + 主播列表 + 运行日志(默认打开的就是这里) - 录制:保存目录、录播/弹幕和成片子文件夹、录播格式、默认码率上限、ffmpeg 路径 - 合并:自动合并的三个开关、成片输出目录和合成码率、手动合并 - 通知:推送哪些事件、QQ 机器人渠道、MeoW 渠道 - 文件:磁盘容量 + 文件列表 - 设置:外网访问、管理口令 - 分区只靠 class 切显示,所有元素始终在 DOM 里,所以原来按 id 取值的 那一大堆逻辑一行都不用改;「监控」是默认页,因为看录制状态是最高频的操作 2) 地址栏带上 #分区名 - 用 replaceState 写,不往历史里塞记录,但刷新、收藏、把链接发给别人 都能落到同一个分区;也顺便记住了上次看的是哪一页(localStorage 兜底) 3) 大改样式(都在同一个模板里,没有引入任何外部依赖) - 颜色、圆角、阴影收进 CSS 变量,卡片改成柔和阴影 + 大圆角,输入框加了 hover 和 focus 光晕,按钮分主次两级 - 勾选框改成开关样式(隐藏原生 input + 兄弟节点画轨道,所以 el.checked 和 :focus 判断照旧能用);主播列表的「弹幕/录播」改成小圆片 - 把原来那个行内的小 #msg 文案换成右下角提示条:成功 2.6 秒、失败 6 秒 自动消失,最多同时留 4 条,报错信息不会再一闪而过 - 顶栏常驻状态摘要(录制中 N / 直播中 N / 空闲),「保存设置」也挪到顶栏, 一直可见——文本框可能在别的分区里,按钮跟着分区走会找不到 - 跟随系统深浅色(prefers-color-scheme),配套改了 color-scheme 元信息; 日志面板在两种模式下都是深色终端风格 - 手机上标题居中、右侧按钮收成两个字(「保存」),分区导航横向滚动; 顶栏用 1fr auto 1fr 的网格,两侧等宽才是真的居中 - 文件列表里的文件名原来被 max-width:200px 截成「2026-09-24 20-00-00 Xsp…」, 文件名本来就是用来认文件的,改成不截断、允许换行 4) 顺手修的两个真问题 - 口令里有中文时,页面彻底用不了:HTTP 请求头只接受 ISO-8859-1,浏览器 遇到非 ASCII 的 X-Auth-Token 会直接抛 TypeError (Failed to read the 'headers' property…),请求根本发不出去,页面只显示 「无法连接到服务器」,用户既进不去也没法在页面上把口令改回来。 现在页面统一把口令 encodeURIComponent 之后再放进头里,服务端 authTokenCandidates() 把原样和解码后的都作为候选逐个定长比较 (不提前返回,避免响应时间泄露是哪一种对上了),所以中文口令能用, curl / 脚本按文档原样发中文口令也照样能过 - 删「正在写入」的文件返回 500 而不是 409,见上一个提交 5) 文档 - README 的「使用方法」补分区表、保存规则(开关立即生效 / 文本框点保存)、 顶栏状态和深浅色说明;各处「设置区」改成对应的分区名 - doc/webapi.md 补口令编码那段,说明脚本侧按原样填即可 已验证:新增 webui_test.go 5 个用例(ASCII 口令、含 % 的口令、中文口令的 编码与原样两种写法、Bearer 与 ?token= 三个来源、以及错口令/空口令/口令未配 一律拒绝、候选顺序与非 Bearer 的 Authorization 不算数),全量 39 个用例通过。 真机用 Edge 无头浏览器跑了两轮共 40 项断言,覆盖:自动登录、六个分区逐一点开 (只显示该分区、页签高亮、hash 跟着变)、六个分区 + 深色 + 三个移动端截图、 开关点了立刻落盘并能点回来、顶栏保存写到配置文件、在别的分区保存不会把隐藏 表单的值清空、刷新后仍在原分区、MeoW 红字提示的显示与隐藏、合并中按钮禁用 并改文案、口令弹窗的失败与重试,桌面/深色/移动端三处控制台零报错。 中文口令另做了端到端验证:配置里放中文口令能直接进、换口令后接口继续可用、 重开页面用新口令能进、旧口令被拒;把客户端编码去掉后同一个测试立刻失败, 确认这个修复是必需的而不是想当然。Windows/Linux 双平台构建、go vet、 gofmt 全干净。51880 的上游原版全程未动。 --- README.md | 25 +- doc/webapi.md | 5 +- webui.go | 615 ++++++++++++++++++++++++++++++++++---------------- webui_test.go | 157 +++++++++++++ 4 files changed, 596 insertions(+), 206 deletions(-) create mode 100644 webui_test.go diff --git a/README.md b/README.md index 37ff8ea..df5bb4b 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe 录播原片和弹幕文件(`.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 秒后强杀: @@ -131,9 +131,9 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe `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,找不到时启动日志里会有明确提示。 @@ -145,6 +145,21 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe web UI 默认通过`http://localhost:51880`访问(端口对应`webPort`),可以在网页里增删主播、切换每个主播的弹幕和录播开关、修改码率上限、修改保存目录和自动合并设置。 +页面按用途分成六个分区,顶部一条导航切换,地址栏会跟着带上`#分区名`,所以刷新、收藏、直接把链接发给别人都能落到同一个分区: + +| 分区 | 内容 | +| --- | --- | +| 监控 | 添加主播、主播列表(默认打开的分区),以及运行日志 | +| 录制 | 保存目录和子文件夹、录播格式、默认码率上限、ffmpeg 路径 | +| 合并 | 自动合并开关、成片输出目录和合成码率、手动合并 | +| 通知 | 推送哪些事件,以及 QQ 机器人 / MeoW 两个渠道的配置和测试连通 | +| 文件 | 磁盘剩余容量、录播和成片的文件列表 | +| 设置 | 是否允许外网访问、管理口令的查看与更换 | + +开关和下拉框改完立即保存;文本框(目录、地址、口令这些)填完点右上角的`保存设置`才生效,这一条在每个有文本框的分区里也写了提示。 + +顶栏右上角一直显示当前状态(`录制中 N` / `直播中 N` / `空闲`)。窄屏上按钮收起成短文案、标题居中,手机上也能正常用;页面跟随系统的深浅色设置,深色模式下日志和表单自动变深色,不用额外配置。 + 主播列表每行右侧的`停止`和`重新开始`按钮控制该主播当前的录制:`停止`会结束这一场的弹幕和录播(同时把该主播的弹幕、录播开关关掉,避免被监控循环自动重启),`重新开始`会重新开启开关并立刻开始新的一组录制文件。这两个按钮同时作用于弹幕和录播,只控制其中一项可以用对应的勾选框。 命令行模式运行时可以输入命令控制本程序,输入`help`查看全部命令的解释,输入`quit`退出程序。 @@ -196,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),还原结果写回录播目录,页面的下拉列表里也会出现。 @@ -266,7 +281,7 @@ webhook 返回 200 只代表**地址和口令没问题**,能不能真的收到 ### 文件管理 -web UI 的`文件管理`区可以列出录播文件夹和成片文件夹里的文件(名称、大小、修改时间),并显示保存目录所在磁盘的剩余和总容量。 +web UI 的`文件`分区可以列出录播文件夹和成片文件夹里的文件(名称、大小、修改时间),并显示保存目录所在磁盘的剩余和总容量。 - `下载`:以附件形式下载,中文文件名用 RFC 5987 编码,不会乱码 - `播放`:只对浏览器能直接播的格式(mp4、m4v、webm、ogv)显示,在新标签页里内联播放;`.flv` 浏览器放不了,只提供下载 diff --git a/doc/webapi.md b/doc/webapi.md index 191f21a..6706def 100644 --- a/doc/webapi.md +++ b/doc/webapi.md @@ -76,8 +76,11 @@ web API 默认本地端口为 51880 ## 新版 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` 预览一次手动合并的计划,不落盘。 ``` diff --git a/webui.go b/webui.go index d3aeb6e..ef27b6d 100644 --- a/webui.go +++ b/webui.go @@ -8,6 +8,7 @@ import ( "fmt" "net" "net/http" + "net/url" "os" "sort" "strconv" @@ -28,221 +29,368 @@ const webuiHTML = ` + AcFun 直播录制 +
+
+
+ +
AcFun 直播录制弹幕 / 视频录制,直播结束后自动合并
+
+
+ + +
+
+ +
-

AcFun 直播录制

-
弹幕 / 视频录制,直播结束后自动合并
-
-

设置

-
-
-
- - - -
-
三项互不影响,可以同时勾选;需该主播同时开启弹幕和录播且都录到直播结束,手动停止或重新开始不会触发合并
-
-
-
-
-
-
-
-
-
-
录播和弹幕合并都要用 ffmpeg:留空时先查 PATH,再查程序所在目录及其 bin、ffmpeg 子目录
-
录制是 -c copy 直接封装,不转码,四种格式画质完全一样,只是容器不同。正常停止(停止按钮、Ctrl+C)都没问题;如果进程被强杀(任务管理器结束进程、断电),mp4 / flv / mkv 都会整个报废,实测只有 ts 还能播出已经录到的部分。合并出来的成片始终是 mp4。
-
同一主播下播后 20 秒内又上播会当成网络波动,接着录同一场,最终只合成一个成片。
-
-
通知推送
-
- - - - -
-
这一行勾的是「推哪些事件」,下面两个渠道共用;推送失败只记警告,不影响录制
-
渠道一 · QQ 机器人(webhook)
-
-
-
-
-
+ +
+
+

添加主播

填 uid 后默认同时开启弹幕和录播
+
+ + +
-
推送体是 json,字段 event / uid / name / title / url / file / group / text / time,text 可以直接发给群
-
- - 用上面填写的地址发一条测试推送,不用先保存 +
+

主播列表

勾选 = 开启对应录制,改完立即生效
+
下载码率上限 = 挑选码率不超过该值的直播源档位(2000 约超清档),0 = 最高档;正在录制时修改会自动换档重新开始
+
+
+

运行日志

手动合并的进度也在这里
+

+    
+
-
渠道二 · MeoW
-
-
-
-
-
-
+ +
+
+

保存位置

录播、弹幕和成片都放在这里
+
+
+
+
+
+
文本框改完点右上角「保存设置」生效,开关和下拉框改完立即生效。
-
推给 MeoW 里这个昵称,手机上直接收通知,点开可以跳回直播间;接口有频控(3 秒 1 条、每分钟 15 条、每小时 60 条)
- -
- - 用上面填写的昵称发一条测试推送,不用先保存 +
+

录制参数

+
+
+
+
+
+
录播和弹幕合并都要用 ffmpeg:留空时先查 PATH,再查程序所在目录及其 bin、ffmpeg 子目录。
+
录制是 -c copy 直接封装,不转码,四种格式画质完全一样,只是容器不同。正常停止(停止按钮、Ctrl+C)都没问题;如果进程被强杀(任务管理器结束进程、断电),mp4 / flv / mkv 都会整个报废,实测只有 ts 还能播出已经录到的部分。合并出来的成片始终是 mp4。
-
- +
+ + +
+
+

自动合并

直播正常结束时的自动处理
+
+ + + +
+
三项互不影响,可以同时开启;需该主播同时开启弹幕和录播且都录到直播结束。手动停止或重新开始不会触发自动合并,那种情况用下面的手动合并。
+
同一主播下播后 20 秒内又上播会当成网络波动,接着录同一场,最终只合成一个成片。
-
开启后监听所有网卡,同一局域网的设备可用本机 IP 访问;修改后 web 服务会自动重启,页面会自动重连
-
-
- +
+

成片输出

+
+
+
+
+
文本框改完点右上角「保存设置」生效。
-
勾选类设置修改后立即保存,文本框内容点「保存设置」生效
-
-
-
- - +
+

手动合并

自己挑视频和弹幕合成
+
自动合并只在录制自然结束时触发(手动停止过、弹幕和视频对不上号、或者把文件挪过位置的都不会自动合并),这些可以在这里自己挑文件合成。视频一行一个,填多个会按文件名里的录制时间顺序拼成一个再合成。合并进度见「监控」页的运行日志。
+
+
+ + +
+
+ + + +
+
+
+
+ + +
+
+ + + + +
+
+
+ + + +
+
+
+
+ + +
+
+

推送哪些事件

下面两个渠道共用这份勾选
+
+ + + + +
+
推送失败只记警告,不影响录制。
+
+
+

渠道一 · QQ 机器人

自建机器人,走 HTTP POST JSON
+ +
+
+
+
+
+
推送体是 json,字段 event / uid / name / title / url / file / group / text / time,text 可以直接发给群。
+
+ + 用上面填写的地址发一条测试推送,不用先保存;改完记得点右上角「保存设置」
-
-
- - +
+

渠道二 · MeoW

手机上直接收通知
+ +
+
+
+
+
+
+
推给 MeoW 里这个昵称,点开通知可以跳回直播间;接口有频控(3 秒 1 条、每分钟 15 条、每小时 60 条)。
+ +
+ + 用上面填写的昵称发一条测试推送,不用先保存;改完记得点右上角「保存设置」
-
忘记口令时可以打开程序所在目录的 config.json,查看 authToken 字段
-
-
- - -
-
-
-

主播列表(勾选 = 开启对应录制,修改立即生效)

-
下载码率上限 = 挑选码率不超过该值的直播源档位(2000 约超清档),0 = 最高档;正在录制时修改会自动换档重新开始
-
-
-
-

文件管理

-
点击下面的按钮读取文件和磁盘信息
-
- - -
-
-
-
-

手动合并

-
自动合并只在录制自然结束时触发(手动停止过、弹幕和视频对不上号、或者把文件挪过位置的都不会自动合并),这些可以在这里自己挑文件合成。视频一行一个,填多个会按文件名里的录制时间顺序拼成一个再合成。合并进度见下方运行日志。
-
-
- - -
-
- - - +
+ + +
+
+

文件管理

正在读取磁盘信息…
+
+ +
+
-
-
- - -
-
- - - - -
+
+ + +
+
+

访问方式

+ +
开启后监听所有网卡,同一局域网的设备可用本机 IP 访问;修改后 web 服务会自动重启,页面会自动重连。
-
- - - +
+

管理口令

打开本页和管理录制都需要
+
+
+ + +
+
+
+
+ + +
+
+
忘记口令时可以打开程序所在目录的 config.json,查看 authToken 字段。
-
-
-
-

运行日志

-

-  
+
+
+

请输入管理口令

管理口令在程序首次启动时自动生成并打印在运行日志里,也可以在程序所在目录的 config.json 的 authToken 字段查看。

-
+
@@ -256,16 +404,44 @@ var authed = false; // 是否已通过口令校验,未通过时停止轮询 var tokenLoaded = false; function setChk(id, v){ var el = document.getElementById(id); if(!saving && !el.matches(":focus")) el.checked = !!v; } function setVal(id, v){ var el = document.getElementById(id); if(!saving && document.activeElement !== el) el.value = v; } +function show(el, v){ var e = document.getElementById(el); if(e) e.style.display = v ? "" : "none"; } // 管理接口都要带上口令 function api(url, opt){ opt = opt || {}; var h = {}; var src = opt.headers || {}; for(var k in src){ h[k] = src[k]; } - h["X-Auth-Token"] = token; + // 请求头只接受 ISO-8859-1,口令里有中文的话浏览器会直接报错、请求发不出去, + // 所以统一先百分号编码,服务端两种写法都认 + h["X-Auth-Token"] = encodeURIComponent(token); opt.headers = h; return fetch(url, opt); } +// ========== 分区导航 ========== +var tabNames = {mon:"监控", rec:"录制", mrg:"合并", ntf:"通知", file:"文件", sys:"设置"}; +var curTab = ""; +function showTab(name){ + if(!tabNames[name]) name = "mon"; + if(curTab === name) return; + curTab = name; + var tabs = document.querySelectorAll(".tab"); + for(var i = 0; i < tabs.length; i++){ tabs[i].classList.toggle("on", tabs[i].dataset.tab === name); } + var panes = document.querySelectorAll(".pane"); + for(var j = 0; j < panes.length; j++){ panes[j].classList.toggle("on", panes[j].dataset.pane === name); } + try{ localStorage.setItem("acfunlive_tab", name); }catch(e){} + // 地址栏留个 #分区,刷新和收藏都能回到同一个分区(replaceState 不往历史里塞记录) + if(location.hash.slice(1) !== name){ + try{ history.replaceState(null, "", "#" + name); }catch(e){ location.hash = name; } + } + // 切过去的时候顺手刷新那个分区里的实时数据 + if(name === "file" && authed) loadFiles(); + if(name === "mrg" && authed) fillMergePickers(); +} +document.getElementById("tabs").addEventListener("click", function(e){ + var t = e.target.closest(".tab"); + if(t) showTab(t.dataset.tab); +}); +window.addEventListener("hashchange", function(){ showTab(location.hash.slice(1)); }); function showLogin(text){ authed = false; document.getElementById("auth").classList.add("on"); @@ -368,13 +544,22 @@ async function refresh(){ toggleMeowHeight(); var mb = document.getElementById("meowBlocked"); if(st.meowBlocked){ mb.style.display = ""; mb.textContent = "当前推不出去:" + st.meowBlocked; } - else mb.style.display = "none"; + else show("meowBlocked", false); setChk("webExternal", st.webExternal); setChk("autoMerge", st.autoMerge); setChk("mergeDelVideo", st.mergeDelVideo); setChk("mergeDelDanmu", st.mergeDelDanmu); syncMergeState(st); - var rows = (st.streamers||[]).map(function(s){ + // 顶栏的状态摘要 + var sl = st.streamers||[]; + var rec = 0, live = 0; + sl.forEach(function(s){ if(s.recordActive || s.danmuActive) rec++; else if(s.live) live++; }); + var ts = document.getElementById("topStat"); + if(rec) ts.innerHTML = '录制中 ' + rec; + else if(live) ts.innerHTML = '直播中 ' + live; + else if(sl.length) ts.innerHTML = '空闲'; + else ts.innerHTML = ""; + var rows = sl.map(function(s){ var badges = ""; if(s.recordActive) badges += '录播中'; if(s.danmuActive) badges += '弹幕中'; @@ -383,8 +568,8 @@ async function refresh(){ return '
' + 'uid ' + s.uid + '' + '' + (esc(s.name) || "uid " + s.uid) + '' - + '' - + '' + + '' + + '' + '' + '' + badges + '' + '' + title + '' @@ -398,7 +583,7 @@ async function refresh(){ // 数据没变化不重建;正在列表里输入时也跳过,避免把没提交的输入清掉 if(sig !== stSig && !list.contains(document.activeElement)){ stSig = sig; - list.innerHTML = rows || '
列表为空,请在上方添加 uid
'; + list.innerHTML = rows || '
列表为空,在上面填 uid 添加
'; } var lr = await api("/api/log?since=" + logOffset); if(lr.status === 401){ showLogin("管理口令已失效,请重新输入"); return; } @@ -415,10 +600,20 @@ async function refresh(){ } }catch(e){} } +// 右下角弹一条提示,成功 2.6 秒、失败 6 秒 function showMsg(ok, text){ - var m = document.getElementById("msg"); - m.textContent = text; m.style.color = ok ? "#1d7a46" : "#d63046"; - setTimeout(function(){ m.textContent = ""; }, 3000); + var w = document.getElementById("toast"); + if(!w){ return; } + var d = document.createElement("div"); + d.className = "toast" + (ok ? "" : " bad"); + d.textContent = text; + w.appendChild(d); + while(w.children.length > 4){ w.removeChild(w.firstChild); } + var life = ok ? 2600 : 6000; + setTimeout(function(){ + d.classList.add("out"); + setTimeout(function(){ if(d.parentNode) d.parentNode.removeChild(d); }, 220); + }, life); } async function post(url, body){ saving = true; @@ -590,7 +785,7 @@ function setMergeState(){ go.disabled = mrgBusy; go.textContent = mrgBusy ? "合并中…" : "开始合并"; document.getElementById("mrgCheck").disabled = mrgBusy; - document.getElementById("mrgState").textContent = mrgBusy ? "正在合并,进度见下方运行日志" : ""; + document.getElementById("mrgState").textContent = mrgBusy ? "正在合并,进度见「监控」页的运行日志" : ""; } async function checkMerge(){ var body = mrgPayload(); @@ -619,7 +814,7 @@ async function startMerge(){ var body = mrgPayload(); if(!body) return; if(!body.videos.length || !body.danmu){ showMsg(false, "请先填写视频和弹幕文件"); return; } - if(!confirm("开始合并?合成期间可以在下方运行日志里看进度。")) return; + if(!confirm("开始合并?合成期间可以在「监控」页的运行日志里看进度。")) return; var b = document.getElementById("mrgGo"); b.disabled = true; var r = null; @@ -644,7 +839,7 @@ function syncMergeState(st){ if(busy) return; loadFiles(); if(st.mergeErr) showMsg(false, "合并失败:" + st.mergeErr); - else showMsg(true, "手动合并结束,结果见运行日志"); + else showMsg(true, "手动合并结束,结果见「监控」页的运行日志"); } async function testNotify(b, channel){ if(b) b.disabled = true; @@ -695,15 +890,17 @@ document.addEventListener("change", function(e){ post("/api/streamer", {uid: uid, danmu: danmu, record: record, bitrate: bitrate}); } if(t.matches('input.br')){ - var uid = parseInt(t.dataset.uid, 10); - var row = t.closest(".row"); + var uid2 = parseInt(t.dataset.uid, 10); + var row2 = t.closest(".row"); var v = parseInt(t.value.trim(), 10); if(isNaN(v) || v < 0){ showMsg(false, "码率必须是大于等于 0 的数字"); refresh(); return; } - var danmu = row.querySelector('input[data-k=danmu]').checked; - var record = row.querySelector('input[data-k=record]').checked; - post("/api/streamer", {uid: uid, danmu: danmu, record: record, bitrate: v}); + var danmu2 = row2.querySelector('input[data-k=danmu]').checked; + var record2 = row2.querySelector('input[data-k=record]').checked; + post("/api/streamer", {uid: uid2, danmu: danmu2, record: record2, bitrate: v}); } }); +// 回到地址栏或上次看的那个分区 +showTab(location.hash.slice(1) || localStorage.getItem("acfunlive_tab") || "mon"); // 先试一次(用本地保存的口令),失败会自动弹出输入框并停止轮询 authed = true; refresh(); @@ -1351,17 +1548,32 @@ func webUIAddr() string { return "127.0.0.1:" + itoa(config.WebPort) } -// 从请求里取管理口令:优先 Authorization: Bearer,其次 X-Auth-Token,最后 ?token= -func authTokenFromRequest(r *http.Request) string { +// 从请求里取管理口令的所有可能写法,按优先级排:Authorization: Bearer、X-Auth-Token、?token=。 +// 浏览器不允许把非 ISO-8859-1 的字符放进请求头(中文口令会直接抛 TypeError,请求根本发不出去), +// 所以页面会先把口令百分号编码再放进头里;这里把原样和解码后的都当候选, +// 这样中文口令能用,手写 curl / 脚本用原样口令也照样能用。 +func authTokenCandidates(r *http.Request) []string { + var raw []string if h := strings.TrimSpace(r.Header.Get("Authorization")); h != "" { if len(h) > 7 && strings.EqualFold(h[:7], "Bearer ") { - return strings.TrimSpace(h[7:]) + raw = append(raw, strings.TrimSpace(h[7:])) } } if t := strings.TrimSpace(r.Header.Get("X-Auth-Token")); t != "" { - return t + raw = append(raw, t) } - return strings.TrimSpace(r.URL.Query().Get("token")) + if t := strings.TrimSpace(r.URL.Query().Get("token")); t != "" { + raw = append(raw, t) + } + + out := make([]string, 0, len(raw)*2) + for _, v := range raw { + out = append(out, v) + if d, err := url.QueryUnescape(v); err == nil && d != v { + out = append(out, d) + } + } + return out } // 校验请求里的管理口令,用固定时间比较避免通过响应时间猜口令 @@ -1370,11 +1582,14 @@ func checkAuthToken(r *http.Request) bool { if want == "" { return false } - got := authTokenFromRequest(r) - if got == "" { - return false + // 逐个候选都比完再出结果,不提前返回,免得响应时间泄露是哪一种写法对上了 + ok := false + for _, got := range authTokenCandidates(r) { + if subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1 { + ok = true + } } - return subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1 + return ok } // 口令错误次数的记录,给反复尝试的请求加一点延迟 diff --git a/webui_test.go b/webui_test.go new file mode 100644 index 0000000..3c7ba32 --- /dev/null +++ b/webui_test.go @@ -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) + } +}