feat: soundboard, account menu, and call timer
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Soundboard: the trigger travels over the WebSocket and every client in the call plays the clip locally, instead of mixing it into the presser's microphone or publishing a LiveKit track. No upstream bandwidth, no media stack changes, and the clip is not degraded by voice processing. Fan-out uses a new sendToRoomParticipants rather than sendToRoom: the latter broadcasts a space room to the whole space, which is right for the presence the sidebar shows and wrong for anything audible. The cooldown is enforced server-side — a client-side one only slows down people not trying to abuse it, and a soundboard is the easiest thing here to turn into a weapon. Playing is open to anyone in the call; deciding what the buttons are needs MANAGE_SPACE. Account menu: the name in the user bar had cursor-pointer and no handler, so the interface was already promising a click that did nothing. Offers profile, status and copy-id — not the Clips or account switching the reference design shows, which would be dead UI here. Call timer: startedAt comes from the server, so a late joiner sees the call's age rather than their own arrival. Empty space rooms are destroyed already, which is what makes the next call start from zero — no reset logic needed.
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
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<void> {
|
||||
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();
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user