mirror of
https://github.com/ZHANGTIANYAO1/teamspeak-music-bot.git
synced 2026-10-02 04:52:50 +08:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0401534b88 | ||
|
|
f720da49d6 | ||
|
|
3abb468cca | ||
|
|
c8daa14219 | ||
|
|
81cd8a2bec | ||
|
|
35210cf570 | ||
|
|
d2bad58aa8 | ||
|
|
a2982948a7 | ||
|
|
b7e1f9f30b |
No files matched your search
+54
-2
@@ -136,6 +136,9 @@ export class AudioPlayer extends EventEmitter {
|
||||
private static readonly HEALTHY_FRAME_RESET = 50; // ~1 second of audio
|
||||
private downloader: ChildProcess | null = null;
|
||||
private currentTempDir: string | null = null;
|
||||
private emptyFrameAttempts = 0;
|
||||
private static readonly MAX_EMPTY_ATTEMPTS = 250; // ~5秒的20ms帧循环(增加容错)
|
||||
private currentSongDuration = 0; // 当前歌曲总时长(秒)
|
||||
|
||||
constructor(logger: Logger) {
|
||||
super();
|
||||
@@ -143,7 +146,7 @@ export class AudioPlayer extends EventEmitter {
|
||||
this.logger = logger;
|
||||
}
|
||||
|
||||
play(url: string, seekSeconds = 0): void {
|
||||
play(url: string, seekSeconds = 0, songDuration = 0): void {
|
||||
// 1. 停止当前所有播放,自增 sessionId 屏蔽旧回调 (
|
||||
this.stop();
|
||||
|
||||
@@ -154,6 +157,8 @@ export class AudioPlayer extends EventEmitter {
|
||||
this.healthyFrames = 0;
|
||||
this.ffmpegPaused = false;
|
||||
this.spawnFailed = false;
|
||||
this.emptyFrameAttempts = 0;
|
||||
this.currentSongDuration = songDuration;
|
||||
|
||||
if (this.consecutiveFailures >= AudioPlayer.MAX_CONSECUTIVE_FAILURES) {
|
||||
this.logger.error({ failures: this.consecutiveFailures }, "FFmpeg failures limit reached");
|
||||
@@ -420,6 +425,49 @@ export class AudioPlayer extends EventEmitter {
|
||||
if (this.state === "playing") this.sendNextFrame();
|
||||
else if (this.state === "paused") this.nextFrameTime = performance.now();
|
||||
|
||||
// 检测pcmBuffer不足PCM_FRAME_BYTES导致连续循环卡死:
|
||||
// 条件1: FFmpeg仍在运行但缓冲区不足一帧,且连续多次无法获取数据
|
||||
// 条件2: 已播放时间接近歌曲结尾(最后5秒内)或未知时长
|
||||
const elapsed = this.getElapsed();
|
||||
const isNearEnd = this.currentSongDuration > 0
|
||||
? (this.currentSongDuration - elapsed) <= 5 // 距离结尾不足5秒
|
||||
: true; // 未知时长时保守处理
|
||||
|
||||
if (this.ffmpeg !== null && this.pcmBuffer.length < PCM_FRAME_BYTES) {
|
||||
this.emptyFrameAttempts++;
|
||||
|
||||
// 只有同时满足:达到空帧阈值 + 接近结尾,才判定为播放结束
|
||||
if (this.emptyFrameAttempts >= AudioPlayer.MAX_EMPTY_ATTEMPTS && isNearEnd) {
|
||||
this.logger.info({
|
||||
sessionId: this.sessionId,
|
||||
emptyAttempts: this.emptyFrameAttempts,
|
||||
bufferSize: this.pcmBuffer.length,
|
||||
elapsed: Math.round(elapsed),
|
||||
duration: this.currentSongDuration,
|
||||
remaining: Math.round(this.currentSongDuration - elapsed)
|
||||
}, "FFmpeg stopped outputting data near end, ending track");
|
||||
this.frameLoopRunning = false;
|
||||
if (this.state !== "idle") {
|
||||
this.state = "idle";
|
||||
// 清理FFmpeg进程
|
||||
if (this.ffmpeg) {
|
||||
const procToKill = this.ffmpeg;
|
||||
const pidToKill = procToKill.pid;
|
||||
this.ffmpeg = null;
|
||||
if (pidToKill) {
|
||||
this.forceCleanup(procToKill, pidToKill);
|
||||
}
|
||||
}
|
||||
this.consecutiveFailures = 0;
|
||||
this.emit("trackEnd");
|
||||
}
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// 成功获取数据或FFmpeg已结束,重置计数器
|
||||
this.emptyFrameAttempts = 0;
|
||||
}
|
||||
|
||||
if (!this.ffmpeg && this.pcmBuffer.length < PCM_FRAME_BYTES) {
|
||||
this.frameLoopRunning = false;
|
||||
if (this.state !== "idle") {
|
||||
@@ -472,7 +520,11 @@ export class AudioPlayer extends EventEmitter {
|
||||
}
|
||||
|
||||
getElapsed(): number { return this.seekOffset + (this.framesPlayed * FRAME_DURATION_MS) / 1000; }
|
||||
seek(seconds: number): void { if (this.currentUrl && Number.isFinite(seconds) && seconds >= 0) this.play(this.currentUrl, seconds); }
|
||||
seek(seconds: number): void {
|
||||
if (this.currentUrl && Number.isFinite(seconds) && seconds >= 0) {
|
||||
this.play(this.currentUrl, seconds, this.currentSongDuration);
|
||||
}
|
||||
}
|
||||
pause(): void { if (this.state === "playing") this.state = "paused"; }
|
||||
resume(): void { if (this.state === "paused") { this.state = "playing"; this.nextFrameTime = performance.now(); } }
|
||||
resetFailures(): void { this.consecutiveFailures = 0; }
|
||||
|
||||
+1
-1
@@ -380,7 +380,7 @@ export class BotInstance extends EventEmitter {
|
||||
return false;
|
||||
}
|
||||
song.url = url;
|
||||
this.player.play(url);
|
||||
this.player.play(url, 0, song.duration);
|
||||
this.database.addPlayHistory({
|
||||
botId: this.id,
|
||||
songId: song.id,
|
||||
|
||||
@@ -55,4 +55,19 @@ describe("csrfOriginCheck middleware", () => {
|
||||
.set("Referer", "https://evil.com/some/path");
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
// Documents the server side of the QR-login outage: a `no-referrer` document
|
||||
// policy makes the browser send the literal `Origin: null` on same-origin
|
||||
// POSTs, which this guard cannot parse a host from and therefore rejects.
|
||||
// The fix lives in the frontend (referrer policy -> same-origin); this test
|
||||
// pins the gate behavior so the interaction stays understood. See
|
||||
// src/web/referrer-policy.test.ts.
|
||||
it('rejects POST with the literal Origin: "null" (no-referrer downgrade)', async () => {
|
||||
const res = await request(app)
|
||||
.post("/")
|
||||
.set("Host", "example.com")
|
||||
.set("Origin", "null");
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body).toEqual({ error: "bad origin" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/**
|
||||
* Regression guard for the QR-login / cookie-save outage (and in fact every
|
||||
* mutating WebUI action). On 2026-05-27 the WebUI-auth feature added the
|
||||
* same-origin CSRF gate `app.use("/api", csrfOriginCheck)` in server.ts, and
|
||||
* the same day a `<meta name="referrer" content="no-referrer">` was added to
|
||||
* web/index.html so cross-origin CDN cover thumbnails would load.
|
||||
*
|
||||
* Those two changes conflict: per the WHATWG Fetch "Append a request Origin
|
||||
* header" algorithm, the `no-referrer` policy sets the Origin header to the
|
||||
* literal string "null" on same-origin non-GET requests. csrfOriginCheck then
|
||||
* fails to parse a host (`new URL("null")` throws) and returns 403 "bad
|
||||
* origin", so POST /api/auth/qrcode (and every other POST/PUT/DELETE under
|
||||
* /api/* except /api/session/*) never reaches its handler.
|
||||
*
|
||||
* `same-origin` is the correct policy: it keeps the real Origin on same-origin
|
||||
* requests (CSRF passes) while still sending no Referer cross-origin (CDN
|
||||
* thumbnails keep loading). Never switch this back to `no-referrer`.
|
||||
*/
|
||||
describe("frontend referrer policy (CSRF / Origin-header regression)", () => {
|
||||
const indexHtmlPath = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"../../web/index.html"
|
||||
);
|
||||
const html = fs.readFileSync(indexHtmlPath, "utf-8");
|
||||
|
||||
const referrerMeta = html.match(
|
||||
/<meta\s+name=["']referrer["']\s+content=["']([^"']+)["']\s*\/?>/i
|
||||
);
|
||||
|
||||
it("declares a referrer policy meta tag", () => {
|
||||
expect(referrerMeta).not.toBeNull();
|
||||
});
|
||||
|
||||
it("uses same-origin (NOT no-referrer, which sends Origin: null and 403s every POST)", () => {
|
||||
expect(referrerMeta?.[1]).toBe("same-origin");
|
||||
});
|
||||
|
||||
it("does not contain no-referrer anywhere in the document head", () => {
|
||||
expect(html).not.toMatch(/content=["']no-referrer["']/i);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,18 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<!-- Bilibili / NetEase / QQ image CDNs reject requests whose Referer is not on
|
||||
their whitelist, so we must not leak a Referer cross-origin. "same-origin"
|
||||
does exactly that: full Referer for our own requests, none for cross-origin
|
||||
ones — so cover thumbnails (<img> AND CSS background-image) still load.
|
||||
Do NOT switch this back to "no-referrer": per the WHATWG Fetch spec
|
||||
("Append a request Origin header") no-referrer downgrades the Origin header
|
||||
to the literal string "null" on same-origin non-GET requests. The /api/*
|
||||
CSRF guard (src/web/middleware/csrf.ts) then can't parse a host from it and
|
||||
responds 403 "bad origin", silently breaking EVERY POST/PUT/DELETE — QR
|
||||
login, cookie save, playback controls, bot management, user admin, etc.
|
||||
"same-origin" keeps the real Origin on same-origin requests, so CSRF passes. -->
|
||||
<meta name="referrer" content="same-origin">
|
||||
<title>TSMusicBot</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Barlow:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
|
||||
@@ -27,10 +27,10 @@
|
||||
<div class="player-left" @click="toggleLyrics">
|
||||
<CoverArt :url="currentSong.coverUrl" :size="40" />
|
||||
<div class="song-info">
|
||||
<div class="song-name">{{ currentSong.name }}</div>
|
||||
<div class="song-name" :title="currentSong.name">{{ currentSong.name }}</div>
|
||||
<div class="song-artist">
|
||||
<span v-if="showBotBadge" class="bot-badge">{{ activeBot?.name }}</span>
|
||||
{{ currentSong.artist }}
|
||||
<span class="artist-name" :title="currentSong.artist">{{ currentSong.artist }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -310,6 +310,8 @@ function cycleMode() {
|
||||
|
||||
.song-info {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.song-name {
|
||||
@@ -326,6 +328,16 @@ function cycleMode() {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.artist-name {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bot-badge {
|
||||
|
||||
@@ -379,6 +379,7 @@ onMounted(() => {
|
||||
|
||||
.daily-card {
|
||||
cursor: pointer;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.daily-name {
|
||||
|
||||
Reference in new issue
Block a user