VIDEO_ENABLED is now permission rather than behaviour: it decides whether the feature exists at all, while turning it on for a server is a toggle in the panel or !video in chat, off by default. Video costs real CPU for every playing channel, so that should be a deliberate choice rather than something a config flag switches on everywhere. With the env flag off the panel renders no toggle at all and !video says so, and the switch applies from the next track — swapping tracks mid-play would cut the current one. The loop button no longer reads "выкл" either: it sat next to the video button showing the same word, so the two states were indistinguishable. Both now name what they do and rely on highlighting for state. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
397 lines
16 KiB
TypeScript
397 lines
16 KiB
TypeScript
import { existsSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import cookie from "@fastify/cookie";
|
|
import fastifyStatic from "@fastify/static";
|
|
import websocket from "@fastify/websocket";
|
|
import Fastify, { type FastifyReply, type FastifyRequest } from "fastify";
|
|
import { z } from "zod";
|
|
import { signSession, verifyToken } from "../auth/tokens.js";
|
|
import { config } from "../config.js";
|
|
import { logger } from "../logger.js";
|
|
import type { MusicManager } from "../core/manager.js";
|
|
import type { BotStoatContext } from "../bot/context.js";
|
|
import { fetchSelf, loginWithPassword, revokeSession } from "../stoat/rest.js";
|
|
import { UserFacingError, type Track } from "../types.js";
|
|
|
|
const log = logger.child({ mod: "api" });
|
|
const COOKIE_NAME = "mbot_session";
|
|
const SEARCH_CACHE_TTL_MS = 15 * 60_000;
|
|
const SEARCH_CACHE_LIMIT = 5000;
|
|
|
|
/**
|
|
* The panel never sends whole track objects back to us — it sends ids that were
|
|
* produced by a server-side search. That keeps clients from pointing playback at
|
|
* arbitrary local paths or internal URLs.
|
|
*/
|
|
const searchCache = new Map<string, { track: Track; at: number }>();
|
|
|
|
function cacheTracks(tracks: Track[]): void {
|
|
const now = Date.now();
|
|
for (const track of tracks) searchCache.set(track.id, { track, at: now });
|
|
if (searchCache.size > SEARCH_CACHE_LIMIT) {
|
|
for (const [id, entry] of searchCache) {
|
|
if (now - entry.at > SEARCH_CACHE_TTL_MS) searchCache.delete(id);
|
|
if (searchCache.size <= SEARCH_CACHE_LIMIT) break;
|
|
}
|
|
}
|
|
}
|
|
|
|
function takeTracks(ids: string[]): Track[] {
|
|
const tracks: Track[] = [];
|
|
for (const id of ids) {
|
|
const entry = searchCache.get(id);
|
|
if (!entry || Date.now() - entry.at > SEARCH_CACHE_TTL_MS) continue;
|
|
tracks.push(entry.track);
|
|
}
|
|
if (tracks.length === 0) throw new UserFacingError("Результаты поиска устарели, повторите поиск");
|
|
return tracks;
|
|
}
|
|
|
|
interface Session {
|
|
userId: string;
|
|
username: string;
|
|
}
|
|
|
|
declare module "fastify" {
|
|
interface FastifyRequest {
|
|
session?: Session;
|
|
}
|
|
}
|
|
|
|
export interface ApiServerOptions {
|
|
manager: MusicManager;
|
|
context: BotStoatContext;
|
|
}
|
|
|
|
export async function startApiServer({ manager, context }: ApiServerOptions) {
|
|
const app = Fastify({ logger: false, trustProxy: true });
|
|
|
|
await app.register(cookie);
|
|
await app.register(websocket);
|
|
|
|
const secureCookies = config.PUBLIC_URL.startsWith("https://");
|
|
|
|
function setSessionCookie(reply: FastifyReply, token: string): void {
|
|
reply.setCookie(COOKIE_NAME, token, {
|
|
httpOnly: true,
|
|
sameSite: "lax",
|
|
secure: secureCookies,
|
|
path: "/",
|
|
maxAge: config.SESSION_TTL_HOURS * 3600,
|
|
});
|
|
}
|
|
|
|
async function readSession(request: FastifyRequest): Promise<Session | null> {
|
|
const token = request.cookies[COOKIE_NAME];
|
|
if (!token) return null;
|
|
const claims = await verifyToken(token);
|
|
if (!claims || claims.typ !== "session") return null;
|
|
return { userId: claims.sub, username: claims.username };
|
|
}
|
|
|
|
async function requireSession(request: FastifyRequest, reply: FastifyReply): Promise<Session | null> {
|
|
const session = await readSession(request);
|
|
if (!session) {
|
|
await reply.code(401).send({ error: "Не авторизован" });
|
|
return null;
|
|
}
|
|
request.session = session;
|
|
return session;
|
|
}
|
|
|
|
/** Membership is re-checked on every request: roles change in Stoat, not here. */
|
|
async function requireServerAccess(
|
|
request: FastifyRequest,
|
|
reply: FastifyReply,
|
|
serverId: string,
|
|
): Promise<Session | null> {
|
|
const session = await requireSession(request, reply);
|
|
if (!session) return null;
|
|
if (!(await context.isMember(serverId, session.userId))) {
|
|
await reply.code(403).send({ error: "Нет доступа к этому серверу" });
|
|
return null;
|
|
}
|
|
return session;
|
|
}
|
|
|
|
app.setErrorHandler((error, _request, reply) => {
|
|
if (error instanceof UserFacingError) {
|
|
void reply.code(400).send({ error: error.message });
|
|
return;
|
|
}
|
|
if ((error as { validation?: unknown }).validation) {
|
|
void reply.code(400).send({ error: "Некорректный запрос" });
|
|
return;
|
|
}
|
|
log.error({ err: error }, "request failed");
|
|
void reply.code(500).send({ error: "Внутренняя ошибка" });
|
|
});
|
|
|
|
// ------------------------------------------------------------------ auth ---
|
|
|
|
const loginSchema = z.object({
|
|
email: z.string().min(1).optional(),
|
|
password: z.string().min(1).optional(),
|
|
mfaTicket: z.string().optional(),
|
|
totpCode: z.string().optional(),
|
|
recoveryCode: z.string().optional(),
|
|
});
|
|
|
|
app.post("/api/auth/login", async (request, reply) => {
|
|
const body = loginSchema.parse(request.body ?? {});
|
|
const mfa = body.mfaTicket
|
|
? { ticket: body.mfaTicket, totpCode: body.totpCode, recoveryCode: body.recoveryCode }
|
|
: undefined;
|
|
if (!mfa && (!body.email || !body.password)) {
|
|
throw new UserFacingError("Укажите e-mail и пароль");
|
|
}
|
|
|
|
const result = await loginWithPassword(body.email ?? "", body.password ?? "", mfa);
|
|
if (result.kind === "mfa") {
|
|
return reply.send({ mfaRequired: true, ticket: result.ticket, methods: result.methods });
|
|
}
|
|
|
|
// We only needed the session to prove who the user is; drop it immediately.
|
|
const profile = await fetchSelf(result.token).catch(() => null);
|
|
await revokeSession(result.token);
|
|
|
|
const username = profile?.display_name || profile?.username || "user";
|
|
const token = await signSession(result.userId, username);
|
|
setSessionCookie(reply, token);
|
|
return reply.send({ user: { id: result.userId, username } });
|
|
});
|
|
|
|
app.post("/api/auth/link", async (request, reply) => {
|
|
const body = z.object({ token: z.string().min(1) }).parse(request.body ?? {});
|
|
const claims = await verifyToken(body.token);
|
|
if (!claims || claims.typ !== "link") throw new UserFacingError("Ссылка недействительна или устарела");
|
|
const token = await signSession(claims.sub, claims.username);
|
|
setSessionCookie(reply, token);
|
|
return reply.send({ user: { id: claims.sub, username: claims.username }, serverId: claims.srv ?? null });
|
|
});
|
|
|
|
app.post("/api/auth/logout", async (_request, reply) => {
|
|
reply.clearCookie(COOKIE_NAME, { path: "/" });
|
|
return reply.send({ ok: true });
|
|
});
|
|
|
|
app.get("/api/me", async (request, reply) => {
|
|
const session = await requireSession(request, reply);
|
|
if (!session) return reply;
|
|
const servers = await context.listServersForUser(session.userId);
|
|
return reply.send({
|
|
user: { id: session.userId, username: session.username },
|
|
servers,
|
|
features: {
|
|
localLibrary: Boolean(config.LOCAL_MEDIA_DIR),
|
|
requireListener: config.REQUIRE_LISTENER,
|
|
video: config.VIDEO_ENABLED,
|
|
},
|
|
});
|
|
});
|
|
|
|
// --------------------------------------------------------------- player ---
|
|
|
|
const serverParams = z.object({ id: z.string().min(1) });
|
|
|
|
app.get("/api/servers/:id/state", async (request, reply) => {
|
|
const { id } = serverParams.parse(request.params);
|
|
const session = await requireServerAccess(request, reply, id);
|
|
if (!session) return reply;
|
|
return reply.send({
|
|
state: manager.snapshot(id),
|
|
voiceChannels: context.listVoiceChannels(id),
|
|
yourVoiceChannel: context.findUserVoiceChannel(id, session.userId),
|
|
canControl: await context.canControl(id, session.userId),
|
|
});
|
|
});
|
|
|
|
app.get("/api/servers/:id/search", async (request, reply) => {
|
|
const { id } = serverParams.parse(request.params);
|
|
const session = await requireServerAccess(request, reply, id);
|
|
if (!session) return reply;
|
|
const { q, source } = z
|
|
.object({
|
|
q: z.string().min(1),
|
|
source: z.enum(["all", "youtube", "soundcloud", "local"]).default("all"),
|
|
})
|
|
.parse(request.query);
|
|
const tracks = await manager.search(
|
|
q,
|
|
{ id: session.userId, username: session.username },
|
|
undefined,
|
|
source,
|
|
);
|
|
cacheTracks(tracks);
|
|
return reply.send({ tracks });
|
|
});
|
|
|
|
const playSchema = z.object({
|
|
query: z.string().min(1).optional(),
|
|
trackIds: z.array(z.string()).optional(),
|
|
mode: z.enum(["append", "next", "now"]).default("append"),
|
|
voiceChannelId: z.string().nullable().optional(),
|
|
});
|
|
|
|
app.post("/api/servers/:id/play", async (request, reply) => {
|
|
const { id } = serverParams.parse(request.params);
|
|
const session = await requireServerAccess(request, reply, id);
|
|
if (!session) return reply;
|
|
const body = playSchema.parse(request.body ?? {});
|
|
const requester = { id: session.userId, username: session.username };
|
|
|
|
const outcome = body.trackIds?.length
|
|
? await manager.enqueueTracks(id, requester, takeTracks(body.trackIds), {
|
|
mode: body.mode,
|
|
voiceChannelId: body.voiceChannelId ?? null,
|
|
})
|
|
: await manager.play(id, requester, body.query ?? "", {
|
|
mode: body.mode,
|
|
voiceChannelId: body.voiceChannelId ?? null,
|
|
});
|
|
|
|
return reply.send({ ok: true, added: outcome.tracks.length, state: manager.snapshot(id) });
|
|
});
|
|
|
|
const actions: Record<string, (serverId: string, userId: string, body: unknown) => Promise<unknown>> = {
|
|
pause: (serverId, userId) => manager.pause(serverId, userId),
|
|
resume: (serverId, userId) => manager.resume(serverId, userId),
|
|
toggle: (serverId, userId) => manager.togglePause(serverId, userId),
|
|
skip: (serverId, userId, body) =>
|
|
manager.skip(serverId, userId, z.object({ count: z.number().int().min(1).default(1) }).parse(body ?? {}).count),
|
|
stop: (serverId, userId) => manager.stop(serverId, userId),
|
|
shuffle: (serverId, userId) => manager.shuffle(serverId, userId),
|
|
clear: (serverId, userId) => manager.clearQueue(serverId, userId),
|
|
leave: (serverId, userId) => manager.leave(serverId, userId),
|
|
volume: (serverId, userId, body) =>
|
|
manager.setVolume(serverId, userId, z.object({ volume: z.number().min(0).max(200) }).parse(body).volume),
|
|
video: (serverId, userId, body) =>
|
|
manager.setVideo(serverId, userId, z.object({ enabled: z.boolean() }).parse(body).enabled),
|
|
loop: (serverId, userId, body) =>
|
|
manager.setLoop(serverId, userId, z.object({ mode: z.enum(["off", "track", "queue"]) }).parse(body).mode),
|
|
seek: (serverId, userId, body) =>
|
|
manager.seek(serverId, userId, z.object({ position: z.number().min(0) }).parse(body).position),
|
|
};
|
|
|
|
app.post("/api/servers/:id/actions/:action", async (request, reply) => {
|
|
const { id } = serverParams.parse(request.params);
|
|
const { action } = z.object({ action: z.string() }).parse(request.params);
|
|
const session = await requireServerAccess(request, reply, id);
|
|
if (!session) return reply;
|
|
|
|
const handler = actions[action];
|
|
if (!handler) return reply.code(404).send({ error: "Неизвестное действие" });
|
|
|
|
const result = await handler(id, session.userId, request.body);
|
|
return reply.send({ ok: true, result: result ?? null, state: manager.snapshot(id) });
|
|
});
|
|
|
|
app.post("/api/servers/:id/join", async (request, reply) => {
|
|
const { id } = serverParams.parse(request.params);
|
|
const session = await requireServerAccess(request, reply, id);
|
|
if (!session) return reply;
|
|
const body = z.object({ voiceChannelId: z.string().nullable().optional() }).parse(request.body ?? {});
|
|
await manager.assertControl(id, session.userId);
|
|
await manager.connect(id, session.userId, { voiceChannelId: body.voiceChannelId ?? null });
|
|
return reply.send({ ok: true, state: manager.snapshot(id) });
|
|
});
|
|
|
|
app.delete("/api/servers/:id/queue/:trackId", async (request, reply) => {
|
|
const { id } = serverParams.parse(request.params);
|
|
const { trackId } = z.object({ trackId: z.string() }).parse(request.params);
|
|
const session = await requireServerAccess(request, reply, id);
|
|
if (!session) return reply;
|
|
await manager.remove(id, session.userId, trackId);
|
|
return reply.send({ ok: true, state: manager.snapshot(id) });
|
|
});
|
|
|
|
app.post("/api/servers/:id/queue/:trackId/move", async (request, reply) => {
|
|
const { id } = serverParams.parse(request.params);
|
|
const { trackId } = z.object({ trackId: z.string() }).parse(request.params);
|
|
const session = await requireServerAccess(request, reply, id);
|
|
if (!session) return reply;
|
|
const { index } = z.object({ index: z.number().int().min(0) }).parse(request.body);
|
|
await manager.move(id, session.userId, trackId, index);
|
|
return reply.send({ ok: true, state: manager.snapshot(id) });
|
|
});
|
|
|
|
// ----------------------------------------------------------- websockets ---
|
|
|
|
const subscribers = new Map<string, Set<{ send(data: string): void }>>();
|
|
|
|
function broadcast(serverId: string, payload: unknown): void {
|
|
const listeners = subscribers.get(serverId);
|
|
if (!listeners?.size) return;
|
|
const message = JSON.stringify(payload);
|
|
for (const socket of listeners) {
|
|
try {
|
|
socket.send(message);
|
|
} catch {
|
|
listeners.delete(socket);
|
|
}
|
|
}
|
|
}
|
|
|
|
manager.on("update", (snapshot) => broadcast(snapshot.serverId, { type: "state", state: snapshot }));
|
|
manager.on("position", (position) => broadcast(position.serverId, { type: "position", ...position }));
|
|
|
|
app.get("/ws", { websocket: true }, (socket, request) => {
|
|
void (async () => {
|
|
const session = await readSession(request);
|
|
const serverId = (request.query as { server?: string }).server;
|
|
if (!session || !serverId || !(await context.isMember(serverId, session.userId))) {
|
|
socket.close(4001, "unauthorized");
|
|
return;
|
|
}
|
|
|
|
const listeners = subscribers.get(serverId) ?? new Set();
|
|
listeners.add(socket);
|
|
subscribers.set(serverId, listeners);
|
|
|
|
socket.send(JSON.stringify({ type: "state", state: manager.snapshot(serverId) }));
|
|
|
|
// Stoat's SDK keeps voice participants up to date but emits no event for
|
|
// them, so we watch our own view and push when this viewer's presence
|
|
// changes — otherwise the panel only learns about it on reload.
|
|
let lastPresence = "";
|
|
const sendPresence = () => {
|
|
const yourVoiceChannel = context.findUserVoiceChannel(serverId, session.userId);
|
|
const voiceChannels = context.listVoiceChannels(serverId);
|
|
const fingerprint = JSON.stringify([yourVoiceChannel, voiceChannels]);
|
|
if (fingerprint === lastPresence) return;
|
|
lastPresence = fingerprint;
|
|
socket.send(JSON.stringify({ type: "presence", yourVoiceChannel, voiceChannels }));
|
|
};
|
|
sendPresence();
|
|
const presenceTimer = setInterval(sendPresence, 3000);
|
|
presenceTimer.unref?.();
|
|
|
|
socket.on("close", () => {
|
|
clearInterval(presenceTimer);
|
|
listeners.delete(socket);
|
|
if (listeners.size === 0) subscribers.delete(serverId);
|
|
});
|
|
})();
|
|
});
|
|
|
|
// --------------------------------------------------------------- static ---
|
|
|
|
const webRoot = path.resolve(fileURLToPath(new URL("../..", import.meta.url)), "web/dist");
|
|
if (existsSync(webRoot)) {
|
|
await app.register(fastifyStatic, { root: webRoot });
|
|
app.setNotFoundHandler((request, reply) => {
|
|
if (request.url.startsWith("/api") || request.url.startsWith("/ws")) {
|
|
return reply.code(404).send({ error: "Not found" });
|
|
}
|
|
return reply.sendFile("index.html");
|
|
});
|
|
} else {
|
|
log.warn({ webRoot }, "web/dist not found — panel UI is not served (run npm run web:build)");
|
|
}
|
|
|
|
await app.listen({ port: config.PORT, host: config.HOST });
|
|
log.info({ port: config.PORT, url: config.PUBLIC_URL }, "panel is listening");
|
|
return app;
|
|
}
|