feat: instance-level streaming limits with admin settings panel

Add a server-side instance_settings table (single-row, CHECK(id=1))
that stores admin-configurable streaming bounds: bitrate min/max/step,
allowed resolutions, and allowed framerates.

Backend:
- New instance_settings schema + migrations (is_admin on users, default
  settings row, first-registered-user promoted to admin)
- GET/PATCH /api/settings/streaming endpoints with admin-only writes
  and full input validation including cross-field checks

Frontend:
- settingsStore fetches limits on WebSocket ready, exposes isAdmin flag
- ScreenShareSettingsPopover reads bounds from store instead of
  hardcoded constants, auto-clamps stale localStorage values
- buildScreenShareOptions() clamps bitrate to server limits at build
  time as enforcement backstop
- ServerSettings modal gains a "Streaming" tab (admin-only) with
  bitrate range sliders, resolution/framerate toggles, and save/reset
This commit is contained in:
Jannis Braun
2026-02-26 03:33:29 +01:00
parent 2184ded2c1
commit 773a03b1aa
22 changed files with 541 additions and 9 deletions
+124
View File
@@ -0,0 +1,124 @@
import type { FastifyInstance } from 'fastify';
import { eq } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import type { InstanceStreamingLimits } from '@opencord/shared';
const VALID_RESOLUTIONS = [540, 720, 1080];
const VALID_FRAMERATES = [30, 45, 60];
function rowToLimits(row: typeof schema.instanceSettings.$inferSelect): InstanceStreamingLimits {
return {
maxBitrateKbps: row.maxBitrateKbps,
minBitrateKbps: row.minBitrateKbps,
bitrateStepKbps: row.bitrateStepKbps,
allowedResolutions: row.allowedResolutions.split(',').map(Number).filter((n) => VALID_RESOLUTIONS.includes(n)),
allowedFramerates: row.allowedFramerates.split(',').map(Number).filter((n) => VALID_FRAMERATES.includes(n)),
maxResolution: row.maxResolution,
maxFramerate: row.maxFramerate,
};
}
export async function settingsRoutes(app: FastifyInstance): Promise<void> {
// GET /api/settings/streaming — any authenticated user can read instance limits
app.get('/api/settings/streaming', { preHandler: authenticate }, async (_request, reply) => {
const db = getDb();
const row = db.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get();
if (!row) {
return reply.code(500).send({ error: 'Instance settings not initialized', statusCode: 500 });
}
return reply.code(200).send(rowToLimits(row));
});
// PATCH /api/settings/streaming — admin only
app.patch<{ Body: Partial<InstanceStreamingLimits> }>('/api/settings/streaming', { preHandler: authenticate }, async (request, reply) => {
const db = getDb();
// Verify caller is an instance admin
const caller = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
if (!caller || caller.isAdmin !== 1) {
return reply.code(403).send({ error: 'Only instance admins can modify streaming settings', statusCode: 403 });
}
const body = request.body;
const updateData: Record<string, number | string> = { updatedAt: Date.now() };
if (body.maxBitrateKbps !== undefined) {
if (typeof body.maxBitrateKbps !== 'number' || body.maxBitrateKbps < 500 || body.maxBitrateKbps > 50000) {
return reply.code(400).send({ error: 'maxBitrateKbps must be between 500 and 50000', statusCode: 400 });
}
updateData.maxBitrateKbps = body.maxBitrateKbps;
}
if (body.minBitrateKbps !== undefined) {
if (typeof body.minBitrateKbps !== 'number' || body.minBitrateKbps < 100 || body.minBitrateKbps > 50000) {
return reply.code(400).send({ error: 'minBitrateKbps must be between 100 and 50000', statusCode: 400 });
}
updateData.minBitrateKbps = body.minBitrateKbps;
}
if (body.bitrateStepKbps !== undefined) {
if (typeof body.bitrateStepKbps !== 'number' || body.bitrateStepKbps < 50 || body.bitrateStepKbps > 5000) {
return reply.code(400).send({ error: 'bitrateStepKbps must be between 50 and 5000', statusCode: 400 });
}
updateData.bitrateStepKbps = body.bitrateStepKbps;
}
if (body.allowedResolutions !== undefined) {
if (!Array.isArray(body.allowedResolutions) || body.allowedResolutions.length === 0) {
return reply.code(400).send({ error: 'allowedResolutions must be a non-empty array', statusCode: 400 });
}
const invalid = body.allowedResolutions.filter((r) => !VALID_RESOLUTIONS.includes(r));
if (invalid.length > 0) {
return reply.code(400).send({ error: `Invalid resolutions: ${invalid.join(', ')}. Allowed: ${VALID_RESOLUTIONS.join(', ')}`, statusCode: 400 });
}
updateData.allowedResolutions = body.allowedResolutions.sort((a, b) => a - b).join(',');
}
if (body.allowedFramerates !== undefined) {
if (!Array.isArray(body.allowedFramerates) || body.allowedFramerates.length === 0) {
return reply.code(400).send({ error: 'allowedFramerates must be a non-empty array', statusCode: 400 });
}
const invalid = body.allowedFramerates.filter((f) => !VALID_FRAMERATES.includes(f));
if (invalid.length > 0) {
return reply.code(400).send({ error: `Invalid framerates: ${invalid.join(', ')}. Allowed: ${VALID_FRAMERATES.join(', ')}`, statusCode: 400 });
}
updateData.allowedFramerates = body.allowedFramerates.sort((a, b) => a - b).join(',');
}
if (body.maxResolution !== undefined) {
if (!VALID_RESOLUTIONS.includes(body.maxResolution)) {
return reply.code(400).send({ error: `maxResolution must be one of: ${VALID_RESOLUTIONS.join(', ')}`, statusCode: 400 });
}
updateData.maxResolution = body.maxResolution;
}
if (body.maxFramerate !== undefined) {
if (!VALID_FRAMERATES.includes(body.maxFramerate)) {
return reply.code(400).send({ error: `maxFramerate must be one of: ${VALID_FRAMERATES.join(', ')}`, statusCode: 400 });
}
updateData.maxFramerate = body.maxFramerate;
}
// Cross-field validation: min < max
const currentRow = db.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get();
if (!currentRow) {
return reply.code(500).send({ error: 'Instance settings not initialized', statusCode: 500 });
}
const effectiveMin = (updateData.minBitrateKbps as number | undefined) ?? currentRow.minBitrateKbps;
const effectiveMax = (updateData.maxBitrateKbps as number | undefined) ?? currentRow.maxBitrateKbps;
if (effectiveMin >= effectiveMax) {
return reply.code(400).send({ error: 'minBitrateKbps must be less than maxBitrateKbps', statusCode: 400 });
}
db.update(schema.instanceSettings).set(updateData).where(eq(schema.instanceSettings.id, 1)).run();
const updatedRow = db.select().from(schema.instanceSettings).where(eq(schema.instanceSettings.id, 1)).get();
if (!updatedRow) {
return reply.code(500).send({ error: 'Failed to read updated settings', statusCode: 500 });
}
return reply.code(200).send(rowToLimits(updatedRow));
});
}