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(); 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 { 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 { 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 { 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 Promise> = { 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>(); 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; }