import type { FastifyInstance } from 'fastify'; import { eq } from 'drizzle-orm'; import { getDb, schema } from '../db/index.js'; import { authenticate } from '../utils/auth.js'; import { hasPermission, isMember } from '../utils/permissions.js'; import { PermissionBits } from '@backspace/shared/src/permissions.js'; import { generateSnowflake } from '../utils/snowflake.js'; /** A soundboard is a shortlist of gags, not a media library. */ const MAX_SOUNDS_PER_SPACE = 48; const MAX_NAME_LENGTH = 32; export async function soundboardRoutes(app: FastifyInstance): Promise { app.get<{ Params: { id: string } }>( '/api/spaces/:id/sounds', { preHandler: authenticate }, async (request, reply) => { if (!isMember(request.params.id, request.userId)) { return reply.code(403).send({ error: 'Not a member of this space', statusCode: 403 }); } const rows = getDb().select().from(schema.soundboardSounds) .where(eq(schema.soundboardSounds.spaceId, request.params.id)).all(); return reply.code(200).send({ sounds: rows }); }, ); app.post<{ Params: { id: string }; Body: { name?: string; filename?: string } }>( '/api/spaces/:id/sounds', { preHandler: authenticate }, async (request, reply) => { const { id } = request.params; // Adding is gated but playing is not: anyone in the call may press a // button, only the people who run the space decide what the buttons are. if (!hasPermission(request.userId, id, PermissionBits.MANAGE_SPACE)) { return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 }); } const name = (request.body?.name ?? '').trim().slice(0, MAX_NAME_LENGTH); const filename = (request.body?.filename ?? '').trim(); if (!name || !filename) { return reply.code(400).send({ error: 'name and filename are required', statusCode: 400 }); } // The filename is a key into the upload directory, never a path. if (filename.includes('/') || filename.includes('\\') || filename.includes('..')) { return reply.code(400).send({ error: 'Invalid filename', statusCode: 400 }); } const db = getDb(); const count = db.select().from(schema.soundboardSounds) .where(eq(schema.soundboardSounds.spaceId, id)).all().length; if (count >= MAX_SOUNDS_PER_SPACE) { return reply.code(409).send({ error: `At most ${MAX_SOUNDS_PER_SPACE} sounds`, statusCode: 409 }); } const row = { id: generateSnowflake(), spaceId: id, name, filename, uploaderId: request.userId, createdAt: Date.now(), }; db.insert(schema.soundboardSounds).values(row).run(); return reply.code(201).send(row); }, ); app.delete<{ Params: { id: string } }>( '/api/sounds/:id', { preHandler: authenticate }, async (request, reply) => { const db = getDb(); const sound = db.select().from(schema.soundboardSounds) .where(eq(schema.soundboardSounds.id, request.params.id)).get(); if (!sound) return reply.code(404).send({ error: 'Sound not found', statusCode: 404 }); if (!hasPermission(request.userId, sound.spaceId, PermissionBits.MANAGE_SPACE)) { return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 }); } db.delete(schema.soundboardSounds).where(eq(schema.soundboardSounds.id, request.params.id)).run(); return reply.code(204).send(); }, ); }