mirror of
https://github.com/ancsemi/Haven.git
synced 2026-10-27 17:15:24 +00:00
Went through all of them (about 220 across server.js and src/). The ones
that could leave data, settings or files wrong without anyone knowing now
log a short warning: failed file moves and deletes after a delete or
restore, backup entries that could not be added, startup steps like the
sticker folder or the Referrer-Policy, auto-role grants on join, audit log
writes, attachment tags dropped on send, webhook delivery status, the AFK,
temp-channel and disk-space timers, DM call and push lookups, Discord import
files that could not be parsed, Ferry's loop guard and automod checks, the
media cache folder and the one-time data folder migration. Ones that repeat
on busy paths log at most once every few minutes.
The rest are expected failures (a temp file already gone, a socket already
closed, untrusted input with a fallback) and each now carries a comment
saying so instead of a bare catch {}. Two guards that could never fire were
removed. No bare catch {} is left in the server code.
365 lines
13 KiB
JavaScript
365 lines
13 KiB
JavaScript
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
let musicMetadataPromise;
|
|
function loadMusicMetadata() {
|
|
if (!musicMetadataPromise) musicMetadataPromise = import('music-metadata');
|
|
return musicMetadataPromise;
|
|
}
|
|
|
|
const MAX_AUDIO_BYTES = 10 * 1024 * 1024;
|
|
const MAX_AUDIO_DURATION_SECONDS = 5 * 60;
|
|
const MAX_AUDIO_QUEUE_ITEMS = 25;
|
|
|
|
function detectAudioFormat(header) {
|
|
if (!Buffer.isBuffer(header)) return null;
|
|
if (header.length >= 12 && header.toString('ascii', 0, 4) === 'RIFF' && header.toString('ascii', 8, 12) === 'WAVE') {
|
|
return { extension: '.wav', mime: 'audio/wav' };
|
|
}
|
|
if (header.length >= 4 && header.toString('ascii', 0, 4) === 'OggS') {
|
|
return { extension: '.ogg', mime: 'audio/ogg' };
|
|
}
|
|
if (header.length >= 3 && header.toString('ascii', 0, 3) === 'ID3') {
|
|
return { extension: '.mp3', mime: 'audio/mpeg' };
|
|
}
|
|
const isMpegLayer3 = header.length >= 3 &&
|
|
header[0] === 0xff && (header[1] & 0xe0) === 0xe0 &&
|
|
((header[1] >> 3) & 0x03) !== 0x01 &&
|
|
((header[1] >> 1) & 0x03) === 0x01 &&
|
|
(header[2] >> 4) !== 0x00 && (header[2] >> 4) !== 0x0f &&
|
|
((header[2] >> 2) & 0x03) !== 0x03;
|
|
if (isMpegLayer3) {
|
|
return { extension: '.mp3', mime: 'audio/mpeg' };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function inspectAudioFile(filePath) {
|
|
const stat = await fs.promises.stat(filePath);
|
|
if (!stat.isFile() || stat.size <= 0) throw new Error('The uploaded file is empty');
|
|
if (stat.size > MAX_AUDIO_BYTES) throw new Error('Audio must be 10 MB or smaller');
|
|
|
|
const handle = await fs.promises.open(filePath, 'r');
|
|
let header;
|
|
try {
|
|
header = Buffer.alloc(12);
|
|
const result = await handle.read(header, 0, header.length, 0);
|
|
header = header.subarray(0, result.bytesRead);
|
|
} finally {
|
|
await handle.close();
|
|
}
|
|
|
|
const format = detectAudioFormat(header);
|
|
if (!format) throw new Error('Only valid MP3, WAV, or OGG audio is allowed');
|
|
|
|
let metadata;
|
|
try {
|
|
const { parseStream } = await loadMusicMetadata();
|
|
const stream = fs.createReadStream(filePath);
|
|
try {
|
|
// Supplying the already-validated type selects a specific parser and
|
|
// avoids generic container sniffing on attacker-controlled bytes.
|
|
metadata = await parseStream(
|
|
stream,
|
|
{ mimeType: format.mime, size: stat.size },
|
|
{ duration: true, skipCovers: true }
|
|
);
|
|
} finally {
|
|
stream.destroy();
|
|
}
|
|
} catch {
|
|
throw new Error('The uploaded file is not valid audio');
|
|
}
|
|
|
|
const durationSeconds = Number(metadata?.format?.duration);
|
|
if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) {
|
|
throw new Error('Could not determine audio duration');
|
|
}
|
|
if (durationSeconds > MAX_AUDIO_DURATION_SECONDS) {
|
|
throw new Error(`Audio must be ${MAX_AUDIO_DURATION_SECONDS} seconds or shorter`);
|
|
}
|
|
|
|
const container = String(metadata?.format?.container || '');
|
|
const codec = String(metadata?.format?.codec || '');
|
|
const formatMatches =
|
|
(format.extension === '.mp3' && container === 'MPEG' && /Layer 3/i.test(codec)) ||
|
|
(format.extension === '.wav' && container === 'WAVE') ||
|
|
(format.extension === '.ogg' && container === 'Ogg' && codec.length > 0);
|
|
if (!formatMatches) throw new Error('The uploaded file is not valid audio');
|
|
|
|
return {
|
|
...format,
|
|
durationMs: Math.max(1, Math.ceil(durationSeconds * 1000))
|
|
};
|
|
}
|
|
|
|
class BotAudioManager {
|
|
constructor(io, audioDir, options = {}) {
|
|
this.io = io;
|
|
this.audioDir = audioDir;
|
|
this.channels = new Map();
|
|
this.now = typeof options.now === 'function' ? options.now : Date.now;
|
|
this.setTimer = typeof options.setTimer === 'function' ? options.setTimer : setTimeout;
|
|
this.clearTimer = typeof options.clearTimer === 'function' ? options.clearTimer : clearTimeout;
|
|
fs.mkdirSync(audioDir, { recursive: true });
|
|
|
|
// Queue state is in memory, so files left by an interrupted process can
|
|
// never be resumed safely and must not survive a restart.
|
|
for (const name of fs.readdirSync(audioDir)) {
|
|
try { fs.rmSync(path.join(audioDir, name), { recursive: true, force: true }); } catch { /* leftover temp audio still locked; cleared on the next start */ }
|
|
}
|
|
}
|
|
|
|
enqueue(entry) {
|
|
let state = this.channels.get(entry.channelCode);
|
|
if (!state) {
|
|
state = { current: null, pending: [], timer: null };
|
|
this.channels.set(entry.channelCode, state);
|
|
}
|
|
|
|
let ownedCount = 0;
|
|
for (const channelState of this.channels.values()) {
|
|
ownedCount += channelState.pending.filter(item => item.webhookId === entry.webhookId).length;
|
|
if (channelState.current?.webhookId === entry.webhookId) ownedCount++;
|
|
}
|
|
if (ownedCount >= MAX_AUDIO_QUEUE_ITEMS) {
|
|
if (!state.current && state.pending.length === 0) this.channels.delete(entry.channelCode);
|
|
return { error: `Audio queue is full (max ${MAX_AUDIO_QUEUE_ITEMS} items per bot)` };
|
|
}
|
|
|
|
state.pending.push(entry);
|
|
if (!state.current) this._startNext(entry.channelCode, state);
|
|
|
|
const isCurrent = state.current?.playbackId === entry.playbackId;
|
|
const pendingIndex = state.pending.findIndex(item => item.playbackId === entry.playbackId);
|
|
return {
|
|
playbackId: entry.playbackId,
|
|
position: isCurrent ? 0 : pendingIndex + 1,
|
|
queued: !isCurrent
|
|
};
|
|
}
|
|
|
|
getCurrent(channelCode) {
|
|
const current = this.channels.get(channelCode)?.current;
|
|
if (!current) return null;
|
|
const offsetMs = Math.max(0, this.now() - current.startedAt);
|
|
if (offsetMs >= current.durationMs) return null;
|
|
return this._payload(current, offsetMs);
|
|
}
|
|
|
|
getPlayable(playbackId, accessToken) {
|
|
if (typeof playbackId !== 'string' || typeof accessToken !== 'string') return null;
|
|
for (const state of this.channels.values()) {
|
|
const current = state.current;
|
|
if (current?.playbackId !== playbackId || current.accessToken !== accessToken) continue;
|
|
if (this.now() >= current.accessExpiresAt) return null;
|
|
return { filePath: current.filePath, mime: current.mime };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
skip(channelCode, webhookId) {
|
|
const state = this.channels.get(channelCode);
|
|
if (!state) return { skipped: false };
|
|
|
|
if (state.current?.webhookId === webhookId) {
|
|
const playbackId = state.current.playbackId;
|
|
this._finishCurrent(channelCode, state, 'skipped', true);
|
|
return { skipped: true, playbackId, current: true };
|
|
}
|
|
|
|
const index = state.pending.findIndex(item => item.webhookId === webhookId);
|
|
if (index === -1) return { skipped: false };
|
|
const [entry] = state.pending.splice(index, 1);
|
|
this._deleteFile(entry.filePath);
|
|
this._deleteStateIfEmpty(channelCode, state);
|
|
return { skipped: true, playbackId: entry.playbackId, current: false };
|
|
}
|
|
|
|
stop(channelCode, webhookId) {
|
|
const state = this.channels.get(channelCode);
|
|
if (!state) return { stopped: false, removed: 0 };
|
|
|
|
const removed = state.pending.filter(item => item.webhookId === webhookId);
|
|
state.pending = state.pending.filter(item => item.webhookId !== webhookId);
|
|
for (const entry of removed) this._deleteFile(entry.filePath);
|
|
|
|
let stoppedCurrent = false;
|
|
if (state.current?.webhookId === webhookId) {
|
|
stoppedCurrent = true;
|
|
this._finishCurrent(channelCode, state, 'stopped', true);
|
|
} else {
|
|
this._deleteStateIfEmpty(channelCode, state);
|
|
}
|
|
|
|
return {
|
|
stopped: stoppedCurrent || removed.length > 0,
|
|
removed: removed.length + (stoppedCurrent ? 1 : 0)
|
|
};
|
|
}
|
|
|
|
stopWebhook(webhookId) {
|
|
let removed = 0;
|
|
for (const channelCode of Array.from(this.channels.keys())) {
|
|
removed += this.stop(channelCode, webhookId).removed;
|
|
}
|
|
return removed;
|
|
}
|
|
|
|
stopChannel(channelCode, reason = 'channel-deleted') {
|
|
const state = this.channels.get(channelCode);
|
|
if (!state) return 0;
|
|
this.channels.delete(channelCode);
|
|
if (state.timer) this.clearTimer(state.timer);
|
|
const entries = [...(state.current ? [state.current] : []), ...state.pending];
|
|
if (state.current) {
|
|
this._emit(channelCode, 'bot-audio-stop', {
|
|
channelCode,
|
|
playbackId: state.current.playbackId,
|
|
reason
|
|
});
|
|
}
|
|
for (const entry of entries) this._deleteFile(entry.filePath);
|
|
return entries.length;
|
|
}
|
|
|
|
getScopes() {
|
|
const scopes = [];
|
|
for (const [channelCode, state] of this.channels) {
|
|
const webhookIds = new Set();
|
|
if (state.current) webhookIds.add(state.current.webhookId);
|
|
for (const entry of state.pending) webhookIds.add(entry.webhookId);
|
|
for (const webhookId of webhookIds) scopes.push({ webhookId, channelCode });
|
|
}
|
|
return scopes;
|
|
}
|
|
|
|
renameChannel(oldCode, newCode) {
|
|
if (oldCode === newCode || !this.channels.has(oldCode) || this.channels.has(newCode)) return false;
|
|
const state = this.channels.get(oldCode);
|
|
this.channels.delete(oldCode);
|
|
this.channels.set(newCode, state);
|
|
if (state.current) state.current.channelCode = newCode;
|
|
for (const entry of state.pending) entry.channelCode = newCode;
|
|
|
|
if (state.timer) this.clearTimer(state.timer);
|
|
if (state.current) {
|
|
const current = state.current;
|
|
const remainingMs = Math.max(1, current.durationMs - (this.now() - current.startedAt) + 500);
|
|
state.timer = this.setTimer(() => {
|
|
if (state.current?.playbackId === current.playbackId) {
|
|
this._finishCurrent(newCode, state, 'finished', false);
|
|
}
|
|
}, remainingMs);
|
|
state.timer.unref?.();
|
|
}
|
|
return true;
|
|
}
|
|
|
|
shutdown() {
|
|
let removed = 0;
|
|
for (const [channelCode, state] of this.channels) {
|
|
if (state.timer) this.clearTimer(state.timer);
|
|
const entries = [...(state.current ? [state.current] : []), ...state.pending];
|
|
removed += entries.length;
|
|
if (state.current) {
|
|
this._emit(channelCode, 'bot-audio-stop', {
|
|
channelCode,
|
|
playbackId: state.current.playbackId,
|
|
reason: 'shutdown'
|
|
});
|
|
}
|
|
for (const entry of entries) {
|
|
try { fs.rmSync(entry.filePath, { force: true }); } catch { /* temp audio; the folder sweep below and the next start clear it */ }
|
|
}
|
|
}
|
|
this.channels.clear();
|
|
try {
|
|
for (const name of fs.readdirSync(this.audioDir)) {
|
|
fs.rmSync(path.join(this.audioDir, name), { recursive: true, force: true });
|
|
}
|
|
} catch { /* shutting down; temp audio left behind is cleared on the next start */ }
|
|
return removed;
|
|
}
|
|
|
|
_startNext(channelCode, state) {
|
|
if (state.current || state.pending.length === 0) {
|
|
this._deleteStateIfEmpty(channelCode, state);
|
|
return;
|
|
}
|
|
|
|
const entry = state.pending.shift();
|
|
entry.startedAt = this.now();
|
|
entry.accessExpiresAt = entry.startedAt + entry.durationMs;
|
|
state.current = entry;
|
|
this._emit(channelCode, 'bot-audio-play', this._payload(entry, 0));
|
|
state.timer = this.setTimer(() => {
|
|
if (state.current?.playbackId === entry.playbackId) {
|
|
this._finishCurrent(entry.channelCode, state, 'finished', false);
|
|
}
|
|
}, entry.durationMs + 500);
|
|
state.timer.unref?.();
|
|
}
|
|
|
|
_finishCurrent(channelCode, state, reason, notifyClients) {
|
|
const current = state.current;
|
|
if (!current) return;
|
|
if (state.timer) this.clearTimer(state.timer);
|
|
state.timer = null;
|
|
state.current = null;
|
|
|
|
if (notifyClients) {
|
|
this._emit(channelCode, 'bot-audio-stop', {
|
|
channelCode,
|
|
playbackId: current.playbackId,
|
|
reason
|
|
});
|
|
}
|
|
|
|
this._deleteFile(current.filePath);
|
|
this._startNext(channelCode, state);
|
|
}
|
|
|
|
_payload(entry, offsetMs) {
|
|
return {
|
|
playbackId: entry.playbackId,
|
|
channelCode: entry.channelCode,
|
|
audioUrl: entry.audioUrl,
|
|
botName: entry.botName,
|
|
durationMs: entry.durationMs,
|
|
startedAt: new Date(entry.startedAt).toISOString(),
|
|
expiresAt: new Date(entry.accessExpiresAt).toISOString(),
|
|
offsetMs
|
|
};
|
|
}
|
|
|
|
_emit(channelCode, event, payload) {
|
|
const audience = this.io.to(`voice:${channelCode}`);
|
|
const humans = typeof audience.except === 'function' ? audience.except('bot-sockets') : audience;
|
|
humans.emit(event, payload);
|
|
}
|
|
|
|
_deleteStateIfEmpty(channelCode, state) {
|
|
if (!state.current && state.pending.length === 0) this.channels.delete(channelCode);
|
|
}
|
|
|
|
_deleteFile(filePath, attempt = 0) {
|
|
fs.promises.unlink(filePath).catch(err => {
|
|
if (err?.code === 'ENOENT' || attempt >= 3) return;
|
|
const timer = this.setTimer(() => this._deleteFile(filePath, attempt + 1), 250 * (attempt + 1));
|
|
timer.unref?.();
|
|
});
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
BotAudioManager,
|
|
detectAudioFormat,
|
|
inspectAudioFile,
|
|
MAX_AUDIO_BYTES,
|
|
MAX_AUDIO_DURATION_SECONDS,
|
|
MAX_AUDIO_QUEUE_ITEMS
|
|
};
|