Files
ancsemi 6d9a76eec5 Every silent catch in the server now either reports the error or says why ignoring it is safe
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.
2026-10-02 07:36:58 -04:00

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
};