Files
acfunlive-remake/webui.go
T
tamakyi 8528499a69 通知推送拆成两个渠道:QQ 机器人和 MeoW,各自带开关和测试连通
用户要求两件事:① QQ bot 推送要能测连通、并加一个开关;② 照 MeoW 的接口
文档(https://www.chuckfang.com/MeoW/api_doc.html)加一个推送渠道,同样要有
测试和开关。确认过的三个决定:QQ bot 就是现有 webhook;事件勾选两个渠道
共用、开关按渠道分开;MeoW 只填一个昵称。

1) 通知推送改成「共享事件勾选 + 每渠道一个开关」
   - notify.go: notifySetting 扩成两个渠道,新增 qqReady()/meowReady();
     sendNotify 按渠道各自起后台 goroutine 分发,事件没勾选、或两个渠道都
     没启用时什么都不做
   - config.go: 新增 notifyEnabled(QQ 机器人渠道开关)、meowEnabled、
     meowNickname、meowMsgType、meowHtmlHeight、meowImgUrl
   - 关掉开关只是暂停推送,地址、昵称这些配置都留着,随时能再打开

2) 老配置的兼容(重要)
   - notifyEnabled 是普通 bool,老 config.json 里没有这个字段会解成 false,
     升级后会把已经配好的推送静默关掉
   - 解法:loadConfig() 里先置 true 再 json.Unmarshal(Unmarshal 只覆盖文件里
     出现的字段),默认值里也写 true;文件里显式写 false 的仍然会关掉
   - 已加回归测试 TestNotifyEnabledBackwardCompat 覆盖三种写法

3) MeoW 渠道(新增 meow.go)
   - 按文档走 POST {昵称},title/msg/url/imgUrl 放 json body,
     msgType/htmlHeight 是 query 参数(文档明确说这两个不属于 JSON 参数)
   - 文档自己前后矛盾:响应格式那节写 msg,示例写 message,两个字段都解析
   - 空字段一律不放进 body:body 里的 title 优先级高于路径参数,
     传空串会把 MeoW 的默认标题顶掉
   - 业务状态码翻成中文(400 参数错、403 内容被拦、404 昵称未注册、
     429 频控、500 服务器错误),失败信息直接显示在页面上
   - msgType 为 html 时渲染成 HTML(首行加粗、链接可点、内容转义),
     高度默认 200、范围 40-2000;事件正文复用 webhook 的那份 text

4) MeoW 的错误昵称会封 IP,所以加了保护(踩出来的)
   - 实测用不存在的昵称打真实接口,返回的提示是「一天之内累计3次输错对方
     昵称您的IP将被封禁」。而开播+开始录制+下播+合并完成一天轻松触发 4 次,
     昵称配错就会把用户 IP 封了
   - meow.go: 新增 meowBadNick 记录,pushMeow 收到 404 就记下这个昵称;
     sendNotify 和测试接口命中记录时直接跳过/拒绝并说明原因,
     改掉昵称或重启程序即恢复
   - 只有 404 触发拦截:429 是频控、400 是参数问题,都应该继续尝试
   - 已加测试 TestMeowBadNickGuard(404 要拦、429/400 不能拦、不影响别的昵称)

5) 测试连通
   - /api/notify/test 合并成一个接口,用 channel 区分 qq / meow,
     每个渠道的配置下面各有一个「测试连通」按钮
   - 用页面上当前填的值直接测、不用先保存,留空的项自动用已保存的值补齐;
     测试不受渠道开关和事件勾选影响(本来就是为了打开之前先确认配置)
   - 成功返回 {ok, detail},detail 就是页面上的结论:QQ 渠道显示 webhook 的
     HTTP 状态码和耗时,MeoW 渠道显示它返回的 status 和耗时;
     webhook 返回 200 只代表地址口令没问题,能不能收到要看机器人那边,
     这点也写在结果里
   - /api/status 新增 meowBlocked,页面上红字显示「当前推不出去:…」
     (昵称没填、格式不对、或已被判过未注册)

6) 页面
   - 通知区分成「事件勾选 / 渠道一 QQ 机器人 / 渠道二 MeoW」三块,
     每块用一条带标题的细线隔开,两个渠道各带开关和测试按钮
   - 消息类型选 text 时自动收起 HTML 高度那一行

另外:README 的通知推送一节重写(两个渠道的对比表、MeoW 的频控与封禁提醒、
测试连通的说明),配置文件详解补上新字段;doc/webapi.md 补 /api/notify/test
和 meowBlocked。doc/momoko-acfunlive-notify.py 本来就处理 event="test",不用改。

已验证:新增 notify_test.go,15 个用例全部通过(URL 拼接与转义、昵称校验、
消息类型与高度归一、HTML 渲染与转义、接口成功/失败/空字段处理、6 种业务状态码
的文案、404 拦截保护、老配置兼容、测试接口各分支),其中 TestSendNotifyFanOut
是端到端分发测试:两个渠道都开→各收到 1 条、关掉任一个→只剩另一个、
两个都关→0 条、事件没勾→0 条。真机冒烟两轮:页面控件齐全、老配置默认开启、
QQ 渠道实测本地 webhook 收到完整推送(含 Authorization 和 X-Auth-Token 头与群号)、
空值回退到已保存配置、6 种非法输入全 400、开关能落盘且关开关不清地址、
MeoW 真实接口返回的 404 被正确翻成中文。全量 34 个测试通过,
Windows/Linux 双平台构建、go vet、gofmt 全干净。
2026-09-25 13:42:49 +08:00

1530 lines
66 KiB
Go
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// web UI 相关
package main
import (
"context"
"crypto/subtle"
"encoding/json"
"fmt"
"net"
"net/http"
"os"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// 储存日志
var logString struct {
sync.Mutex
str strings.Builder
total int64 // 从启动到现在写入的总字节数,用于按偏移量增量读取日志
}
const webuiHTML = `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>AcFun 直播录制</title>
<style>
:root{--line:#e8eaed;--soft:#f0f1f3;--sub:#8a8f98;--red:#fd4c5d}
*{box-sizing:border-box}
body{margin:0;background:#f6f7f8;color:#222;font:14px/1.5 system-ui,-apple-system,"Microsoft YaHei",sans-serif;-webkit-text-size-adjust:100%}
.wrap{max-width:1000px;margin:0 auto;padding:14px 12px 28px}
h1{font-size:16px;font-weight:600;margin:0 0 2px}
.head-sub{font-size:12px;color:var(--sub);margin-bottom:12px}
.card{background:#fff;border:1px solid var(--line);border-radius:6px;padding:12px 14px;margin-bottom:10px}
.card h2{font-size:13px;font-weight:600;margin:0 0 10px;color:#333}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:10px 14px}
.f label{display:block;font-size:12px;color:var(--sub);margin-bottom:4px}
input[type=text],select,textarea{width:100%;border:1px solid #dcdfe4;border-radius:4px;padding:7px 9px;font:inherit;background:#fff;color:#222}
input[type=text]:focus,select:focus,textarea:focus{outline:none;border-color:var(--red)}
textarea{resize:vertical;line-height:1.5;font-size:13px}
select[multiple]{padding:4px}
#mrgInfo{white-space:pre-wrap;margin-top:8px}
#mrgInfo:empty{display:none}
input.br{width:88px;text-align:center}
.chk{display:inline-flex;align-items:center;gap:5px;margin:0 14px 0 0;font-size:13px;color:#333;cursor:pointer}
button{border:none;border-radius:4px;padding:8px 16px;font:inherit;cursor:pointer;background:var(--red);color:#fff}
button.ghost,a.ghost{background:#f1f2f4;color:#555;padding:6px 10px;font-size:12px;text-decoration:none;display:inline-block}
button:active,a.ghost:active{opacity:.8}
button:disabled{opacity:.5}
.hint{font-size:12px;color:var(--sub);margin-top:6px;line-height:1.6}
.hint.bad{color:#d63046}
.hr{height:1px;background:var(--soft);margin:12px -14px}
.ch{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;color:#333;margin:12px 0 8px}
.ch::after{content:"";flex:1;height:1px;background:var(--soft)}
.row{display:flex;flex-wrap:wrap;align-items:center;gap:6px 12px;padding:10px 0;border-top:1px solid var(--soft)}
.row:first-child{border-top:none;padding-top:2px}
.uid{font-size:12px;color:var(--sub);font-variant-numeric:tabular-nums}
.name{font-weight:600;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.title{flex:1 1 200px;min-width:0;font-size:12px;color:var(--sub);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ops{margin-left:auto;display:flex;gap:6px;flex-wrap:wrap}
.badge{display:inline-block;padding:1px 7px;border-radius:3px;font-size:12px;margin-right:4px}
.on{background:#ffe8e8;color:#d63046}
.rec{background:#e6f4ec;color:#1d7a46}
.off{font-size:12px;color:var(--sub)}
pre{background:#1e1e1e;color:#d6d6d6;border-radius:6px;padding:10px;font-size:12px;max-height:300px;overflow:auto;white-space:pre-wrap;word-break:break-all;margin:0}
#msg{font-size:12px;margin-left:8px}
input[readonly]{background:#f6f7f8;color:#555}
.mask{position:fixed;left:0;top:0;right:0;bottom:0;background:rgba(20,22,26,.55);display:none;align-items:center;justify-content:center;z-index:9;padding:16px}
.mask.on{display:flex}
.mask .box{background:#fff;border-radius:8px;padding:18px;width:100%;max-width:380px}
.mask h3{margin:0 0 6px;font-size:15px}
.mask p{margin:0 0 12px;font-size:12px;color:var(--sub);line-height:1.6}
#authMsg{font-size:12px;color:#d63046;min-height:17px;margin-top:8px}
@media (max-width:600px){
.name{max-width:none}
.title{flex:1 1 100%;order:8}
.ops{width:100%;margin-left:0;order:9}
.ops button,.ops a{flex:1 1 0;padding:8px 4px;text-align:center}
button.ghost,.mask button{padding:8px 10px;font-size:13px}
}
</style>
</head>
<body>
<div class="wrap">
<h1>AcFun 直播录制</h1>
<div class="head-sub">弹幕 / 视频录制,直播结束后自动合并</div>
<div class="card">
<h2>设置</h2>
<div class="f"><label>文件保存目录(留空 = 程序所在目录)</label><input type="text" id="directory" placeholder="例如 D:\record"></div>
<div class="hr"></div>
<div>
<label class="chk"><input type="checkbox" id="autoMerge">直播结束后自动合并</label>
<label class="chk"><input type="checkbox" id="mergeDelVideo">合并后删除源视频</label>
<label class="chk"><input type="checkbox" id="mergeDelDanmu">合并后删除弹幕</label>
</div>
<div class="hint">三项互不影响,可以同时勾选;需该主播同时开启弹幕和录播且都录到直播结束,手动停止或重新开始不会触发合并</div>
<div class="grid" style="margin-top:10px">
<div class="f"><label>成片移动到(留空 = 保存目录下的成片文件夹)</label><input type="text" id="mergeOutputDir" placeholder="例如 D:\merged"></div>
<div class="f"><label>合成码率(Kbps,0 = 默认质量)</label><input type="text" id="mergeBitrate" inputmode="numeric" placeholder="例如 1500"></div>
<div class="f"><label>默认下载码率上限(Kbps,0 = 最高档)</label><input type="text" id="defaultBitrate" inputmode="numeric" placeholder="例如 2000"></div>
<div class="f"><label>录播/弹幕子文件夹</label><input type="text" id="recordSubdir" placeholder="record"></div>
<div class="f"><label>成片子文件夹</label><input type="text" id="mergeSubdir" placeholder="merged"></div>
<div class="f"><label>ffmpeg 路径(留空自动查找)</label><input type="text" id="ffmpegPath" placeholder="例如 D:\ffmpeg\bin\ffmpeg.exe"></div>
<div class="f"><label>录播文件格式</label><select id="recordFormat">
<option value="mp4">mp4(推荐,最通用)</option>
<option value="ts">ts(强杀后还能播已录部分)</option>
<option value="flv">flv(兼容性一般)</option>
<option value="mkv">mkv(可装多音轨)</option>
</select></div>
</div>
<div class="hint">录播和弹幕合并都要用 ffmpeg:留空时先查 PATH,再查程序所在目录及其 bin、ffmpeg 子目录</div>
<div class="hint">录制是 -c copy 直接封装,不转码,四种格式画质完全一样,只是容器不同。正常停止(停止按钮、Ctrl+C)都没问题;如果进程被强杀(任务管理器结束进程、断电),mp4 / flv / mkv 都会整个报废,实测只有 ts 还能播出已经录到的部分。合并出来的成片始终是 mp4。</div>
<div class="hint">同一主播下播后 20 秒内又上播会当成网络波动,接着录同一场,最终只合成一个成片。</div>
<div class="hr"></div>
<div class="ch">通知推送</div>
<div>
<label class="chk"><input type="checkbox" id="notifyLiveOn">开播</label>
<label class="chk"><input type="checkbox" id="notifyLiveOff">下播</label>
<label class="chk"><input type="checkbox" id="notifyRecordStart">开始录制</label>
<label class="chk"><input type="checkbox" id="notifyMergeDone">合并完成</label>
</div>
<div class="hint">这一行勾的是「推哪些事件」,下面两个渠道共用;推送失败只记警告,不影响录制</div>
<div class="ch">渠道一 · QQ 机器人(webhook)</div>
<div><label class="chk"><input type="checkbox" id="notifyEnabled">启用这个渠道</label></div>
<div class="grid" style="margin-top:8px">
<div class="f"><label>通知推送地址(留空 = 不推送,POST JSON)</label><input type="text" id="notifyUrl" placeholder="例如 http://127.0.0.1:8080/acfunlive/notify"></div>
<div class="f"><label>推送口令(留空 = 不带,作为 Bearer 发送)</label><input type="text" id="notifyToken" placeholder="可选"></div>
<div class="f"><label>通知群号(随推送下发,机器人用它决定发到哪)</label><input type="text" id="notifyGroup" inputmode="numeric" placeholder="例如 777163254,留空则用机器人自己的默认群"></div>
</div>
<div class="hint">推送体是 json,字段 event / uid / name / title / url / file / group / text / time,text 可以直接发给群</div>
<div style="margin-top:8px;display:flex;flex-wrap:wrap;gap:8px;align-items:center">
<button class="ghost" onclick="testNotify(this,'qq')">测试连通</button>
<span class="hint" style="margin:0">用上面填写的地址发一条测试推送,不用先保存</span>
</div>
<div class="ch">渠道二 · MeoW</div>
<div><label class="chk"><input type="checkbox" id="meowEnabled">启用这个渠道</label></div>
<div class="grid" style="margin-top:8px">
<div class="f"><label>MeoW 昵称(接收者)</label><input type="text" id="meowNickname" placeholder="你在 MeoW 里的昵称"></div>
<div class="f"><label>消息类型</label><select id="meowMsgType">
<option value="text">text(纯文本)</option>
<option value="html">html(在 App 里渲染)</option>
</select></div>
<div class="f" id="meowHeightRow"><label>HTML 显示高度(像素)</label><input type="text" id="meowHtmlHeight" inputmode="numeric" placeholder="200"></div>
<div class="f"><label>通知图标 URL(可选)</label><input type="text" id="meowImgUrl" placeholder="建议 216×216 的 PNG,留空用 MeoW 默认图标"></div>
</div>
<div class="hint">推给 MeoW 里这个昵称,手机上直接收通知,点开可以跳回直播间;接口有频控(3 秒 1 条、每分钟 15 条、每小时 60 条)</div>
<div class="hint bad" id="meowBlocked" style="display:none"></div>
<div style="margin-top:8px;display:flex;flex-wrap:wrap;gap:8px;align-items:center">
<button class="ghost" onclick="testNotify(this,'meow')">测试连通</button>
<span class="hint" style="margin:0">用上面填写的昵称发一条测试推送,不用先保存</span>
</div>
<div style="margin-top:10px">
<label class="chk"><input type="checkbox" id="webExternal">允许外网访问(关闭时只有本机能打开本页)</label>
</div>
<div class="hint">开启后监听所有网卡,同一局域网的设备可用本机 IP 访问;修改后 web 服务会自动重启,页面会自动重连</div>
<div class="hr"></div>
<div style="display:flex;flex-wrap:wrap;align-items:center;gap:8px">
<button onclick="saveSettings(this)">保存设置</button><span id="msg"></span>
</div>
<div class="hint">勾选类设置修改后立即保存,文本框内容点「保存设置」生效</div>
<div class="hr"></div>
<div class="f"><label>管理口令(打开本页和管理录制都需要,建议复制保存到别处)</label>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
<input type="text" id="authTokenView" readonly style="flex:1 1 200px">
<button class="ghost" onclick="copyToken()">复制</button>
</div>
</div>
<div class="f" style="margin-top:8px"><label>更换管理口令(至少 8 个字符)</label>
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
<input type="text" id="authTokenNew" autocomplete="off" placeholder="输入新的管理口令" style="flex:1 1 200px">
<button class="ghost" onclick="changeToken()">更换</button>
</div>
</div>
<div class="hint">忘记口令时可以打开程序所在目录的 config.json,查看 authToken 字段</div>
<div class="hr"></div>
<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center">
<input type="text" id="newuid" inputmode="numeric" placeholder="新增主播 uid,例如 23682490" style="max-width:230px">
<button class="ghost" onclick="addUid()">添加(弹幕+录播)</button>
</div>
</div>
<div class="card">
<h2>主播列表(勾选 = 开启对应录制,修改立即生效)</h2>
<div class="hint" style="margin:0 0 8px">下载码率上限 = 挑选码率不超过该值的直播源档位(2000 约超清档),0 = 最高档;正在录制时修改会自动换档重新开始</div>
<div id="st"></div>
</div>
<div class="card">
<h2>文件管理</h2>
<div class="hint" id="diskInfo" style="margin:0 0 8px">点击下面的按钮读取文件和磁盘信息</div>
<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center">
<button class="ghost" onclick="loadFiles(this)">刷新文件列表</button>
<span class="hint" style="margin:0" id="filesInfo"></span>
</div>
<div id="files" style="margin-top:6px"></div>
</div>
<div class="card">
<h2>手动合并</h2>
<div class="hint" style="margin:0 0 8px">自动合并只在录制自然结束时触发(手动停止过、弹幕和视频对不上号、或者把文件挪过位置的都不会自动合并),这些可以在这里自己挑文件合成。视频一行一个,填多个会按文件名里的录制时间顺序拼成一个再合成。合并进度见下方运行日志。</div>
<div class="grid">
<div class="f">
<label>视频文件(一行一个,也可填绝对路径)</label>
<textarea id="mrgVideos" rows="3" spellcheck="false" placeholder="2026-09-25 01-44-57 Xsphere 电脑自己画画.mp4"></textarea>
</div>
<div class="f">
<label>弹幕文件(.jsonl 直接用,.ass 会先还原成 .jsonl)</label>
<input type="text" id="mrgDanmu" list="mrgDanmuList" autocomplete="off" spellcheck="false" placeholder="2026-09-25 01-44-57 Xsphere 电脑自己画画.jsonl">
<datalist id="mrgDanmuList"></datalist>
</div>
</div>
<div class="grid" style="margin-top:10px">
<div class="f">
<label>从文件列表选视频(可多选,按 Ctrl 点选)</label>
<select id="mrgVideoPick" multiple size="4" onchange="pickVideos()"></select>
</div>
<div class="f">
<label>偏移(秒,留空 = 自动推算)</label>
<input type="text" id="mrgOffset" inputmode="decimal" autocomplete="off" placeholder="留空自动">
<label style="margin-top:8px">输出文件名(留空 = 自动命名,不覆盖已有成片)</label>
<input type="text" id="mrgOut" autocomplete="off" placeholder="留空自动">
</div>
</div>
<div style="margin-top:8px;display:flex;flex-wrap:wrap;gap:8px;align-items:center">
<button class="ghost" id="mrgCheck" onclick="checkMerge()">检查</button>
<button id="mrgGo" onclick="startMerge()">开始合并</button>
<span class="hint" style="margin:0" id="mrgState"></span>
</div>
<div class="hint" id="mrgInfo"></div>
</div>
<div class="card">
<h2>运行日志</h2>
<pre id="log"></pre>
</div>
</div>
<div class="mask" id="auth">
<div class="box">
<h3>请输入管理口令</h3>
<p>管理口令在程序首次启动时自动生成并打印在运行日志里,也可以在程序所在目录的 config.json 的 authToken 字段查看。</p>
<input type="text" id="tokenInput" autocomplete="off" placeholder="管理口令">
<div style="margin-top:10px"><button onclick="login()">进入</button></div>
<div id="authMsg"></div>
</div>
</div>
<script>
var esc = function(s){ return (s||"").replace(/[&<>"]/g, function(c){ return {"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;"}[c]; }); };
var saving = false; // 正在保存设置时暂停定时刷新对表单的覆写
var stSig = ""; // 主播列表的数据摘要,没变化就不重建 DOM
var logOffset = "0"; // 已经读取到的日志偏移量
var token = localStorage.getItem("acfunlive_token") || ""; // 管理口令,存在浏览器本地
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 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;
opt.headers = h;
return fetch(url, opt);
}
function showLogin(text){
authed = false;
document.getElementById("auth").classList.add("on");
document.getElementById("authMsg").textContent = text || "";
var ti = document.getElementById("tokenInput");
if(!ti.value) ti.value = token;
}
function hideLogin(){
authed = true;
document.getElementById("auth").classList.remove("on");
document.getElementById("authMsg").textContent = "";
}
async function login(){
var m = document.getElementById("authMsg");
var t = document.getElementById("tokenInput").value.trim();
if(!t){ m.textContent = "请输入管理口令"; return; }
token = t;
authed = true;
var r = null;
try{ r = await api("/api/auth"); }catch(e){}
if(r && r.ok){
localStorage.setItem("acfunlive_token", token);
hideLogin();
tokenLoaded = false;
refresh();
}else{
authed = false;
m.textContent = r ? "管理口令不正确" : "无法连接到服务器";
}
}
function copyToken(){
var el = document.getElementById("authTokenView");
if(!el.value) return;
el.removeAttribute("readonly");
el.select();
el.setSelectionRange(0, 999);
var ok = false;
try{ ok = document.execCommand("copy"); }catch(e){}
el.setAttribute("readonly", "readonly");
// 局域网 http 下 navigator.clipboard 不可用,上面的 execCommand 才是主要手段
if(window.isSecureContext && navigator.clipboard){
try{ navigator.clipboard.writeText(el.value); ok = true; }catch(e){}
}
showMsg(ok, ok ? "已复制管理口令" : "自动复制失败,请手动选中复制");
}
async function changeToken(){
var t = document.getElementById("authTokenNew").value.trim();
if(t.length < 8){ showMsg(false, "管理口令至少 8 个字符"); return; }
var r = null;
try{
r = await api("/api/auth/token", {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify({token: t})});
}catch(e){}
if(!r || !r.ok){ showMsg(false, r ? await r.text() : "连接失败"); return; }
token = t;
localStorage.setItem("acfunlive_token", token);
document.getElementById("authTokenView").value = t;
document.getElementById("authTokenNew").value = "";
hideLogin();
showMsg(true, "管理口令已更换,本机已自动更新");
refresh();
}
async function refresh(){
if(!authed) return;
try{
var r = await api("/api/status");
if(r.status === 401){ showLogin("管理口令已失效,请重新输入"); return; }
if(!r.ok) return;
var st = await r.json();
hideLogin();
// 口令只在登录后拉取一次,填进设置区的只读框
if(!tokenLoaded){
tokenLoaded = true;
api("/api/auth/token").then(function(x){ return x.ok ? x.json() : null; }).then(function(d){
if(d && d.token) document.getElementById("authTokenView").value = d.token;
}).catch(function(){});
// 顺便读一次文件列表,不用等用户点刷新
loadFiles();
}
setVal("directory", st.directory||"");
setVal("mergeOutputDir", st.mergeOutputDir||"");
setVal("mergeBitrate", st.mergeBitrate||0);
setVal("defaultBitrate", st.defaultBitrate||0);
setVal("recordSubdir", st.recordSubdir||"record");
setVal("mergeSubdir", st.mergeSubdir||"merged");
setVal("ffmpegPath", st.ffmpegPath||"");
setVal("recordFormat", st.recordFormat||"mp4");
setVal("notifyUrl", st.notifyUrl||"");
setVal("notifyToken", st.notifyToken||"");
setVal("notifyGroup", st.notifyGroup||"");
setChk("notifyEnabled", st.notifyEnabled);
setChk("notifyLiveOn", st.notifyLiveOn);
setChk("notifyLiveOff", st.notifyLiveOff);
setChk("notifyRecordStart", st.notifyRecordStart);
setChk("notifyMergeDone", st.notifyMergeDone);
setChk("meowEnabled", st.meowEnabled);
setVal("meowNickname", st.meowNickname||"");
setVal("meowMsgType", st.meowMsgType||"text");
setVal("meowHtmlHeight", st.meowHtmlHeight||200);
setVal("meowImgUrl", st.meowImgUrl||"");
toggleMeowHeight();
var mb = document.getElementById("meowBlocked");
if(st.meowBlocked){ mb.style.display = ""; mb.textContent = "当前推不出去:" + st.meowBlocked; }
else mb.style.display = "none";
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 badges = "";
if(s.recordActive) badges += '<span class="badge on">录播中</span>';
if(s.danmuActive) badges += '<span class="badge rec">弹幕中</span>';
if(!badges) badges = s.live ? '<span class="badge on">直播中</span>' : '<span class="off">离线</span>';
var title = esc(s.title);
return '<div class="row">'
+ '<span class="uid">uid ' + s.uid + '</span>'
+ '<span class="name">' + (esc(s.name) || "uid " + s.uid) + '</span>'
+ '<label class="chk"><input type="checkbox" data-uid="' + s.uid + '" data-k="danmu"' + (s.danmu?" checked":"") + '>弹幕</label>'
+ '<label class="chk"><input type="checkbox" data-uid="' + s.uid + '" data-k="record"' + (s.record?" checked":"") + '>录播</label>'
+ '<input type="text" class="br" inputmode="numeric" data-uid="' + s.uid + '" value="' + (s.bitrate||0) + '" placeholder="0=最高" title="下载码率上限(Kbps),0 = 最高档">'
+ '<span class="state">' + badges + '</span>'
+ '<span class="title"' + (title?' title="' + title + '"':'') + '>' + title + '</span>'
+ '<span class="ops">'
+ '<button class="ghost" onclick="ctl(' + s.uid + ',\'stop\')">停止</button>'
+ '<button class="ghost" onclick="ctl(' + s.uid + ',\'restart\')">重新开始</button>'
+ '<button class="ghost" onclick="delUid(' + s.uid + ')">删除</button></span></div>';
}).join("");
var list = document.getElementById("st");
var sig = JSON.stringify(st.streamers);
// 数据没变化不重建;正在列表里输入时也跳过,避免把没提交的输入清掉
if(sig !== stSig && !list.contains(document.activeElement)){
stSig = sig;
list.innerHTML = rows || '<div class="off">列表为空,请在上方添加 uid</div>';
}
var lr = await api("/api/log?since=" + logOffset);
if(lr.status === 401){ showLogin("管理口令已失效,请重新输入"); return; }
var off = lr.headers.get("X-Log-Offset");
var chunk = await lr.text();
if(off) logOffset = off;
if(chunk){
var p = document.getElementById("log");
var stick = p.scrollHeight - p.scrollTop - p.clientHeight < 40;
p.textContent += chunk;
// 页面里保留的日志太长会拖慢浏览器,只留末尾一段
if(p.textContent.length > 300000) p.textContent = p.textContent.slice(-150000);
if(stick) p.scrollTop = p.scrollHeight;
}
}catch(e){}
}
function showMsg(ok, text){
var m = document.getElementById("msg");
m.textContent = text; m.style.color = ok ? "#1d7a46" : "#d63046";
setTimeout(function(){ m.textContent = ""; }, 3000);
}
async function post(url, body){
saving = true;
try{
var r = await api(url, {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify(body)});
if(r.status === 401){ showLogin("管理口令已失效,请重新输入"); return false; }
if(!r.ok) showMsg(false, await r.text());
return r.ok;
}finally{
saving = false;
refresh();
// 保存目录、子文件名可能刚被改过,顺手刷新一遍文件列表
loadFiles();
}
}
async function saveSettings(b){
if(b) b.disabled = true;
var ok = await post("/api/config", {
directory: document.getElementById("directory").value.trim(),
autoMerge: document.getElementById("autoMerge").checked,
mergeDelVideo: document.getElementById("mergeDelVideo").checked,
mergeDelDanmu: document.getElementById("mergeDelDanmu").checked,
mergeOutputDir: document.getElementById("mergeOutputDir").value.trim(),
mergeBitrate: parseInt(document.getElementById("mergeBitrate").value, 10) || 0,
defaultBitrate: parseInt(document.getElementById("defaultBitrate").value, 10) || 0,
recordSubdir: document.getElementById("recordSubdir").value.trim(),
mergeSubdir: document.getElementById("mergeSubdir").value.trim(),
ffmpegPath: document.getElementById("ffmpegPath").value.trim(),
recordFormat: document.getElementById("recordFormat").value,
notifyEnabled: document.getElementById("notifyEnabled").checked,
notifyUrl: document.getElementById("notifyUrl").value.trim(),
notifyToken: document.getElementById("notifyToken").value.trim(),
notifyGroup: document.getElementById("notifyGroup").value.trim(),
notifyLiveOn: document.getElementById("notifyLiveOn").checked,
notifyLiveOff: document.getElementById("notifyLiveOff").checked,
notifyRecordStart: document.getElementById("notifyRecordStart").checked,
notifyMergeDone: document.getElementById("notifyMergeDone").checked,
meowEnabled: document.getElementById("meowEnabled").checked,
meowNickname: document.getElementById("meowNickname").value.trim(),
meowMsgType: document.getElementById("meowMsgType").value,
meowHtmlHeight: parseInt(document.getElementById("meowHtmlHeight").value, 10) || 0,
meowImgUrl: document.getElementById("meowImgUrl").value.trim(),
webExternal: document.getElementById("webExternal").checked
});
if(ok) showMsg(true, "已保存");
if(b) b.disabled = false;
}
// 勾选类设置修改后立即保存,避免被定时刷新还原
["autoMerge","mergeDelVideo","mergeDelDanmu","webExternal","notifyEnabled","notifyLiveOn","notifyLiveOff","notifyRecordStart","notifyMergeDone","meowEnabled"].forEach(function(id){
document.getElementById(id).addEventListener("change", function(){ saveSettings(null); });
});
// 下拉框改完也立即保存
document.getElementById("recordFormat").addEventListener("change", function(){ saveSettings(null); });
// HTML 高度只对 html 类型有意义,选 text 时收起来
function toggleMeowHeight(){
var isHTML = document.getElementById("meowMsgType").value === "html";
document.getElementById("meowHeightRow").style.display = isHTML ? "" : "none";
}
document.getElementById("meowMsgType").addEventListener("change", function(){ toggleMeowHeight(); saveSettings(null); });
var fileList = [];
function fmtSize(n){
if(!n) return "0 B";
if(n >= 1073741824) return (n/1073741824).toFixed(2) + " GB";
if(n >= 1048576) return (n/1048576).toFixed(1) + " MB";
if(n >= 1024) return (n/1024).toFixed(0) + " KB";
return n + " B";
}
function fmtTime(ts){
var d = new Date(ts*1000);
function p(x){ return (x<10?"0":"")+x; }
return d.getFullYear()+"-"+p(d.getMonth()+1)+"-"+p(d.getDate())+" "+p(d.getHours())+":"+p(d.getMinutes());
}
function fileUrl(f){
return "/api/file?dir=" + encodeURIComponent(f.dir) + "&name=" + encodeURIComponent(f.name) + "&token=" + encodeURIComponent(token);
}
async function loadFiles(b){
if(b) b.disabled = true;
var r = null;
try{ r = await api("/api/files"); }catch(e){}
if(b) b.disabled = false;
if(!r){ showMsg(false, "读取文件列表失败"); return; }
if(r.status === 401){ showLogin("管理口令已失效,请重新输入"); return; }
if(!r.ok){ showMsg(false, await r.text()); return; }
var d = await r.json();
fileList = d.files || [];
fillMergePickers();
document.getElementById("diskInfo").textContent =
"磁盘剩余 " + fmtSize(d.free) + " / 共 " + fmtSize(d.total) +
" 录播:" + d.recordDir + " 成片:" + d.mergeDir;
var used = 0;
fileList.forEach(function(f){ used += f.size; });
document.getElementById("filesInfo").textContent =
fileList.length ? ("共 " + fileList.length + " 个文件,占用 " + fmtSize(used)) : "没有文件";
document.getElementById("files").innerHTML = fileList.length ? fileList.map(function(f, i){
var canPlay = /\.(mp4|m4v|webm|ogv)$/i.test(f.name);
var u = esc(fileUrl(f));
return '<div class="row">'
+ '<span class="name" title="' + esc(f.name) + '">' + esc(f.name) + '</span>'
+ '<span class="uid">' + (f.dir === "merge" ? "成片" : "录播") + '</span>'
+ '<span class="uid">' + fmtSize(f.size) + '</span>'
+ '<span class="uid">' + fmtTime(f.modTime) + '</span>'
+ (f.inUse ? ' <span class="badge on">写入中</span>' : '')
+ '<span class="ops">'
+ (canPlay ? '<a class="ghost" href="' + u + '&inline=1" target="_blank" rel="noopener">播放</a>' : '')
+ '<a class="ghost" href="' + u + '">下载</a>'
+ (f.inUse ? '' : '<button class="ghost" onclick="delFile(' + i + ')">删除</button>')
+ '</span></div>';
}).join("") : '<div class="off">没有文件</div>';
}
async function delFile(i){
var f = fileList[i];
if(!f) return;
if(!confirm("确定删除 " + f.name + " ?删除后无法恢复。")) return;
var r = null;
try{
r = await api("/api/file/remove", {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify({dir: f.dir, name: f.name})});
}catch(e){}
if(r && r.status === 401){ showLogin("管理口令已失效,请重新输入"); return; }
showMsg(!!(r && r.ok), (r && r.ok) ? "已删除" : ("删除失败:" + (r ? await r.text() : "连接失败")));
loadFiles();
}
// ========== 手动合并 ==========
var mrgBusy = false; // 是否正在合并,跟着 /api/status 一起刷新
function mrgPayload(){
var videos = document.getElementById("mrgVideos").value.split("\n").map(function(s){ return s.trim(); }).filter(function(s){ return s; });
var off = document.getElementById("mrgOffset").value.trim();
var body = {
videos: videos,
danmu: document.getElementById("mrgDanmu").value.trim(),
outName: document.getElementById("mrgOut").value.trim()
};
if(off !== ""){
var v = parseFloat(off);
if(isNaN(v)){ showMsg(false, "偏移必须是数字"); return null; }
body.offset = v;
}
return body;
}
// 用文件列表里的文件填下拉框和输入建议
function fillMergePickers(){
var vids = [], dms = [];
fileList.forEach(function(f){
if(/\.(mp4|m4v|flv|ts|mkv|mov|webm)$/i.test(f.name)) vids.push(f);
else if(/\.(jsonl|ass)$/i.test(f.name)) dms.push(f);
});
// 文件名以录制时间开头,升序就是录制顺序
vids.sort(function(a,b){ return a.name < b.name ? -1 : (a.name > b.name ? 1 : 0); });
dms.sort(function(a,b){ return a.name < b.name ? -1 : (a.name > b.name ? 1 : 0); });
var sel = document.getElementById("mrgVideoPick");
var keep = {};
Array.prototype.forEach.call(sel.options, function(o){ if(o.selected) keep[o.value] = 1; });
sel.innerHTML = vids.map(function(f){
return '<option value="' + esc(f.name) + '">' + esc(f.name) + ' ' + fmtSize(f.size) + '</option>';
}).join("");
// 刷新列表时不要把已经选好的分段弄丢
Array.prototype.forEach.call(sel.options, function(o){ if(keep[o.value]) o.selected = true; });
document.getElementById("mrgDanmuList").innerHTML = dms.map(function(f){
return '<option value="' + esc(f.name) + '"></option>';
}).join("");
}
function pickVideos(){
var names = [];
Array.prototype.forEach.call(document.getElementById("mrgVideoPick").options, function(o){ if(o.selected) names.push(o.value); });
names.sort(); // 按文件名里的录制时间排,点选顺序无所谓
if(names.length) document.getElementById("mrgVideos").value = names.join("\n");
}
function setMergeState(){
var go = document.getElementById("mrgGo");
go.disabled = mrgBusy;
go.textContent = mrgBusy ? "合并中…" : "开始合并";
document.getElementById("mrgCheck").disabled = mrgBusy;
document.getElementById("mrgState").textContent = mrgBusy ? "正在合并,进度见下方运行日志" : "";
}
async function checkMerge(){
var body = mrgPayload();
if(!body) return;
if(!body.videos.length || !body.danmu){ showMsg(false, "请先填写视频和弹幕文件"); return; }
var b = document.getElementById("mrgCheck");
b.disabled = true;
var r = null;
try{ r = await api("/api/merge/info", {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify(body)}); }catch(e){}
b.disabled = mrgBusy;
if(r && r.status === 401){ showLogin("管理口令已失效,请重新输入"); return; }
if(!r || !r.ok){ showMsg(false, r ? await r.text() : "连接失败"); return; }
var d = await r.json();
var lines = [];
(d.videos||[]).forEach(function(v){
lines.push("视频 " + v.name + " " + fmtSize(v.size) + " 时长 " + v.duration.toFixed(1) + " 秒" + (v.inUse ? "(正在写入)" : ""));
});
lines.push("视频总时长 " + d.duration.toFixed(1) + " 秒");
lines.push("弹幕 " + d.danmuName + " " + d.danmuCount + " 条 时间 " + d.danmuFirst.toFixed(1) + " ~ " + d.danmuLast.toFixed(1) + " 秒(" + d.source + ")");
lines.push("偏移 " + d.offset.toFixed(3) + " 秒(" + d.offsetNote + ")");
lines.push("输出 " + d.outName);
(d.warnings||[]).forEach(function(w){ lines.push("注意:" + w); });
document.getElementById("mrgInfo").textContent = lines.join("\n");
}
async function startMerge(){
var body = mrgPayload();
if(!body) return;
if(!body.videos.length || !body.danmu){ showMsg(false, "请先填写视频和弹幕文件"); return; }
if(!confirm("开始合并?合成期间可以在下方运行日志里看进度。")) return;
var b = document.getElementById("mrgGo");
b.disabled = true;
var r = null;
try{ r = await api("/api/merge", {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify(body)}); }catch(e){}
if(r && r.status === 401){ showLogin("管理口令已失效,请重新输入"); b.disabled = false; return; }
if(!r || !r.ok){
showMsg(false, r ? await r.text() : "连接失败");
b.disabled = false;
return;
}
var d = await r.json();
mrgBusy = true;
setMergeState();
showMsg(true, "已开始合并:" + d.outName);
}
// 合并状态跟着 /api/status 一起刷新,只在「进行中 → 结束」这一刻提示一次
function syncMergeState(st){
var busy = !!st.mergeBusy;
if(busy === mrgBusy) return;
mrgBusy = busy;
setMergeState();
if(busy) return;
loadFiles();
if(st.mergeErr) showMsg(false, "合并失败:" + st.mergeErr);
else showMsg(true, "手动合并结束,结果见运行日志");
}
async function testNotify(b, channel){
if(b) b.disabled = true;
var body = {channel: channel || "qq"};
if(body.channel === "meow"){
body.nickname = document.getElementById("meowNickname").value.trim();
body.msgType = document.getElementById("meowMsgType").value;
body.imgUrl = document.getElementById("meowImgUrl").value.trim();
body.htmlHeight = parseInt(document.getElementById("meowHtmlHeight").value, 10) || 0;
}else{
body.url = document.getElementById("notifyUrl").value.trim();
body.token = document.getElementById("notifyToken").value.trim();
body.group = document.getElementById("notifyGroup").value.trim();
}
var r = null, d = null;
try{
r = await api("/api/notify/test", {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify(body)});
if(r && r.ok) d = await r.json();
}catch(e){}
if(r && r.status === 401){ showLogin("管理口令已失效,请重新输入"); if(b) b.disabled = false; return; }
if(r && r.ok) showMsg(true, "测试成功:" + ((d && d.detail) ? d.detail : "推送已发出"));
else showMsg(false, "测试失败:" + (r ? await r.text() : "连接失败"));
if(b) b.disabled = false;
}
async function addUid(){
var uid = parseInt(document.getElementById("newuid").value.trim(), 10);
if(!(uid > 0)){ showMsg(false, "uid 不合法"); return; }
if(await post("/api/streamer", {uid: uid, danmu: true, record: true})) document.getElementById("newuid").value = "";
}
async function ctl(uid, action){
var name = action === "stop" ? "停止" : "重新开始";
if(action === "stop" && !confirm("确定停止 uid " + uid + " 当前的录制?停止后需点重新开始才会继续录。")) return;
await post("/api/streamer/control", {uid: uid, target: "all", action: action});
showMsg(true, "已" + name + "录制");
}
async function delUid(uid){
if(!confirm("确定删除 uid " + uid + " 并停止其录制?")) return;
post("/api/streamer/remove", {uid: uid});
}
document.addEventListener("change", function(e){
var t = e.target;
if(t.matches('input[type=checkbox][data-uid]')){
var uid = parseInt(t.dataset.uid, 10);
var row = t.closest(".row");
var danmu = row.querySelector('input[data-k=danmu]').checked;
var record = row.querySelector('input[data-k=record]').checked;
var bitrate = parseInt(row.querySelector('input.br').value, 10) || 0;
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 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});
}
});
// 先试一次(用本地保存的口令),失败会自动弹出输入框并停止轮询
authed = true;
refresh();
setInterval(refresh, 3000);
document.getElementById("tokenInput").addEventListener("keydown", function(e){ if(e.key === "Enter") login(); });
</script>
</body>
</html>`
// web UI 的主播状态
type webStreamer struct {
UID int `json:"uid"`
Name string `json:"name"`
Live bool `json:"live"`
Danmu bool `json:"danmu"` // 弹幕录制开关
Record bool `json:"record"` // 视频录制开关
Bitrate int `json:"bitrate"` // 录制的直播源最高码率(Kbps),0 表示码率最高的直播源
DanmuActive bool `json:"danmuActive"` // 是否正在录制弹幕
RecordActive bool `json:"recordActive"` // 是否正在录制视频
Title string `json:"title"`
URL string `json:"url"`
}
// web UI 的状态数据
type webStatus struct {
UIDs []uidSetting `json:"uids"`
Directory string `json:"directory"`
AutoMerge bool `json:"autoMerge"`
MergeDelVideo bool `json:"mergeDelVideo"`
MergeDelDanmu bool `json:"mergeDelDanmu"`
MergeOutputDir string `json:"mergeOutputDir"`
MergeBitrate int `json:"mergeBitrate"`
DefaultBitrate int `json:"defaultBitrate"`
WebExternal bool `json:"webExternal"`
RecordSubdir string `json:"recordSubdir"`
MergeSubdir string `json:"mergeSubdir"`
FFmpegPath string `json:"ffmpegPath"`
RecordFormat string `json:"recordFormat"`
NotifyEnabled bool `json:"notifyEnabled"`
NotifyURL string `json:"notifyUrl"`
NotifyToken string `json:"notifyToken"`
NotifyGroup string `json:"notifyGroup"`
NotifyLiveOn bool `json:"notifyLiveOn"`
NotifyLiveOff bool `json:"notifyLiveOff"`
NotifyRecordStart bool `json:"notifyRecordStart"`
NotifyMergeDone bool `json:"notifyMergeDone"`
MeowEnabled bool `json:"meowEnabled"`
MeowNickname string `json:"meowNickname"`
MeowMsgType string `json:"meowMsgType"`
MeowHTMLHeight int `json:"meowHtmlHeight"`
MeowImgURL string `json:"meowImgUrl"`
MeowBlocked string `json:"meowBlocked"` // MeoW 推不出去的原因,空字符串表示正常
MergeBusy bool `json:"mergeBusy"` // 手动合并是否正在进行
MergeOutName string `json:"mergeOutName"` // 正在进行的手动合并的输出文件名
MergeErr string `json:"mergeErr"` // 最近一次手动合并的错误
MergeDoneAt int64 `json:"mergeDoneAt"` // 最近一次手动合并结束的时刻
Streamers []webStreamer `json:"streamers"`
}
// 打印日志到 web UI
func lPrintln(msg ...any) {
logger.Println(msg...)
logString.Lock()
defer logString.Unlock()
// 防止内存泄漏
if logString.str.Len() > 1000000 {
logString.str.Reset()
}
n, _ := fmt.Fprint(&logString.str, getLogTime()+" ")
n2, _ := fmt.Fprintln(&logString.str, msg...)
logString.total += int64(n + n2)
}
// 显示 web UI 页面
func webuiHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprint(w, webuiHTML)
}
// 显示状态
func statusHandler(w http.ResponseWriter, r *http.Request) {
// 手动合并的状态在另一把锁上,先取了再用,不要在持 streamers 锁时去拿
mergeBusy, mergeOut, mergeErr, mergeDoneAt := mergeRunSnapshot()
streamers.RLock()
status := webStatus{
UIDs: append(uidList{}, config.UIDs...),
Directory: config.Directory,
AutoMerge: config.AutoMerge,
MergeDelVideo: config.MergeDelVideo,
MergeDelDanmu: config.MergeDelDanmu,
MergeOutputDir: config.MergeOutputDir,
MergeBitrate: config.MergeBitrate,
WebExternal: config.WebExternal,
RecordSubdir: config.RecordSubdir,
MergeSubdir: config.MergeSubdir,
FFmpegPath: config.FFmpegPath,
RecordFormat: normalizeRecordFormat(config.RecordFormat),
DefaultBitrate: config.DefaultBitrate,
NotifyEnabled: config.NotifyEnabled,
NotifyURL: config.NotifyURL,
NotifyToken: config.NotifyToken,
NotifyGroup: config.NotifyGroup,
NotifyLiveOn: config.NotifyLiveOn,
NotifyLiveOff: config.NotifyLiveOff,
NotifyRecordStart: config.NotifyRecordStart,
NotifyMergeDone: config.NotifyMergeDone,
MeowEnabled: config.MeowEnabled,
MeowNickname: config.MeowNickname,
MeowMsgType: normalizeMeowMsgType(config.MeowMsgType),
MeowHTMLHeight: clampMeowHTMLHeight(config.MeowHTMLHeight),
MeowImgURL: config.MeowImgURL,
MeowBlocked: meowStatusProblem(),
MergeBusy: mergeBusy,
MergeOutName: mergeOut,
MergeErr: mergeErr,
MergeDoneAt: mergeDoneAt,
Streamers: make([]webStreamer, 0, len(streamers.crt)),
}
for _, s := range streamers.crt {
live := s.isLiveOn()
var title string
if live {
title = s.getTitle()
}
ws := webStreamer{UID: s.UID, Name: s.Name, Live: live, Title: title, URL: s.getURL(), Danmu: s.Danmu, Record: s.Record, Bitrate: s.Bitrate}
if infoList, ok := getLiveInfoByUID(s.UID); ok {
for _, info := range infoList {
if info.isDanmu {
ws.DanmuActive = true
}
if info.isRecording {
ws.RecordActive = true
}
}
}
status.Streamers = append(status.Streamers, ws)
}
streamers.RUnlock()
sort.Slice(status.Streamers, func(i, j int) bool {
return status.Streamers[i].UID < status.Streamers[j].UID
})
w.Header().Set("Content-Type", "application/json")
err := json.NewEncoder(w).Encode(status)
checkErr(err)
}
// 检查配置里的文件夹字段,返回错误信息,空字符串表示通过
func checkWebDir(path, name string) string {
if path == "" {
return ""
}
info, err := os.Stat(path)
if err != nil || !info.IsDir() {
return name + " 必须是存在的文件夹:" + path
}
return ""
}
// 修改设置(保存目录和自动合并相关)
func webConfigHandler(w http.ResponseWriter, r *http.Request) {
var req struct {
Directory string `json:"directory"`
AutoMerge bool `json:"autoMerge"`
MergeDelVideo bool `json:"mergeDelVideo"`
MergeDelDanmu bool `json:"mergeDelDanmu"`
MergeOutputDir string `json:"mergeOutputDir"`
MergeBitrate int `json:"mergeBitrate"`
DefaultBitrate int `json:"defaultBitrate"`
WebExternal bool `json:"webExternal"`
RecordSubdir string `json:"recordSubdir"`
MergeSubdir string `json:"mergeSubdir"`
FFmpegPath string `json:"ffmpegPath"`
RecordFormat string `json:"recordFormat"`
NotifyEnabled bool `json:"notifyEnabled"`
NotifyURL string `json:"notifyUrl"`
NotifyToken string `json:"notifyToken"`
NotifyGroup string `json:"notifyGroup"`
NotifyLiveOn bool `json:"notifyLiveOn"`
NotifyLiveOff bool `json:"notifyLiveOff"`
NotifyRecordStart bool `json:"notifyRecordStart"`
NotifyMergeDone bool `json:"notifyMergeDone"`
MeowEnabled bool `json:"meowEnabled"`
MeowNickname string `json:"meowNickname"`
MeowMsgType string `json:"meowMsgType"`
MeowHTMLHeight int `json:"meowHtmlHeight"`
MeowImgURL string `json:"meowImgUrl"`
}
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest)
return
}
if msg := checkWebDir(req.Directory, "directory"); msg != "" {
http.Error(w, msg, http.StatusBadRequest)
return
}
if msg := checkWebDir(req.MergeOutputDir, "mergeOutputDir"); msg != "" {
http.Error(w, msg, http.StatusBadRequest)
return
}
if req.MergeBitrate < 0 {
http.Error(w, "mergeBitrate 不能小于 0", http.StatusBadRequest)
return
}
if req.DefaultBitrate < 0 {
http.Error(w, "defaultBitrate 不能小于 0", http.StatusBadRequest)
return
}
recordSubdir := strings.TrimSpace(req.RecordSubdir)
mergeSubdir := strings.TrimSpace(req.MergeSubdir)
if recordSubdir == "" {
recordSubdir = defaultRecordSubdir
}
if mergeSubdir == "" {
mergeSubdir = defaultMergeSubdir
}
if isBadSubdir(recordSubdir) {
http.Error(w, "录播子文件夹名不能包含路径分隔符或非法字符:"+recordSubdir, http.StatusBadRequest)
return
}
if isBadSubdir(mergeSubdir) {
http.Error(w, "成片子文件夹名不能包含路径分隔符或非法字符:"+mergeSubdir, http.StatusBadRequest)
return
}
if recordSubdir == mergeSubdir && req.MergeOutputDir == "" {
http.Error(w, "录播子文件夹和成片子文件夹不能相同", http.StatusBadRequest)
return
}
if msg := checkFFmpegPath(req.FFmpegPath); msg != "" {
http.Error(w, msg, http.StatusBadRequest)
return
}
recordFormat := strings.ToLower(strings.TrimSpace(req.RecordFormat))
if recordFormat == "" {
recordFormat = defaultRecordFormat
}
if _, ok := recordFormats[recordFormat]; !ok {
http.Error(w, "录播格式只能是 mp4、flv、ts 或 mkv:"+recordFormat, http.StatusBadRequest)
return
}
notifyURL := strings.TrimSpace(req.NotifyURL)
if msg := checkNotifyURL(notifyURL); msg != "" {
http.Error(w, msg, http.StatusBadRequest)
return
}
meowNickname := strings.TrimSpace(req.MeowNickname)
if msg := checkMeowNickname(meowNickname, true); msg != "" {
http.Error(w, msg, http.StatusBadRequest)
return
}
meowMsgType := strings.ToLower(strings.TrimSpace(req.MeowMsgType))
if meowMsgType == "" {
meowMsgType = meowMsgTypeText
}
if meowMsgType != meowMsgTypeText && meowMsgType != meowMsgTypeHTML {
http.Error(w, "MeoW 消息类型只能是 text 或 html:"+meowMsgType, http.StatusBadRequest)
return
}
if req.MeowHTMLHeight < 0 || req.MeowHTMLHeight > maxMeowHTMLHeight {
http.Error(w, fmt.Sprintf("MeoW 的 HTML 显示高度只能是 0(用默认 %d)到 %d 之间的整数",
defaultMeowHTMLHeight, maxMeowHTMLHeight), http.StatusBadRequest)
return
}
meowImgURL := strings.TrimSpace(req.MeowImgURL)
streamers.Lock()
config.Directory = req.Directory
config.AutoMerge = req.AutoMerge
config.MergeDelVideo = req.MergeDelVideo
config.MergeDelDanmu = req.MergeDelDanmu
config.MergeOutputDir = req.MergeOutputDir
config.MergeBitrate = req.MergeBitrate
config.DefaultBitrate = req.DefaultBitrate
restartWeb := config.WebExternal != req.WebExternal
config.WebExternal = req.WebExternal
config.RecordSubdir = recordSubdir
config.MergeSubdir = mergeSubdir
config.FFmpegPath = strings.TrimSpace(req.FFmpegPath)
config.RecordFormat = recordFormat
config.NotifyEnabled = req.NotifyEnabled
config.NotifyURL = notifyURL
config.NotifyToken = strings.TrimSpace(req.NotifyToken)
config.NotifyGroup = strings.TrimSpace(req.NotifyGroup)
config.NotifyLiveOn = req.NotifyLiveOn
config.NotifyLiveOff = req.NotifyLiveOff
config.NotifyRecordStart = req.NotifyRecordStart
config.NotifyMergeDone = req.NotifyMergeDone
config.MeowEnabled = req.MeowEnabled
config.MeowNickname = meowNickname
config.MeowMsgType = meowMsgType
config.MeowHTMLHeight = clampMeowHTMLHeight(req.MeowHTMLHeight)
config.MeowImgURL = meowImgURL
// 修改了子文件夹名后确保新文件夹存在
if err := os.MkdirAll(recordingDir(), 0755); err != nil {
lPrintWarnf("创建文件夹 %s 失败:%v", recordingDir(), err)
}
if err := os.MkdirAll(recordSaveDir(), 0755); err != nil {
lPrintWarnf("创建文件夹 %s 失败:%v", recordSaveDir(), err)
}
if err := os.MkdirAll(mergeSaveDir(), 0755); err != nil {
lPrintWarnf("创建文件夹 %s 失败:%v", mergeSaveDir(), err)
}
streamers.Unlock()
saveConfig()
if restartWeb {
lPrintln("外网访问设置已改变,重启 web UI 服务器")
restartWebUI()
}
lPrintf("通过 web UI 修改了设置:目录 %q、自动合并 %v、合并删视频 %v、合并删弹幕 %v、成片目录 %q、合并码率 %d、默认下载码率 %d、外网访问 %v、录播子文件夹 %q、成片子文件夹 %q、ffmpeg 路径 %q、录播格式 %q",
req.Directory, req.AutoMerge, req.MergeDelVideo, req.MergeDelDanmu, req.MergeOutputDir,
req.MergeBitrate, req.DefaultBitrate, req.WebExternal, recordSubdir, mergeSubdir, config.FFmpegPath, recordFormat)
lPrintf("推送设置:QQ 机器人渠道 %v(地址 %q、群号 %q)、MeoW 渠道 %v(昵称 %q、类型 %s、高度 %d);事件 开播 %v、下播 %v、开始录制 %v、合并完成 %v",
req.NotifyEnabled, notifyURL, config.NotifyGroup, req.MeowEnabled, meowNickname, meowMsgType,
clampMeowHTMLHeight(req.MeowHTMLHeight),
req.NotifyLiveOn, req.NotifyLiveOff, req.NotifyRecordStart, req.NotifyMergeDone)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte("true"))
}
// 新增主播或修改主播的弹幕/录播开关和码率
func webStreamerHandler(w http.ResponseWriter, r *http.Request) {
var req struct {
UID int `json:"uid"`
Danmu bool `json:"danmu"`
Record bool `json:"record"`
Bitrate *int `json:"bitrate"` // 不传时使用默认下载码率上限
}
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest)
return
}
if req.UID <= 0 {
http.Error(w, "uid 必须大于 0", http.StatusBadRequest)
return
}
bitrate := config.DefaultBitrate
if req.Bitrate != nil {
bitrate = *req.Bitrate
}
if bitrate < 0 {
http.Error(w, "bitrate 不能小于 0", http.StatusBadRequest)
return
}
// 码率变化时先停掉正在进行的录播,setStreamerFlags 会用新码率重启
if cur, ok := getStreamer(req.UID); ok && cur.Bitrate != bitrate && cur.Record && req.Record {
stopRecord(req.UID)
}
if !setStreamerFlags(req.UID, req.Danmu, req.Record) {
http.Error(w, "不存在 uid 为"+itoa(req.UID)+"的用户", http.StatusBadRequest)
return
}
if cur, ok := getStreamer(req.UID); ok && cur.Bitrate != bitrate {
setBitrate(req.UID, bitrate)
}
lPrintf("通过 web UI 设置 uid %d:弹幕 %v、录播 %v、码率上限 %d", req.UID, req.Danmu, req.Record, bitrate)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte("true"))
}
// 停止或重新开始某个主播的录制
func webStreamerControlHandler(w http.ResponseWriter, r *http.Request) {
var req struct {
UID int `json:"uid"`
Target string `json:"target"` // danmu、record 或 all
Action string `json:"action"` // stop 或 restart
}
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest)
return
}
if req.UID <= 0 {
http.Error(w, "uid 必须大于 0", http.StatusBadRequest)
return
}
if err := controlStreamer(req.UID, req.Target, req.Action); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
actionName := "停止"
if req.Action == "restart" {
actionName = "重新开始"
}
lPrintf("通过 web UI %s uid %d 的录制(%s)", actionName, req.UID, req.Target)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte("true"))
}
// 删除主播
func webStreamerRemoveHandler(w http.ResponseWriter, r *http.Request) {
var req struct {
UID int `json:"uid"`
}
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest)
return
}
removeUID(req.UID)
lPrintf("通过 web UI 删除了 uid %d", req.UID)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte("true"))
}
// 发送一条测试通知,验证推送地址、群号和口令是否配置正确
// 测试某个推送渠道能不能连通。
// 页面上填的值优先,留空的项用已经保存的配置补齐,所以改完不用先保存也能测
func notifyTestHandler(w http.ResponseWriter, r *http.Request) {
var req struct {
Channel string `json:"channel"` // qq(默认)或 meow
URL string `json:"url"`
Token string `json:"token"`
Group string `json:"group"`
Nickname string `json:"nickname"`
MsgType string `json:"msgType"`
ImgURL string `json:"imgUrl"`
Height int `json:"htmlHeight"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
switch strings.ToLower(strings.TrimSpace(req.Channel)) {
case "", "qq", "webhook":
testNotifyQQ(w, req.URL, req.Token, req.Group)
case "meow":
testNotifyMeow(w, req.Nickname, req.MsgType, req.ImgURL, req.Height)
default:
http.Error(w, "未知的推送渠道:"+req.Channel, http.StatusBadRequest)
}
}
// 给 webhook 发一条测试推送
func testNotifyQQ(w http.ResponseWriter, addr, token, group string) {
n := currentNotifySetting()
if addr = strings.TrimSpace(addr); addr == "" {
addr = n.url
}
if token = strings.TrimSpace(token); token == "" {
token = n.token
}
if group = strings.TrimSpace(group); group == "" {
group = n.group
}
if addr == "" {
http.Error(w, "还没有填写通知推送地址", http.StatusBadRequest)
return
}
if msg := checkNotifyURL(addr); msg != "" {
http.Error(w, msg, http.StatusBadRequest)
return
}
p := notifyPayload{
Event: "test",
Name: "AcFun 直播录制",
Group: group,
Text: "这是一条测试通知,收到就说明推送地址和口令都配置正确",
Time: getLogTime(),
}
start := time.Now()
code, err := postNotify(addr, token, p)
if err != nil {
lPrintWarnf("推送测试通知失败:%v", err)
http.Error(w, "推送失败:"+err.Error(), http.StatusBadGateway)
return
}
lPrintln("测试通知推送成功:" + addr)
writeTestResult(w, fmt.Sprintf("webhook 返回 HTTP %d,用时 %s。地址和口令没问题,能不能收到还要看机器人那边有没有正常处理",
code, elapsedText(time.Since(start))))
}
// 给 MeoW 发一条测试推送
func testNotifyMeow(w http.ResponseWriter, nick, msgType, imgURL string, height int) {
n := currentNotifySetting()
if nick = strings.TrimSpace(nick); nick == "" {
nick = n.meowNick
}
if imgURL = strings.TrimSpace(imgURL); imgURL == "" {
imgURL = n.meowImg
}
if strings.TrimSpace(msgType) == "" {
msgType = n.meowMsgType
}
if height <= 0 {
height = n.meowHTML
}
if nick == "" {
http.Error(w, "还没有填写 MeoW 昵称", http.StatusBadRequest)
return
}
if msg := checkMeowNickname(nick, false); msg != "" {
http.Error(w, msg, http.StatusBadRequest)
return
}
// 已经被 MeoW 判过「未注册」的昵称不再重试:官方提示一天内累计 3 次输错昵称就封 IP
if reason := meowBadNickReason(nick); reason != "" {
http.Error(w, fmt.Sprintf("昵称 %s 之前已被 MeoW 判定为%s,为避免 IP 被封不再重试。"+
"确认昵称没填错(或换一个昵称重新保存)就能恢复,重启程序也会清掉这个记录", nick, reason),
http.StatusBadRequest)
return
}
msgType = normalizeMeowMsgType(msgType)
title := "测试推送"
plain := "这是一条测试通知,收到就说明 MeoW 渠道配置正确"
content := plain
if msgType == meowMsgTypeHTML {
content = meowHTMLText(title + "\n" + plain)
}
start := time.Now()
if err := pushMeow(nick, title, content, "", imgURL, msgType, height); err != nil {
lPrintWarnf("推送测试通知到 MeoW 失败:%v", err)
detail := "推送失败:" + err.Error()
if meowBadNickReason(nick) != "" {
detail += "。MeoW 一天内累计 3 次收到不存在的昵称就会封 IP,这里已经暂停向这个昵称推送"
}
http.Error(w, detail, http.StatusBadGateway)
return
}
lPrintf("测试通知推送成功(MeoW):%s", nick)
writeTestResult(w, fmt.Sprintf("MeoW 返回 status 200,用时 %s。手机上应该已经收到通知(昵称 %s)",
elapsedText(time.Since(start)), nick))
}
// 测试结果,detail 会显示在页面的提示里
func writeTestResult(w http.ResponseWriter, detail string) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "detail": detail})
}
func elapsedText(d time.Duration) string {
return fmt.Sprintf("%.2f 秒", d.Seconds())
}
// 显示日志,支持 ?since=<偏移量> 只返回新增的部分,避免每 3 秒全量传输
func logHandler(w http.ResponseWriter, r *http.Request) {
since, err := strconv.ParseInt(r.URL.Query().Get("since"), 10, 64)
if err != nil {
since = 0
}
logString.Lock()
text := logString.str.String()
start := logString.total - int64(len(text)) // 缓冲区里现有内容对应的起始偏移
data := text
if since > start && since <= logString.total {
data = text[since-start:]
}
total := logString.total
logString.Unlock()
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
// 用响应头返回新的偏移量:前端拿到的是解码后的字符串,长度不等于字节数
w.Header().Set("X-Log-Offset", strconv.FormatInt(total, 10))
fmt.Fprint(w, data)
}
// 启动 web UI 服务器
func startWebUI(ctx context.Context) {
defer func() {
if err := recover(); err != nil {
lPrintErr("Recovering from panic in startWebUI(), the error is:", err)
lPrintErr("web UI 服务器发生错误,尝试重启 web UI 服务器")
time.Sleep(2 * time.Second)
go startWebUI(ctx)
}
}()
addr := webUIAddr()
if config.WebExternal {
lPrintln("启动 web UI 服务器,监听 " + addr + ",局域网或外网设备可以通过本机 IP 访问 web UI 界面")
} else {
lPrintln("启动 web UI 服务器,只监听本机 " + addr + ",现在可以通过 http://localhost:" + itoa(config.WebPort) + " 访问 web UI 界面")
}
mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", webuiHandler)
mux.HandleFunc("GET /api/auth", authCheckHandler)
mux.HandleFunc("GET /api/auth/token", authTokenHandler)
mux.HandleFunc("POST /api/auth/token", authTokenSetHandler)
mux.HandleFunc("GET /api/status", statusHandler)
mux.HandleFunc("POST /api/config", webConfigHandler)
mux.HandleFunc("POST /api/streamer", webStreamerHandler)
mux.HandleFunc("POST /api/streamer/control", webStreamerControlHandler)
mux.HandleFunc("POST /api/streamer/remove", webStreamerRemoveHandler)
mux.HandleFunc("GET /api/log", logHandler)
mux.HandleFunc("POST /api/notify/test", notifyTestHandler)
mux.HandleFunc("GET /api/files", filesHandler)
mux.HandleFunc("GET /api/file", fileDownloadHandler)
mux.HandleFunc("POST /api/file/remove", fileRemoveHandler)
mux.HandleFunc("POST /api/merge/info", mergeInfoHandler)
mux.HandleFunc("POST /api/merge", mergeHandler)
srvCtx, srvCancel := context.WithCancel(ctx)
webSrvMu.Lock()
webSrvCancel = srvCancel
webSrvMu.Unlock()
srv := &http.Server{
Addr: addr,
IdleTimeout: 60 * time.Second,
// 没有这几个超时的话,慢速连接可以一直占着不放
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
Handler: webMiddleware(webAuth(mux)),
}
// 退出程序或主动重启时关闭 web UI 服务器
go func() {
<-srvCtx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
lPrintErr("web UI 服务器关闭错误:", err)
}
}()
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
// 主动重启导致的关闭不算错误
if srvCtx.Err() != nil {
lPrintln("停止 web UI 服务器")
return
}
lPrintErr("web UI 服务器出现错误:", err)
panic(err)
}
lPrintln("停止 web UI 服务器")
}
// web UI 监听地址,允许外网访问时监听所有网卡,否则只监听本机
func webUIAddr() string {
if config.WebExternal {
return ":" + itoa(config.WebPort)
}
return "127.0.0.1:" + itoa(config.WebPort)
}
// 从请求里取管理口令:优先 Authorization: Bearer,其次 X-Auth-Token,最后 ?token=
func authTokenFromRequest(r *http.Request) string {
if h := strings.TrimSpace(r.Header.Get("Authorization")); h != "" {
if len(h) > 7 && strings.EqualFold(h[:7], "Bearer ") {
return strings.TrimSpace(h[7:])
}
}
if t := strings.TrimSpace(r.Header.Get("X-Auth-Token")); t != "" {
return t
}
return strings.TrimSpace(r.URL.Query().Get("token"))
}
// 校验请求里的管理口令,用固定时间比较避免通过响应时间猜口令
func checkAuthToken(r *http.Request) bool {
want := currentAuthToken()
if want == "" {
return false
}
got := authTokenFromRequest(r)
if got == "" {
return false
}
return subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1
}
// 口令错误次数的记录,给反复尝试的请求加一点延迟
var authFail struct {
sync.Mutex
m map[string]authFailEntry
}
type authFailEntry struct {
count int
last time.Time
}
// 口令错误时最多延迟这么久,避免拖垮服务器
const authMaxDelay = 2 * time.Second
// 取请求的来源 IP
func clientIP(r *http.Request) string {
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
return host
}
return r.RemoteAddr
}
// 记录一次口令错误并返回应该延迟的时长;口令正确时调用 authFailClear 清掉记录
func authFailDelay(ip string) time.Duration {
authFail.Lock()
defer authFail.Unlock()
if authFail.m == nil {
authFail.m = make(map[string]authFailEntry)
}
// 记录太多时清掉过期的,避免大量不同来源的请求把内存撑满
if len(authFail.m) > 1000 {
for k, v := range authFail.m {
if time.Since(v.last) > 10*time.Minute {
delete(authFail.m, k)
}
}
}
e := authFail.m[ip]
e.count++
e.last = time.Now()
authFail.m[ip] = e
d := time.Duration(e.count) * 100 * time.Millisecond
if d > authMaxDelay {
d = authMaxDelay
}
return d
}
// 口令正确时清掉错误记录
func authFailClear(ip string) {
authFail.Lock()
defer authFail.Unlock()
delete(authFail.m, ip)
}
// 管理接口的口令校验:页面本身可以匿名打开(前端拿到 401 后再提示输入口令),
// 所有 /api/ 接口都必须带上正确的口令
func webAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/api/") {
next.ServeHTTP(w, r)
return
}
ip := clientIP(r)
if checkAuthToken(r) {
authFailClear(ip)
next.ServeHTTP(w, r)
return
}
if d := authFailDelay(ip); d > 0 {
time.Sleep(d)
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"error":"管理口令不正确,请在页面上重新输入"}`))
})
}
// 口令校验通过后才会走到这里,直接返回成功供前端登录使用
func authCheckHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte("true"))
}
// 返回当前的管理口令,方便用户在设置区查看和复制
func authTokenHandler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"token": currentAuthToken()})
}
// 更换管理口令
func authTokenSetHandler(w http.ResponseWriter, r *http.Request) {
var req struct {
Token string `json:"token"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest)
return
}
token := strings.TrimSpace(req.Token)
if len([]rune(token)) < 8 {
http.Error(w, "管理口令至少需要 8 个字符", http.StatusBadRequest)
return
}
streamers.Lock()
config.AuthToken = token
streamers.Unlock()
saveConfig()
lPrintln("通过 web UI 更换了管理口令(authToken)")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte("true"))
}
// 统一的响应头和请求体限制
func webMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("X-Content-Type-Options", "nosniff")
h.Set("X-Frame-Options", "DENY")
h.Set("Referrer-Policy", "no-referrer")
// 页面用的是内联脚本和样式,所以只放行内联
h.Set("Content-Security-Policy",
"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; media-src 'self'")
// 写接口的请求体都很小,限制大小避免被慢速大请求拖着
if r.Method == http.MethodPost {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
}
next.ServeHTTP(w, r)
})
}
var (
webSrvMu sync.Mutex
webSrvCancel context.CancelFunc
)
// 切换外网访问后监听地址会变,需要重启 web UI 服务器
func restartWebUI() {
webSrvMu.Lock()
cancel := webSrvCancel
webSrvMu.Unlock()
if cancel != nil {
cancel()
}
go func() {
time.Sleep(500 * time.Millisecond)
startWebUI(mainCtx)
}()
}